diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/bill.rs | 167 | ||||
| -rw-r--r-- | src/calc.rs | 163 | ||||
| -rw-r--r-- | src/cli.rs | 6 | ||||
| -rw-r--r-- | src/cli/calc_cmd.rs | 9 | ||||
| -rw-r--r-- | src/cli/consts.rs | 1 | ||||
| -rw-r--r-- | src/cli/dispatchers.rs | 12 | ||||
| -rw-r--r-- | src/cli/entry.rs | 21 | ||||
| -rw-r--r-- | src/cli/io_error.rs | 42 | ||||
| -rw-r--r-- | src/cli/ops_cmd.rs | 80 | ||||
| -rw-r--r-- | src/error.rs | 8 | ||||
| -rw-r--r-- | src/main.rs | 23 | ||||
| -rw-r--r-- | src/test.rs | 409 | ||||
| -rw-r--r-- | src/who.rs | 44 |
13 files changed, 985 insertions, 0 deletions
diff --git a/src/bill.rs b/src/bill.rs new file mode 100644 index 0000000..16923f9 --- /dev/null +++ b/src/bill.rs @@ -0,0 +1,167 @@ +use std::collections::BTreeMap; + +use uuid::Uuid; + +use crate::who::Who; + +#[derive(Default)] +pub struct Bills { + pub items: BTreeMap<Uuid, BillItem>, +} + +pub struct BillItem { + pub who_paid: Who, + pub reason: String, + pub paid: f64, + pub split: Vec<Who>, +} + +pub struct SplitResult { + pub items: BTreeMap<Who, Vec<SplitResultItem>>, + pub final_result: BTreeMap<(Who, Who), f64>, +} + +pub struct SplitResultItem { + pub payee: Who, + pub bill: f64, + pub reason: String, +} + +impl Bills { + /// Add a new bill item + pub fn add_bill(&mut self, who_paid: &str, reason: &str, paid: f64, split: Vec<&str>) -> Uuid { + let item = BillItem { + who_paid: who_paid.into(), + reason: reason.to_string(), + paid, + split: split.into_iter().map(|s| s.into()).collect(), + }; + self.add_item(item) + } + + /// Add a new bill item + pub fn add_item(&mut self, item: BillItem) -> Uuid { + let id = Uuid::new_v4(); + self.items.insert(id, item); + id + } + + /// Get a bill item by ID (immutable reference) + pub fn get_item(&self, id: &Uuid) -> Option<&BillItem> { + self.items.get(id) + } + + /// 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) + } + + /// 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); + true + } else { + false + } + } + + /// Delete the bill item with the specified ID + pub fn delete_item(&mut self, id: &Uuid) -> Option<BillItem> { + self.items.remove(id) + } + + /// Get all bill items + pub fn get_all_items(&self) -> &BTreeMap<Uuid, BillItem> { + &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) + } + + /// Clear all bill items + pub fn clear_items(&mut self) { + self.items.clear(); + } +} + +impl SplitResult { + /// Add a bill (who pays whom, amount, reason) + pub fn add_bill(&mut self, payer: Who, payee: Who, amount: f64, reason: String) { + let result_item = SplitResultItem { + payee, + bill: amount, + reason, + }; + + self.items + .entry(payer) + .or_insert_with(Vec::new) + .push(result_item); + } + + /// Get all bill items for a specified payer (immutable reference) + pub fn get_bills(&self, payer: &Who) -> Option<&Vec<SplitResultItem>> { + self.items.get(payer) + } + + /// Get all bill items for a specified payer (mutable reference) + pub fn get_bills_mut(&mut self, payer: &Who) -> Option<&mut Vec<SplitResultItem>> { + self.items.get_mut(payer) + } + + /// Update the bill list for a specified payer + pub fn update_bills( + &mut self, + payer: Who, + bills: Vec<SplitResultItem>, + ) -> Option<Vec<SplitResultItem>> { + self.items.insert(payer, bills) + } + + /// Delete all bill items for a specified payer + pub fn delete_bills(&mut self, payer: &Who) -> Option<Vec<SplitResultItem>> { + self.items.remove(payer) + } + + /// Get all bill items for all payers + pub fn get_all_bills(&self) -> &BTreeMap<Who, Vec<SplitResultItem>> { + &self.items + } + + /// Check if bill items exist for a specified payer + pub fn contains_payer(&self, payer: &Who) -> bool { + self.items.contains_key(payer) + } + + /// Clear all bill items + pub fn clear_bills(&mut self) { + self.items.clear(); + } + /// Set the simplified result + pub fn set_final_result(&mut self, result: BTreeMap<(Who, Who), f64>) { + self.final_result = result; + } + + /// Get the simplified result (immutable reference) + pub fn get_final_result(&self) -> &BTreeMap<(Who, Who), f64> { + &self.final_result + } + + /// Get the simplified result (mutable reference) + pub fn get_final_result_mut(&mut self) -> &mut BTreeMap<(Who, Who), f64> { + &mut self.final_result + } + + /// Clear the simplified result + pub fn clear_final_result(&mut self) { + self.final_result.clear(); + } + + /// Get a specific item from the simplified result (who pays whom, returns Option<f64>) + pub fn get_final_result_item(&self, payer: Who, payee: Who) -> Option<f64> { + self.final_result.get(&(payer, payee)).copied() + } +} diff --git a/src/calc.rs b/src/calc.rs new file mode 100644 index 0000000..fcd0f5b --- /dev/null +++ b/src/calc.rs @@ -0,0 +1,163 @@ +use std::collections::BTreeMap; + +use crate::{ + bill::{Bills, SplitResult, SplitResultItem}, + error::BillSplitError, + who::Who, +}; + +pub fn calculate_from(item: Bills) -> Result<SplitResult, BillSplitError> { + // Validate input data + precheck(&item)?; + + // Calculate each person's net balance and original transactions + let (direct_transactions, items) = calculate_balances_and_transactions(&item); + + // Generate the simplest result: net settlement between each pair + let final_result = calculate_net_settlements(&direct_transactions); + + // Add "Total" reason to final_result + let mut items = items; + add_total_reason(&mut items); + + Ok(SplitResult { + items, + final_result, + }) +} + +fn precheck(item: &Bills) -> Result<(), BillSplitError> { + for (_, bill_item) in &item.items { + // Check if the paid amount is negative + if bill_item.paid < 0.0 { + return Err(BillSplitError::NegativePaidAmount); + } + + // Check for duplicate members in the split list + let mut seen = std::collections::HashSet::new(); + for person in &bill_item.split { + if !seen.insert(person) { + return Err(BillSplitError::DuplicateSplitMembers); + } + } + } + Ok(()) +} + +fn calculate_balances_and_transactions( + item: &Bills, +) -> ( + BTreeMap<(Who, Who), f64>, + BTreeMap<Who, Vec<SplitResultItem>>, +) { + let mut direct_transactions: BTreeMap<(Who, Who), f64> = BTreeMap::new(); + let mut items: BTreeMap<Who, Vec<SplitResultItem>> = BTreeMap::new(); + + for (_, bill_item) in &item.items { + let who_paid = &bill_item.who_paid; + let paid = bill_item.paid; + let split_count = bill_item.split.len() as f64; + + if split_count == 0.0 { + continue; + } + + // Round + let share = (paid / split_count * 100.0).round() / 100.0; + + // Calculate the amount each person should pay + for person in &bill_item.split { + // If the payer is also in the split list, deduct their own share + if person != who_paid { + // Record direct transaction + let key = (person.clone(), who_paid.clone()); + *direct_transactions.entry(key).or_insert(0.0) += share; + + // Add to full record + let bill_result_item = SplitResultItem { + payee: who_paid.clone(), + bill: share, + reason: bill_item.reason.clone(), + }; + + items + .entry(person.clone()) + .or_insert_with(Vec::new) + .push(bill_result_item); + } + } + } + + (direct_transactions, items) +} + +fn calculate_net_settlements( + direct_transactions: &BTreeMap<(Who, Who), f64>, +) -> BTreeMap<(Who, Who), f64> { + let mut final_result: BTreeMap<(Who, Who), f64> = BTreeMap::new(); + + // First, calculate net amounts for each transaction pair + let mut net_transactions: BTreeMap<(Who, Who), f64> = BTreeMap::new(); + for ((from, to), amount) in direct_transactions { + let key = (from.clone(), to.clone()); + *net_transactions.entry(key).or_insert(0.0) += amount; + } + + // Now process net transactions, ensuring correct direction + let mut processed_pairs = std::collections::HashSet::new(); + + for ((from, to), amount) in &net_transactions { + // Create a normalized transaction pair key (sorted alphabetically) + let pair_key = if from < to { + (from.clone(), to.clone()) + } else { + (to.clone(), from.clone()) + }; + + // If this pair has already been processed, skip it + if processed_pairs.contains(&pair_key) { + continue; + } + processed_pairs.insert(pair_key.clone()); + + // Check for reverse transaction + let reverse_key = (to.clone(), from.clone()); + if let Some(reverse_amount) = net_transactions.get(&reverse_key) { + // There is a reverse transaction, calculate net amount + let net_amount = *amount - *reverse_amount; + + if net_amount > 0.0001 { + // from owes to (net) + final_result.insert( + (from.clone(), to.clone()), + (net_amount * 100.0).round() / 100.0, + ); + } else if net_amount < -0.0001 { + // to owes from (net) + final_result.insert( + (to.clone(), from.clone()), + (-net_amount * 100.0).round() / 100.0, + ); + } + // If net amount is close to 0, don't add any transaction + } else { + // No reverse transaction, add directly + if *amount > 0.0001 { + final_result.insert( + (from.clone(), to.clone()), + (*amount * 100.0).round() / 100.0, + ); + } + } + } + + final_result +} + +fn add_total_reason(items: &mut BTreeMap<Who, Vec<SplitResultItem>>) { + for (_payer, bills_list) in items.iter_mut() { + for bill in bills_list { + bill.reason = format!("{} (Total)", bill.reason); + } + } +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..c1d1240 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,6 @@ +pub mod calc_cmd; +pub mod consts; +pub mod dispatchers; +pub mod entry; +pub mod io_error; +pub mod ops_cmd; diff --git a/src/cli/calc_cmd.rs b/src/cli/calc_cmd.rs new file mode 100644 index 0000000..09f4e03 --- /dev/null +++ b/src/cli/calc_cmd.rs @@ -0,0 +1,9 @@ +// 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 new file mode 100644 index 0000000..254b611 --- /dev/null +++ b/src/cli/consts.rs @@ -0,0 +1 @@ +pub const BILL_WORKSPACE_CONFIG_FILE: &str = "cobill.yml"; diff --git a/src/cli/dispatchers.rs b/src/cli/dispatchers.rs new file mode 100644 index 0000000..27b7747 --- /dev/null +++ b/src/cli/dispatchers.rs @@ -0,0 +1,12 @@ +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<ThisProgram, ThisProgram>) { + program.with_dispatcher(InitHereCommand); + program.with_dispatcher(CreateCommand); + + // program.with_dispatcher(CalculateCommand); +} diff --git a/src/cli/entry.rs b/src/cli/entry.rs new file mode 100644 index 0000000..e68b7b4 --- /dev/null +++ b/src/cli/entry.rs @@ -0,0 +1,21 @@ +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 new file mode 100644 index 0000000..49b9939 --- /dev/null +++ b/src/cli/io_error.rs @@ -0,0 +1,42 @@ +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<std::io::Error> for IOError { + fn from(error: std::io::Error) -> Self { + Self::new(error) + } +} + +impl Serialize for IOError { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + 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 new file mode 100644 index 0000000..4b0eea7 --- /dev/null +++ b/src/cli/ops_cmd.rs @@ -0,0 +1,80 @@ +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<String>) -> PathBuf { + let path = Picker::<()>::new(args) + .pick::<String>(()) + .unpack_directly() + .0; + PathBuf::from(path) +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..a9c23b1 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,8 @@ +#[derive(thiserror::Error, Debug)] +pub enum BillSplitError { + #[error("Paid amount cannot be negative")] + NegativePaidAmount, + + #[error("Duplicate split members found")] + DuplicateSplitMembers, +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..a97d952 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,23 @@ +use mingling::macros::gen_program; + +mod bill; +mod calc; +mod cli; +mod error; +mod who; + +#[cfg(test)] +mod test; + +#[tokio::main] +async fn main() { + cli::entry::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/test.rs b/src/test.rs new file mode 100644 index 0000000..15a7002 --- /dev/null +++ b/src/test.rs @@ -0,0 +1,409 @@ +use crate::{bill::Bills, calc::calculate_from}; + +#[test] +fn test_no_zero_amount_transactions() { + let mut bills = Bills::default(); + + // Create some bills where some transaction amounts might be 0 + // A pays 30, split among A, B, C (10 each) + bills.add_bill("A", "Lunch", 30., vec!["A", "B", "C"]); + // B pays 30, split among A, B (15 each) + bills.add_bill("B", "Coffee", 30., vec!["A", "B"]); + + let result = calculate_from(bills); + assert!(result.is_ok(), "calculate should be success"); + let result = result.unwrap(); + + // Check if there are any transactions with amount 0 in the complete record (items) + let all_items = result.get_all_bills(); + for (payer, bills_list) in all_items { + for bill in bills_list { + assert_ne!( + bill.bill, 0.0, + "There should be no transactions with amount 0 in the complete record: {} to {} amount is 0", + payer, bill.payee + ); + } + } + + // Check if there are any transactions with amount 0 in the simplified result (final_result) + let final_result = result.get_final_result(); + for ((payer, payee), amount) in final_result { + assert_ne!( + *amount, 0.0, + "There should be no transactions with amount 0 in the simplified result: {} to {} amount is 0", + payer, payee + ); + } + + // Verify transaction count + // Should have: C->A (10), A->B (5) after netting + assert_eq!( + final_result.len(), + 2, + "There should be 2 non-zero transactions" + ); + assert!( + final_result.contains_key(&("C".into(), "A".into())), + "Should contain transaction C->A" + ); + assert!( + final_result.contains_key(&("A".into(), "B".into())), + "Should contain transaction A->B" + ); + + // Verify specific amounts + let c_to_a = final_result.get(&("C".into(), "A".into())).unwrap(); + assert_eq!(*c_to_a, 10.0, "C should pay A 10"); + + let a_to_b = final_result.get(&("A".into(), "B".into())).unwrap(); + assert_eq!(*a_to_b, 5.0, "A should pay B 5"); +} + +#[test] +fn test_zero_amount_edge_cases() { + let mut bills = Bills::default(); + + // Test perfectly balanced case: A and B prepay the same amount for each other + // A prepays 20 for A, B (10 each) + bills.add_bill("A", "Dinner", 20., vec!["A", "B"]); + // B prepays 20 for A, B (10 each) + bills.add_bill("B", "Movie", 20., vec!["A", "B"]); + + let result = calculate_from(bills); + assert!(result.is_ok(), "calculate should be success"); + let result = result.unwrap(); + + // Check complete record + let all_items = result.get_all_bills(); + for (payer, bills_list) in all_items { + for bill in bills_list { + assert_ne!( + bill.bill, 0.0, + "There should be no transactions with amount 0 in the complete record: {} to {} amount is 0", + payer, bill.payee + ); + } + } + + // Check simplified result - should be empty because all transactions cancel out + let final_result = result.get_final_result(); + assert_eq!( + final_result.len(), + 0, + "The simplified result should be empty because all transaction amounts are 0" + ); + + // Verify no transactions are included + assert!( + !final_result.contains_key(&("A".into(), "B".into())), + "Should not contain A->B zero amount transaction" + ); + assert!( + !final_result.contains_key(&("B".into(), "A".into())), + "Should not contain B->A zero amount transaction" + ); +} + +#[test] +fn test_items_count() { + let mut bills = Bills::default(); + + // Add 3 bill items + let id1 = bills.add_bill("A", "Lunch", 30., vec!["A", "B"]); + let id2 = bills.add_bill("B", "Coffee", 20., vec!["B", "C"]); + let id3 = bills.add_bill("C", "Snack", 15., vec!["A", "C"]); + + // Verify items count + assert_eq!(bills.get_all_items().len(), 3, "Should have 3 items"); + + // Verify each ID exists + assert!(bills.contains_item(&id1), "Item 1 should exist"); + assert!(bills.contains_item(&id2), "Item 2 should exist"); + assert!(bills.contains_item(&id3), "Item 3 should exist"); + + // Verify count after deleting one item + let removed = bills.delete_item(&id2); + assert!(removed.is_some(), "Should remove item 2"); + assert_eq!( + bills.get_all_items().len(), + 2, + "Should have 2 items after removal" + ); + + // Verify deleted item no longer exists + assert!( + !bills.contains_item(&id2), + "Item 2 should not exist after removal" + ); + + // Verify count after clearing + bills.clear_items(); + assert_eq!( + bills.get_all_items().len(), + 0, + "Should have 0 items after clear" + ); +} + +#[test] +fn test_result() { + let mut bills = Bills::default(); + + // Define data + bills.add_bill("A", "BBQ", 90., vec!["A", "B", "C"]); + bills.add_bill("B", "Water", 21., vec!["A", "B", "C"]); + + // Calculate + let result = calculate_from(bills); + + // Check result + assert!(result.is_ok(), "calculate should be success"); + + let result = result.unwrap(); + + // Verify split results + let c_to_a = result + .get_final_result_item("C".into(), "A".into()) + .expect("Item C to A should be exist"); + assert_eq!(c_to_a, 30.0, "C should pay A 30 for BBQ"); + + let c_to_b = result + .get_final_result_item("C".into(), "B".into()) + .expect("Item C to B should be exist"); + assert_eq!(c_to_b, 7.0, "C should pay B 7 for Water"); + + let b_to_a = result + .get_final_result_item("B".into(), "A".into()) + .expect("Item B to A should be exist"); + assert_eq!(b_to_a, 23.0, "B should pay A 23 (30 - 7)"); + + // Verify count + let final_result = result.get_final_result(); + assert_eq!(final_result.len(), 3, "Should have exactly 3 transactions"); +} + +#[test] +fn test_complex_bills() { + let mut bills = Bills::default(); + + // A prepays 50 for B and C, B and C should each pay A 25 + bills.add_bill("A", "Dinner", 50., vec!["B", "C"]); + + let result = calculate_from(bills); + assert!(result.is_ok(), "calculate should be success"); + let result = result.unwrap(); + + let b_to_a = result + .get_final_result_item("B".into(), "A".into()) + .expect("Item B to A should be exist"); + assert_eq!(b_to_a, 25.0, "B should pay A 25"); + + let c_to_a = result + .get_final_result_item("C".into(), "A".into()) + .expect("Item C to A should be exist"); + assert_eq!(c_to_a, 25.0, "C should pay A 25"); + + let final_result = result.get_final_result(); + assert_eq!(final_result.len(), 2, "Should have exactly 2 transactions"); +} + +#[test] +fn test_unrelated_bills() { + let mut bills = Bills::default(); + + // A prepays 30 split among A, B, C, each should pay A 10 + // B prepays 30 split among A, B, each should pay B 15 + // Final result: C only has transaction with A, not with B + bills.add_bill("A", "Lunch", 30., vec!["A", "B", "C"]); + bills.add_bill("B", "Coffee", 30., vec!["A", "B"]); + + let result = calculate_from(bills); + assert!(result.is_ok(), "calculate should be success"); + let result = result.unwrap(); + + // Verify C only has transaction with A, not with B + let c_to_a = result.get_final_result_item("C".into(), "A".into()); + assert!(c_to_a.is_some(), "C should pay A"); + assert_eq!(c_to_a.unwrap(), 10.0, "C should pay A 10"); + + let c_to_b = result.get_final_result_item("C".into(), "B".into()); + assert!(c_to_b.is_none(), "C should not have any transaction with B"); + + // Verify transaction between A and B + let a_to_b = result.get_final_result_item("A".into(), "B".into()); + assert!(a_to_b.is_some(), "A should pay B"); + assert_eq!(a_to_b.unwrap(), 5.0, "A should pay B 5 (15 - 10)"); + + // Verify total transaction count + let final_result = result.get_final_result(); + assert_eq!(final_result.len(), 2, "Should have exactly 2 transactions"); +} + +#[test] +fn test_duplicate_split_members() { + let mut bills = Bills::default(); + + // Duplicate members in split list, should return Error + bills.add_bill("Alice", "Lunch", 60., vec!["Bob", "Bob", "Charlie"]); + + let result = calculate_from(bills); + assert!( + result.is_err(), + "Should return error for duplicate split members" + ); +} + +#[test] +fn test_negative_paid_amount() { + let mut bills = Bills::default(); + + // Negative prepaid amount, should return Error + bills.add_bill("Alice", "Refund?", -30., vec!["Alice", "Bob"]); + + let result = calculate_from(bills); + assert!( + result.is_err(), + "Should return error for negative paid amount" + ); +} + +#[test] +fn test_rounding() { + let mut bills = Bills::default(); + + // Test rounding: 51.0333333333 => 51.00, 51.599999999999 => 52.00 + // 100 / 3 = 33.333..., each should pay 33.33, payer gets back 66.67 + bills.add_bill("Alice", "Concert", 100., vec!["Alice", "Bob", "Charlie"]); + + let result = calculate_from(bills); + assert!(result.is_ok(), "calculate should be success"); + let result = result.unwrap(); + + let bob_to_alice = result + .get_final_result_item("Bob".into(), "Alice".into()) + .expect("Item Bob to Alice should be exist"); + // 33.333... rounded to 2 decimal places => 33.33 + assert_eq!(bob_to_alice, 33.33, "Bob should pay Alice 33.33"); + + let charlie_to_alice = result + .get_final_result_item("Charlie".into(), "Alice".into()) + .expect("Item Charlie to Alice should be exist"); + assert_eq!(charlie_to_alice, 33.33, "Charlie should pay Alice 33.33"); + + // Another test: 51.599999999999 => 52.00 + let mut bills2 = Bills::default(); + bills2.add_bill("Bob", "Dinner", 51.6, vec!["Alice", "Bob"]); // 51.6 / 2 = 25.8 + + let result2 = calculate_from(bills2); + assert!(result2.is_ok(), "calculate should be success"); + let result2 = result2.unwrap(); + + let alice_to_bob = result2 + .get_final_result_item("Alice".into(), "Bob".into()) + .expect("Item Alice to Bob should be exist"); + // 25.8 rounded to 2 decimal places => 25.80 + assert_eq!(alice_to_bob, 25.8, "Alice should pay Bob 25.8"); +} + +#[test] +fn test_empty_bills() { + let bills = Bills::default(); + let result = calculate_from(bills); + assert!( + result.is_ok(), + "calculate should be success for empty bills" + ); + let result = result.unwrap(); + + assert_eq!( + result.get_all_bills().len(), + 0, + "Items should be empty for empty bills" + ); + assert_eq!( + result.get_final_result().len(), + 0, + "Final result should be empty for empty bills" + ); +} + +#[test] +fn test_single_person_bill() { + let mut bills = Bills::default(); + + // Single person bill: paying for oneself + bills.add_bill("Alice", "Personal", 50., vec!["Alice"]); + + let result = calculate_from(bills); + assert!( + result.is_ok(), + "calculate should be success for single person bill" + ); + let result = result.unwrap(); + + // Single person bill should not generate any transactions + assert_eq!( + result.get_final_result().len(), + 0, + "Should have no transactions for single person bill" + ); +} + +#[test] +fn test_split_not_include_payer() { + let mut bills = Bills::default(); + + // Payer not included in split list + bills.add_bill("Alice", "Gift", 100., vec!["Bob", "Charlie"]); + + let result = calculate_from(bills); + assert!( + result.is_ok(), + "calculate should be success when payer not in split" + ); + let result = result.unwrap(); + + // Bob and Charlie should each pay Alice 50 + let bob_to_alice = result + .get_final_result_item("Bob".into(), "Alice".into()) + .expect("Bob should pay Alice"); + assert_eq!(bob_to_alice, 50.0, "Bob should pay Alice 50"); + + let charlie_to_alice = result + .get_final_result_item("Charlie".into(), "Alice".into()) + .expect("Charlie should pay Alice"); + assert_eq!(charlie_to_alice, 50.0, "Charlie should pay Alice 50"); +} + +#[test] +fn test_large_number_of_people() { + let mut bills = Bills::default(); + + // Test large group scenario + let people = vec!["A", "B", "C", "D", "E", "F", "G", "H", "I", "J"]; + bills.add_bill("A", "Group Dinner", 1000., people.clone()); + + let result = calculate_from(bills); + assert!( + result.is_ok(), + "calculate should be success for large group" + ); + let result = result.unwrap(); + + // Each person should pay A 100 (1000/10) + for person in &people { + if *person != "A" { + let amount = result + .get_final_result_item(person.to_string().into(), "A".into()) + .expect(&format!("{} should pay A", person)); + assert_eq!(amount, 100.0, "{} should pay A 100", person); + } + } + + assert_eq!( + result.get_final_result().len(), + 9, + "Should have 9 transactions" + ); +} diff --git a/src/who.rs b/src/who.rs new file mode 100644 index 0000000..9d6e5b5 --- /dev/null +++ b/src/who.rs @@ -0,0 +1,44 @@ +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Who { + name: String, +} + +impl std::ops::Deref for Who { + type Target = String; + + fn deref(&self) -> &Self::Target { + &self.name + } +} + +impl std::ops::DerefMut for Who { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.name + } +} + +impl From<String> for Who { + fn from(s: String) -> Self { + Who { name: s } + } +} + +impl From<&str> for Who { + fn from(s: &str) -> Self { + Who { + name: s.to_string(), + } + } +} + +impl Into<String> for Who { + fn into(self) -> String { + self.name + } +} + +impl std::fmt::Display for Who { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.name) + } +} |
