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
This commit is contained in:
Alexey 2025-12-04 13:56:53 +03:00
commit 47f55105dd
5 changed files with 37 additions and 7 deletions

681
cli/src/main.rs Normal file
View file

@ -0,0 +1,681 @@
use std::path::{Path, PathBuf};
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 toml::value::Date;
use chrono::{Datelike, NaiveDate, Utc};
#[derive(Deserialize)]
struct DateWrapper {
date: Date,
}
fn parse_date(arg: &str) -> Result<Date,toml::de::Error> {
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<QuestDifficulty> 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<Date>,
/// Quest expiration date (format = YYYY-MM-DD, ex. 2025-12-24)
#[arg(short,long,value_parser = parse_date)]
deadline: Option<Date>,
}
#[derive(Args)]
struct QuestUpdateArgs {
/// Id of the quest to update
id: u16,
/// Difficulty of the quest
#[arg(value_enum,long)]
difficulty: Option<QuestDifficulty>,
/// Reward for the quest
#[arg(long)]
reward: Option<u32>,
/// Name of the quest
#[arg(long)]
name: Option<String>,
/// Visible description of the quest
#[arg(long)]
description: Option<String>,
/// Answer for the quest for admins
#[arg(long)]
answer: Option<String>,
/// Create quest and make it public immediately
#[arg(long)]
public: Option<bool>,
/// Make quest available on date (format = YYYY-MM-DD, ex. 2025-12-24)
#[arg(long,value_parser = parse_date)]
available: Option<Date>,
/// Quest expiration date (format = YYYY-MM-DD, ex. 2025-12-24)
#[arg(long,value_parser = parse_date)]
deadline: Option<Date>,
}
#[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<String>,
}
#[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<String>,
/// Room description
#[arg(short,long)]
description: Option<String>,
/// Room price
#[arg(short,long)]
value: Option<u32>,
}
fn print_quest_short(quest: &Quest) {
println!("Quest #{}: {}", quest.id, quest.name);
}
fn print_quest_long(quest: &Quest) {
print_quest_short(quest);
println!("Difficulty: {:?}", quest.difficulty);
println!("Description:\n{}", quest.description);
println!("Answer:\n{}", quest.answer);
}
fn main() -> Result<(), Error> {
let cli = Cli::parse();
let config = Config::load(cli.config.clone());
match &cli.command {
Objects::Quest(commands) => {
match commands {
QuestCommands::List(args) => {
let quests = config.load_quests();
for quest in quests {
if args.short {
print_quest_short(&quest);
} else {
print_quest_long(&quest);
}
}
},
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) {
Ok(exists) => {
if exists {
panic!("Error: {:?} is not empty.", quest_path);
}
},
Err(error) => {
panic!("Error while retrieving {:?}: {}.", quest_path, error);
}
}
let quest = Quest {
id: next_id,
difficulty: args.difficulty.into(),
reward: args.reward,
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}.");
} else {
println!("Successfully saved quest #{}.", quest.id);
}
},
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);
};
let quest = Quest {
id: args.id,
difficulty: match args.difficulty {
Some(diff) => diff.into(),
None => quest.difficulty
},
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()),
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) {
Ok(_) => println!("Updated quest #{}", quest.id),
Err(error) => 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),
}
},
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 {
panic!("Quest #{} is already{}public", quest.id, not_str);
}
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);
}
}
},
}
},
Objects::Account(commands) => {
match commands {
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) {
panic!("Error: account {} exists.", account.id);
}
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 => {
panic!("Could not find account \"{}\"", args.id);
}
};
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 {
panic!("Error: balance ({}) is less than {}.", account.balance, args.value);
}
} 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 => {
panic!("Could not find account \"{}\"", args.account);
}
};
let quests = config.load_quests();
if let None = quests.iter().find(|q| q.id == args.quest) {
panic!("Could not find quest #{}", args.quest);
}
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}");
}
}
},
}
},
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(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(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}");
}
}
}
}
}
}
Ok(())
}