aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
author魏曹先生 <1992414357@qq.com>2026-04-16 21:31:57 +0800
committer魏曹先生 <1992414357@qq.com>2026-04-16 21:31:57 +0800
commit363fbc6e98f832471a17a10ec18e8823df6a2ed5 (patch)
tree98f71ab1796c1a9c1df411eee5174dd92001ef94 /src
Initialize Rust project with billing calculation functionality
Diffstat (limited to 'src')
-rw-r--r--src/bill.rs167
-rw-r--r--src/calc.rs163
-rw-r--r--src/cli.rs6
-rw-r--r--src/cli/calc_cmd.rs9
-rw-r--r--src/cli/consts.rs1
-rw-r--r--src/cli/dispatchers.rs12
-rw-r--r--src/cli/entry.rs21
-rw-r--r--src/cli/io_error.rs42
-rw-r--r--src/cli/ops_cmd.rs80
-rw-r--r--src/error.rs8
-rw-r--r--src/main.rs23
-rw-r--r--src/test.rs409
-rw-r--r--src/who.rs44
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)
+ }
+}