From 78da6dde057db4d9da52ab696bdcc8f74a215d5e Mon Sep 17 00:00:00 2001 From: 2ndbeam <2ndbeam@disroot.org> Date: Mon, 1 Dec 2025 13:26:38 +0300 Subject: [PATCH 01/11] feat!: Added several fields to Quest struct - Added field public - Added optional field available_on - Added optional field deadline - Updated tests and CLI to use these fields --- src/bin/cli.rs | 45 ++++++++++++++++++++++++++++++++++++---- src/quest/mod.rs | 15 +++++++++++++- tests/main.rs | 5 ++++- tests/main/quests/1.toml | 1 + 4 files changed, 60 insertions(+), 6 deletions(-) diff --git a/src/bin/cli.rs b/src/bin/cli.rs index fef2fc1..f46d387 100644 --- a/src/bin/cli.rs +++ b/src/bin/cli.rs @@ -1,7 +1,20 @@ use std::path::PathBuf; use clap::{Parser,Subcommand,Args,ValueEnum}; +use serde::Deserialize; use squad_quest::{config::Config,quest::{Quest,QuestDifficulty as LibQuestDifficulty}}; +use toml::value::Date; + +#[derive(Deserialize)] +struct DateWrapper { + date: Date +} + +fn parse_date(arg: &str) -> Result { + let toml_str = format!("date = {arg}"); + let wrapper: DateWrapper = toml::from_str(&toml_str)?; + Ok(wrapper.date) +} #[derive(Parser)] #[command(version, about, long_about = None)] @@ -76,7 +89,16 @@ struct QuestCreateArgs { /// Visible description of the quest description: String, /// Answer for the quest for admins - answer: String + answer: String, + /// Create quest and make it public immediately + #[arg(short,long)] + public: bool, + /// Make quest available on date (format = YYYY-MM-DD, ex. 2025-12-24) + #[arg(short,long,value_parser = parse_date)] + available: Option, + /// Quest expiration date (format = YYYY-MM-DD, ex. 2025-12-24) + #[arg(short,long,value_parser = parse_date)] + deadline: Option, } #[derive(Args)] @@ -97,7 +119,16 @@ struct QuestUpdateArgs { description: Option, /// Answer for the quest for admins #[arg(long)] - answer: Option + answer: Option, + /// Create quest and make it public immediately + #[arg(long)] + public: Option, + /// Make quest available on date (format = YYYY-MM-DD, ex. 2025-12-24) + #[arg(long,value_parser = parse_date)] + available: Option, + /// Quest expiration date (format = YYYY-MM-DD, ex. 2025-12-24) + #[arg(long,value_parser = parse_date)] + deadline: Option, } #[derive(Args)] @@ -141,7 +172,7 @@ fn main() { let next_id = match quests.last() { Some(quest) if quest.id == u16::MAX => { panic!("Error: quest list contains quest with u16::MAX id."); - } + }, Some(quest) => quest.id + 1u16, None => 0u16 }; @@ -167,6 +198,9 @@ fn main() { name: args.name.clone(), description: args.description.clone(), answer: args.answer.clone(), + public: args.public, + available_on: args.available.clone(), + deadline: args.deadline.clone() }; if let Err(error) = quest.save(path) { eprintln!("Error while saving quest: {error}."); @@ -188,7 +222,10 @@ fn main() { reward: args.reward.unwrap_or(quest.reward), name: args.name.clone().unwrap_or(quest.name.clone()), description: args.description.clone().unwrap_or(quest.description.clone()), - answer: args.answer.clone().unwrap_or(quest.answer.clone()) + answer: args.answer.clone().unwrap_or(quest.answer.clone()), + public: args.public.unwrap_or(quest.public), + available_on: args.available.clone().or(quest.available_on.clone()), + deadline: args.deadline.clone().or(quest.deadline.clone()) }; let path = config.full_quests_path(); match quest.save(path) { diff --git a/src/quest/mod.rs b/src/quest/mod.rs index 6c95f69..ccbdbbb 100644 --- a/src/quest/mod.rs +++ b/src/quest/mod.rs @@ -6,6 +6,7 @@ use std::{fs, io::Write, path::PathBuf}; use serde::{ Serialize, Deserialize }; use error::QuestError; +use toml::value::Date; /// Difficulty of the quest #[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Copy)] @@ -59,6 +60,15 @@ pub struct Quest { /// Quest answer, available for admins pub answer: String, + + /// Is quest available for regular users + pub public: bool, + + /// When quest becomes public + pub available_on: Option, + + /// When quest expires + pub deadline: Option } impl Default for Quest { @@ -69,7 +79,10 @@ impl Default for Quest { reward: u32::default(), name: default_name(), description: default_description(), - answer: default_answer() + answer: default_answer(), + public: false, + available_on: None, + deadline: None } } } diff --git a/tests/main.rs b/tests/main.rs index 321674a..1210380 100644 --- a/tests/main.rs +++ b/tests/main.rs @@ -35,7 +35,10 @@ fn quest_one() { reward: 100, name: "Example easy quest".to_owned(), description: "Answer this quest without any attachments or comments".to_owned(), - answer: "Accept the answer if it has no attachments and an empty comment".to_owned() + answer: "Accept the answer if it has no attachments and an empty comment".to_owned(), + public: false, + available_on: None, + deadline: None }; assert_eq!(*quest, expected); diff --git a/tests/main/quests/1.toml b/tests/main/quests/1.toml index 5b67270..855b576 100644 --- a/tests/main/quests/1.toml +++ b/tests/main/quests/1.toml @@ -6,3 +6,4 @@ reward = 100 name = "Example easy quest" description = "Answer this quest without any attachments or comments" answer = "Accept the answer if it has no attachments and an empty comment" +public = false From a0bec4003ca2bba15b1529ca69bfcd54bee38862 Mon Sep 17 00:00:00 2001 From: 2ndbeam <2ndbeam@disroot.org> Date: Mon, 1 Dec 2025 16:43:58 +0300 Subject: [PATCH 02/11] feat: Quest publication features for CLI - Added "quest daily" command - Added "quest publish" command --- Cargo.lock | 234 +++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 1 + src/bin/cli.rs | 62 ++++++++++++- 3 files changed, 294 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a71bcc7..a8145e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.21" @@ -52,6 +61,47 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "cc" +version = "1.2.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c481bdbf0ed3b892f6f806287d72acd515b352a4ec27a208489b8c1bc839633a" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "clap" version = "4.5.53" @@ -98,12 +148,24 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "find-msvc-tools" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" + [[package]] name = "hashbrown" version = "0.16.1" @@ -116,6 +178,30 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "indexmap" version = "2.12.1" @@ -132,6 +218,43 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "js-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + [[package]] name = "once_cell_polyfill" version = "1.70.2" @@ -156,6 +279,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "serde" version = "1.0.228" @@ -195,10 +324,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "squad-quest" version = "0.1.0" dependencies = [ + "chrono", "clap", "clap_derive", "serde", @@ -273,12 +409,110 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "wasm-bindgen" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.61.2" diff --git a/Cargo.toml b/Cargo.toml index bef570a..b981d44 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] +chrono = "0.4.42" clap = { version = "4.5.53", features = ["derive"] } clap_derive = "4.5.49" serde = { version = "1.0.228", features = ["derive"] } diff --git a/src/bin/cli.rs b/src/bin/cli.rs index f46d387..e5ee968 100644 --- a/src/bin/cli.rs +++ b/src/bin/cli.rs @@ -4,6 +4,7 @@ use clap::{Parser,Subcommand,Args,ValueEnum}; use serde::Deserialize; use squad_quest::{config::Config,quest::{Quest,QuestDifficulty as LibQuestDifficulty}}; use toml::value::Date; +use chrono::{Datelike, NaiveDate, Utc}; #[derive(Deserialize)] struct DateWrapper { @@ -68,6 +69,10 @@ enum QuestCommands { Update(QuestUpdateArgs), /// Delete quest Delete(QuestDeleteArgs), + /// Make certain quests public + Daily, + /// Publish quest with specified id + Publish(QuestPublishArgs), } #[derive(Args)] @@ -78,9 +83,7 @@ struct QuestListArgs { } #[derive(Args)] -struct QuestCreateArgs { - /// Difficulty of the quest - #[arg(value_enum)] +struct QuestCreateArgs { /// Difficulty of the quest #[arg(value_enum)] difficulty: QuestDifficulty, /// Reward for the quest reward: u32, @@ -137,6 +140,15 @@ struct QuestDeleteArgs { id: u16 } +#[derive(Args)] +struct QuestPublishArgs { + /// Id of the quest to publish + id: u16, + /// Make it non-public instead + #[arg(long,short)] + reverse: bool, +} + fn print_quest_short(quest: &Quest) { println!("Quest #{}: {}", quest.id, quest.name); } @@ -241,6 +253,50 @@ fn main() { Err(error) => eprintln!("Error deleting quest #{}: {}", args.id, error), } }, + QuestCommands::Daily => { + let mut quests = config.load_quests(); + let today: NaiveDate = Utc::now().date_naive(); + let toml_today = Date { + year: today.year() as u16, + month: today.month() as u8, + day: today.day() as u8 + }; + + let path = config.full_quests_path(); + + for quest in quests.iter_mut().filter(|q| !q.public && q.available_on.is_some_and(|date| date.le(&toml_today))) { + println!("Quest #{} will be published.", quest.id); + quest.public = true; + if let Err(error) = quest.save(path.clone()) { + eprintln!("Error while saving quest: {error}."); + } + } + }, + QuestCommands::Publish(args) => { + let mut quests = config.load_quests(); + let quest = quests.iter_mut().find(|q| q.id == args.id); + + let path = config.full_quests_path(); + + match quest { + Some(quest) => { + let not_str = if args.reverse {" not "} else {" "}; + + if quest.public != args.reverse { + println!("Quest #{} is already{}public", quest.id, not_str); + return; + } + + quest.public = !args.reverse; + if let Err(error) = quest.save(path) { + eprintln!("Error while saving quest: {error}."); + }; + }, + None => { + eprintln!("Error: couldn't find quest with id {}.", args.id); + } + } + } } } } From 1dc7d947853e66925e0cb0d008aff66e21de7701 Mon Sep 17 00:00:00 2001 From: 2ndbeam <2ndbeam@disroot.org> Date: Mon, 1 Dec 2025 17:15:08 +0300 Subject: [PATCH 03/11] refactor!: moved quest::error::QuestError to crate::error::Error --- src/config/mod.rs | 8 ++++---- src/error.rs | 28 ++++++++++++++++++++++++++++ src/lib.rs | 1 + src/quest/mod.rs | 30 ++++++++++++++---------------- tests/io.rs | 4 ++-- 5 files changed, 49 insertions(+), 22 deletions(-) create mode 100644 src/error.rs diff --git a/src/config/mod.rs b/src/config/mod.rs index 407f8dc..5057e16 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -3,7 +3,7 @@ use std::{fs::{self, DirEntry},path::{Path, PathBuf}}; use serde::Deserialize; -use crate::quest::{Quest,error::QuestError}; +use crate::{error::Error,quest::Quest}; /// Struct for containing paths to other (de-)serializable things #[derive(Deserialize)] @@ -34,17 +34,17 @@ impl Default for Config { } } -fn handle_quest_entry(quest_entry: DirEntry) -> Result{ +fn handle_quest_entry(quest_entry: DirEntry) -> Result{ let filetype = quest_entry.file_type(); if let Err(error) = filetype { - return Err(QuestError::IoError(error)); + return Err(Error::IoError(error)); } let path = quest_entry.path(); let filetype = filetype.unwrap(); if !filetype.is_file() { - return Err(QuestError::IsNotAFile(path)); + return Err(Error::IsNotAFile(path)); } Quest::load(path) diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..501d6c4 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,28 @@ +//! Module for handling crate errors + +use std::{fmt, path::PathBuf}; + +/// Error struct +#[derive(Debug)] +#[non_exhaustive] +pub enum Error { + /// 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 Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::IsNotAFile(path) => write!(f, "{:?} is not a file", path), + Error::IoError(error) => write!(f, "io error: {error}"), + Error::TomlSerializeError(error) => write!(f, "serialize error: {error}"), + Error::TomlDeserializeError(error) => write!(f, "parse error: {error}") + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 594f090..90670d7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,5 +4,6 @@ pub mod account; pub mod config; +pub mod error; pub mod map; pub mod quest; diff --git a/src/quest/mod.rs b/src/quest/mod.rs index ccbdbbb..6cbaf7d 100644 --- a/src/quest/mod.rs +++ b/src/quest/mod.rs @@ -1,11 +1,9 @@ //! Text-based quests and user solutions for them -pub mod error; - use std::{fs, io::Write, path::PathBuf}; use serde::{ Serialize, Deserialize }; -use error::QuestError; +use crate::error::Error; use toml::value::Date; /// Difficulty of the quest @@ -92,12 +90,12 @@ impl Quest { /// /// # Examples /// ```rust - /// use squad_quest::quest::{Quest,error::QuestError}; + /// use squad_quest::{error::Error,quest::Quest}; /// # fn main() { /// # let _ = wrapper(); /// # } /// - /// # fn wrapper() -> Result<(), QuestError> { + /// # fn wrapper() -> Result<(), Error> { /// let path = "quests/0.toml".into(); /// /// let quest = Quest::load(path)?; @@ -105,15 +103,15 @@ impl Quest { /// # Ok(()) /// # } /// ``` - pub fn load(path: PathBuf) -> Result { + pub fn load(path: PathBuf) -> Result { match std::fs::read_to_string(path) { Ok(string) => { match toml::from_str::(&string) { Ok(quest) => Ok(quest), - Err(error) => Err(QuestError::TomlDeserializeError(error)) + Err(error) => Err(Error::TomlDeserializeError(error)) } }, - Err(error) => Err(QuestError::IoError(error)) + Err(error) => Err(Error::IoError(error)) } } @@ -122,7 +120,7 @@ impl Quest { /// /// # Examples /// ```rust - /// use squad_quest::quest::{Quest,error::QuestError}; + /// use squad_quest::{error::Error,quest::Quest}; /// /// let path = "quests/0.toml".into(); /// @@ -130,11 +128,11 @@ impl Quest { /// // handle the error /// } /// ``` - pub fn delete(path: PathBuf) -> Result<(), QuestError> { + pub fn delete(path: PathBuf) -> Result<(), Error> { match Quest::load(path.clone()) { Ok(_) => { if let Err(error) = fs::remove_file(path) { - return Err(QuestError::IoError(error)); + return Err(Error::IoError(error)); } Ok(()) @@ -150,7 +148,7 @@ impl Quest { /// # Examples /// ```rust /// # fn main() { - /// use squad_quest::quest::{Quest,error::QuestError}; + /// use squad_quest::{error::Error,quest::Quest}; /// use std::path::PathBuf; /// /// let quest = Quest::default(); @@ -165,7 +163,7 @@ impl Quest { /// # let _ = Quest::delete(path2.with_file_name(filename)); /// # } /// ``` - pub fn save(&self, path: PathBuf) -> Result<(), QuestError> { + pub fn save(&self, path: PathBuf) -> Result<(), Error> { let filename = format!("{}.toml", self.id); let mut full_path = path; full_path.push(filename); @@ -173,19 +171,19 @@ impl Quest { let str = match toml::to_string_pretty(&self) { Ok(string) => string, Err(error) => { - return Err(QuestError::TomlSerializeError(error)); + return Err(Error::TomlSerializeError(error)); } }; let mut file = match fs::File::create(full_path) { Ok(f) => f, Err(error) => { - return Err(QuestError::IoError(error)); + return Err(Error::IoError(error)); } }; if let Err(error) = file.write_all(str.as_bytes()) { - return Err(QuestError::IoError(error)); + return Err(Error::IoError(error)); } Ok(()) diff --git a/tests/io.rs b/tests/io.rs index fc3def8..90068f5 100644 --- a/tests/io.rs +++ b/tests/io.rs @@ -1,4 +1,4 @@ -use squad_quest::{config::Config,quest::{error::{QuestError}, Quest}}; +use squad_quest::{config::Config,error::Error,quest::Quest}; use std::path::PathBuf; const CONFIG_PATH: &str = "tests/io/config.toml"; @@ -7,7 +7,7 @@ const CONFIG_PATH: &str = "tests/io/config.toml"; // and Quest::save can override files, // so this test covers full quest CRUD #[test] -fn quest_crud() -> Result<(), QuestError> { +fn quest_crud() -> Result<(), Error> { let config = Config::load(CONFIG_PATH.into()); let mut quests_path = PathBuf::from(CONFIG_PATH).parent().unwrap().to_owned(); From 0e8cdde697d831aee55f7e414243e2e103d36916 Mon Sep 17 00:00:00 2001 From: 2ndbeam <2ndbeam@disroot.org> Date: Tue, 2 Dec 2025 14:33:38 +0300 Subject: [PATCH 04/11] 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 --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/account/mod.rs | 83 +++++++++++++++++++++++++++++++--- src/bin/cli.rs | 2 +- src/config/mod.rs | 85 ++++++++++++++++++++++++++++++++++- src/lib.rs | 67 +++++++++++++++++++++++++++ src/quest/error.rs | 28 ------------ src/quest/mod.rs | 66 +++------------------------ tests/io.rs | 2 +- tests/main.rs | 40 ++++++++++++++++- tests/main/accounts/none.toml | 1 + tests/main/accounts/test.toml | 5 +++ 12 files changed, 285 insertions(+), 98 deletions(-) delete mode 100644 src/quest/error.rs create mode 100644 tests/main/accounts/none.toml create mode 100644 tests/main/accounts/test.toml diff --git a/Cargo.lock b/Cargo.lock index a8145e0..1c88b30 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -332,7 +332,7 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "squad-quest" -version = "0.1.0" +version = "0.2.0" dependencies = [ "chrono", "clap", diff --git a/Cargo.toml b/Cargo.toml index b981d44..f912a91 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "squad-quest" -version = "0.1.0" +version = "0.2.0" edition = "2024" [dependencies] diff --git a/src/account/mod.rs b/src/account/mod.rs index 0960db8..5ed0373 100644 --- a/src/account/mod.rs +++ b/src/account/mod.rs @@ -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, + + /// Vec of rooms unlocked by this user + pub rooms_unlocked: Vec, +} + +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 { + match std::fs::read_to_string(path) { + Ok(string) => { + match toml::from_str::(&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(()) + } } diff --git a/src/bin/cli.rs b/src/bin/cli.rs index e5ee968..4d06a2a 100644 --- a/src/bin/cli.rs +++ b/src/bin/cli.rs @@ -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}; diff --git a/src/config/mod.rs b/src/config/mod.rs index 5057e16..d5a86da 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -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::load(path) } +fn handle_account_entry(account_entry: DirEntry) -> Result{ + 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 { + 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 + } } diff --git a/src/lib.rs b/src/lib.rs index 90670d7..db152a5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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 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>; +} diff --git a/src/quest/error.rs b/src/quest/error.rs deleted file mode 100644 index 55ddbcf..0000000 --- a/src/quest/error.rs +++ /dev/null @@ -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}") - } - } -} diff --git a/src/quest/mod.rs b/src/quest/mod.rs index 6cbaf7d..b8d7886 100644 --- a/src/quest/mod.rs +++ b/src/quest/mod.rs @@ -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 { +impl SquadObject for Quest { + fn load(path: PathBuf) -> Result { match std::fs::read_to_string(path) { Ok(string) => { - match toml::from_str::(&string) { - Ok(quest) => Ok(quest), + match toml::from_str::(&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); diff --git a/tests/io.rs b/tests/io.rs index 90068f5..983e4cd 100644 --- a/tests/io.rs +++ b/tests/io.rs @@ -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"; diff --git a/tests/main.rs b/tests/main.rs index 1210380..b582e5c 100644 --- a/tests/main.rs +++ b/tests/main.rs @@ -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); +} diff --git a/tests/main/accounts/none.toml b/tests/main/accounts/none.toml new file mode 100644 index 0000000..ca0a7fb --- /dev/null +++ b/tests/main/accounts/none.toml @@ -0,0 +1 @@ +# Empty account for testing diff --git a/tests/main/accounts/test.toml b/tests/main/accounts/test.toml new file mode 100644 index 0000000..439a946 --- /dev/null +++ b/tests/main/accounts/test.toml @@ -0,0 +1,5 @@ +id = "test" +balance = 150 +location = 0 +quests_completed = [ 0 ] +rooms_unlocked = [] From dc94f2060c456087aa71239aeb7edfb26eb86989 Mon Sep 17 00:00:00 2001 From: 2ndbeam <2ndbeam@disroot.org> Date: Tue, 2 Dec 2025 16:12:42 +0300 Subject: [PATCH 05/11] feat: Added interaction with accounts in CLI - Account creation - Account deletion - Account balance management - Account quest completion - Added account CRUD test in tests/io.rs --- src/bin/cli.rs | 199 +++++++++++++++++++++++++++++++-- tests/io.rs | 33 ++++-- tests/io/accounts/.placeholder | 1 + 3 files changed, 215 insertions(+), 18 deletions(-) create mode 100644 tests/io/accounts/.placeholder diff --git a/src/bin/cli.rs b/src/bin/cli.rs index 4d06a2a..05e2c24 100644 --- a/src/bin/cli.rs +++ b/src/bin/cli.rs @@ -2,13 +2,13 @@ use std::path::PathBuf; use clap::{Parser,Subcommand,Args,ValueEnum}; use serde::Deserialize; -use squad_quest::{SquadObject, config::Config, quest::{Quest,QuestDifficulty as LibQuestDifficulty}}; +use squad_quest::{SquadObject, account::Account, config::Config, quest::{Quest,QuestDifficulty as LibQuestDifficulty}}; use toml::value::Date; use chrono::{Datelike, NaiveDate, Utc}; #[derive(Deserialize)] struct DateWrapper { - date: Date + date: Date, } fn parse_date(arg: &str) -> Result { @@ -33,7 +33,10 @@ struct Cli { enum Objects { /// Operations on the quests #[command(subcommand)] - Quest(QuestCommands) + Quest(QuestCommands), + /// Operations on the accounts + #[command(subcommand)] + Account(AccountCommands), } #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] @@ -45,7 +48,7 @@ enum QuestDifficulty { /// Hard quest Hard, /// Special case of hard quests. - Secret + Secret, } impl From for LibQuestDifficulty { @@ -75,11 +78,12 @@ enum QuestCommands { Publish(QuestPublishArgs), } + #[derive(Args)] struct QuestListArgs { /// Only list id and name of the quest #[arg(short, long)] - short: bool + short: bool, } #[derive(Args)] @@ -137,7 +141,7 @@ struct QuestUpdateArgs { #[derive(Args)] struct QuestDeleteArgs { /// Id of the quest to delete - id: u16 + id: u16, } #[derive(Args)] @@ -149,6 +153,61 @@ struct QuestPublishArgs { reverse: bool, } +#[derive(Subcommand)] +enum AccountCommands { + /// List accounts + List, + /// Create empty account + Create(AccountCreateArgs), + /// Update balance value + Balance(AccountBalanceArgs), + /// Approve account answer for quest + Complete(AccountCompleteArgs), + /// Delete account + Delete(AccountDeleteArgs), +} + +#[derive(Args)] +struct AccountCreateArgs { + /// Account will be created with this id + id: String, +} + +#[derive(Clone, Copy, PartialEq, Eq, ValueEnum)] +enum AccountBalanceActions { + Set, + Add, + Remove, +} + +#[derive(Args)] +struct AccountBalanceArgs { + /// Account id + id: String, + /// What to do with the balance + #[arg(value_enum)] + action: AccountBalanceActions, + /// Amount of doing + value: u32, + /// If action is remove, set balance to 0 if the result is negative instead of returning error + #[arg(short,long)] + negative_ok: bool, +} + +#[derive(Args)] +struct AccountCompleteArgs { + /// Id of the account + account: String, + /// Id of the quest + quest: u16, +} + +#[derive(Args)] +struct AccountDeleteArgs { + /// Id of the account to delete + id: String, +} + fn print_quest_short(quest: &Quest) { println!("Quest #{}: {}", quest.id, quest.name); } @@ -296,7 +355,133 @@ fn main() { eprintln!("Error: couldn't find quest with id {}.", args.id); } } - } + }, + } + }, + Objects::Account(args) => { + match args { + AccountCommands::List => { + let accounts = config.load_accounts(); + + for account in accounts { + println!("\"{}\": Balance {}", account.id, account.balance); + } + }, + AccountCommands::Create(args) => { + let account = Account { + id: args.id.clone(), + ..Default::default() + }; + + let accounts = config.load_accounts(); + + if let Some(_) = accounts.iter().find(|a| a.id == account.id) { + eprintln!("Error: account {} exists.", account.id); + return; + } + + let accounts_path = config.full_accounts_path(); + + match account.save(accounts_path) { + Ok(_) => { + println!("Successfully created account \"{}\"", account.id); + }, + Err(error) => { + eprintln!("Error while saving account: {error}"); + } + } + }, + AccountCommands::Balance(args) => { + let mut accounts = config.load_accounts(); + + let account = match accounts.iter_mut().find(|a| a.id == args.id) { + Some(acc) => acc, + None => { + eprintln!("Could not find account \"{}\"", args.id); + return; + } + }; + + match args.action { + AccountBalanceActions::Set => { + account.balance = args.value; + }, + AccountBalanceActions::Add => { + account.balance += args.value; + }, + AccountBalanceActions::Remove => { + if args.value > account.balance { + if args.negative_ok { + account.balance = 0u32; + } else { + eprintln!("Error: balance ({}) is less than {}.", account.balance, args.value); + return; + } + } else { + account.balance -= args.value; + } + } + } + + let accounts_path = config.full_accounts_path(); + + match account.save(accounts_path) { + Ok(_) => { + println!("Successfully updated account \"{}\" balance.", account.id); + }, + Err(error) => { + eprintln!("Error while saving account: {error}"); + } + }; + }, + AccountCommands::Complete(args) => { + let mut accounts = config.load_accounts(); + + let account = match accounts.iter_mut().find(|a| a.id == args.account) { + Some(acc) => acc, + None => { + eprintln!("Could not find account \"{}\"", args.account); + return; + } + }; + + let quests = config.load_quests(); + + if let None = quests.iter().find(|q| q.id == args.quest) { + eprintln!("Could not find quest #{}", args.quest); + return; + } + + match account.quests_completed.iter().find(|qid| **qid == args.quest) { + Some(_) => { + println!("Quest #{} is already completed on account \"{}\"", args.quest, args.account); + }, + None => { + account.quests_completed.push(args.quest); + let accounts_path = config.full_accounts_path(); + match account.save(accounts_path) { + Ok(_) => { + println!("Account \"{}\" completed quest #{}.", args.account, args.quest); + }, + Err(error) => { + eprintln!("Error while saving account: {error}"); + } + } + } + } + }, + AccountCommands::Delete(args) => { + let mut accounts_path = config.full_accounts_path(); + accounts_path.push(format!("{}.toml", args.id)); + match Account::delete(accounts_path) { + Ok(_) => { + println!("Successfully deleted account \"{}\".", args.id); + }, + Err(error) => { + eprintln!("Error deleting account: {error}"); + } + } + }, } } } diff --git a/tests/io.rs b/tests/io.rs index 983e4cd..356f972 100644 --- a/tests/io.rs +++ b/tests/io.rs @@ -1,5 +1,4 @@ -use squad_quest::{SquadObject, config::Config, error::Error, quest::Quest}; -use std::path::PathBuf; +use squad_quest::{SquadObject, account::Account, config::Config, quest::Quest}; const CONFIG_PATH: &str = "tests/io/config.toml"; @@ -7,23 +6,35 @@ const CONFIG_PATH: &str = "tests/io/config.toml"; // and Quest::save can override files, // so this test covers full quest CRUD #[test] -fn quest_crud() -> Result<(), Error> { +fn quest_crud() { let config = Config::load(CONFIG_PATH.into()); - let mut quests_path = PathBuf::from(CONFIG_PATH).parent().unwrap().to_owned(); - quests_path.push(config.quests_path); + let mut quests_path = config.full_quests_path(); let quest = Quest::default(); - println!("{:?}", quests_path.clone()); - - quest.save(quests_path.clone())?; + quest.save(quests_path.clone()).unwrap(); let filename = format!("{}.toml", quest.id); quests_path.push(filename); - Quest::delete(quests_path)?; - - Ok(()) + Quest::delete(quests_path).unwrap(); +} + +#[test] +fn account_crud() { + let config = Config::load(CONFIG_PATH.into()); + + let mut accounts_path = config.full_accounts_path(); + + let account = Account::default(); + + account.save(accounts_path.clone()).unwrap(); + + let filename = format!("{}.toml", account.id); + + accounts_path.push(filename); + + Account::delete(accounts_path).unwrap(); } diff --git a/tests/io/accounts/.placeholder b/tests/io/accounts/.placeholder new file mode 100644 index 0000000..16680ce --- /dev/null +++ b/tests/io/accounts/.placeholder @@ -0,0 +1 @@ +Placeholder file for git From b9f75e426c7c37140622541f32b00bb9f43cac3e Mon Sep 17 00:00:00 2001 From: 2ndbeam <2ndbeam@disroot.org> Date: Wed, 3 Dec 2025 17:01:40 +0300 Subject: [PATCH 06/11] feat: Added Map - Implemented Map - Partially implemented CLI interaction with map - Added load_map test --- src/bin/cli.rs | 185 +++++++++++++++++++++++++++++++++++++++----- src/config/mod.rs | 27 ++++++- src/map/mod.rs | 108 +++++++++++++++++++++++++- tests/io/map.toml | 1 + tests/main.rs | 49 +++++++++++- tests/main/map.toml | 26 +++++++ 6 files changed, 371 insertions(+), 25 deletions(-) create mode 100644 tests/io/map.toml create mode 100644 tests/main/map.toml diff --git a/src/bin/cli.rs b/src/bin/cli.rs index 05e2c24..19bbf4e 100644 --- a/src/bin/cli.rs +++ b/src/bin/cli.rs @@ -1,8 +1,8 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use clap::{Parser,Subcommand,Args,ValueEnum}; use serde::Deserialize; -use squad_quest::{SquadObject, account::Account, config::Config, quest::{Quest,QuestDifficulty as LibQuestDifficulty}}; +use squad_quest::{SquadObject, account::Account, config::Config, error::Error, map::{Map, Room}, quest::{Quest,QuestDifficulty as LibQuestDifficulty}}; use toml::value::Date; use chrono::{Datelike, NaiveDate, Utc}; @@ -37,6 +37,9 @@ enum Objects { /// Operations on the accounts #[command(subcommand)] Account(AccountCommands), + /// Operations on the map rooms + #[command(subcommand)] + Map(MapCommands), } #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] @@ -208,6 +211,62 @@ struct AccountDeleteArgs { id: String, } +#[derive(Subcommand)] +enum MapCommands { + /// List all rooms with connections + List, + /// Add new room to map + Add(MapAddArgs), + /// Connect two rooms + Connect(MapConnectArgs), + /// Disconnect two rooms if they're connected + Disconnect(MapConnectArgs), + /// Remove all connections with the room + Delete(MapDeleteArgs), + /// Update room data + Update(MapUpdateArgs), +} + +#[derive(Args)] +struct MapAddArgs { + /// Name of the room + name: String, + /// Price of the room + value: u32, + /// Optional description for the room + #[arg(long,short)] + description: Option, +} + +#[derive(Args)] +struct MapConnectArgs { + /// First room ID + first: u16, + /// Second room ID + second: u16, +} + +#[derive(Args)] +struct MapDeleteArgs { + /// ID of the room to delete + id: u16, +} + +#[derive(Args)] +struct MapUpdateArgs { + /// ID of the room to update + id: u16, + /// Room name + #[arg(short,long)] + name: Option, + /// Room description + #[arg(short,long)] + description: Option, + /// Room price + #[arg(short,long)] + value: Option, +} + fn print_quest_short(quest: &Quest) { println!("Quest #{}: {}", quest.id, quest.name); } @@ -219,7 +278,7 @@ fn print_quest_long(quest: &Quest) { println!("Answer:\n{}", quest.answer); } -fn main() { +fn main() -> Result<(), Error> { let cli = Cli::parse(); let config = Config::load(cli.config.clone()); @@ -241,9 +300,6 @@ fn main() { let mut quests = config.load_quests(); quests.sort_by(|a,b| a.id.cmp(&b.id)); let next_id = match quests.last() { - Some(quest) if quest.id == u16::MAX => { - panic!("Error: quest list contains quest with u16::MAX id."); - }, Some(quest) => quest.id + 1u16, None => 0u16 }; @@ -342,8 +398,7 @@ fn main() { let not_str = if args.reverse {" not "} else {" "}; if quest.public != args.reverse { - println!("Quest #{} is already{}public", quest.id, not_str); - return; + panic!("Quest #{} is already{}public", quest.id, not_str); } quest.public = !args.reverse; @@ -358,8 +413,8 @@ fn main() { }, } }, - Objects::Account(args) => { - match args { + Objects::Account(commands) => { + match commands { AccountCommands::List => { let accounts = config.load_accounts(); @@ -376,8 +431,7 @@ fn main() { let accounts = config.load_accounts(); if let Some(_) = accounts.iter().find(|a| a.id == account.id) { - eprintln!("Error: account {} exists.", account.id); - return; + panic!("Error: account {} exists.", account.id); } let accounts_path = config.full_accounts_path(); @@ -397,8 +451,7 @@ fn main() { let account = match accounts.iter_mut().find(|a| a.id == args.id) { Some(acc) => acc, None => { - eprintln!("Could not find account \"{}\"", args.id); - return; + panic!("Could not find account \"{}\"", args.id); } }; @@ -414,8 +467,7 @@ fn main() { if args.negative_ok { account.balance = 0u32; } else { - eprintln!("Error: balance ({}) is less than {}.", account.balance, args.value); - return; + panic!("Error: balance ({}) is less than {}.", account.balance, args.value); } } else { account.balance -= args.value; @@ -440,16 +492,14 @@ fn main() { let account = match accounts.iter_mut().find(|a| a.id == args.account) { Some(acc) => acc, None => { - eprintln!("Could not find account \"{}\"", args.account); - return; + panic!("Could not find account \"{}\"", args.account); } }; let quests = config.load_quests(); if let None = quests.iter().find(|q| q.id == args.quest) { - eprintln!("Could not find quest #{}", args.quest); - return; + panic!("Could not find quest #{}", args.quest); } match account.quests_completed.iter().find(|qid| **qid == args.quest) { @@ -483,6 +533,101 @@ fn main() { } }, } + }, + Objects::Map(commands) => { + let map_path = config.full_map_path(); + let mut map = Map::load(map_path.clone())?; + map.room.sort_by(|a,b| a.id.cmp(&b.id)); + match commands { + MapCommands::List => { + for room in map.room { + println!("Room #{}: {}; Connections: {:?}", room.id, room.name, room.children); + } + }, + MapCommands::Add(args) => { + let last_id = match map.room.last() { + Some(r) => r.id + 1u16, + None => 0u16 + }; + let room = Room { + id: last_id, + name: args.name.clone(), + description: args.description.clone(), + ..Default::default() + }; + let r_id = room.id; + map.room.push(room); + match map.save(map_path.parent().unwrap_or(Path::new("")).to_owned()) { + Ok(_) => { + println!("Created room #{}.", r_id); + println!("Successfully saved map."); + }, + Err(error) => { + eprintln!("Error while saving map: {error}"); + } + } + }, + MapCommands::Delete(args) => { + let Some(room) = map.room.iter().find(|r| r.id == args.id) else { + panic!("Error: Room #{} not found", args.id); + }; + + let r_id = room.id; + let index = map.room.iter().position(|r| r.eq(room)).unwrap(); + map.room.remove(index); + + for room in map.room.iter_mut().filter(|r| r.children.contains(&r_id)) { + let idx = room.children.iter() + .position(|id| *id == r_id) + .unwrap(); + room.children.remove(idx); + } + + match map.save(map_path.parent().unwrap_or(Path::new("")).to_owned()) { + Ok(_) => { + println!("Removed room #{}.", r_id); + println!("Successfully saved map."); + }, + Err(error) => { + eprintln!("Error while saving map: {error}"); + } + } + }, + MapCommands::Update(args) => { + let Some(room) = map.room.iter_mut().find(|r| r.id == args.id) else { + panic!("Error: Room #{} not found", args.id); + }; + + if let Some(name) = &args.name { + room.name = name.to_string(); + } + + if args.description.is_some() { + room.description = args.description.clone(); + } + + if let Some(value) = args.value { + room.value = value; + } + + match map.save(map_path.parent().unwrap_or(Path::new("")).to_owned()) { + Ok(_) => { + println!("Updated room #{}.", args.id); + println!("Successfully saved map."); + }, + Err(error) => { + eprintln!("Error while saving map: {error}"); + } + } + }, + MapCommands::Connect(_) => { + todo!(); + }, + MapCommands::Disconnect(_) => { + todo!(); + } + } } } + Ok(()) } diff --git a/src/config/mod.rs b/src/config/mod.rs index d5a86da..b47b565 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -171,7 +171,7 @@ impl Config { out_vec } - /// Returns full path to quests folder + /// Returns full path to accounts folder /// This path will be relative to $PWD, not to config. /// /// # Examples @@ -237,4 +237,29 @@ impl Config { out_vec } + + /// Returns full path to map.toml + /// This path will be relative to $PWD, not to config. + /// + /// # Examples + /// ```rust + /// use squad_quest::{config::Config,error::Error,map::Map,SquadObject}; + /// # fn main() { + /// # let _ = wrapper(); + /// # } + /// # fn wrapper() -> Result<(),Error> { + /// + /// let path = "cfg/config.toml".into(); + /// let config = Config::load(path); + /// + /// let map_path = config.full_map_path(); + /// let map = Map::load(map_path)?; + /// # Ok(()) + /// # } + /// ``` + pub fn full_map_path(&self) -> PathBuf { + let mut path = self.path.clone(); + path.push(self.map.clone()); + path + } } diff --git a/src/map/mod.rs b/src/map/mod.rs index d4eb205..17e42ce 100644 --- a/src/map/mod.rs +++ b/src/map/mod.rs @@ -1,5 +1,107 @@ //! Map, a.k.a. a graph of rooms -#![allow(dead_code)] -/// Graph for room nodes -pub struct Map; +use std::{fs, io::Write, path::PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::{SquadObject, error::Error}; + +/// THE Graph. Actually, this is a Vec. +#[derive(Serialize, Deserialize)] +#[serde(default)] +pub struct Map { + /// Rooms go here + pub room: Vec +} + +impl Default for Map { + fn default() -> Self { + Map { room: Vec::new() } + } +} + +impl SquadObject for Map { + fn load(path: PathBuf) -> Result { + match std::fs::read_to_string(path) { + Ok(string) => { + match toml::from_str::(&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 = "map.toml".to_string(); + 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(()) + } + +} + +/// Component of the map +#[derive(Serialize, Deserialize, PartialEq, Debug)] +#[serde(default)] +pub struct Room { + /// Room id + pub id: u16, + /// Rooms that are connected with this + pub children: Vec, + /// Price of the room + pub value: u32, + /// Room name + pub name: String, + /// Room description + pub description: Option, +} + +fn default_name() -> String { + "Hall".to_string() +} + +impl Default for Room { + fn default() -> Self { + Room { + id: u16::default(), + children: Vec::new(), + value: u32::default(), + name: default_name(), + description: None, + } + } +} diff --git a/tests/io/map.toml b/tests/io/map.toml new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/io/map.toml @@ -0,0 +1 @@ + diff --git a/tests/main.rs b/tests/main.rs index b582e5c..b2d054d 100644 --- a/tests/main.rs +++ b/tests/main.rs @@ -1,4 +1,4 @@ -use squad_quest::{account::Account, config::Config, quest::Quest}; +use squad_quest::{SquadObject, account::Account, config::Config, map::{Map, Room}, quest::Quest}; static CONFIG_PATH: &str = "./tests/main/config.toml"; @@ -81,3 +81,50 @@ fn account_test() { assert_eq!(*account, expected); } + +#[test] +fn load_map() { + let config = Config::load(CONFIG_PATH.into()); + + let room0 = Room { + id: 0, + children: vec![1, 2], + value: 0, + name: "Entrance".to_string(), + description: Some("Enter the dungeon".to_string()), + }; + + let room1 = Room { + id: 1, + children: vec![0, 3], + value: 100, + name: "Kitchen hall".to_string(), + description: None, + }; + + let room2 = Room { + id: 2, + children: vec![0], + value: 250, + name: "Room".to_string(), + description: Some("Simple room with no furniture".to_string()), + }; + + let room3 = Room { + id: 3, + children: vec![1], + value: 175, + name: "Kitchen".to_string(), + description: Some("Knives are stored here".to_string()), + }; + + let expected = Map { + room: vec![room0, room1, room2, room3], + }; + + let map_path = config.full_map_path(); + + let map = Map::load(map_path).unwrap(); + + assert_eq!(map.room, expected.room); +} diff --git a/tests/main/map.toml b/tests/main/map.toml new file mode 100644 index 0000000..018fb2e --- /dev/null +++ b/tests/main/map.toml @@ -0,0 +1,26 @@ +[[room]] +id = 0 +children = [ 1, 2 ] +value = 0 +name = "Entrance" +description = "Enter the dungeon" + +[[room]] +id = 1 +children = [ 0, 3 ] +value = 100 +name = "Kitchen hall" + +[[room]] +id = 2 +children = [ 0 ] +value = 250 +name = "Room" +description = "Simple room with no furniture" + +[[room]] +id = 3 +children = [ 1 ] +value = 175 +name = "Kitchen" +description = "Knives are stored here" From 5d6aa0422dd7a1a8ad3602fd30d0e81cdd47196d Mon Sep 17 00:00:00 2001 From: 2ndbeam <2ndbeam@disroot.org> Date: Thu, 4 Dec 2025 12:54:22 +0300 Subject: [PATCH 07/11] feat: Added CLI rooms (dis-)connect functionality --- src/bin/cli.rs | 56 +++++++++++++++++++++++++++++++++++++++++++---- tests/io/map.toml | 2 +- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/src/bin/cli.rs b/src/bin/cli.rs index 19bbf4e..ff2bac7 100644 --- a/src/bin/cli.rs +++ b/src/bin/cli.rs @@ -620,11 +620,59 @@ fn main() -> Result<(), Error> { } } }, - MapCommands::Connect(_) => { - todo!(); + MapCommands::Connect(args) => { + // We iterate twice to make references first->second and second->first + for (first, second) in [(args.first, args.second),(args.second, args.first)] { + let Some(room) = map.room.iter_mut().find(|r| r.id == first) else { + panic!("Error: Room #{} not found", first); + }; + + match room.children.iter().find(|id| **id == second) { + Some(_) => { + println!("Room #{} already has reference to #{}", first, second); + }, + None => { + room.children.push(second); + } + } + } + + match map.save(map_path.parent().unwrap_or(Path::new("")).to_owned()) { + Ok(_) => { + println!("Connected rooms #{} <-> #{}.", args.first, args.second); + println!("Successfully saved map."); + }, + Err(error) => { + eprintln!("Error while saving map: {error}"); + } + } }, - MapCommands::Disconnect(_) => { - todo!(); + MapCommands::Disconnect(args) => { + // We iterate twice to make references first->second and second->first + for (first, second) in [(args.first, args.second),(args.second, args.first)] { + let Some(room) = map.room.iter_mut().find(|r| r.id == first) else { + panic!("Error: Room #{} not found", first); + }; + + match room.children.iter().position(|id| *id == second) { + Some(id) => { + room.children.remove(id as usize); + }, + None => { + println!("Room #{} has no reference to #{}", first, second); + } + } + } + + match map.save(map_path.parent().unwrap_or(Path::new("")).to_owned()) { + Ok(_) => { + println!("Disconnected rooms #{} #{}.", args.first, args.second); + println!("Successfully saved map."); + }, + Err(error) => { + eprintln!("Error while saving map: {error}"); + } + } } } } diff --git a/tests/io/map.toml b/tests/io/map.toml index 8b13789..38dffa5 100644 --- a/tests/io/map.toml +++ b/tests/io/map.toml @@ -1 +1 @@ - +room = [] From 47f55105ddae1d3df2f373e25aabd2b6ba7e84a7 Mon Sep 17 00:00:00 2001 From: 2ndbeam <2ndbeam@disroot.org> Date: Thu, 4 Dec 2025 13:56:53 +0300 Subject: [PATCH 08/11] refactor!: Moved CLI to inner package - Increased version to 0.3.0 - Repository now is a Cargo workspace - Added squad-quest-cli package - Removed CLI-specific dependencies from main crate - Removed bin target from main package --- .gitignore | 1 + Cargo.lock | 12 ++++++++++-- Cargo.toml | 18 +++++++++++++----- cli/Cargo.toml | 13 +++++++++++++ src/bin/cli.rs => cli/src/main.rs | 0 5 files changed, 37 insertions(+), 7 deletions(-) create mode 100644 cli/Cargo.toml rename src/bin/cli.rs => cli/src/main.rs (100%) diff --git a/.gitignore b/.gitignore index ea8c4bf..f20fcf9 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +/cli/target diff --git a/Cargo.lock b/Cargo.lock index 1c88b30..d263382 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -332,12 +332,20 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "squad-quest" -version = "0.2.0" +version = "0.3.0" +dependencies = [ + "serde", + "toml", +] + +[[package]] +name = "squad-quest-cli" +version = "0.3.0" dependencies = [ "chrono", "clap", - "clap_derive", "serde", + "squad-quest", "toml", ] diff --git a/Cargo.toml b/Cargo.toml index f912a91..669dcea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,19 @@ +[workspace] +members = ["cli"] + +[workspace.package] +version = "0.3.0" +edition = "2024" +repository = "https://2ndbeam.ru/git/2ndbeam/squad-quest" +license = "MIT" + [package] name = "squad-quest" -version = "0.2.0" -edition = "2024" +edition.workspace = true +version.workspace = true +repository.workspace = true +license.workspace = true [dependencies] -chrono = "0.4.42" -clap = { version = "4.5.53", features = ["derive"] } -clap_derive = "4.5.49" serde = { version = "1.0.228", features = ["derive"] } toml = "0.9.8" diff --git a/cli/Cargo.toml b/cli/Cargo.toml new file mode 100644 index 0000000..2612f50 --- /dev/null +++ b/cli/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "squad-quest-cli" +version.workspace = true +edition.workspace = true +repository.workspace = true +license.workspace = true + +[dependencies] +chrono = "0.4.42" +clap = { version = "4.5.53", features = ["derive"] } +serde = { version = "1.0.228", features = ["derive"] } +squad-quest = { version = "0.3.0", path = ".." } +toml = "0.9.8" diff --git a/src/bin/cli.rs b/cli/src/main.rs similarity index 100% rename from src/bin/cli.rs rename to cli/src/main.rs From 790fa88fe3b034c7823ce62f71768bfccc2a3491 Mon Sep 17 00:00:00 2001 From: 2ndbeam <2ndbeam@disroot.org> Date: Thu, 4 Dec 2025 17:37:01 +0300 Subject: [PATCH 09/11] refactor(cli)!: Moved CLI stuff to crate::cli - Bump version to 0.4.0 - Added Config::try_load - Added Config.verbose field - Made Config.path public - Added -q/--quiet flag to CLI BREAKING CHANGE: Moved CLI-related objects to squad-quest-cli::cli --- Cargo.lock | 4 +- Cargo.toml | 2 +- cli/Cargo.toml | 2 +- cli/src/cli/account.rs | 57 ++++ cli/src/cli/map.rs | 57 ++++ cli/src/cli/mod.rs | 36 +++ cli/src/cli/quest.rs | 130 +++++++++ cli/src/lib.rs | 1 + cli/src/main.rs | 582 ++++++++++++----------------------------- src/config/mod.rs | 111 ++++++-- 10 files changed, 529 insertions(+), 453 deletions(-) create mode 100644 cli/src/cli/account.rs create mode 100644 cli/src/cli/map.rs create mode 100644 cli/src/cli/mod.rs create mode 100644 cli/src/cli/quest.rs create mode 100644 cli/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index d263382..3d80605 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -332,7 +332,7 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "squad-quest" -version = "0.3.0" +version = "0.4.0" dependencies = [ "serde", "toml", @@ -340,7 +340,7 @@ dependencies = [ [[package]] name = "squad-quest-cli" -version = "0.3.0" +version = "0.4.0" dependencies = [ "chrono", "clap", diff --git a/Cargo.toml b/Cargo.toml index 669dcea..e66a59f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ members = ["cli"] [workspace.package] -version = "0.3.0" +version = "0.4.0" edition = "2024" repository = "https://2ndbeam.ru/git/2ndbeam/squad-quest" license = "MIT" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 2612f50..ca88ec5 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -9,5 +9,5 @@ license.workspace = true chrono = "0.4.42" clap = { version = "4.5.53", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] } -squad-quest = { version = "0.3.0", path = ".." } +squad-quest = { version = "0.4.0", path = ".." } toml = "0.9.8" diff --git a/cli/src/cli/account.rs b/cli/src/cli/account.rs new file mode 100644 index 0000000..c6da1ac --- /dev/null +++ b/cli/src/cli/account.rs @@ -0,0 +1,57 @@ +use clap::{Args,Subcommand,ValueEnum}; + +#[derive(Subcommand)] +pub enum AccountCommands { + /// List accounts + List, + /// Create empty account + Create(AccountCreateArgs), + /// Update balance value + Balance(AccountBalanceArgs), + /// Approve account answer for quest + Complete(AccountCompleteArgs), + /// Delete account + Delete(AccountDeleteArgs), +} + +#[derive(Args)] +pub struct AccountCreateArgs { + /// Account will be created with this id + pub id: String, +} + +#[derive(Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum AccountBalanceActions { + Set, + Add, + Remove, +} + +#[derive(Args)] +pub struct AccountBalanceArgs { + /// Account id + pub id: String, + /// What to do with the balance + #[arg(value_enum)] + pub action: AccountBalanceActions, + /// Amount of doing + pub value: u32, + /// If action is remove, set balance to 0 if the result is negative instead of returning error + #[arg(short,long)] + pub negative_ok: bool, +} + +#[derive(Args)] +pub struct AccountCompleteArgs { + /// Id of the account + pub account: String, + /// Id of the quest + pub quest: u16, +} + +#[derive(Args)] +pub struct AccountDeleteArgs { + /// Id of the account to delete + pub id: String, +} + diff --git a/cli/src/cli/map.rs b/cli/src/cli/map.rs new file mode 100644 index 0000000..c98a158 --- /dev/null +++ b/cli/src/cli/map.rs @@ -0,0 +1,57 @@ +use clap::{Args,Subcommand}; + +#[derive(Subcommand)] +pub enum MapCommands { + /// List all rooms with connections + List, + /// Add new room to map + Add(MapAddArgs), + /// Connect two rooms + Connect(MapConnectArgs), + /// Disconnect two rooms if they're connected + Disconnect(MapConnectArgs), + /// Remove all connections with the room + Delete(MapDeleteArgs), + /// Update room data + Update(MapUpdateArgs), +} + +#[derive(Args)] +pub struct MapAddArgs { + /// Name of the room + pub name: String, + /// Price of the room + pub value: u32, + /// Optional description for the room + #[arg(long,short)] + pub description: Option, +} + +#[derive(Args)] +pub struct MapConnectArgs { + /// First room ID + pub first: u16, + /// Second room ID + pub second: u16, +} + +#[derive(Args)] +pub struct MapDeleteArgs { + /// ID of the room to delete + pub id: u16, +} + +#[derive(Args)] +pub struct MapUpdateArgs { + /// ID of the room to update + pub id: u16, + /// Room name + #[arg(short,long)] + pub name: Option, + /// Room description + #[arg(short,long)] + pub description: Option, + /// Room price + #[arg(short,long)] + pub value: Option, +} diff --git a/cli/src/cli/mod.rs b/cli/src/cli/mod.rs new file mode 100644 index 0000000..e44c361 --- /dev/null +++ b/cli/src/cli/mod.rs @@ -0,0 +1,36 @@ +use std::path::PathBuf; + +use clap::{Parser,Subcommand}; + +pub mod account; +pub mod map; +pub mod quest; + +#[derive(Parser)] +#[command(version, about, long_about = None)] +#[command(propagate_version = true)] +pub struct Cli { + /// Path to config + #[arg(short, long)] + pub config: PathBuf, + /// Object to make operation on + #[command(subcommand)] + pub command: Objects, + /// Suppress most output + #[arg(short, long)] + pub quiet: bool, +} + +#[derive(Subcommand)] +pub enum Objects { + /// Operations on the quests + #[command(subcommand)] + Quest(quest::QuestCommands), + /// Operations on the accounts + #[command(subcommand)] + Account(account::AccountCommands), + /// Operations on the map rooms + #[command(subcommand)] + Map(map::MapCommands), +} + diff --git a/cli/src/cli/quest.rs b/cli/src/cli/quest.rs new file mode 100644 index 0000000..41a7820 --- /dev/null +++ b/cli/src/cli/quest.rs @@ -0,0 +1,130 @@ +use squad_quest::quest::QuestDifficulty as LibQuestDifficulty; +use toml::value::Date; +use serde::Deserialize; +use clap::{Args,Subcommand,ValueEnum}; + +#[derive(Deserialize)] +struct DateWrapper { + date: Date, +} + +fn parse_date(arg: &str) -> Result { + let toml_str = format!("date = {arg}"); + let wrapper: DateWrapper = toml::from_str(&toml_str)?; + Ok(wrapper.date) +} + +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] +pub enum QuestDifficulty { + /// Easy quest + Easy, + /// Normal quest + Normal, + /// Hard quest + Hard, + /// Special case of hard quests. + Secret, +} + +impl From for LibQuestDifficulty { + fn from(value: QuestDifficulty) -> Self { + match value { + QuestDifficulty::Easy => LibQuestDifficulty::Easy, + QuestDifficulty::Normal => LibQuestDifficulty::Normal, + QuestDifficulty::Hard => LibQuestDifficulty::Hard, + QuestDifficulty::Secret => LibQuestDifficulty::Secret, + } + } +} + +#[derive(Subcommand)] +pub enum QuestCommands { + /// List available quests + List(QuestListArgs), + /// Create new quest and automatically assign it id + Create(QuestCreateArgs), + /// Update existing quest + Update(QuestUpdateArgs), + /// Delete quest + Delete(QuestDeleteArgs), + /// Make certain quests public + Daily, + /// Publish quest with specified id + Publish(QuestPublishArgs), +} + + +#[derive(Args)] +pub struct QuestListArgs { + /// Only list id and name of the quest + #[arg(short, long)] + pub short: bool, +} + +#[derive(Args)] +pub struct QuestCreateArgs { + /// Difficulty of the quest #[arg(value_enum)] + pub difficulty: QuestDifficulty, + /// Reward for the quest + pub reward: u32, + /// Name of the quest + pub name: String, + /// Visible description of the quest + pub description: String, + /// Answer for the quest for admins + pub answer: String, + /// Create quest and make it public immediately + #[arg(short,long)] + pub public: bool, + /// Make quest available on date (format = YYYY-MM-DD, ex. 2025-12-24) + #[arg(short,long,value_parser = parse_date)] + pub available: Option, + /// Quest expiration date (format = YYYY-MM-DD, ex. 2025-12-24) + #[arg(short,long,value_parser = parse_date)] + pub deadline: Option, +} + +#[derive(Args)] +pub struct QuestUpdateArgs { + /// Id of the quest to update + pub id: u16, + /// Difficulty of the quest + #[arg(value_enum,long)] + pub difficulty: Option, + /// Reward for the quest + #[arg(long)] + pub reward: Option, + /// Name of the quest + #[arg(long)] + pub name: Option, + /// Visible description of the quest + #[arg(long)] + pub description: Option, + /// Answer for the quest for admins + #[arg(long)] + pub answer: Option, + /// Create quest and make it public immediately + #[arg(long)] + pub public: Option, + /// Make quest available on date (format = YYYY-MM-DD, ex. 2025-12-24) + #[arg(long,value_parser = parse_date)] + pub available: Option, + /// Quest expiration date (format = YYYY-MM-DD, ex. 2025-12-24) + #[arg(long,value_parser = parse_date)] + pub deadline: Option, +} + +#[derive(Args)] +pub struct QuestDeleteArgs { + /// Id of the quest to delete + pub id: u16, +} + +#[derive(Args)] +pub struct QuestPublishArgs { + /// Id of the quest to publish + pub id: u16, + /// Make it non-public instead + #[arg(long,short)] + pub reverse: bool, +} diff --git a/cli/src/lib.rs b/cli/src/lib.rs new file mode 100644 index 0000000..4f77372 --- /dev/null +++ b/cli/src/lib.rs @@ -0,0 +1 @@ +pub mod cli; diff --git a/cli/src/main.rs b/cli/src/main.rs index ff2bac7..cd2e5f6 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,272 +1,11 @@ -use std::path::{Path, PathBuf}; +use std::path::Path; -use clap::{Parser,Subcommand,Args,ValueEnum}; -use serde::Deserialize; -use squad_quest::{SquadObject, account::Account, config::Config, error::Error, map::{Map, Room}, quest::{Quest,QuestDifficulty as LibQuestDifficulty}}; +use clap::Parser; +use squad_quest_cli::cli::{Cli,Objects,account::*,map::*,quest::*}; +use squad_quest::{SquadObject, account::Account, config::Config, map::{Map, Room}, quest::Quest}; use toml::value::Date; use chrono::{Datelike, NaiveDate, Utc}; -#[derive(Deserialize)] -struct DateWrapper { - date: Date, -} - -fn parse_date(arg: &str) -> Result { - let toml_str = format!("date = {arg}"); - let wrapper: DateWrapper = toml::from_str(&toml_str)?; - Ok(wrapper.date) -} - -#[derive(Parser)] -#[command(version, about, long_about = None)] -#[command(propagate_version = true)] -struct Cli { - /// Path to config - #[arg(short, long)] - config: PathBuf, - /// Object to make operation on - #[command(subcommand)] - command: Objects, -} - -#[derive(Subcommand)] -enum Objects { - /// Operations on the quests - #[command(subcommand)] - Quest(QuestCommands), - /// Operations on the accounts - #[command(subcommand)] - Account(AccountCommands), - /// Operations on the map rooms - #[command(subcommand)] - Map(MapCommands), -} - -#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] -enum QuestDifficulty { - /// Easy quest - Easy, - /// Normal quest - Normal, - /// Hard quest - Hard, - /// Special case of hard quests. - Secret, -} - -impl From for LibQuestDifficulty { - fn from(value: QuestDifficulty) -> Self { - match value { - QuestDifficulty::Easy => LibQuestDifficulty::Easy, - QuestDifficulty::Normal => LibQuestDifficulty::Normal, - QuestDifficulty::Hard => LibQuestDifficulty::Hard, - QuestDifficulty::Secret => LibQuestDifficulty::Secret, - } - } -} - -#[derive(Subcommand)] -enum QuestCommands { - /// List available quests - List(QuestListArgs), - /// Create new quest and automatically assign it id - Create(QuestCreateArgs), - /// Update existing quest - Update(QuestUpdateArgs), - /// Delete quest - Delete(QuestDeleteArgs), - /// Make certain quests public - Daily, - /// Publish quest with specified id - Publish(QuestPublishArgs), -} - - -#[derive(Args)] -struct QuestListArgs { - /// Only list id and name of the quest - #[arg(short, long)] - short: bool, -} - -#[derive(Args)] -struct QuestCreateArgs { /// Difficulty of the quest #[arg(value_enum)] - difficulty: QuestDifficulty, - /// Reward for the quest - reward: u32, - /// Name of the quest - name: String, - /// Visible description of the quest - description: String, - /// Answer for the quest for admins - answer: String, - /// Create quest and make it public immediately - #[arg(short,long)] - public: bool, - /// Make quest available on date (format = YYYY-MM-DD, ex. 2025-12-24) - #[arg(short,long,value_parser = parse_date)] - available: Option, - /// Quest expiration date (format = YYYY-MM-DD, ex. 2025-12-24) - #[arg(short,long,value_parser = parse_date)] - deadline: Option, -} - -#[derive(Args)] -struct QuestUpdateArgs { - /// Id of the quest to update - id: u16, - /// Difficulty of the quest - #[arg(value_enum,long)] - difficulty: Option, - /// Reward for the quest - #[arg(long)] - reward: Option, - /// Name of the quest - #[arg(long)] - name: Option, - /// Visible description of the quest - #[arg(long)] - description: Option, - /// Answer for the quest for admins - #[arg(long)] - answer: Option, - /// Create quest and make it public immediately - #[arg(long)] - public: Option, - /// Make quest available on date (format = YYYY-MM-DD, ex. 2025-12-24) - #[arg(long,value_parser = parse_date)] - available: Option, - /// Quest expiration date (format = YYYY-MM-DD, ex. 2025-12-24) - #[arg(long,value_parser = parse_date)] - deadline: Option, -} - -#[derive(Args)] -struct QuestDeleteArgs { - /// Id of the quest to delete - id: u16, -} - -#[derive(Args)] -struct QuestPublishArgs { - /// Id of the quest to publish - id: u16, - /// Make it non-public instead - #[arg(long,short)] - reverse: bool, -} - -#[derive(Subcommand)] -enum AccountCommands { - /// List accounts - List, - /// Create empty account - Create(AccountCreateArgs), - /// Update balance value - Balance(AccountBalanceArgs), - /// Approve account answer for quest - Complete(AccountCompleteArgs), - /// Delete account - Delete(AccountDeleteArgs), -} - -#[derive(Args)] -struct AccountCreateArgs { - /// Account will be created with this id - id: String, -} - -#[derive(Clone, Copy, PartialEq, Eq, ValueEnum)] -enum AccountBalanceActions { - Set, - Add, - Remove, -} - -#[derive(Args)] -struct AccountBalanceArgs { - /// Account id - id: String, - /// What to do with the balance - #[arg(value_enum)] - action: AccountBalanceActions, - /// Amount of doing - value: u32, - /// If action is remove, set balance to 0 if the result is negative instead of returning error - #[arg(short,long)] - negative_ok: bool, -} - -#[derive(Args)] -struct AccountCompleteArgs { - /// Id of the account - account: String, - /// Id of the quest - quest: u16, -} - -#[derive(Args)] -struct AccountDeleteArgs { - /// Id of the account to delete - id: String, -} - -#[derive(Subcommand)] -enum MapCommands { - /// List all rooms with connections - List, - /// Add new room to map - Add(MapAddArgs), - /// Connect two rooms - Connect(MapConnectArgs), - /// Disconnect two rooms if they're connected - Disconnect(MapConnectArgs), - /// Remove all connections with the room - Delete(MapDeleteArgs), - /// Update room data - Update(MapUpdateArgs), -} - -#[derive(Args)] -struct MapAddArgs { - /// Name of the room - name: String, - /// Price of the room - value: u32, - /// Optional description for the room - #[arg(long,short)] - description: Option, -} - -#[derive(Args)] -struct MapConnectArgs { - /// First room ID - first: u16, - /// Second room ID - second: u16, -} - -#[derive(Args)] -struct MapDeleteArgs { - /// ID of the room to delete - id: u16, -} - -#[derive(Args)] -struct MapUpdateArgs { - /// ID of the room to update - id: u16, - /// Room name - #[arg(short,long)] - name: Option, - /// Room description - #[arg(short,long)] - description: Option, - /// Room price - #[arg(short,long)] - value: Option, -} - fn print_quest_short(quest: &Quest) { println!("Quest #{}: {}", quest.id, quest.name); } @@ -278,16 +17,36 @@ fn print_quest_long(quest: &Quest) { println!("Answer:\n{}", quest.answer); } -fn main() -> Result<(), Error> { +fn main() { let cli = Cli::parse(); - let config = Config::load(cli.config.clone()); + let config = match cli.quiet { + false => Config::load(cli.config.clone()), + true => { + match Config::try_load(cli.config.clone()) { + Ok(mut config) => { + config.verbose = false; + config + }, + Err(_) => { + let path = cli.config.clone().parent().unwrap_or(&Path::new(".")).to_owned(); + Config { + verbose: false, + path, + ..Default::default() + } + } + } + }, + }; match &cli.command { Objects::Quest(commands) => { + let mut quests = config.load_quests(); + let mut path = config.full_quests_path(); + match commands { QuestCommands::List(args) => { - let quests = config.load_quests(); for quest in quests { if args.short { print_quest_short(&quest); @@ -297,24 +56,27 @@ fn main() -> Result<(), Error> { } }, QuestCommands::Create(args) => { - let mut quests = config.load_quests(); quests.sort_by(|a,b| a.id.cmp(&b.id)); let next_id = match quests.last() { Some(quest) => quest.id + 1u16, None => 0u16 }; - let path = config.full_quests_path(); - let mut quest_path = path.clone(); - quest_path.push(format!("{next_id}.toml")); - match std::fs::exists(&quest_path) { + path.push(format!("{next_id}.toml")); + match std::fs::exists(&path) { Ok(exists) => { if exists { - panic!("Error: {:?} is not empty.", quest_path); + if !cli.quiet { + eprintln!("Error: {:?} is not empty.", path); + } + return; } }, Err(error) => { - panic!("Error while retrieving {:?}: {}.", quest_path, error); + if !cli.quiet { + eprintln!("Error while retrieving {:?}: {}.", path, error); + } + return; } } @@ -329,16 +91,19 @@ fn main() -> Result<(), Error> { available_on: args.available.clone(), deadline: args.deadline.clone() }; - if let Err(error) = quest.save(path) { - eprintln!("Error while saving quest: {error}."); - } else { - println!("Successfully saved quest #{}.", quest.id); + + match quest.save(path) { + Ok(_) if !cli.quiet => println!("Successfully saved quest #{}", quest.id), + Err(error) if !cli.quiet => eprintln!("Error while saving quest: {error}"), + _ => {}, } }, QuestCommands::Update(args) => { - let quests = config.load_quests(); let Some(quest) = quests.iter().find(|q| q.id == args.id) else { - panic!("Error: Quest #{} not found.", args.id); + if !cli.quiet { + eprintln!("Error: Quest #{} not found.", args.id); + } + return; }; let quest = Quest { id: args.id, @@ -354,22 +119,20 @@ fn main() -> Result<(), Error> { available_on: args.available.clone().or(quest.available_on.clone()), deadline: args.deadline.clone().or(quest.deadline.clone()) }; - let path = config.full_quests_path(); match quest.save(path) { - Ok(_) => println!("Updated quest #{}", quest.id), - Err(error) => eprintln!("Error while updating quest: {error}") + Ok(_) if !cli.quiet => println!("Updated quest #{}", quest.id), + Err(error) if !cli.quiet => eprintln!("Error while updating quest: {error}"), + _ => {}, } }, QuestCommands::Delete(args) => { - let mut path = config.full_quests_path(); - path.push(format!("{}.toml", args.id)); match Quest::delete(path) { - Ok(_) => println!("Successfully deleted quest #{}", args.id), - Err(error) => eprintln!("Error deleting quest #{}: {}", args.id, error), + Ok(_) if !cli.quiet => println!("Successfully deleted quest #{}", args.id), + Err(error) if !cli.quiet => eprintln!("Error deleting quest #{}: {}", args.id, error), + _ => {}, } }, QuestCommands::Daily => { - let mut quests = config.load_quests(); let today: NaiveDate = Utc::now().date_naive(); let toml_today = Date { year: today.year() as u16, @@ -377,46 +140,52 @@ fn main() -> Result<(), Error> { day: today.day() as u8 }; - let path = config.full_quests_path(); - for quest in quests.iter_mut().filter(|q| !q.public && q.available_on.is_some_and(|date| date.le(&toml_today))) { - println!("Quest #{} will be published.", quest.id); quest.public = true; - if let Err(error) = quest.save(path.clone()) { - eprintln!("Error while saving quest: {error}."); + + match quest.save(path.clone()) { + Ok(_) if !cli.quiet => println!("Published quest #{}", quest.id), + Err(error) if !cli.quiet => eprintln!("Error while publishing quest: {error}"), + _ => {}, } } }, QuestCommands::Publish(args) => { - let mut quests = config.load_quests(); let quest = quests.iter_mut().find(|q| q.id == args.id); - let path = config.full_quests_path(); - match quest { Some(quest) => { let not_str = if args.reverse {" not "} else {" "}; if quest.public != args.reverse { - panic!("Quest #{} is already{}public", quest.id, not_str); + if !cli.quiet { + eprintln!("Quest #{} is already{}public", quest.id, not_str); + } + return; } quest.public = !args.reverse; - if let Err(error) = quest.save(path) { - eprintln!("Error while saving quest: {error}."); - }; + + match quest.save(path.clone()) { + Ok(_) if !cli.quiet => println!("Published quest #{}", quest.id), + Err(error) if !cli.quiet => eprintln!("Error while publishing quest: {error}"), + _ => {}, + } }, - None => { + None if !cli.quiet => { eprintln!("Error: couldn't find quest with id {}.", args.id); - } + }, + _ => {}, } }, } }, Objects::Account(commands) => { + let mut accounts = config.load_accounts(); + let mut path = config.full_accounts_path(); + match commands { AccountCommands::List => { - let accounts = config.load_accounts(); for account in accounts { println!("\"{}\": Balance {}", account.id, account.balance); @@ -428,30 +197,27 @@ fn main() -> Result<(), Error> { ..Default::default() }; - let accounts = config.load_accounts(); - if let Some(_) = accounts.iter().find(|a| a.id == account.id) { - panic!("Error: account {} exists.", account.id); + if !cli.quiet { + eprintln!("Error: account {} exists.", account.id); + } + return; } - let accounts_path = config.full_accounts_path(); - - match account.save(accounts_path) { - Ok(_) => { - println!("Successfully created account \"{}\"", account.id); - }, - Err(error) => { - eprintln!("Error while saving account: {error}"); - } + match account.save(path) { + Ok(_) if !cli.quiet => println!("Successfully created account \"{}\"", account.id), + Err(error) if !cli.quiet => eprintln!("Error while saving account: {error}"), + _ => {}, } }, AccountCommands::Balance(args) => { - let mut accounts = config.load_accounts(); - let account = match accounts.iter_mut().find(|a| a.id == args.id) { Some(acc) => acc, None => { - panic!("Could not find account \"{}\"", args.id); + if !cli.quiet { + eprintln!("Could not find account \"{}\"", args.id); + } + return; } }; @@ -467,7 +233,10 @@ fn main() -> Result<(), Error> { if args.negative_ok { account.balance = 0u32; } else { - panic!("Error: balance ({}) is less than {}.", account.balance, args.value); + if !cli.quiet { + eprintln!("Error: balance ({}) is less than {}.", account.balance, args.value); + } + return; } } else { account.balance -= args.value; @@ -475,69 +244,68 @@ fn main() -> Result<(), Error> { } } - let accounts_path = config.full_accounts_path(); - - match account.save(accounts_path) { - Ok(_) => { - println!("Successfully updated account \"{}\" balance.", account.id); - }, - Err(error) => { - eprintln!("Error while saving account: {error}"); - } - }; + match account.save(path) { + Ok(_) if !cli.quiet => println!("Successfully updated account \"{}\" balance", account.id), + Err(error) if !cli.quiet => eprintln!("Error while saving account: {error}"), + _ => {}, + } }, AccountCommands::Complete(args) => { - let mut accounts = config.load_accounts(); - let account = match accounts.iter_mut().find(|a| a.id == args.account) { Some(acc) => acc, None => { - panic!("Could not find account \"{}\"", args.account); + if !cli.quiet { + eprintln!("Could not find account \"{}\"", args.account); + } + return; } }; let quests = config.load_quests(); if let None = quests.iter().find(|q| q.id == args.quest) { - panic!("Could not find quest #{}", args.quest); + if !cli.quiet { + eprintln!("Could not find quest #{}", args.quest); + } + return; } match account.quests_completed.iter().find(|qid| **qid == args.quest) { - Some(_) => { + Some(_) if !cli.quiet => { println!("Quest #{} is already completed on account \"{}\"", args.quest, args.account); }, None => { account.quests_completed.push(args.quest); - let accounts_path = config.full_accounts_path(); - match account.save(accounts_path) { - Ok(_) => { - println!("Account \"{}\" completed quest #{}.", args.account, args.quest); - }, - Err(error) => { - eprintln!("Error while saving account: {error}"); - } + match account.save(path) { + Ok(_) if !cli.quiet => println!("Successfully completed quest #{} on account \"{}\".", args.quest, account.id), + Err(error) if !cli.quiet => eprintln!("Error while saving account: {error}"), + _ => {}, } - } + }, + _ => {}, } }, AccountCommands::Delete(args) => { - let mut accounts_path = config.full_accounts_path(); - accounts_path.push(format!("{}.toml", args.id)); - match Account::delete(accounts_path) { - Ok(_) => { - println!("Successfully deleted account \"{}\".", args.id); - }, - Err(error) => { - eprintln!("Error deleting account: {error}"); - } + path.push(format!("{}.toml", args.id)); + match Account::delete(path) { + Ok(_) if !cli.quiet => println!("Successfully deleted account \"{}\".", args.id), + Err(error) if !cli.quiet => eprintln!("Error while deleting account: {error}"), + _ => {}, } }, } }, Objects::Map(commands) => { let map_path = config.full_map_path(); - let mut map = Map::load(map_path.clone())?; - map.room.sort_by(|a,b| a.id.cmp(&b.id)); + let mut map = match Map::load(map_path.clone()) { + Ok(map) => map, + Err(error) => { + if !cli.quiet { + eprintln!("Error while loading map: {error}"); + } + return; + } + }; match commands { MapCommands::List => { for room in map.room { @@ -545,6 +313,7 @@ fn main() -> Result<(), Error> { } }, MapCommands::Add(args) => { + map.room.sort_by(|a,b| a.id.cmp(&b.id)); let last_id = match map.room.last() { Some(r) => r.id + 1u16, None => 0u16 @@ -558,18 +327,17 @@ fn main() -> Result<(), Error> { let r_id = room.id; map.room.push(room); match map.save(map_path.parent().unwrap_or(Path::new("")).to_owned()) { - Ok(_) => { - println!("Created room #{}.", r_id); - println!("Successfully saved map."); - }, - Err(error) => { - eprintln!("Error while saving map: {error}"); - } + Ok(_) if !cli.quiet => println!("Created room #{}.", r_id), + Err(error) if !cli.quiet => eprintln!("Error while saving map: {error}"), + _ => {}, } }, MapCommands::Delete(args) => { let Some(room) = map.room.iter().find(|r| r.id == args.id) else { - panic!("Error: Room #{} not found", args.id); + if !cli.quiet { + eprintln!("Error: Room #{} not found", args.id); + } + return; }; let r_id = room.id; @@ -584,18 +352,17 @@ fn main() -> Result<(), Error> { } match map.save(map_path.parent().unwrap_or(Path::new("")).to_owned()) { - Ok(_) => { - println!("Removed room #{}.", r_id); - println!("Successfully saved map."); - }, - Err(error) => { - eprintln!("Error while saving map: {error}"); - } + Ok(_) if !cli.quiet => println!("Deleted room #{}.", r_id), + Err(error) if !cli.quiet => eprintln!("Error while saving map: {error}"), + _ => {}, } }, MapCommands::Update(args) => { let Some(room) = map.room.iter_mut().find(|r| r.id == args.id) else { - panic!("Error: Room #{} not found", args.id); + if !cli.quiet { + eprintln!("Error: Room #{} not found", args.id); + } + return; }; if let Some(name) = &args.name { @@ -611,71 +378,44 @@ fn main() -> Result<(), Error> { } match map.save(map_path.parent().unwrap_or(Path::new("")).to_owned()) { - Ok(_) => { - println!("Updated room #{}.", args.id); - println!("Successfully saved map."); - }, - Err(error) => { - eprintln!("Error while saving map: {error}"); - } + Ok(_) if !cli.quiet => println!("Updated room #{}.", args.id), + Err(error) if !cli.quiet => eprintln!("Error while saving map: {error}"), + _ => {}, } }, - MapCommands::Connect(args) => { + MapCommands::Connect(args) | MapCommands::Disconnect(args) => { + let connect = match commands { + MapCommands::Connect(_) => true, + _ => false, + }; + // We iterate twice to make references first->second and second->first for (first, second) in [(args.first, args.second),(args.second, args.first)] { let Some(room) = map.room.iter_mut().find(|r| r.id == first) else { - panic!("Error: Room #{} not found", first); - }; - - match room.children.iter().find(|id| **id == second) { - Some(_) => { - println!("Room #{} already has reference to #{}", first, second); - }, - None => { - room.children.push(second); + if !cli.quiet { + eprintln!("Error: Room #{} not found", first); } - } - } - - match map.save(map_path.parent().unwrap_or(Path::new("")).to_owned()) { - Ok(_) => { - println!("Connected rooms #{} <-> #{}.", args.first, args.second); - println!("Successfully saved map."); - }, - Err(error) => { - eprintln!("Error while saving map: {error}"); - } - } - }, - MapCommands::Disconnect(args) => { - // We iterate twice to make references first->second and second->first - for (first, second) in [(args.first, args.second),(args.second, args.first)] { - let Some(room) = map.room.iter_mut().find(|r| r.id == first) else { - panic!("Error: Room #{} not found", first); + return; }; match room.children.iter().position(|id| *id == second) { - Some(id) => { - room.children.remove(id as usize); - }, - None => { - println!("Room #{} has no reference to #{}", first, second); - } + Some(_) if connect && !cli.quiet => println!("Room #{} already has reference to #{}", first, second), + None if connect => room.children.push(second), + Some(id) if !connect => {room.children.remove(id as usize);}, + None if !connect && !cli.quiet => println!("Room #{} has no reference to #{}", first, second), + _ => {}, } } + let connected = if connect { "Connected" } else { "Disconnected" }; + match map.save(map_path.parent().unwrap_or(Path::new("")).to_owned()) { - Ok(_) => { - println!("Disconnected rooms #{} #{}.", args.first, args.second); - println!("Successfully saved map."); - }, - Err(error) => { - eprintln!("Error while saving map: {error}"); - } + Ok(_) if !cli.quiet => println!("{connected} rooms #{} <-> #{}.", args.first, args.second), + Err(error) if !cli.quiet => eprintln!("Error while saving map: {error}"), + _ => {}, } - } + }, } } } - Ok(()) } diff --git a/src/config/mod.rs b/src/config/mod.rs index b47b565..c805002 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -11,7 +11,7 @@ use crate::{SquadObject, account::Account, error::Error, quest::Quest}; pub struct Config { /// Path to config directory #[serde(skip)] - path: PathBuf, + pub path: PathBuf, /// Path to serialized [quests][`crate::quest::Quest`] folder pub quests_path: PathBuf, @@ -20,7 +20,10 @@ pub struct Config { pub accounts_path: PathBuf, /// Path to serialized [map][`crate::map::Map`] file - pub map: PathBuf + pub map: PathBuf, + + /// If true, print to std{out/err} + pub verbose: bool, } impl Default for Config { @@ -29,7 +32,8 @@ impl Default for Config { path: ".".into(), quests_path: "quests".into(), accounts_path: "accounts".into(), - map: "map.toml".into() + map: "map.toml".into(), + verbose: true, } } } @@ -67,8 +71,12 @@ fn handle_account_entry(account_entry: DirEntry) -> Result{ } impl Config { - /// Deserialize config from TOML - /// Logs all errors and returns default config if that happens + /// Deserialize config from TOML. + /// + /// This function wraps [try_load][Config::try_load]. + /// + /// Logs all errors if `config.verbose == true`. + /// Returns default config on error. /// /// # Examples /// ```rust @@ -81,25 +89,62 @@ impl Config { let dir = path.parent() .unwrap_or(Path::new(".")) .to_owned(); + + match Self::try_load(path) { + Ok(conf) => { + if conf.verbose { + println!("Successfully loaded config"); + } + conf + }, + Err(error) => { + let conf = Config { + path: dir, + ..Default::default() + }; + + if conf.verbose { + println!("Error while loading config: {error}"); + } + + conf + } + } + } + + /// Deserialize config from TOML + /// + /// # Examples + /// ```rust + /// use squad_quest::{config::Config,error::Error}; + /// # fn main() { + /// # let _ = wrapper(); + /// # } + /// # fn wrapper() -> Result<(), Error> { + /// let path = "cfg/config.toml".into(); + /// let config = Config::try_load(path)?; + /// # Ok(()) + /// # } + /// ``` + pub fn try_load(path: PathBuf) -> Result { + let dir = path.parent() + .unwrap_or(Path::new(".")) + .to_owned(); + match fs::read_to_string(path) { Ok(string) => { match toml::from_str::(&string) { Ok(mut conf) => { - println!("Successfully loaded config"); conf.path = dir; - conf + Ok(conf) }, Err(error) => { - eprintln!("Error on parsing config: {error}"); - let mut cfg = Config::default(); - cfg.path = dir; - cfg + Err(Error::TomlDeserializeError(error)) } } }, Err(error) => { - eprintln!("Error on reading config path: {error}"); - Config::default() + Err(Error::IoError(error)) } } } @@ -150,23 +195,28 @@ impl Config { Ok(quest_entry) => { match handle_quest_entry(quest_entry) { Ok(quest) => out_vec.push(quest), - Err(error) => { + Err(error) if self.verbose => { eprintln!("Error on loading single quest: {error}"); - } + }, + _ => {}, } }, - Err(error) => { + Err(error) if self.verbose => { eprintln!("Error on loading single quest: {error}"); - } + }, + _ => {}, } } }, - Err(error) => { + Err(error) if self.verbose => { eprintln!("Error on loading quests: {error}"); - } + }, + _ => {}, + } + + if self.verbose { + println!("Loaded {} quests successfully", out_vec.len()); } - - println!("Loaded {} quests successfully", out_vec.len()); out_vec } @@ -217,23 +267,28 @@ impl Config { Ok(acc_entry) => { match handle_account_entry(acc_entry) { Ok(quest) => out_vec.push(quest), - Err(error) => { + Err(error) if self.verbose => { eprintln!("Error on loading single account: {error}"); - } + }, + _ => {}, } }, - Err(error) => { + Err(error) if self.verbose => { eprintln!("Error on loading single account: {error}"); - } + }, + _ => {}, } } }, - Err(error) => { + Err(error) if self.verbose => { eprintln!("Error on loading accounts: {error}"); - } + }, + _ => {}, } - println!("Loaded {} accounts successfully", out_vec.len()); + if self.verbose { + println!("Loaded {} accounts successfully", out_vec.len()); + } out_vec } From f88e010b4f0bf9758f802bf852eb8d4148026e1f Mon Sep 17 00:00:00 2001 From: 2ndbeam <2ndbeam@disroot.org> Date: Fri, 5 Dec 2025 15:38:14 +0300 Subject: [PATCH 10/11] feat: Added interactions with accounts/quests/map - Bump version to 0.5.0 - Added Quest::complete_for_account public function - Added Map::unlock_room_for_account public function - cli: Added "account unlock" command - cli: "account complete" now uses Quest::complete_for_account - cli: refactored logging --- Cargo.lock | 4 +- Cargo.toml | 2 +- cli/Cargo.toml | 2 +- cli/src/cli/account.rs | 9 ++ cli/src/cli/quest.rs | 3 +- cli/src/main.rs | 242 +++++++++++++++++++---------------------- src/error.rs | 38 +++++++ src/map/mod.rs | 38 ++++++- src/quest/mod.rs | 31 +++++- 9 files changed, 231 insertions(+), 138 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3d80605..a87f7bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -332,7 +332,7 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "squad-quest" -version = "0.4.0" +version = "0.5.0" dependencies = [ "serde", "toml", @@ -340,7 +340,7 @@ dependencies = [ [[package]] name = "squad-quest-cli" -version = "0.4.0" +version = "0.5.0" dependencies = [ "chrono", "clap", diff --git a/Cargo.toml b/Cargo.toml index e66a59f..d4e8eca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ members = ["cli"] [workspace.package] -version = "0.4.0" +version = "0.5.0" edition = "2024" repository = "https://2ndbeam.ru/git/2ndbeam/squad-quest" license = "MIT" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index ca88ec5..56fed8f 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -9,5 +9,5 @@ license.workspace = true chrono = "0.4.42" clap = { version = "4.5.53", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] } -squad-quest = { version = "0.4.0", path = ".." } +squad-quest = { version = "0.5.0", path = ".." } toml = "0.9.8" diff --git a/cli/src/cli/account.rs b/cli/src/cli/account.rs index c6da1ac..bde7393 100644 --- a/cli/src/cli/account.rs +++ b/cli/src/cli/account.rs @@ -12,6 +12,8 @@ pub enum AccountCommands { Complete(AccountCompleteArgs), /// Delete account Delete(AccountDeleteArgs), + /// Unlock room for account if it has enough balance + Unlock(AccountUnlockArgs), } #[derive(Args)] @@ -55,3 +57,10 @@ pub struct AccountDeleteArgs { pub id: String, } +#[derive(Args)] +pub struct AccountUnlockArgs { + /// Id of the account + pub account: String, + /// Id of the room to unlock + pub room: u16, +} diff --git a/cli/src/cli/quest.rs b/cli/src/cli/quest.rs index 41a7820..1ed541c 100644 --- a/cli/src/cli/quest.rs +++ b/cli/src/cli/quest.rs @@ -63,7 +63,8 @@ pub struct QuestListArgs { #[derive(Args)] pub struct QuestCreateArgs { - /// Difficulty of the quest #[arg(value_enum)] + /// Difficulty of the quest + #[arg(value_enum)] pub difficulty: QuestDifficulty, /// Reward for the quest pub reward: u32, diff --git a/cli/src/main.rs b/cli/src/main.rs index cd2e5f6..31b5b41 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,8 +1,8 @@ -use std::path::Path; +use std::path::{Path, PathBuf}; use clap::Parser; use squad_quest_cli::cli::{Cli,Objects,account::*,map::*,quest::*}; -use squad_quest::{SquadObject, account::Account, config::Config, map::{Map, Room}, quest::Quest}; +use squad_quest::{SquadObject, account::Account, config::Config, error::Error, map::{Map, Room}, quest::Quest}; use toml::value::Date; use chrono::{Datelike, NaiveDate, Utc}; @@ -17,6 +17,14 @@ fn print_quest_long(quest: &Quest) { println!("Answer:\n{}", quest.answer); } +fn do_and_log(result: Result<(),Error>, log: bool, ok_text: String) { + match result { + Ok(_) if log => println!("{ok_text}"), + Err(error) if log => eprintln!("Error: {error}"), + _ => {}, + } +} + fn main() { let cli = Cli::parse(); @@ -61,20 +69,19 @@ fn main() { Some(quest) => quest.id + 1u16, None => 0u16 }; - - path.push(format!("{next_id}.toml")); - match std::fs::exists(&path) { + + let mut check_path = path.clone(); + check_path.push(format!("{next_id}.toml")); + match std::fs::exists(&check_path) { Ok(exists) => { if exists { - if !cli.quiet { - eprintln!("Error: {:?} is not empty.", path); - } + if !cli.quiet { eprintln!("Error: {:?} is not empty.", path); } return; } }, Err(error) => { if !cli.quiet { - eprintln!("Error while retrieving {:?}: {}.", path, error); + eprintln!("Error: {error}"); } return; } @@ -92,17 +99,11 @@ fn main() { deadline: args.deadline.clone() }; - match quest.save(path) { - Ok(_) if !cli.quiet => println!("Successfully saved quest #{}", quest.id), - Err(error) if !cli.quiet => eprintln!("Error while saving quest: {error}"), - _ => {}, - } + do_and_log(quest.save(path), !cli.quiet, format!("Created quest #{}.", quest.id)); }, QuestCommands::Update(args) => { let Some(quest) = quests.iter().find(|q| q.id == args.id) else { - if !cli.quiet { - eprintln!("Error: Quest #{} not found.", args.id); - } + if !cli.quiet { eprintln!("Error: Quest #{} not found.", args.id); } return; }; let quest = Quest { @@ -119,17 +120,28 @@ fn main() { available_on: args.available.clone().or(quest.available_on.clone()), deadline: args.deadline.clone().or(quest.deadline.clone()) }; - match quest.save(path) { - Ok(_) if !cli.quiet => println!("Updated quest #{}", quest.id), - Err(error) if !cli.quiet => eprintln!("Error while updating quest: {error}"), - _ => {}, - } + + do_and_log(quest.save(path), !cli.quiet, format!("Updated quest #{}.", quest.id)); }, QuestCommands::Delete(args) => { + path.push(format!("{}.toml", args.id)); match Quest::delete(path) { - Ok(_) if !cli.quiet => println!("Successfully deleted quest #{}", args.id), - Err(error) if !cli.quiet => eprintln!("Error deleting quest #{}: {}", args.id, error), - _ => {}, + Ok(_) => { + if !cli.quiet { println!("Deleted quest #{}.", args.id); } + + let mut accounts = config.load_accounts(); + let accounts_path = config.full_accounts_path(); + for account in accounts.iter_mut() { + if let Some(index) = account.quests_completed.iter().position(|qid| *qid == args.id) { + account.quests_completed.remove(index); + do_and_log(account.save(accounts_path.clone()), !cli.quiet, format!("Removed quest #{} from account \"{}\" completed quests", args.id, account.id)); + } + } + }, + Err(error) if !cli.quiet => { + eprintln!("Error: {error}"); + }, + _ => {}, } }, QuestCommands::Daily => { @@ -142,12 +154,7 @@ fn main() { for quest in quests.iter_mut().filter(|q| !q.public && q.available_on.is_some_and(|date| date.le(&toml_today))) { quest.public = true; - - match quest.save(path.clone()) { - Ok(_) if !cli.quiet => println!("Published quest #{}", quest.id), - Err(error) if !cli.quiet => eprintln!("Error while publishing quest: {error}"), - _ => {}, - } + do_and_log(quest.save(path.clone()), !cli.quiet, format!("Published quest #{}.", quest.id)); } }, QuestCommands::Publish(args) => { @@ -158,23 +165,14 @@ fn main() { let not_str = if args.reverse {" not "} else {" "}; if quest.public != args.reverse { - if !cli.quiet { - eprintln!("Quest #{} is already{}public", quest.id, not_str); - } + if !cli.quiet { eprintln!("Error: quest #{} is already{}public.", quest.id, not_str); } return; } quest.public = !args.reverse; - - match quest.save(path.clone()) { - Ok(_) if !cli.quiet => println!("Published quest #{}", quest.id), - Err(error) if !cli.quiet => eprintln!("Error while publishing quest: {error}"), - _ => {}, - } - }, - None if !cli.quiet => { - eprintln!("Error: couldn't find quest with id {}.", args.id); + do_and_log(quest.save(path), !cli.quiet, format!("Published quest #{}.", quest.id)); }, + None if !cli.quiet => eprintln!("Error: quest #{} not found.", args.id), _ => {}, } }, @@ -186,7 +184,6 @@ fn main() { match commands { AccountCommands::List => { - for account in accounts { println!("\"{}\": Balance {}", account.id, account.balance); } @@ -198,27 +195,16 @@ fn main() { }; if let Some(_) = accounts.iter().find(|a| a.id == account.id) { - if !cli.quiet { - eprintln!("Error: account {} exists.", account.id); - } + if !cli.quiet { eprintln!("Error: account \"{}\" exists.", account.id); } return; } - match account.save(path) { - Ok(_) if !cli.quiet => println!("Successfully created account \"{}\"", account.id), - Err(error) if !cli.quiet => eprintln!("Error while saving account: {error}"), - _ => {}, - } + do_and_log(account.save(path), !cli.quiet, format!("Created account \"{}\".", account.id)); }, AccountCommands::Balance(args) => { - let account = match accounts.iter_mut().find(|a| a.id == args.id) { - Some(acc) => acc, - None => { - if !cli.quiet { - eprintln!("Could not find account \"{}\"", args.id); - } - return; - } + let Some(account) = accounts.iter_mut().find(|a| a.id == args.id) else { + if !cli.quiet { eprintln!("Error: account \"{}\" not found.", args.id); } + return; }; match args.action { @@ -233,9 +219,7 @@ fn main() { if args.negative_ok { account.balance = 0u32; } else { - if !cli.quiet { - eprintln!("Error: balance ({}) is less than {}.", account.balance, args.value); - } + if !cli.quiet { eprintln!("Error: account \"{}\" balance is less than {}.", account.id, args.value); } return; } } else { @@ -244,54 +228,54 @@ fn main() { } } - match account.save(path) { - Ok(_) if !cli.quiet => println!("Successfully updated account \"{}\" balance", account.id), - Err(error) if !cli.quiet => eprintln!("Error while saving account: {error}"), - _ => {}, - } + do_and_log(account.save(path), !cli.quiet, format!("Updated balance of account \"{}\".", account.id)); }, AccountCommands::Complete(args) => { - let account = match accounts.iter_mut().find(|a| a.id == args.account) { - Some(acc) => acc, - None => { - if !cli.quiet { - eprintln!("Could not find account \"{}\"", args.account); - } - return; - } + let Some(account) = accounts.iter_mut().find(|a| a.id == args.account) else { + if !cli.quiet { eprintln!("Error: account \"{}\" not found.", args.account); } + return; }; let quests = config.load_quests(); - - if let None = quests.iter().find(|q| q.id == args.quest) { - if !cli.quiet { - eprintln!("Could not find quest #{}", args.quest); - } - return; - } - - match account.quests_completed.iter().find(|qid| **qid == args.quest) { - Some(_) if !cli.quiet => { - println!("Quest #{} is already completed on account \"{}\"", args.quest, args.account); - }, + + let quest = match quests.iter().find(|q| q.id == args.quest) { + Some(quest) => quest, None => { - account.quests_completed.push(args.quest); - match account.save(path) { - Ok(_) if !cli.quiet => println!("Successfully completed quest #{} on account \"{}\".", args.quest, account.id), - Err(error) if !cli.quiet => eprintln!("Error while saving account: {error}"), - _ => {}, - } + if !cli.quiet { eprintln!("Error: quest #{} not found.", args.quest); } + return; }, + }; + + match quest.complete_for_account(account) { + Err(error) if !cli.quiet => println!("Error: {error}"), + Ok(_) => do_and_log(account.save(path), !cli.quiet, format!("Completed quest #{} on account \"{}\".", args.quest, account.id)), _ => {}, } }, AccountCommands::Delete(args) => { path.push(format!("{}.toml", args.id)); - match Account::delete(path) { - Ok(_) if !cli.quiet => println!("Successfully deleted account \"{}\".", args.id), - Err(error) if !cli.quiet => eprintln!("Error while deleting account: {error}"), - _ => {}, + do_and_log(Account::delete(path), !cli.quiet, format!("Deleted account \"{}\".", args.id)) + }, + AccountCommands::Unlock(args) => { + let Some(account) = accounts.iter_mut().find(|a| a.id == args.account) else { + if !cli.quiet { eprintln!("Error: account \"{}\" not found.", args.account) }; + return; + }; + + let map = match Map::load(config.full_map_path()) { + Ok(map) => map, + Err(error) => { + if !cli.quiet { eprintln!("Error: {error}"); } + return; + } + }; + + if let Err(error) = map.unlock_room_for_account(args.room, account) { + eprintln!("Error: {error}"); + return; } + + do_and_log(account.save(path), !cli.quiet, format!("Unlocked room #{} for account \"{}\"", args.room, args.account)); }, } }, @@ -300,12 +284,12 @@ fn main() { let mut map = match Map::load(map_path.clone()) { Ok(map) => map, Err(error) => { - if !cli.quiet { - eprintln!("Error while loading map: {error}"); - } + if !cli.quiet { eprintln!("Error: {error}"); } return; } }; + + let map_save = |map: Map, map_path: PathBuf| { map.save(map_path.parent().unwrap_or(Path::new("")).to_owned()) }; match commands { MapCommands::List => { for room in map.room { @@ -321,22 +305,17 @@ fn main() { let room = Room { id: last_id, name: args.name.clone(), + value: args.value, description: args.description.clone(), ..Default::default() }; let r_id = room.id; map.room.push(room); - match map.save(map_path.parent().unwrap_or(Path::new("")).to_owned()) { - Ok(_) if !cli.quiet => println!("Created room #{}.", r_id), - Err(error) if !cli.quiet => eprintln!("Error while saving map: {error}"), - _ => {}, - } + do_and_log(map_save(map, map_path), !cli.quiet, format!("Created room #{r_id}.")) }, MapCommands::Delete(args) => { let Some(room) = map.room.iter().find(|r| r.id == args.id) else { - if !cli.quiet { - eprintln!("Error: Room #{} not found", args.id); - } + if !cli.quiet { eprintln!("Error: room #{} not found.", args.id); } return; }; @@ -351,17 +330,29 @@ fn main() { room.children.remove(idx); } - match map.save(map_path.parent().unwrap_or(Path::new("")).to_owned()) { - Ok(_) if !cli.quiet => println!("Deleted room #{}.", r_id), - Err(error) if !cli.quiet => eprintln!("Error while saving map: {error}"), + match map_save(map, map_path) { + Ok(_) => { + if !cli.quiet { println!("Deleted room #{r_id}."); } + + let mut accounts = config.load_accounts(); + let accounts_path = config.full_accounts_path(); + + for account in accounts.iter_mut() { + if let Some(index) = account.rooms_unlocked.iter().position(|rid| *rid == r_id) { + account.rooms_unlocked.remove(index); + do_and_log(account.save(accounts_path.clone()), !cli.quiet, format!("Removed room #{r_id} from account \"{}\" unlocked rooms.", account.id)); + } + } + }, + Err(error) if !cli.quiet => { + eprintln!("Error: {error}"); + }, _ => {}, } }, MapCommands::Update(args) => { let Some(room) = map.room.iter_mut().find(|r| r.id == args.id) else { - if !cli.quiet { - eprintln!("Error: Room #{} not found", args.id); - } + if !cli.quiet { eprintln!("Error: room #{} not found", args.id); } return; }; @@ -377,11 +368,7 @@ fn main() { room.value = value; } - match map.save(map_path.parent().unwrap_or(Path::new("")).to_owned()) { - Ok(_) if !cli.quiet => println!("Updated room #{}.", args.id), - Err(error) if !cli.quiet => eprintln!("Error while saving map: {error}"), - _ => {}, - } + do_and_log(map_save(map, map_path), !cli.quiet, format!("Updated room #{}.", args.id)) }, MapCommands::Connect(args) | MapCommands::Disconnect(args) => { let connect = match commands { @@ -392,28 +379,21 @@ fn main() { // We iterate twice to make references first->second and second->first for (first, second) in [(args.first, args.second),(args.second, args.first)] { let Some(room) = map.room.iter_mut().find(|r| r.id == first) else { - if !cli.quiet { - eprintln!("Error: Room #{} not found", first); - } + if !cli.quiet { eprintln!("Error: room #{} not found.", first); } return; }; match room.children.iter().position(|id| *id == second) { - Some(_) if connect && !cli.quiet => println!("Room #{} already has reference to #{}", first, second), + Some(_) if connect && !cli.quiet => println!("Room #{} already has reference to #{}.", first, second), None if connect => room.children.push(second), Some(id) if !connect => {room.children.remove(id as usize);}, - None if !connect && !cli.quiet => println!("Room #{} has no reference to #{}", first, second), + None if !connect && !cli.quiet => println!("Room #{} has no reference to #{}.", first, second), _ => {}, } } let connected = if connect { "Connected" } else { "Disconnected" }; - - match map.save(map_path.parent().unwrap_or(Path::new("")).to_owned()) { - Ok(_) if !cli.quiet => println!("{connected} rooms #{} <-> #{}.", args.first, args.second), - Err(error) if !cli.quiet => eprintln!("Error while saving map: {error}"), - _ => {}, - } + do_and_log(map_save(map, map_path), !cli.quiet, format!("{connected} rooms #{} <-> #{}.", args.first, args.second)); }, } } diff --git a/src/error.rs b/src/error.rs index 501d6c4..0804b58 100644 --- a/src/error.rs +++ b/src/error.rs @@ -26,3 +26,41 @@ impl fmt::Display for Error { } } } + +/// Error related to quest logic +#[derive(Debug)] +#[non_exhaustive] +pub enum QuestError { + /// Quest (self.0) is already completed for given account (self.1) + AlreadyCompleted(u16, String), +} + +impl fmt::Display for QuestError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::AlreadyCompleted(quest_id, account_id) => write!(f, "quest #{quest_id} is already completed for account \"{account_id}\""), + } + } +} + +/// Error related to map logic +#[derive(Debug)] +#[non_exhaustive] +pub enum MapError { + /// Room not found in map file + RoomNotFound(u16), + /// Room (self.0) is already unlocked on account (self.1) + RoomAlreadyUnlocked(u16, String), + /// Account (self.1) does not have much money (self.0) + InsufficientFunds(u16, String), +} + +impl fmt::Display for MapError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::RoomNotFound(id) => write!(f, "could not find room #{id}"), + Self::RoomAlreadyUnlocked(room_id, account_id) => write!(f, "room #{room_id} is already unlocked on account \"{account_id}\""), + Self::InsufficientFunds(room_id, account_id) => write!(f, "account \"{account_id}\" does not have enough money to unlock room #{room_id}"), + } + } +} diff --git a/src/map/mod.rs b/src/map/mod.rs index 17e42ce..69ccb68 100644 --- a/src/map/mod.rs +++ b/src/map/mod.rs @@ -4,7 +4,7 @@ use std::{fs, io::Write, path::PathBuf}; use serde::{Deserialize, Serialize}; -use crate::{SquadObject, error::Error}; +use crate::{SquadObject, account::Account, error::{Error, MapError}}; /// THE Graph. Actually, this is a Vec. #[derive(Serialize, Deserialize)] @@ -71,7 +71,43 @@ impl SquadObject for Map { Ok(()) } +} +impl Map { + /// Try to unlock room for account, or return [MapError] + /// + /// # Examples + /// ```rust + /// use squad_quest::{account::Account,map::{Map,Room},error::MapError}; + /// + /// let map = Map { + /// room: vec![Room { id: 0, value: 100, ..Default::default() }], + /// }; + /// + /// let mut account = Account { balance: 100, ..Default::default() }; + /// + /// if let Err(error) = map.unlock_room_for_account(0, &mut account) { + /// // handle error + /// } + /// ``` + pub fn unlock_room_for_account(&self, room_id: u16, account: &mut Account) -> Result<(), MapError> { + let Some(room) = self.room.iter().find(|r| r.id == room_id) else { + return Err(MapError::RoomNotFound(room_id)); + }; + + if let Some(_) = account.rooms_unlocked.iter().find(|rid| **rid == room_id) { + return Err(MapError::RoomAlreadyUnlocked(room_id, account.id.clone())); + } + + if account.balance < room.value { + return Err(MapError::InsufficientFunds(room_id, account.id.clone())); + } + + account.balance -= room.value; + account.rooms_unlocked.push(room_id); + + Ok(()) + } } /// Component of the map diff --git a/src/quest/mod.rs b/src/quest/mod.rs index b8d7886..669d061 100644 --- a/src/quest/mod.rs +++ b/src/quest/mod.rs @@ -3,7 +3,7 @@ use std::{fs, io::Write, path::PathBuf}; use serde::{ Serialize, Deserialize }; -use crate::{SquadObject, error::Error}; +use crate::{SquadObject, account::Account, error::{Error, QuestError}}; use toml::value::Date; /// Difficulty of the quest @@ -137,3 +137,32 @@ impl SquadObject for Quest { Ok(()) } } + +impl Quest { + /// Complete quest for account and add reward to it's balance. + /// Does nothing and returns [QuestError::AlreadyCompleted] + /// if it is already completed. + /// + /// # Examples + /// + /// ```rust + /// use squad_quest::{account::Account,quest::Quest}; + /// + /// let quest = Quest::default(); + /// let mut account = Account::default(); + /// + /// if let Err(error) = quest.complete_for_account(&mut account) { + /// // handle error + /// } + /// ``` + pub fn complete_for_account(&self, account: &mut Account) -> Result<(),QuestError> { + match account.quests_completed.iter().find(|qid| **qid == self.id) { + Some(_) => Err(QuestError::AlreadyCompleted(self.id, account.id.clone())), + None => { + account.quests_completed.push(self.id); + account.balance += self.reward; + Ok(()) + }, + } + } +} From 2960b6dfc4ff5647f012869583326c2820bc864a Mon Sep 17 00:00:00 2001 From: 2ndbeam <2ndbeam@disroot.org> Date: Fri, 5 Dec 2025 17:16:40 +0300 Subject: [PATCH 11/11] feat: Implemented file hierarchy initialization - Bump version to 0.5.1 - Added Config::save method - cli: Added init command --- Cargo.toml | 2 +- cfg/config.toml | 14 +++------- cli/src/cli/mod.rs | 9 +++++- cli/src/main.rs | 69 +++++++++++++++++++++++++++++++++++++++------- src/config/mod.rs | 42 ++++++++++++++++++++++++++-- 5 files changed, 111 insertions(+), 25 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index d4e8eca..ec4ada1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ members = ["cli"] [workspace.package] -version = "0.5.0" +version = "0.5.1" edition = "2024" repository = "https://2ndbeam.ru/git/2ndbeam/squad-quest" license = "MIT" diff --git a/cfg/config.toml b/cfg/config.toml index 1d81f41..0a9e166 100644 --- a/cfg/config.toml +++ b/cfg/config.toml @@ -1,10 +1,4 @@ -# Default config - -# Path to quests folder relative to config -quests_path = "./quests" - -# Path to accounts folder relative to config -accounts_path = "./accounts" - -# Path to map .toml file relative to config -map = "./map.toml" +quests_path = "quests" +accounts_path = "accounts" +map = "map.toml" +verbose = true diff --git a/cli/src/cli/mod.rs b/cli/src/cli/mod.rs index e44c361..db78d60 100644 --- a/cli/src/cli/mod.rs +++ b/cli/src/cli/mod.rs @@ -1,6 +1,6 @@ use std::path::PathBuf; -use clap::{Parser,Subcommand}; +use clap::{Args,Parser,Subcommand}; pub mod account; pub mod map; @@ -23,6 +23,8 @@ pub struct Cli { #[derive(Subcommand)] pub enum Objects { + /// Initialize new SquadQuest in current working directory + Init(InitArgs), /// Operations on the quests #[command(subcommand)] Quest(quest::QuestCommands), @@ -34,3 +36,8 @@ pub enum Objects { Map(map::MapCommands), } +#[derive(Args)] +pub struct InitArgs { + #[arg(long,short)] + pub path: Option, +} diff --git a/cli/src/main.rs b/cli/src/main.rs index 31b5b41..d120a9d 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,4 +1,4 @@ -use std::path::{Path, PathBuf}; +use std::{fs::DirBuilder, path::{Path, PathBuf}}; use clap::Parser; use squad_quest_cli::cli::{Cli,Objects,account::*,map::*,quest::*}; @@ -25,19 +25,17 @@ fn do_and_log(result: Result<(),Error>, log: bool, ok_text: String) { } } -fn main() { - let cli = Cli::parse(); - - let config = match cli.quiet { - false => Config::load(cli.config.clone()), +fn load_config_silent(quiet: bool, path: PathBuf) -> Config { + match quiet { + false => Config::load(path.clone()), true => { - match Config::try_load(cli.config.clone()) { + match Config::try_load(path.clone()) { Ok(mut config) => { config.verbose = false; config }, Err(_) => { - let path = cli.config.clone().parent().unwrap_or(&Path::new(".")).to_owned(); + let path = path.clone().parent().unwrap_or(&Path::new(".")).to_owned(); Config { verbose: false, path, @@ -46,9 +44,61 @@ fn main() { } } }, - }; + } +} + +fn main() { + let cli = Cli::parse(); + + let config = load_config_silent(cli.quiet, cli.config.clone()); + let map_save = |map: Map, map_path: PathBuf| { map.save(map_path.parent().unwrap_or(Path::new("")).to_owned()) }; match &cli.command { + Objects::Init(args) => { + let path = match args.path.clone() { + Some(path) => path, + None => PathBuf::new(), + }; + + match DirBuilder::new().recursive(true).create(path.clone()) { + Ok(_) if !cli.quiet => println!("Created directory {:?}", path), + Err(error) => { + if !cli.quiet { eprintln!("Error: {error}"); } + return; + }, + _ => {}, + } + + let config = Config { + path: path.clone(), + ..Default::default() + }; + + do_and_log(config.save(path.clone()), !cli.quiet, format!("Created file {:?}/config.toml", path)); + let mut config_path = path.clone(); + config_path.push("config.toml"); + let mut config = load_config_silent(true, config_path); + config.verbose = Config::default().verbose; + + let map = Map::default(); + let map_path = config.full_map_path(); + + do_and_log(map_save(map, map_path.clone()), !cli.quiet, format!("Created file {:?}/map.toml", map_path)); + + let quests_path = config.full_quests_path(); + let accounts_path = config.full_accounts_path(); + + for path in [quests_path, accounts_path] { + match DirBuilder::new().recursive(true).create(path.clone()) { + Ok(_) if !cli.quiet => println!("Created directory {:?}", path), + Err(error) => { + if !cli.quiet { eprintln!("Error: {error}"); } + return; + }, + _ => {}, + } + } + }, Objects::Quest(commands) => { let mut quests = config.load_quests(); let mut path = config.full_quests_path(); @@ -289,7 +339,6 @@ fn main() { } }; - let map_save = |map: Map, map_path: PathBuf| { map.save(map_path.parent().unwrap_or(Path::new("")).to_owned()) }; match commands { MapCommands::List => { for room in map.room { diff --git a/src/config/mod.rs b/src/config/mod.rs index c805002..f0b2f94 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,12 +1,12 @@ //! Configuration file that handles (de-)serializing other components -use std::{fs::{self, DirEntry},path::{Path, PathBuf}}; -use serde::Deserialize; +use std::{fs::{self, DirEntry}, io::Write, path::{Path, PathBuf}}; +use serde::{Deserialize, Serialize}; use crate::{SquadObject, account::Account, error::Error, quest::Quest}; /// Struct for containing paths to other (de-)serializable things -#[derive(Deserialize)] +#[derive(Serialize, Deserialize)] #[serde(default)] pub struct Config { /// Path to config directory @@ -112,6 +112,42 @@ impl Config { } } + /// Serialize config into TOML. + /// Config will be saved as `path/config.toml` + /// + /// # Examples + /// ```rust + /// use squad_quest::config::Config; + /// + /// let path = "cfg".into(); + /// + /// let config = Config::default(); + /// + /// if let Err(error) = config.save(path) { + /// // handle error + /// } + /// ``` + pub fn save(&self, path: PathBuf) -> Result<(), Error> { + let mut path = path; + path.push("config.toml"); + + 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(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(()) + } + /// Deserialize config from TOML /// /// # Examples