feat!: Account features

- Bump version to 0.2.0
- Added trait SquadObject
- Implemented SquadObject for Quest and Account
- Implemented Config::load_accounts
- Removed src/quest/error.rs
- Added account tests in tests/main.rs

BREAKING CHANGE: Quest::{load,delete,save} are now provided by
SquadObject trait
This commit is contained in:
Alexey 2025-12-02 14:33:38 +03:00
commit 0e8cdde697
12 changed files with 285 additions and 98 deletions

2
Cargo.lock generated
View file

@ -332,7 +332,7 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "squad-quest"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"chrono",
"clap",

View file

@ -1,6 +1,6 @@
[package]
name = "squad-quest"
version = "0.1.0"
version = "0.2.0"
edition = "2024"
[dependencies]

View file

@ -1,24 +1,97 @@
//! User accounts
use std::{fs, io::Write, path::PathBuf};
use serde::{ Serialize, Deserialize };
use crate::{SquadObject, error::Error};
fn default_id() -> String {
"none".to_string()
}
/// User account struct, which can be (de-)serialized from/into TOML
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, PartialEq, Debug)]
#[serde(default)]
pub struct Account {
/// User identifier, specific to used service
#[serde(default = "default_id")]
pub id: String,
/// User balance
#[serde(default)]
pub balance: u32,
/// Id of room node where user is located
#[serde(default)]
pub location: u16
pub location: u16,
/// Vec of quests completed by this user
pub quests_completed: Vec<u16>,
/// Vec of rooms unlocked by this user
pub rooms_unlocked: Vec<u16>,
}
impl Default for Account {
fn default() -> Self {
Account {
id: default_id(),
balance: u32::default(),
location: u16::default(),
quests_completed: Vec::new(),
rooms_unlocked: Vec::new(),
}
}
}
impl SquadObject for Account {
fn load(path: PathBuf) -> Result<Self, Error> {
match std::fs::read_to_string(path) {
Ok(string) => {
match toml::from_str::<Self>(&string) {
Ok(object) => Ok(object),
Err(error) => Err(Error::TomlDeserializeError(error))
}
},
Err(error) => Err(Error::IoError(error))
}
}
fn delete(path: PathBuf) -> Result<(), Error> {
match Self::load(path.clone()) {
Ok(_) => {
if let Err(error) = fs::remove_file(path) {
return Err(Error::IoError(error));
}
Ok(())
},
Err(error) => Err(error)
}
}
fn save(&self, path: PathBuf) -> Result<(), Error> {
let filename = format!("{}.toml", self.id);
let mut full_path = path;
full_path.push(filename);
let str = match toml::to_string_pretty(&self) {
Ok(string) => string,
Err(error) => {
return Err(Error::TomlSerializeError(error));
}
};
let mut file = match fs::File::create(full_path) {
Ok(f) => f,
Err(error) => {
return Err(Error::IoError(error));
}
};
if let Err(error) = file.write_all(str.as_bytes()) {
return Err(Error::IoError(error));
}
Ok(())
}
}

View file

@ -2,7 +2,7 @@ use std::path::PathBuf;
use clap::{Parser,Subcommand,Args,ValueEnum};
use serde::Deserialize;
use squad_quest::{config::Config,quest::{Quest,QuestDifficulty as LibQuestDifficulty}};
use squad_quest::{SquadObject, config::Config, quest::{Quest,QuestDifficulty as LibQuestDifficulty}};
use toml::value::Date;
use chrono::{Datelike, NaiveDate, Utc};

View file

@ -3,7 +3,7 @@
use std::{fs::{self, DirEntry},path::{Path, PathBuf}};
use serde::Deserialize;
use crate::{error::Error,quest::Quest};
use crate::{SquadObject, account::Account, error::Error, quest::Quest};
/// Struct for containing paths to other (de-)serializable things
#[derive(Deserialize)]
@ -50,6 +50,22 @@ fn handle_quest_entry(quest_entry: DirEntry) -> Result<Quest, Error>{
Quest::load(path)
}
fn handle_account_entry(account_entry: DirEntry) -> Result<Account, Error>{
let filetype = account_entry.file_type();
if let Err(error) = filetype {
return Err(Error::IoError(error));
}
let path = account_entry.path();
let filetype = filetype.unwrap();
if !filetype.is_file() {
return Err(Error::IsNotAFile(path));
}
Account::load(path)
}
impl Config {
/// Deserialize config from TOML
/// Logs all errors and returns default config if that happens
@ -154,4 +170,71 @@ impl Config {
out_vec
}
/// Returns full path to quests folder
/// This path will be relative to $PWD, not to config.
///
/// # Examples
/// ```rust
/// use squad_quest::config::Config;
///
/// let path = "cfg/config.toml".into();
/// let config = Config::load(path);
///
/// let accounts_path = config.full_accounts_path();
/// ```
pub fn full_accounts_path(&self) -> PathBuf {
let mut path = self.path.clone();
path.push(self.accounts_path.clone());
path
}
/// Load [Vec]<[Account]> from accounts folder.
/// Also logs errors and counts successfully loaded quests.
///
/// # Examples
/// ```rust
/// use squad_quest::{config::Config, account::Account};
///
///
/// let path = "cfg/config.toml".into();
/// let config = Config::load(path);
/// let accounts = config.load_accounts();
///
/// for account in accounts {
/// println!("Account {}", account.id);
/// }
/// ```
pub fn load_accounts(&self) -> Vec<Account> {
let mut out_vec = Vec::new();
let path = self.full_accounts_path();
match fs::read_dir(path) {
Ok(iter) => {
for entry in iter {
match entry {
Ok(acc_entry) => {
match handle_account_entry(acc_entry) {
Ok(quest) => out_vec.push(quest),
Err(error) => {
eprintln!("Error on loading single account: {error}");
}
}
},
Err(error) => {
eprintln!("Error on loading single account: {error}");
}
}
}
},
Err(error) => {
eprintln!("Error on loading accounts: {error}");
}
}
println!("Loaded {} accounts successfully", out_vec.len());
out_vec
}
}

View file

@ -2,8 +2,75 @@
#![warn(missing_docs)]
use std::path::PathBuf;
use crate::error::Error;
pub mod account;
pub mod config;
pub mod error;
pub mod map;
pub mod quest;
/// Trait for objects that are internally used in squad_quest.
/// Contains functions and methods to interact with file system.
/// Files are saved in TOML format.
pub trait SquadObject {
/// Parse SquadObject TOML or return error
///
/// # Examples
/// ```rust
/// use squad_quest::{SquadObject,error::Error,quest::Quest};
/// # fn main() {
/// # let _ = wrapper();
/// # }
///
/// # fn wrapper() -> Result<(), Error> {
/// let path = "quests/0.toml".into();
///
/// let quest = Quest::load(path)?;
/// #
/// # Ok(())
/// # }
/// ```
fn load(path: PathBuf) -> Result<Self, Error> where Self: Sized;
/// Check if given file is a SquadObject, then delete it or raise an error.
/// If file is not a quest, raises [Error::TomlDeserializeError]
///
/// # Examples
/// ```rust
/// use squad_quest::{SquadObject,error::Error,quest::Quest};
///
/// let path = "quests/0.toml".into();
///
/// if let Err(error) = Quest::delete(path) {
/// // handle the error
/// }
/// ```
fn delete(path: PathBuf) -> Result<(), Error>;
/// Save SquadObject to given folder in TOML format.
/// File will be saved as `{id}.toml`.
/// If file exists, this method will override it.
///
/// # Examples
/// ```rust
/// # fn main() {
/// use squad_quest::{SquadObject,error::Error,quest::Quest};
/// use std::path::PathBuf;
///
/// let quest = Quest::default();
///
/// let path: PathBuf = "quests".into();
/// # let path2 = path.clone();
///
/// if let Err(error) = quest.save(path) {
/// // handle the error
/// }
/// # let filename = format!("{}.toml", quest.id);
/// # let _ = Quest::delete(path2.with_file_name(filename));
/// # }
/// ```
fn save(&self, path: PathBuf) -> Result<(), Error>;
}

View file

@ -1,28 +0,0 @@
//! Module for handling quest loading errors
use std::{fmt, path::PathBuf};
/// Error raised when trying to parse quest file
#[derive(Debug)]
#[non_exhaustive]
pub enum QuestError {
/// Given path is not a file
IsNotAFile(PathBuf),
/// std::io::Error happenned when loading
IoError(std::io::Error),
/// toml::ser::Error happened when loading
TomlSerializeError(toml::ser::Error),
/// toml::de::Error happened when loading
TomlDeserializeError(toml::de::Error),
}
impl fmt::Display for QuestError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
QuestError::IsNotAFile(path) => write!(f, "{:?} is not a file", path),
QuestError::IoError(error) => write!(f, "io error: {error}"),
QuestError::TomlSerializeError(error) => write!(f, "serialize error: {error}"),
QuestError::TomlDeserializeError(error) => write!(f, "parse error: {error}")
}
}
}

View file

@ -3,7 +3,7 @@
use std::{fs, io::Write, path::PathBuf};
use serde::{ Serialize, Deserialize };
use crate::error::Error;
use crate::{SquadObject, error::Error};
use toml::value::Date;
/// Difficulty of the quest
@ -85,29 +85,12 @@ impl Default for Quest {
}
}
impl Quest {
/// Parse quest TOML or return error
///
/// # Examples
/// ```rust
/// use squad_quest::{error::Error,quest::Quest};
/// # fn main() {
/// # let _ = wrapper();
/// # }
///
/// # fn wrapper() -> Result<(), Error> {
/// let path = "quests/0.toml".into();
///
/// let quest = Quest::load(path)?;
/// #
/// # Ok(())
/// # }
/// ```
pub fn load(path: PathBuf) -> Result<Self, Error> {
impl SquadObject for Quest {
fn load(path: PathBuf) -> Result<Self, Error> {
match std::fs::read_to_string(path) {
Ok(string) => {
match toml::from_str::<Quest>(&string) {
Ok(quest) => Ok(quest),
match toml::from_str::<Self>(&string) {
Ok(object) => Ok(object),
Err(error) => Err(Error::TomlDeserializeError(error))
}
},
@ -115,20 +98,7 @@ impl Quest {
}
}
/// Check if given file is a quest, then delete it or raise an error.
/// If file is not a quest, raises [QuestError::TomlDeserializeError]
///
/// # Examples
/// ```rust
/// use squad_quest::{error::Error,quest::Quest};
///
/// let path = "quests/0.toml".into();
///
/// if let Err(error) = Quest::delete(path) {
/// // handle the error
/// }
/// ```
pub fn delete(path: PathBuf) -> Result<(), Error> {
fn delete(path: PathBuf) -> Result<(), Error> {
match Quest::load(path.clone()) {
Ok(_) => {
if let Err(error) = fs::remove_file(path) {
@ -141,29 +111,7 @@ impl Quest {
}
}
/// Save quest to given folder in TOML format.
/// File will be saved as `{id}.toml`.
/// If file exists, this method will override it.
///
/// # Examples
/// ```rust
/// # fn main() {
/// use squad_quest::{error::Error,quest::Quest};
/// use std::path::PathBuf;
///
/// let quest = Quest::default();
///
/// let path: PathBuf = "quests".into();
/// # let path2 = path.clone();
///
/// if let Err(error) = quest.save(path) {
/// // handle the error
/// }
/// # let filename = format!("{}.toml", quest.id);
/// # let _ = Quest::delete(path2.with_file_name(filename));
/// # }
/// ```
pub fn save(&self, path: PathBuf) -> Result<(), Error> {
fn save(&self, path: PathBuf) -> Result<(), Error> {
let filename = format!("{}.toml", self.id);
let mut full_path = path;
full_path.push(filename);

View file

@ -1,4 +1,4 @@
use squad_quest::{config::Config,error::Error,quest::Quest};
use squad_quest::{SquadObject, config::Config, error::Error, quest::Quest};
use std::path::PathBuf;
const CONFIG_PATH: &str = "tests/io/config.toml";

View file

@ -1,4 +1,4 @@
use squad_quest::{config::Config, quest::Quest};
use squad_quest::{account::Account, config::Config, quest::Quest};
static CONFIG_PATH: &str = "./tests/main/config.toml";
@ -43,3 +43,41 @@ fn quest_one() {
assert_eq!(*quest, expected);
}
#[test]
fn load_accounts() {
let config = Config::load(CONFIG_PATH.into());
let accounts = config.load_accounts();
assert_eq!(accounts.len(), 2);
}
#[test]
fn empty_account_is_default() {
let config = Config::load(CONFIG_PATH.into());
let accounts = config.load_accounts();
let default = Account::default();
let account = accounts.iter().find(|a| a.id == default.id).unwrap();
assert_eq!(*account, default);
}
#[test]
fn account_test() {
let config = Config::load(CONFIG_PATH.into());
let expected = Account {
id: "test".to_string(),
balance: 150,
location: 0,
quests_completed: vec![0],
rooms_unlocked: Vec::new()
};
let accounts = config.load_accounts();
let account = accounts.iter().find(|a| a.id == expected.id).unwrap();
assert_eq!(*account, expected);
}

View file

@ -0,0 +1 @@
# Empty account for testing

View file

@ -0,0 +1,5 @@
id = "test"
balance = 150
location = 0
quests_completed = [ 0 ]
rooms_unlocked = []