From 6e36fc3707e791c3c748133d648957706b54fd3a Mon Sep 17 00:00:00 2001 From: 魏曹先生 <1992414357@qq.com> Date: Thu, 16 Apr 2026 23:24:52 +0800 Subject: Add CLI commands for bill management and persistence --- src/bill.rs | 24 ++--- src/cli.rs | 235 +++++++++++++++++++++++++++++++++++++++++++++++-- src/cli/calc_cmd.rs | 9 -- src/cli/consts.rs | 1 - src/cli/dispatchers.rs | 12 --- src/cli/entry.rs | 21 ----- src/cli/io_error.rs | 42 --------- src/cli/ops_cmd.rs | 80 ----------------- src/display.rs | 144 ++++++++++++++++++++++++++++++ src/macros.rs | 6 ++ src/main.rs | 14 +-- src/who.rs | 4 +- 12 files changed, 399 insertions(+), 193 deletions(-) delete mode 100644 src/cli/calc_cmd.rs delete mode 100644 src/cli/consts.rs delete mode 100644 src/cli/dispatchers.rs delete mode 100644 src/cli/entry.rs delete mode 100644 src/cli/io_error.rs delete mode 100644 src/cli/ops_cmd.rs create mode 100644 src/display.rs create mode 100644 src/macros.rs (limited to 'src') diff --git a/src/bill.rs b/src/bill.rs index 16923f9..e03cd22 100644 --- a/src/bill.rs +++ b/src/bill.rs @@ -1,14 +1,16 @@ use std::collections::BTreeMap; +use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::who::Who; -#[derive(Default)] +#[derive(Debug, Default, Serialize, Deserialize)] pub struct Bills { - pub items: BTreeMap, + pub items: BTreeMap, } +#[derive(Debug, Default, Serialize, Deserialize)] pub struct BillItem { pub who_paid: Who, pub reason: String, @@ -16,11 +18,13 @@ pub struct BillItem { pub split: Vec, } +#[derive(Debug, Default, Serialize, Deserialize)] pub struct SplitResult { pub items: BTreeMap>, pub final_result: BTreeMap<(Who, Who), f64>, } +#[derive(Debug, Serialize, Deserialize)] pub struct SplitResultItem { pub payee: Who, pub bill: f64, @@ -42,24 +46,24 @@ impl Bills { /// Add a new bill item pub fn add_item(&mut self, item: BillItem) -> Uuid { let id = Uuid::new_v4(); - self.items.insert(id, item); + self.items.insert(id.to_string(), item); id } /// Get a bill item by ID (immutable reference) pub fn get_item(&self, id: &Uuid) -> Option<&BillItem> { - self.items.get(id) + self.items.get(&id.to_string()) } /// Get a bill item by ID (mutable reference) pub fn get_item_mut(&mut self, id: &Uuid) -> Option<&mut BillItem> { - self.items.get_mut(id) + self.items.get_mut(&id.to_string()) } /// Update the bill item with the specified ID pub fn update_item(&mut self, id: &Uuid, item: BillItem) -> bool { - if self.items.contains_key(id) { - self.items.insert(*id, item); + if self.items.contains_key(&id.to_string()) { + self.items.insert(id.to_string(), item); true } else { false @@ -68,17 +72,17 @@ impl Bills { /// Delete the bill item with the specified ID pub fn delete_item(&mut self, id: &Uuid) -> Option { - self.items.remove(id) + self.items.remove(&id.to_string()) } /// Get all bill items - pub fn get_all_items(&self) -> &BTreeMap { + pub fn get_all_items(&self) -> &BTreeMap { &self.items } /// Check if a bill item with the specified ID exists pub fn contains_item(&self, id: &Uuid) -> bool { - self.items.contains_key(id) + self.items.contains_key(&id.to_string()) } /// Clear all bill items diff --git a/src/cli.rs b/src/cli.rs index c1d1240..aa0d317 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,6 +1,229 @@ -pub mod calc_cmd; -pub mod consts; -pub mod dispatchers; -pub mod entry; -pub mod io_error; -pub mod ops_cmd; +use std::{fs::create_dir_all, path::PathBuf}; + +use mingling::{ + AnyOutput, Groupped, + macros::{chain, dispatcher, gen_program, pack, r_println, renderer}, + marker::NextProcess, + parser::Picker, + setup::GeneralRendererSetup, +}; +use serde::Serialize; + +use crate::{ + bill::{BillItem, Bills, SplitResult}, + calc::calculate_from, + display::SimpleTable, + error::BillSplitError, + string_vec, +}; + +pub async fn entry() { + let mut program = ThisProgram::new(); + + // Add Completion + program.with_dispatcher(CompletionDispatcher); + + // Add General Renderer + program.with_setup(GeneralRendererSetup); + + // Add Dispatchers + program.with_dispatchers(( + ClearAllBillCommand, + AddBillCommand, + // RenameMemberCommand, + // RenameBillCommand, + ListAllBillCommand, + )); + + // Execute + program.exec().await; +} + +dispatcher!("clear", ClearAllBillCommand => ClearAllBillEntry); +dispatcher!("add", AddBillCommand => AddBillEntry); +// dispatcher!("rename.member", RenameMemberCommand => RenameMemberEntry); +// dispatcher!("rename.bill", RenameBillCommand => RenameBillEntry); +dispatcher!("ls", ListAllBillCommand => ListAllBillEntry); + +#[chain] +async fn do_clear_cmd(_prev: ClearAllBillEntry) -> NextProcess { + op_bills(|b| b.clear_items()); + Empty::new(()).to_render() +} + +pack!(StateAddBillItem = BillItem); + +#[chain] +async fn parse_add_cmd(prev: AddBillEntry) -> NextProcess { + let picked = Picker::new(prev.inner) + .pick_or_route::(["--paid", "-p"], PaidRequired::new(()).to_render()) + .pick_or_route::>(["--for", "-f"], ForMembersRequired::new(()).to_render()) + .pick_or::( + ["--reason", "-r", "--message", "-m"], + "No reason".to_string(), + ) + .pick_or_route::((), MemberRequired::new(()).to_render()) + .unpack(); + + match picked { + Ok((paid, for_members, reason, who)) => { + let bill_item = BillItem { + who_paid: who.into(), + reason, + paid, + split: for_members.iter().map(|i| i.as_str().into()).collect(), + }; + let state = StateAddBillItem::new(bill_item); + AnyOutput::new(state).route_chain() + } + Err(e) => e, + } +} + +#[chain] +async fn handle_add_bill_item(prev: StateAddBillItem) -> NextProcess { + op_bills(|b| { + b.add_item(prev.inner); + }); + Empty::new(()).to_render() +} + +#[derive(Serialize, Groupped)] +struct StateListBills { + optimize: bool, +} + +pack!(ResultBills = Bills); +pack!(ResultSplitResult = SplitResult); +pack!(ErrorDuplicateSplitMembers = ()); +pack!(ErrorNegativePaidAmount = ()); + +#[chain] +async fn parse_ls_cmd(prev: ListAllBillEntry) -> NextProcess { + let optimize = Picker::<()>::new(prev.inner) + .pick::(["-O", "--optimize"]) + .unpack_directly() + .0; + let state = StateListBills { optimize }; + AnyOutput::new(state).route_chain() +} + +#[chain] +async fn handle_list_bills(prev: StateListBills) -> NextProcess { + if prev.optimize { + let bills = read_bills(); + match calculate_from(bills) { + Ok(r) => AnyOutput::new(ResultSplitResult::new(r)).route_renderer(), + Err(BillSplitError::DuplicateSplitMembers) => { + AnyOutput::new(ErrorDuplicateSplitMembers::new(())).route_renderer() + } + Err(BillSplitError::NegativePaidAmount) => { + AnyOutput::new(ErrorNegativePaidAmount::new(())).route_renderer() + } + } + } else { + let bills = read_bills(); + AnyOutput::new(ResultBills::new(bills)).route_renderer() + } +} + +#[renderer] +fn render_bills(prev: ResultBills) { + let mut table = SimpleTable::new(string_vec!["Who", "|", "Paid", "|", "Split", "|", "Reason"]); + for (_, items) in prev.inner.items { + let split = items + .split + .iter() + .map(|i| i.to_string()) + .collect::>() + .join(", "); + table.push_item(string_vec![ + items.who_paid, + "", + items.paid, + "", + split, + "", + items.reason + ]); + } + r_println!("{}", table) +} + +#[renderer] +fn render_split_result(prev: ResultSplitResult) { + let mut table = SimpleTable::new(string_vec!["Who", "|", "Should Pay", "|", "To"]); + for ((who, to), paid) in prev.inner.final_result { + table.push_item(string_vec![who, "", paid, "", to]); + } + r_println!("{}", table) +} + +#[renderer] +fn render_error_duplicate_split_members(_prev: ErrorDuplicateSplitMembers) { + r_println!("Error: Duplicate members found in split list"); +} + +#[renderer] +fn render_error_negative_paid_amount(_prev: ErrorNegativePaidAmount) { + r_println!("Error: Paid amount cannot be negative"); +} + +pack!(Empty = ()); +pack!(PaidRequired = ()); +pack!(ForMembersRequired = ()); +pack!(MemberRequired = ()); + +#[renderer] +fn render_empty(_prev: Empty) {} + +#[renderer] +fn render_paid_required(_prev: PaidRequired) { + r_println!("Error: Paid amount required, use \"--paid\" or \"-p\""); +} + +#[renderer] +fn render_for_members_required(_prev: ForMembersRequired) { + r_println!("Error: For members required, use \"--for\" or \"-f\""); +} + +#[renderer] +fn render_member_required(_prev: MemberRequired) { + r_println!("Error: Member required"); +} + +fn cobill_dir() -> PathBuf { + dirs::config_dir().unwrap().join(".cobill") +} + +fn state_file_path() -> PathBuf { + cobill_dir().join("state.yml") +} + +fn read_bills() -> Bills { + let dir = cobill_dir(); + create_dir_all(dir).unwrap(); + + let state_file = state_file_path(); + if state_file.exists() { + match std::fs::read_to_string(&state_file) { + Ok(contents) => match serde_yaml::from_str(&contents) { + Ok(bills) => bills, + Err(_) => Bills::default(), + }, + Err(_) => Bills::default(), + } + } else { + Bills::default() + } +} + +fn op_bills(op: F) { + let mut bills = read_bills(); + op(&mut bills); + let state_file = state_file_path(); + let contents = serde_yaml::to_string(&bills).unwrap(); + std::fs::write(state_file, contents).unwrap(); +} + +gen_program!(); diff --git a/src/cli/calc_cmd.rs b/src/cli/calc_cmd.rs deleted file mode 100644 index 09f4e03..0000000 --- a/src/cli/calc_cmd.rs +++ /dev/null @@ -1,9 +0,0 @@ -// use mingling::macros::dispatcher; -// use mingling::{macros::chain, marker::NextProcess}; - -// use crate::cli::entry::*; - -// dispatcher!("calc", CalculateCommand => CalculateEntry); - -// #[chain] -// pub async fn parse_calc_entry(prev: CalculateEntry) -> NextProcess {} diff --git a/src/cli/consts.rs b/src/cli/consts.rs deleted file mode 100644 index 254b611..0000000 --- a/src/cli/consts.rs +++ /dev/null @@ -1 +0,0 @@ -pub const BILL_WORKSPACE_CONFIG_FILE: &str = "cobill.yml"; diff --git a/src/cli/dispatchers.rs b/src/cli/dispatchers.rs deleted file mode 100644 index 27b7747..0000000 --- a/src/cli/dispatchers.rs +++ /dev/null @@ -1,12 +0,0 @@ -use mingling::{Program, macros::program_setup}; - -use crate::ThisProgram; -use crate::cli::ops_cmd::{CreateCommand, InitHereCommand}; - -#[program_setup] -pub fn chaos_billing_setup(program: &mut Program) { - program.with_dispatcher(InitHereCommand); - program.with_dispatcher(CreateCommand); - - // program.with_dispatcher(CalculateCommand); -} diff --git a/src/cli/entry.rs b/src/cli/entry.rs deleted file mode 100644 index e68b7b4..0000000 --- a/src/cli/entry.rs +++ /dev/null @@ -1,21 +0,0 @@ -use mingling::setup::GeneralRendererSetup; - -use crate::__completion_gen::CompletionDispatcher; -use crate::ThisProgram; -use crate::cli::dispatchers::*; - -pub async fn entry() { - let mut program = ThisProgram::new(); - - // Add Completion - program.with_dispatcher(CompletionDispatcher); - - // Add General Renderer - program.with_setup(GeneralRendererSetup); - - // Setup `cobill` - program.with_setup(ChaosBillingSetup); - - // Execute - program.exec().await; -} diff --git a/src/cli/io_error.rs b/src/cli/io_error.rs deleted file mode 100644 index 49b9939..0000000 --- a/src/cli/io_error.rs +++ /dev/null @@ -1,42 +0,0 @@ -use mingling::{ - Groupped, - macros::{r_println, renderer}, -}; -use serde::Serialize; - -use crate::ThisProgram; - -#[derive(Groupped)] -pub struct IOError { - inner: std::io::Error, -} - -impl IOError { - pub fn new(error: std::io::Error) -> Self { - Self { inner: error } - } -} - -impl From for IOError { - fn from(error: std::io::Error) -> Self { - Self::new(error) - } -} - -impl Serialize for IOError { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - use serde::ser::SerializeStruct; - let mut state = serializer.serialize_struct("IOError", 2)?; - state.serialize_field("kind", &self.inner.kind().to_string())?; - state.serialize_field("info", &self.inner.to_string())?; - state.end() - } -} - -#[renderer] -pub fn render_io_error(prev: IOError) { - r_println!("{}: {}", prev.inner.kind(), prev.inner.to_string()) -} diff --git a/src/cli/ops_cmd.rs b/src/cli/ops_cmd.rs deleted file mode 100644 index 4b0eea7..0000000 --- a/src/cli/ops_cmd.rs +++ /dev/null @@ -1,80 +0,0 @@ -use std::{ - env::current_dir, - fs::{self, create_dir_all}, - path::PathBuf, -}; - -use mingling::{ - AnyOutput, - macros::{chain, dispatcher, pack, r_println, renderer}, - marker::NextProcess, - parser::Picker, -}; - -use crate::{ - ThisProgram, - cli::{consts::BILL_WORKSPACE_CONFIG_FILE, io_error::IOError}, -}; - -dispatcher!("init", InitHereCommand => InitEntry); -dispatcher!("create", CreateCommand => CreateEntry); - -pack!(StateCreateWorkspace = PathBuf); - -#[chain] -pub async fn handle_init_command(_prev: InitEntry) -> NextProcess { - let current_dir = match current_dir() { - Ok(d) => d, - Err(e) => return AnyOutput::new(IOError::from(e)).route_renderer(), - }; - StateCreateWorkspace::new(current_dir).to_chain() -} - -#[chain] -pub async fn handle_create_command(prev: CreateEntry) -> NextProcess { - let path = pick_path(prev.inner); - StateCreateWorkspace::new(path).to_chain() -} - -#[chain] -pub async fn handle_state_create_workspace(prev: StateCreateWorkspace) -> NextProcess { - let dir = prev.inner; - let file = dir.join(BILL_WORKSPACE_CONFIG_FILE); - - match create_dir_all(&dir) { - Ok(d) => d, - Err(e) => return AnyOutput::new(IOError::from(e)).route_renderer(), - }; - - if file.exists() { - return AnyOutput::new(WorkspaceConfigAlreadyExists::new(dir)).route_renderer(); - } - - if let Err(e) = fs::write(file, "") { - return AnyOutput::new(IOError::from(e)).route_renderer(); - } - - StateWorkspaceCreated::new(dir).to_render() -} - -pack!(StateWorkspaceCreated = PathBuf); - -#[renderer] -pub fn render_workspace_created(prev: StateWorkspaceCreated) { - r_println!("Workspace created at: {:?}", prev.inner); -} - -pack!(WorkspaceConfigAlreadyExists = PathBuf); - -#[renderer] -pub fn render_workspace_config_already_exists(prev: WorkspaceConfigAlreadyExists) { - r_println!("Workspace config already exists: {:?}", prev.inner); -} - -fn pick_path(args: Vec) -> PathBuf { - let path = Picker::<()>::new(args) - .pick::(()) - .unpack_directly() - .0; - PathBuf::from(path) -} diff --git a/src/display.rs b/src/display.rs new file mode 100644 index 0000000..3b6f938 --- /dev/null +++ b/src/display.rs @@ -0,0 +1,144 @@ +pub struct SimpleTable { + items: Vec, + line: Vec>, + length: Vec, + padding: usize, +} + +#[allow(unused)] +impl SimpleTable { + /// Create a new Table + pub fn new(items: Vec>) -> Self { + Self::new_with_padding(items, 2) + } + + /// Create a new Table with padding + pub fn new_with_padding(items: Vec>, padding: usize) -> Self { + let items: Vec = items.into_iter().map(|v| v.into()).collect(); + let mut length = Vec::with_capacity(items.len()); + + for item in &items { + length.push(display_width(item)); + } + + SimpleTable { + items, + padding, + line: Vec::new(), + length, + } + } + + /// Push a new row of items to the table + pub fn push_item(&mut self, items: Vec>) { + let items: Vec = items.into_iter().map(|v| v.into()).collect(); + + let mut processed_items = Vec::with_capacity(self.items.len()); + + for i in 0..self.items.len() { + if i < items.len() { + processed_items.push(items[i].clone()); + } else { + processed_items.push(String::new()); + } + } + + for (i, d) in processed_items.iter().enumerate() { + let d_len = display_width(d); + if d_len > self.length[i] { + self.length[i] = d_len; + } + } + + self.line.push(processed_items); + } + + /// Insert a new row of items at the specified index + pub fn insert_item(&mut self, index: usize, items: Vec>) { + let items: Vec = items.into_iter().map(|v| v.into()).collect(); + + let mut processed_items = Vec::with_capacity(self.items.len()); + + for i in 0..self.items.len() { + if i < items.len() { + processed_items.push(items[i].clone()); + } else { + processed_items.push(String::new()); + } + } + + for (i, d) in processed_items.iter().enumerate() { + let d_len = display_width(d); + if d_len > self.length[i] { + self.length[i] = d_len; + } + } + + self.line.insert(index, processed_items); + } + + /// Get the current maximum column widths + fn get_column_widths(&self) -> &[usize] { + &self.length + } +} + +impl std::fmt::Display for SimpleTable { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let column_widths = self.get_column_widths(); + + // Build the header row + let header: Vec = self + .items + .iter() + .enumerate() + .map(|(i, item)| { + let target_width = column_widths[i] + self.padding; + let current_width = display_width(item); + let space_count = target_width - current_width; + let space = " ".repeat(space_count); + let result = format!("{}{}", item, space); + result + }) + .collect(); + writeln!(f, "{}", header.join(""))?; + + // Build each data row + for row in &self.line { + let formatted_row: Vec = row + .iter() + .enumerate() + .map(|(i, cell)| { + let target_width = column_widths[i] + self.padding; + let current_width = display_width(cell); + let space_count = target_width - current_width; + let spaces = " ".repeat(space_count); + let result = format!("{}{}", cell, spaces); + result + }) + .collect(); + writeln!(f, "{}", formatted_row.join(""))?; + } + + Ok(()) + } +} + +pub fn display_width(s: &str) -> usize { + // Filter out ANSI escape sequences before calculating width + let filtered_bytes = strip_ansi_escapes::strip(s); + let filtered_str = match std::str::from_utf8(&filtered_bytes) { + Ok(s) => s, + Err(_) => s, // Fallback to original string if UTF-8 conversion fails + }; + + let mut width = 0; + for c in filtered_str.chars() { + if c.is_ascii() { + width += 1; + } else { + width += 2; + } + } + width +} diff --git a/src/macros.rs b/src/macros.rs new file mode 100644 index 0000000..c4070c3 --- /dev/null +++ b/src/macros.rs @@ -0,0 +1,6 @@ +#[macro_export] +macro_rules! string_vec { + ($($elem:expr),* $(,)?) => { + vec![$($elem.to_string()),*] + }; +} diff --git a/src/main.rs b/src/main.rs index a97d952..614693b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,9 @@ -use mingling::macros::gen_program; - mod bill; mod calc; mod cli; +mod display; mod error; +mod macros; mod who; #[cfg(test)] @@ -11,13 +11,5 @@ mod test; #[tokio::main] async fn main() { - cli::entry::entry().await + cli::entry().await } - -use crate::cli::calc_cmd::*; -use crate::cli::dispatchers::*; -use crate::cli::entry::*; -use crate::cli::io_error::*; -use crate::cli::ops_cmd::*; - -gen_program!(); diff --git a/src/who.rs b/src/who.rs index 9d6e5b5..32f536b 100644 --- a/src/who.rs +++ b/src/who.rs @@ -1,4 +1,6 @@ -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Default, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub struct Who { name: String, } -- cgit