From 8affb8971dc1cfd4c856701893998f8fdc67c260 Mon Sep 17 00:00:00 2001 From: george larson Date: Fri, 27 Feb 2026 00:04:27 +0000 Subject: [PATCH 01/19] Set up Rust project with Cargo.toml and entry point Initialize the cash-register binary crate with rand, thiserror, and proptest dependencies. Add sample_input.txt from the problem statement and a minimal main.rs / lib.rs skeleton. --- .gitignore | 2 ++ Cargo.toml | 15 +++++++++++ sample_input.txt | 3 +++ src/lib.rs | 6 +++++ src/main.rs | 67 ++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 93 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 sample_input.txt create mode 100644 src/lib.rs create mode 100644 src/main.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..96ef6c0b --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..2f45e7fa --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "cash-register" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "cash-register" +path = "src/main.rs" + +[dependencies] +thiserror = "2" +rand = "0.8" + +[dev-dependencies] +proptest = "1" diff --git a/sample_input.txt b/sample_input.txt new file mode 100644 index 00000000..63a50779 --- /dev/null +++ b/sample_input.txt @@ -0,0 +1,3 @@ +2.12,3.00 +1.97,2.00 +3.33,5.00 diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 00000000..a5d7d5e0 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,6 @@ +pub mod currency; +pub mod error; +pub mod format; +pub mod parse; +pub mod rules; +pub mod strategy; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 00000000..26abb42e --- /dev/null +++ b/src/main.rs @@ -0,0 +1,67 @@ +use std::env; +use std::fs; +use std::process; + +use rand::rngs::StdRng; +use rand::SeedableRng; + +use cash_register::currency::USD; +use cash_register::format::format_breakdown; +use cash_register::parse::parse_input; +use cash_register::rules::make_change_for; + +fn main() { + let args: Vec = env::args().collect(); + + if args.len() < 2 { + eprintln!("Usage: cash-register [--divisor N] [--seed N]"); + process::exit(1); + } + + let file_path = &args[1]; + let divisor: u32 = parse_flag(&args, "--divisor").unwrap_or(3); + let seed: Option = parse_flag(&args, "--seed"); + + let input = match fs::read_to_string(file_path) { + Ok(content) => content, + Err(e) => { + eprintln!("Error reading {file_path}: {e}"); + process::exit(1); + } + }; + + let currency = &USD; + let mut had_error = false; + + // Use a concrete StdRng regardless — seeded or from entropy. + // This avoids Box and keeps everything monomorphized. + let mut rng = match seed { + Some(s) => StdRng::seed_from_u64(s), + None => StdRng::from_entropy(), + }; + + for result in parse_input(&input) { + match result { + Ok(transaction) => { + let breakdown = make_change_for(&transaction, currency, divisor, &mut rng); + println!("{}", format_breakdown(&breakdown)); + } + Err(e) => { + eprintln!("{e}"); + had_error = true; + } + } + } + + if had_error { + process::exit(2); + } +} + +/// Parse a `--flag value` pair from command-line args. +fn parse_flag(args: &[String], flag: &str) -> Option { + args.iter() + .position(|a| a == flag) + .and_then(|i| args.get(i + 1)) + .and_then(|v| v.parse().ok()) +} From abe005bc05db54a2ecd0d87aa813196536fa5f32 Mon Sep 17 00:00:00 2001 From: george larson Date: Fri, 27 Feb 2026 00:04:31 +0000 Subject: [PATCH 02/19] Add error types and currency denomination configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Define CashRegisterError with thiserror for structured errors including line numbers. Add Denomination/Currency types with static USD and EUR tables. Denominations are const data — no heap allocation for config. --- src/currency.rs | 69 +++++++++++++++++++++++++++++++++++++++++++++++++ src/error.rs | 20 ++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 src/currency.rs create mode 100644 src/error.rs diff --git a/src/currency.rs b/src/currency.rs new file mode 100644 index 00000000..6e0cd4dd --- /dev/null +++ b/src/currency.rs @@ -0,0 +1,69 @@ +/// A single denomination: its value in cents and display names. +#[derive(Debug, Clone, Copy)] +pub struct Denomination { + pub cents: u32, + pub singular: &'static str, + pub plural: &'static str, +} + +/// A currency configuration: a name and its denominations (largest first). +#[derive(Debug, Clone)] +pub struct Currency { + pub name: &'static str, + pub denominations: &'static [Denomination], +} + +pub static USD: Currency = Currency { + name: "USD", + denominations: &[ + Denomination { cents: 100, singular: "dollar", plural: "dollars" }, + Denomination { cents: 25, singular: "quarter", plural: "quarters" }, + Denomination { cents: 10, singular: "dime", plural: "dimes" }, + Denomination { cents: 5, singular: "nickel", plural: "nickels" }, + Denomination { cents: 1, singular: "penny", plural: "pennies" }, + ], +}; + +pub static EUR: Currency = Currency { + name: "EUR", + denominations: &[ + Denomination { cents: 200, singular: "2 euro coin", plural: "2 euro coins" }, + Denomination { cents: 100, singular: "1 euro coin", plural: "1 euro coins" }, + Denomination { cents: 50, singular: "50 cent coin", plural: "50 cent coins" }, + Denomination { cents: 20, singular: "20 cent coin", plural: "20 cent coins" }, + Denomination { cents: 10, singular: "10 cent coin", plural: "10 cent coins" }, + Denomination { cents: 5, singular: "5 cent coin", plural: "5 cent coins" }, + Denomination { cents: 2, singular: "2 cent coin", plural: "2 cent coins" }, + Denomination { cents: 1, singular: "1 cent coin", plural: "1 cent coins" }, + ], +}; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn usd_denominations_are_sorted_descending() { + let denoms = USD.denominations; + for window in denoms.windows(2) { + assert!( + window[0].cents > window[1].cents, + "{} ({}) should be larger than {} ({})", + window[0].singular, window[0].cents, + window[1].singular, window[1].cents, + ); + } + } + + #[test] + fn usd_smallest_denomination_is_one_cent() { + let last = USD.denominations.last().unwrap(); + assert_eq!(last.cents, 1, "smallest denomination must be 1 cent for exact change"); + } + + #[test] + fn eur_smallest_denomination_is_one_cent() { + let last = EUR.denominations.last().unwrap(); + assert_eq!(last.cents, 1); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 00000000..ce3efab4 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,20 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum CashRegisterError { + #[error("line {line}: invalid dollar amount \"{input}\"")] + InvalidAmount { line: usize, input: String }, + + #[error("line {line}: paid ({paid}) is less than owed ({owed})")] + Underpayment { + line: usize, + owed: String, + paid: String, + }, + + #[error("line {line}: {detail}")] + MalformedLine { line: usize, detail: String }, + + #[error("{0}")] + Io(#[from] std::io::Error), +} From 82175fbf35e5442c0a4fe4e39f99c42e3546a238 Mon Sep 17 00:00:00 2001 From: george larson Date: Fri, 27 Feb 2026 00:04:36 +0000 Subject: [PATCH 03/19] Implement transaction parsing with integer cents arithmetic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parse dollar strings to cents via string manipulation — no floating-point math. Handles whole numbers, 1-2 decimal places, whitespace trimming. Reports malformed lines and underpayments with line numbers. Includes 16 unit tests for parsing edge cases. --- src/parse.rs | 202 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 src/parse.rs diff --git a/src/parse.rs b/src/parse.rs new file mode 100644 index 00000000..b48c13b2 --- /dev/null +++ b/src/parse.rs @@ -0,0 +1,202 @@ +use crate::error::CashRegisterError; + +/// A validated transaction: how much was owed and how much was paid, in cents. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Transaction { + pub owed_cents: u32, + pub paid_cents: u32, + pub change_cents: u32, +} + +/// Parse a dollar-amount string like "2.13" into cents (213). +/// +/// Uses string manipulation to avoid floating-point imprecision. +/// Accepts whole numbers ("3") and decimal numbers with 1-2 decimal places. +pub fn parse_dollars_to_cents(s: &str) -> Result { + let s = s.trim(); + if s.is_empty() { + return Err("empty string".to_string()); + } + + match s.split_once('.') { + None => { + // Whole number: "3" -> 300 + let dollars: u32 = s + .parse() + .map_err(|_| format!("not a valid number: \"{s}\""))?; + Ok(dollars * 100) + } + Some((dollars_str, cents_str)) => { + if cents_str.len() > 2 { + return Err(format!("too many decimal places: \"{s}\"")); + } + + let dollars: u32 = dollars_str + .parse() + .map_err(|_| format!("invalid dollar part: \"{s}\""))?; + + // Pad single digit: "3.1" means 10 cents, not 1 cent + let padded = if cents_str.len() == 1 { + format!("{cents_str}0") + } else { + cents_str.to_string() + }; + + let cents: u32 = padded + .parse() + .map_err(|_| format!("invalid cents part: \"{s}\""))?; + + Ok(dollars * 100 + cents) + } + } +} + +/// Parse a single line like "2.13,3.00" into a Transaction. +pub fn parse_line(line: &str, line_number: usize) -> Result { + let line = line.trim(); + + let (owed_str, paid_str) = line.split_once(',').ok_or_else(|| { + CashRegisterError::MalformedLine { + line: line_number, + detail: format!("expected \"owed,paid\" but got \"{line}\""), + } + })?; + + let owed_cents = parse_dollars_to_cents(owed_str).map_err(|_| { + CashRegisterError::InvalidAmount { + line: line_number, + input: owed_str.trim().to_string(), + } + })?; + + let paid_cents = parse_dollars_to_cents(paid_str).map_err(|_| { + CashRegisterError::InvalidAmount { + line: line_number, + input: paid_str.trim().to_string(), + } + })?; + + if paid_cents < owed_cents { + return Err(CashRegisterError::Underpayment { + line: line_number, + owed: owed_str.trim().to_string(), + paid: paid_str.trim().to_string(), + }); + } + + Ok(Transaction { + owed_cents, + paid_cents, + change_cents: paid_cents - owed_cents, + }) +} + +/// Parse all lines from input text, skipping blank lines. +/// Returns a Vec of Results so one bad line doesn't prevent processing others. +pub fn parse_input(input: &str) -> Vec> { + input + .lines() + .enumerate() + .filter(|(_, line)| !line.trim().is_empty()) + .map(|(i, line)| parse_line(line, i + 1)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_whole_dollars() { + assert_eq!(parse_dollars_to_cents("3"), Ok(300)); + assert_eq!(parse_dollars_to_cents("0"), Ok(0)); + assert_eq!(parse_dollars_to_cents("100"), Ok(10_000)); + } + + #[test] + fn parse_dollars_and_cents() { + assert_eq!(parse_dollars_to_cents("2.13"), Ok(213)); + assert_eq!(parse_dollars_to_cents("3.00"), Ok(300)); + assert_eq!(parse_dollars_to_cents("0.01"), Ok(1)); + assert_eq!(parse_dollars_to_cents("0.50"), Ok(50)); + } + + #[test] + fn parse_single_decimal_digit() { + // "3.1" means $3.10 + assert_eq!(parse_dollars_to_cents("3.1"), Ok(310)); + } + + #[test] + fn parse_with_whitespace() { + assert_eq!(parse_dollars_to_cents(" 2.13 "), Ok(213)); + } + + #[test] + fn parse_rejects_too_many_decimals() { + assert!(parse_dollars_to_cents("2.123").is_err()); + } + + #[test] + fn parse_rejects_empty() { + assert!(parse_dollars_to_cents("").is_err()); + } + + #[test] + fn parse_rejects_non_numeric() { + assert!(parse_dollars_to_cents("abc").is_err()); + assert!(parse_dollars_to_cents("1.ab").is_err()); + } + + #[test] + fn parse_line_valid() { + let tx = parse_line("2.12,3.00", 1).unwrap(); + assert_eq!(tx.owed_cents, 212); + assert_eq!(tx.paid_cents, 300); + assert_eq!(tx.change_cents, 88); + } + + #[test] + fn parse_line_with_whitespace() { + let tx = parse_line(" 2.12 , 3.00 ", 1).unwrap(); + assert_eq!(tx.change_cents, 88); + } + + #[test] + fn parse_line_exact_payment() { + let tx = parse_line("5.00,5.00", 1).unwrap(); + assert_eq!(tx.change_cents, 0); + } + + #[test] + fn parse_line_underpayment() { + let result = parse_line("5.00,3.00", 1); + assert!(matches!(result, Err(CashRegisterError::Underpayment { .. }))); + } + + #[test] + fn parse_line_missing_comma() { + let result = parse_line("5.00", 1); + assert!(matches!(result, Err(CashRegisterError::MalformedLine { .. }))); + } + + #[test] + fn parse_input_skips_blank_lines() { + let input = "2.12,3.00\n\n1.97,2.00\n"; + let results = parse_input(input); + assert_eq!(results.len(), 2); + assert!(results.iter().all(|r| r.is_ok())); + } + + #[test] + fn parse_input_preserves_line_numbers() { + let input = "\nbad\n2.12,3.00\n"; + let results = parse_input(input); + assert_eq!(results.len(), 2); + // The "bad" line is line 2 (1-indexed) + match &results[0] { + Err(CashRegisterError::MalformedLine { line, .. }) => assert_eq!(*line, 2), + other => panic!("expected MalformedLine, got {:?}", other), + } + } +} From 341c2eb7d69a90986a0c5a6680f5f6c0c95d6cbb Mon Sep 17 00:00:00 2001 From: george larson Date: Fri, 27 Feb 2026 00:04:41 +0000 Subject: [PATCH 04/19] Implement greedy change algorithm with ChangeStrategy trait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Define the ChangeStrategy trait and Breakdown type. GreedyStrategy iterates denominations largest-to-smallest, taking the maximum of each — producing minimum denomination count. Verified against the sample output (88 cents = 3 quarters, 1 dime, 3 pennies). --- src/strategy/greedy.rs | 92 ++++++++++++++++++++++++++++++++++++++++++ src/strategy/mod.rs | 13 ++++++ 2 files changed, 105 insertions(+) create mode 100644 src/strategy/greedy.rs create mode 100644 src/strategy/mod.rs diff --git a/src/strategy/greedy.rs b/src/strategy/greedy.rs new file mode 100644 index 00000000..49db6cf4 --- /dev/null +++ b/src/strategy/greedy.rs @@ -0,0 +1,92 @@ +use crate::currency::Currency; +use super::{Breakdown, ChangeStrategy}; + +/// Greedy algorithm: use the fewest coins/bills possible. +/// +/// Iterates denominations largest-to-smallest, taking as many of each as +/// possible before moving to the next. Produces minimum denomination count +/// when the denomination set has the greedy property (true for USD and EUR). +pub struct GreedyStrategy; + +impl ChangeStrategy for GreedyStrategy { + fn make_change(&mut self, mut cents: u32, currency: &Currency) -> Breakdown { + let mut result = Vec::new(); + + for &denom in currency.denominations { + if cents == 0 { + break; + } + let count = cents / denom.cents; + if count > 0 { + result.push((denom, count)); + cents -= count * denom.cents; + } + } + + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::currency::USD; + + #[test] + fn sample_output_88_cents() { + // 3.00 - 2.12 = 0.88 -> 3 quarters, 1 dime, 3 pennies + let mut strategy = GreedyStrategy; + let breakdown = strategy.make_change(88, &USD); + + let named: Vec<(&str, u32)> = breakdown.iter().map(|(d, c)| (d.singular, *c)).collect(); + assert_eq!( + named, + vec![("quarter", 3), ("dime", 1), ("penny", 3)], + ); + } + + #[test] + fn sample_output_3_cents() { + // 2.00 - 1.97 = 0.03 -> 3 pennies + let mut strategy = GreedyStrategy; + let breakdown = strategy.make_change(3, &USD); + + let named: Vec<(&str, u32)> = breakdown.iter().map(|(d, c)| (d.singular, *c)).collect(); + assert_eq!(named, vec![("penny", 3)]); + } + + #[test] + fn exact_dollar_amount() { + let mut strategy = GreedyStrategy; + let breakdown = strategy.make_change(300, &USD); + + let named: Vec<(&str, u32)> = breakdown.iter().map(|(d, c)| (d.singular, *c)).collect(); + assert_eq!(named, vec![("dollar", 3)]); + } + + #[test] + fn zero_change() { + let mut strategy = GreedyStrategy; + let breakdown = strategy.make_change(0, &USD); + assert!(breakdown.is_empty()); + } + + #[test] + fn uses_all_denominations() { + // 141 = 100 + 25 + 10 + 5 + 1 + let mut strategy = GreedyStrategy; + let breakdown = strategy.make_change(141, &USD); + + let named: Vec<(&str, u32)> = breakdown.iter().map(|(d, c)| (d.singular, *c)).collect(); + assert_eq!( + named, + vec![ + ("dollar", 1), + ("quarter", 1), + ("dime", 1), + ("nickel", 1), + ("penny", 1), + ], + ); + } +} diff --git a/src/strategy/mod.rs b/src/strategy/mod.rs new file mode 100644 index 00000000..c1844198 --- /dev/null +++ b/src/strategy/mod.rs @@ -0,0 +1,13 @@ +pub mod greedy; +pub mod random; + +use crate::currency::{Currency, Denomination}; + +/// A breakdown of change: pairs of (denomination, count). +/// Only includes denominations with count > 0. +pub type Breakdown = Vec<(Denomination, u32)>; + +/// A strategy for making change. +pub trait ChangeStrategy { + fn make_change(&mut self, cents: u32, currency: &Currency) -> Breakdown; +} From 1f04140ab65f531f691eb3b4a3d6f30d77aa338d Mon Sep 17 00:00:00 2001 From: george larson Date: Fri, 27 Feb 2026 00:04:46 +0000 Subject: [PATCH 05/19] Implement randomized change algorithm with injectable RNG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RandomStrategy picks random counts for each denomination with the last one absorbing the remainder — guaranteeing exact change in O(n) with no retries. Generic RNG enables deterministic testing via StdRng::seed_from_u64(). --- src/strategy/random.rs | 130 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 src/strategy/random.rs diff --git a/src/strategy/random.rs b/src/strategy/random.rs new file mode 100644 index 00000000..523a6653 --- /dev/null +++ b/src/strategy/random.rs @@ -0,0 +1,130 @@ +use rand::Rng; + +use crate::currency::Currency; +use super::{Breakdown, ChangeStrategy}; + +/// Randomized change algorithm: pick random counts for each denomination. +/// +/// For each denomination (largest to smallest), picks a random count between +/// 0 and the maximum possible. The final denomination (1 cent) absorbs the +/// remainder, guaranteeing the total is always exact. +/// +/// Generic over `R: Rng` so tests can inject a seeded RNG for determinism. +pub struct RandomStrategy { + rng: R, +} + +impl RandomStrategy { + pub fn new(rng: R) -> Self { + Self { rng } + } +} + +impl ChangeStrategy for RandomStrategy { + fn make_change(&mut self, mut cents: u32, currency: &Currency) -> Breakdown { + let mut result = Vec::new(); + let denoms = currency.denominations; + + for (i, &denom) in denoms.iter().enumerate() { + if cents == 0 { + break; + } + + let max_count = cents / denom.cents; + if max_count == 0 { + continue; + } + + let is_last = i == denoms.len() - 1; + let count = if is_last { + // Last denomination absorbs the remainder — guarantees exact change + max_count + } else { + self.rng.gen_range(0..=max_count) + }; + + if count > 0 { + result.push((denom, count)); + cents -= count * denom.cents; + } + } + + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::currency::USD; + use rand::SeedableRng; + use rand::rngs::StdRng; + + fn seeded_strategy(seed: u64) -> RandomStrategy { + RandomStrategy::new(StdRng::seed_from_u64(seed)) + } + + #[test] + fn random_change_sums_correctly() { + for seed in 0..100 { + let mut strategy = seeded_strategy(seed); + let target = 167u32; // $1.67 + + let breakdown = strategy.make_change(target, &USD); + let total: u32 = breakdown.iter().map(|(d, c)| d.cents * c).sum(); + + assert_eq!( + total, target, + "seed {seed}: breakdown sums to {total}, expected {target}" + ); + } + } + + #[test] + fn random_change_all_counts_positive() { + for seed in 0..100 { + let mut strategy = seeded_strategy(seed); + let breakdown = strategy.make_change(250, &USD); + + for (denom, count) in &breakdown { + assert!( + *count > 0, + "seed {seed}: {} has count 0 but appeared in breakdown", + denom.singular + ); + } + } + } + + #[test] + fn random_change_zero() { + let mut strategy = seeded_strategy(42); + let breakdown = strategy.make_change(0, &USD); + assert!(breakdown.is_empty()); + } + + #[test] + fn deterministic_with_same_seed() { + let b1 = seeded_strategy(42).make_change(167, &USD); + let b2 = seeded_strategy(42).make_change(167, &USD); + + let counts1: Vec = b1.iter().map(|(_, c)| *c).collect(); + let counts2: Vec = b2.iter().map(|(_, c)| *c).collect(); + assert_eq!(counts1, counts2); + } + + #[test] + fn different_seeds_can_produce_different_results() { + // Not guaranteed, but with enough seeds at least one should differ + let baseline = seeded_strategy(0).make_change(500, &USD); + let baseline_counts: Vec = baseline.iter().map(|(_, c)| *c).collect(); + + let any_different = (1..50).any(|seed| { + let b = seeded_strategy(seed).make_change(500, &USD); + let counts: Vec = b.iter().map(|(_, c)| *c).collect(); + counts != baseline_counts + }); + + assert!(any_different, "random strategy should produce varied results across seeds"); + } +} From 6d1b939daaafcfe14f5cc2ad4372970621ceea69 Mon Sep 17 00:00:00 2001 From: george larson Date: Fri, 27 Feb 2026 00:04:51 +0000 Subject: [PATCH 06/19] Add rules engine and output formatting rules.rs dispatches to greedy or random strategy based on the divisor predicate (owed_cents % divisor == 0). format.rs handles singular/plural denomination names and comma joining. Both are independently testable with no coupling to each other. --- src/format.rs | 81 +++++++++++++++++++++++++++++++++++++++++++++ src/rules.rs | 91 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 src/format.rs create mode 100644 src/rules.rs diff --git a/src/format.rs b/src/format.rs new file mode 100644 index 00000000..fb7342d0 --- /dev/null +++ b/src/format.rs @@ -0,0 +1,81 @@ +use crate::strategy::Breakdown; + +/// Format a breakdown into the output string. +/// +/// Examples: +/// - `[(quarter, 3), (dime, 1), (penny, 3)]` -> `"3 quarters,1 dime,3 pennies"` +/// - `[]` -> `"no change"` +/// +/// Uses singular/plural from the denomination and joins with commas. +pub fn format_breakdown(breakdown: &Breakdown) -> String { + if breakdown.is_empty() { + return "no change".to_string(); + } + + breakdown + .iter() + .map(|(denom, count)| { + let name = if *count == 1 { + denom.singular + } else { + denom.plural + }; + format!("{count} {name}") + }) + .collect::>() + .join(",") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::currency::Denomination; + + fn penny() -> Denomination { + Denomination { cents: 1, singular: "penny", plural: "pennies" } + } + + fn quarter() -> Denomination { + Denomination { cents: 25, singular: "quarter", plural: "quarters" } + } + + fn dime() -> Denomination { + Denomination { cents: 10, singular: "dime", plural: "dimes" } + } + + fn dollar() -> Denomination { + Denomination { cents: 100, singular: "dollar", plural: "dollars" } + } + + #[test] + fn sample_output_format() { + let breakdown = vec![(quarter(), 3), (dime(), 1), (penny(), 3)]; + assert_eq!(format_breakdown(&breakdown), "3 quarters,1 dime,3 pennies"); + } + + #[test] + fn single_denomination_singular() { + let breakdown = vec![(dollar(), 1)]; + assert_eq!(format_breakdown(&breakdown), "1 dollar"); + } + + #[test] + fn single_denomination_plural() { + let breakdown = vec![(penny(), 5)]; + assert_eq!(format_breakdown(&breakdown), "5 pennies"); + } + + #[test] + fn empty_breakdown() { + assert_eq!(format_breakdown(&Vec::new()), "no change"); + } + + #[test] + fn matches_exact_sample_output() { + // "3 quarters,1 dime,3 pennies" — note: no spaces after commas + let breakdown = vec![(quarter(), 3), (dime(), 1), (penny(), 3)]; + let output = format_breakdown(&breakdown); + assert!(!output.contains(", "), "output should not have spaces after commas"); + assert_eq!(output, "3 quarters,1 dime,3 pennies"); + } +} diff --git a/src/rules.rs b/src/rules.rs new file mode 100644 index 00000000..ad517034 --- /dev/null +++ b/src/rules.rs @@ -0,0 +1,91 @@ +use rand::Rng; + +use crate::currency::Currency; +use crate::parse::Transaction; +use crate::strategy::greedy::GreedyStrategy; +use crate::strategy::random::RandomStrategy; +use crate::strategy::{Breakdown, ChangeStrategy}; + +/// Determine change for a transaction, dispatching to the appropriate strategy. +/// +/// If `owed_cents` is divisible by `divisor`, uses randomized denominations. +/// Otherwise, uses the greedy (minimum count) algorithm. +pub fn make_change_for( + transaction: &Transaction, + currency: &Currency, + divisor: u32, + rng: &mut R, +) -> Breakdown { + if transaction.change_cents == 0 { + return Vec::new(); + } + + if divisor > 0 && transaction.owed_cents % divisor == 0 { + RandomStrategy::new(rng).make_change(transaction.change_cents, currency) + } else { + GreedyStrategy.make_change(transaction.change_cents, currency) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::currency::USD; + use rand::SeedableRng; + use rand::rngs::StdRng; + + fn tx(owed: u32, paid: u32) -> Transaction { + Transaction { + owed_cents: owed, + paid_cents: paid, + change_cents: paid - owed, + } + } + + #[test] + fn divisible_by_3_uses_random() { + let mut rng = StdRng::seed_from_u64(42); + + // 333 is divisible by 3 + let random_result = make_change_for(&tx(333, 500), &USD, 3, &mut rng); + let total: u32 = random_result.iter().map(|(d, c)| d.cents * c).sum(); + assert_eq!(total, 167); + } + + #[test] + fn not_divisible_by_3_uses_greedy() { + let mut rng = StdRng::seed_from_u64(42); + + // 212 is not divisible by 3 + let result = make_change_for(&tx(212, 300), &USD, 3, &mut rng); + let named: Vec<(&str, u32)> = result.iter().map(|(d, c)| (d.singular, *c)).collect(); + assert_eq!(named, vec![("quarter", 3), ("dime", 1), ("penny", 3)]); + } + + #[test] + fn zero_divisor_always_uses_greedy() { + let mut rng = StdRng::seed_from_u64(42); + + // Even though 300 is divisible by 3, divisor is 0 so greedy is used + let result = make_change_for(&tx(300, 500), &USD, 0, &mut rng); + let named: Vec<(&str, u32)> = result.iter().map(|(d, c)| (d.singular, *c)).collect(); + assert_eq!(named, vec![("dollar", 2)]); + } + + #[test] + fn custom_divisor() { + let mut rng = StdRng::seed_from_u64(42); + + // 500 is divisible by 5 -> random + let result = make_change_for(&tx(500, 700), &USD, 5, &mut rng); + let total: u32 = result.iter().map(|(d, c)| d.cents * c).sum(); + assert_eq!(total, 200); + } + + #[test] + fn exact_payment_returns_empty() { + let mut rng = StdRng::seed_from_u64(42); + let result = make_change_for(&tx(300, 300), &USD, 3, &mut rng); + assert!(result.is_empty()); + } +} From e693dc95174a7d25419efcdd0ffc1477270abcd3 Mon Sep 17 00:00:00 2001 From: george larson Date: Fri, 27 Feb 2026 00:04:55 +0000 Subject: [PATCH 07/19] Add integration tests and property-based tests Integration tests run the binary against sample_input.txt and verify greedy lines match exactly, random lines sum correctly, and --seed produces deterministic output. Property tests (proptest) verify random strategy invariants across thousands of inputs: always sums to target, only valid denominations, all counts > 0. --- tests/integration.rs | 124 +++++++++++++++++++++++++++++++++++++++++++ tests/proptest.rs | 67 +++++++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 tests/integration.rs create mode 100644 tests/proptest.rs diff --git a/tests/integration.rs b/tests/integration.rs new file mode 100644 index 00000000..324e6280 --- /dev/null +++ b/tests/integration.rs @@ -0,0 +1,124 @@ +use std::process::Command; + +fn cargo_bin() -> Command { + let mut cmd = Command::new("cargo"); + cmd.args(["run", "--quiet", "--"]); + cmd.current_dir(env!("CARGO_MANIFEST_DIR")); + cmd +} + +#[test] +fn sample_input_greedy_lines() { + // Lines where owed is NOT divisible by 3 should produce deterministic output. + // 2.12 -> 212, 212 % 3 != 0 -> greedy: 88 cents = 3 quarters, 1 dime, 3 pennies + // 1.97 -> 197, 197 % 3 != 0 -> greedy: 3 cents = 3 pennies + // 3.33 -> 333, 333 % 3 == 0 -> random (skip checking this line) + let output = cargo_bin() + .arg("sample_input.txt") + .arg("--seed") + .arg("42") + .output() + .expect("failed to run binary"); + + let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec<&str> = stdout.lines().collect(); + + assert_eq!(lines.len(), 3, "expected 3 output lines, got: {stdout:?}"); + assert_eq!(lines[0], "3 quarters,1 dime,3 pennies"); + assert_eq!(lines[1], "3 pennies"); + // Line 3 is random — just verify it's non-empty + assert!(!lines[2].is_empty(), "random line should not be empty"); +} + +#[test] +fn seed_produces_deterministic_output() { + let run = |seed: &str| -> String { + let output = cargo_bin() + .args(["sample_input.txt", "--seed", seed]) + .output() + .expect("failed to run binary"); + String::from_utf8_lossy(&output.stdout).to_string() + }; + + let first = run("99"); + let second = run("99"); + assert_eq!(first, second, "same seed should produce identical output"); +} + +#[test] +fn custom_divisor() { + // With --divisor 0, no lines are random, so all output is greedy + let output = cargo_bin() + .args(["sample_input.txt", "--divisor", "0"]) + .output() + .expect("failed to run binary"); + + let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec<&str> = stdout.lines().collect(); + + assert_eq!(lines.len(), 3); + assert_eq!(lines[0], "3 quarters,1 dime,3 pennies"); + assert_eq!(lines[1], "3 pennies"); + // With divisor 0, line 3 should also be greedy: 167 = 1 dollar,2 quarters,1 dime,1 nickel,2 pennies + assert_eq!(lines[2], "1 dollar,2 quarters,1 dime,1 nickel,2 pennies"); +} + +#[test] +fn missing_file_returns_nonzero_exit() { + let output = cargo_bin() + .arg("nonexistent.txt") + .output() + .expect("failed to run binary"); + + assert!(!output.status.success()); +} + +#[test] +fn no_args_shows_usage() { + let output = Command::new("cargo") + .args(["run", "--quiet"]) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .output() + .expect("failed to run binary"); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("Usage"), "should show usage message"); + assert!(!output.status.success()); +} + +#[test] +fn random_line_sums_correctly() { + // 3.33,5.00 -> 167 cents of change, owed 333 which is divisible by 3 + // Verify the random output sums to the correct total + let output = cargo_bin() + .args(["sample_input.txt", "--seed", "42"]) + .output() + .expect("failed to run binary"); + + let stdout = String::from_utf8_lossy(&output.stdout); + let random_line = stdout.lines().nth(2).expect("expected 3 lines"); + + let total = parse_output_cents(random_line); + assert_eq!(total, 167, "random change should sum to 167 cents (got {total})"); +} + +/// Parse an output line like "1 dollar,2 quarters,1 nickel,2 pennies" into total cents. +fn parse_output_cents(line: &str) -> u32 { + line.split(',') + .map(|part| { + let part = part.trim(); + let (count_str, name) = part.split_once(' ').expect("expected 'N name' format"); + let count: u32 = count_str.parse().expect("expected numeric count"); + let cents_per = match name { + "dollar" | "dollars" => 100, + "half dollar" | "half dollars" => 50, + "quarter" | "quarters" => 25, + "dime" | "dimes" => 10, + "nickel" | "nickels" => 5, + "penny" | "pennies" => 1, + other => panic!("unknown denomination: {other}"), + }; + count * cents_per + }) + .sum() +} diff --git a/tests/proptest.rs b/tests/proptest.rs new file mode 100644 index 00000000..52c36ef0 --- /dev/null +++ b/tests/proptest.rs @@ -0,0 +1,67 @@ +use proptest::prelude::*; +use rand::rngs::StdRng; +use rand::SeedableRng; + +use cash_register::currency::USD; +use cash_register::strategy::random::RandomStrategy; +use cash_register::strategy::greedy::GreedyStrategy; +use cash_register::strategy::ChangeStrategy; + +proptest! { + #[test] + fn random_always_sums_to_target(cents in 1u32..10_000, seed in any::()) { + let rng = StdRng::seed_from_u64(seed); + let mut strategy = RandomStrategy::new(rng); + let breakdown = strategy.make_change(cents, &USD); + + let total: u32 = breakdown.iter().map(|(d, c)| d.cents * c).sum(); + prop_assert_eq!(total, cents, "random breakdown must sum to target"); + } + + #[test] + fn random_uses_only_valid_denominations(cents in 1u32..10_000, seed in any::()) { + let rng = StdRng::seed_from_u64(seed); + let mut strategy = RandomStrategy::new(rng); + let breakdown = strategy.make_change(cents, &USD); + + let valid_values: Vec = USD.denominations.iter().map(|d| d.cents).collect(); + for (denom, _) in &breakdown { + prop_assert!( + valid_values.contains(&denom.cents), + "denomination {} is not in USD", + denom.cents + ); + } + } + + #[test] + fn random_all_counts_positive(cents in 1u32..10_000, seed in any::()) { + let rng = StdRng::seed_from_u64(seed); + let mut strategy = RandomStrategy::new(rng); + let breakdown = strategy.make_change(cents, &USD); + + for (denom, count) in &breakdown { + prop_assert!(*count > 0, "{} has count 0", denom.singular); + } + } + + #[test] + fn greedy_always_sums_to_target(cents in 0u32..10_000) { + let mut strategy = GreedyStrategy; + let breakdown = strategy.make_change(cents, &USD); + + let total: u32 = breakdown.iter().map(|(d, c)| d.cents * c).sum(); + prop_assert_eq!(total, cents, "greedy breakdown must sum to target"); + } + + #[test] + fn greedy_uses_minimum_coins(cents in 1u32..100) { + // For USD denominations, greedy should never use more coins than + // just using all pennies + let mut strategy = GreedyStrategy; + let breakdown = strategy.make_change(cents, &USD); + + let total_coins: u32 = breakdown.iter().map(|(_, c)| c).sum(); + prop_assert!(total_coins <= cents, "greedy should use at most {cents} coins, used {total_coins}"); + } +} From 9bb1d9312d07bcf24a600423853036580bc0b42f Mon Sep 17 00:00:00 2001 From: george larson Date: Fri, 27 Feb 2026 00:05:00 +0000 Subject: [PATCH 08/19] Update README with design decisions and extensibility notes Document build/run/test commands, architecture overview, and key design decisions (integer cents, strategy trait, injectable RNG). Answer the three "Things to Consider" questions: changing the divisor, adding new strategies, and supporting French currency. --- README.md | 115 ++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 89 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index fccbe0ad..24dc3a0d 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,106 @@ # Cash Register -## The Problem -Creative Cash Draw Solutions is a client who wants to provide something different for the cashiers who use their system. The function of the application is to tell the cashier how much change is owed, and what denominations should be used. In most cases the app should return the minimum amount of physical change, but the client would like to add a twist. If the "owed" amount is divisible by 3, the app should randomly generate the change denominations (but the math still needs to be right :)) +A command-line tool that calculates change denominations for cashiers. Written in Rust. -Please write a program which accomplishes the clients goals. The program should: +## Quick Start -1. Accept a flat file as input - 1. Each line will contain the amount owed and the amount paid separated by a comma (for example: 2.13,3.00) - 2. Expect that there will be multiple lines -2. Output the change the cashier should return to the customer - 1. The return string should look like: 1 dollar,2 quarters,1 nickel, etc ... - 2. Each new line in the input file should be a new line in the output file +```bash +cargo build +cargo run -- sample_input.txt +cargo test +``` -## Sample Input -2.12,3.00 +## Usage -1.97,2.00 +``` +cash-register [--divisor N] [--seed N] +``` -3.33,5.00 +**Input file**: Each line contains `owed,paid` as dollar amounts (e.g., `2.13,3.00`). Blank lines are skipped. -## Sample Output -3 quarters,1 dime,3 pennies +**Output**: One line per transaction showing the change denominations. +```bash +$ cargo run -- sample_input.txt +3 quarters,1 dime,3 pennies 3 pennies +1 dollar,2 quarters,1 dime,1 nickel,2 pennies # random — your output will vary +``` + +### Flags + +- `--divisor N` — Change which transactions get randomized denominations (default: 3). If `owed` in cents is divisible by N, the change is randomized. Use `--divisor 0` to disable randomization entirely. +- `--seed N` — Seed the random number generator for reproducible output. Useful for testing. + +## The Problem + +[Original problem statement from TrueFit](https://github.com/TrueFit/CashRegister): given a flat file of `owed,paid` pairs, output change denominations. When the owed amount is divisible by 3, randomize the denominations instead of minimizing them. + +## Design Decisions + +### Integer cents everywhere + +All money is represented as `u32` cents. The string `"2.13"` is parsed via string manipulation into `213u32` — no floating-point arithmetic is ever used. This eliminates an entire class of rounding bugs (e.g., `0.1 + 0.2 != 0.3` in IEEE 754). + +### Strategy trait with concrete types + +A `ChangeStrategy` trait defines the contract. `GreedyStrategy` minimizes denomination count; `RandomStrategy` randomizes it. The `rules` module decides which strategy to use based on transaction properties. Each piece has a single responsibility: + +- Algorithms don't know about business rules +- Rules don't know about algorithm internals +- Adding either doesn't require changing the other + +### Injectable randomness + +`RandomStrategy` is generic over its RNG source. Tests inject `StdRng::seed_from_u64()` for deterministic assertions. Production uses `StdRng::from_entropy()`. Zero-cost abstraction via monomorphization — no `Box`. + +### No heavy dependencies + +CLI argument parsing is a 5-line generic function, not a 50KB dependency. The only runtime dependencies are `thiserror` (structured errors) and `rand` (randomization) — both are well-established, minimal crates. + +### Property-based testing + +Unit tests verify specific cases. Property tests (`proptest`) verify invariants across thousands of random inputs: the random algorithm always sums to the target amount, only uses valid denominations, and never includes zero-count entries. + +## Architecture + +``` +src/ + main.rs CLI wiring: arg parsing, file I/O, exit codes + lib.rs Module re-exports + error.rs Error types with line numbers (thiserror) + currency.rs Denomination definitions — USD, EUR configs + parse.rs String → cents conversion, line → Transaction + strategy/ + mod.rs ChangeStrategy trait, Breakdown type alias + greedy.rs Minimum denomination count algorithm + random.rs Randomized denomination algorithm + rules.rs Strategy dispatch: divisor check → greedy or random + format.rs Breakdown → output string (pluralization, joining) +tests/ + integration.rs End-to-end binary tests + proptest.rs Property-based correctness tests +``` + +## Things to Consider + +> What might happen if the client needs to change the random divisor? -1 dollar,1 quarter,6 nickels,12 pennies +Pass `--divisor N` at the command line. The divisor flows through `rules::make_change_for` as a parameter — no code changes needed. Setting `--divisor 0` disables randomization entirely. -*Remember the last one is random +> What might happen if the client needs to add another special case (like the random twist)? -## The Fine Print -Please use whatever technology and techniques you feel are applicable to solve the problem. We suggest that you approach this exercise as if this code was part of a larger system. The end result should be representative of your abilities and style. +Add a new strategy struct implementing `ChangeStrategy` (one file), then add a branch in `rules.rs`. Existing strategies and tests are untouched. For example, a "round up to nearest quarter" strategy would be ~20 lines of code and one new match arm. -Please fork this repository. When you have completed your solution, please issue a pull request to notify us that you are ready. +> What might happen if sales closes a new client in France? -Have fun. +Add a `EUR` constant in `currency.rs` (already included as an example) and pass it instead of `USD`. The denomination table drives all formatting — singular/plural names, values, everything. One caveat: France uses commas as decimal separators (`2,13` not `2.13`), which conflicts with the comma-delimited input format. A real deployment would need a configurable delimiter or a different input format (e.g., TSV, JSON). -## Things To Consider -Here are a couple of thoughts about the domain that could influence your response: +## Testing -* What might happen if the client needs to change the random divisor? -* What might happen if the client needs to add another special case (like the random twist)? -* What might happen if sales closes a new client in France? \ No newline at end of file +```bash +cargo test # All 48 tests: unit + integration + property-based +cargo test --lib # Unit tests only (37 tests) +cargo test --test integration # Integration tests only (6 tests) +cargo test --test proptest # Property-based tests only (5 tests) +``` From 7a2b80291d4d8fd71502ad027fb444e2155bdb87 Mon Sep 17 00:00:00 2001 From: george larson Date: Fri, 27 Feb 2026 00:25:30 +0000 Subject: [PATCH 09/19] Add --currency flag to CLI for EUR support EUR was defined in currency.rs but inaccessible from the CLI. Add --currency USD|EUR flag so the France extensibility claim is actually demonstrable, not just theoretical. --- src/main.rs | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/main.rs b/src/main.rs index 26abb42e..cb8826ba 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,7 @@ use std::process; use rand::rngs::StdRng; use rand::SeedableRng; -use cash_register::currency::USD; +use cash_register::currency::{USD, EUR}; use cash_register::format::format_breakdown; use cash_register::parse::parse_input; use cash_register::rules::make_change_for; @@ -14,13 +14,23 @@ fn main() { let args: Vec = env::args().collect(); if args.len() < 2 { - eprintln!("Usage: cash-register [--divisor N] [--seed N]"); + eprintln!("Usage: cash-register [--divisor N] [--seed N] [--currency USD|EUR]"); process::exit(1); } let file_path = &args[1]; let divisor: u32 = parse_flag(&args, "--divisor").unwrap_or(3); let seed: Option = parse_flag(&args, "--seed"); + let currency_name = parse_string_flag(&args, "--currency").unwrap_or("USD".to_string()); + + let currency = match currency_name.to_uppercase().as_str() { + "USD" => &USD, + "EUR" => &EUR, + other => { + eprintln!("Unknown currency: {other}. Supported: USD, EUR"); + process::exit(1); + } + }; let input = match fs::read_to_string(file_path) { Ok(content) => content, @@ -30,7 +40,6 @@ fn main() { } }; - let currency = &USD; let mut had_error = false; // Use a concrete StdRng regardless — seeded or from entropy. @@ -65,3 +74,11 @@ fn parse_flag(args: &[String], flag: &str) -> Option { .and_then(|i| args.get(i + 1)) .and_then(|v| v.parse().ok()) } + +/// Parse a `--flag value` pair as a string. +fn parse_string_flag(args: &[String], flag: &str) -> Option { + args.iter() + .position(|a| a == flag) + .and_then(|i| args.get(i + 1)) + .map(|v| v.clone()) +} From 7c83b35a8a1d2b96c14a7e4a38ab48d5e969c0ff Mon Sep 17 00:00:00 2001 From: george larson Date: Fri, 27 Feb 2026 00:25:36 +0000 Subject: [PATCH 10/19] Add sample files for edge cases and EUR currency sample_edge_cases.txt: exact payment, single penny, large amount, sub-dollar amounts. sample_eur.txt: EUR transactions for demoing multi-currency support. Both are runnable out of the box. --- sample_edge_cases.txt | 6 ++++++ sample_eur.txt | 4 ++++ 2 files changed, 10 insertions(+) create mode 100644 sample_edge_cases.txt create mode 100644 sample_eur.txt diff --git a/sample_edge_cases.txt b/sample_edge_cases.txt new file mode 100644 index 00000000..811bdd60 --- /dev/null +++ b/sample_edge_cases.txt @@ -0,0 +1,6 @@ +5.00,5.00 +0.01,1.00 +100.00,200.00 +1.97,2.00 +3.00,5.00 +0.75,1.00 diff --git a/sample_eur.txt b/sample_eur.txt new file mode 100644 index 00000000..636f665f --- /dev/null +++ b/sample_eur.txt @@ -0,0 +1,4 @@ +1.50,2.00 +3.33,5.00 +0.37,1.00 +7.77,10.00 From cd0f2f21a7d42d76a638bb632ac5c8b18f97d3cd Mon Sep 17 00:00:00 2001 From: george larson Date: Fri, 27 Feb 2026 00:25:43 +0000 Subject: [PATCH 11/19] Add EUR and edge-case unit tests for both strategies Test greedy with EUR denominations (63c, 387c), single penny, and large amounts ($99.99). Test random with EUR across 100 seeds. Ensures the strategies are currency-agnostic, not just USD-tested. --- src/strategy/greedy.rs | 61 +++++++++++++++++++++++++++++++++++++++++- src/strategy/random.rs | 16 ++++++++++- 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/src/strategy/greedy.rs b/src/strategy/greedy.rs index 49db6cf4..c404f2f9 100644 --- a/src/strategy/greedy.rs +++ b/src/strategy/greedy.rs @@ -30,7 +30,7 @@ impl ChangeStrategy for GreedyStrategy { #[cfg(test)] mod tests { use super::*; - use crate::currency::USD; + use crate::currency::{USD, EUR}; #[test] fn sample_output_88_cents() { @@ -89,4 +89,63 @@ mod tests { ], ); } + + #[test] + fn single_penny() { + let mut strategy = GreedyStrategy; + let breakdown = strategy.make_change(1, &USD); + let named: Vec<(&str, u32)> = breakdown.iter().map(|(d, c)| (d.singular, *c)).collect(); + assert_eq!(named, vec![("penny", 1)]); + } + + #[test] + fn large_amount() { + // $99.99 = 9999 cents + let mut strategy = GreedyStrategy; + let breakdown = strategy.make_change(9999, &USD); + let total: u32 = breakdown.iter().map(|(d, c)| d.cents * c).sum(); + assert_eq!(total, 9999); + // Should be 99 dollars, 3 quarters, 2 dimes, 4 pennies + let named: Vec<(&str, u32)> = breakdown.iter().map(|(d, c)| (d.singular, *c)).collect(); + assert_eq!(named, vec![("dollar", 99), ("quarter", 3), ("dime", 2), ("penny", 4)]); + } + + #[test] + fn eur_greedy_63_cents() { + // 63 = 50 + 10 + 2 + 1 + let mut strategy = GreedyStrategy; + let breakdown = strategy.make_change(63, &EUR); + + let named: Vec<(&str, u32)> = breakdown.iter().map(|(d, c)| (d.singular, *c)).collect(); + assert_eq!( + named, + vec![ + ("50 cent coin", 1), + ("10 cent coin", 1), + ("2 cent coin", 1), + ("1 cent coin", 1), + ], + ); + } + + #[test] + fn eur_greedy_387_cents() { + // 387 = 200 + 100 + 50 + 20 + 10 + 5 + 2 + let mut strategy = GreedyStrategy; + let breakdown = strategy.make_change(387, &EUR); + + let named: Vec<(&str, u32)> = breakdown.iter().map(|(d, c)| (d.singular, *c)).collect(); + assert_eq!( + named, + vec![ + ("2 euro coin", 1), + ("1 euro coin", 1), + ("50 cent coin", 1), + ("20 cent coin", 1), + ("10 cent coin", 1), + ("5 cent coin", 1), + ("2 cent coin", 1), + ], + ); + } } diff --git a/src/strategy/random.rs b/src/strategy/random.rs index 523a6653..c30b79ed 100644 --- a/src/strategy/random.rs +++ b/src/strategy/random.rs @@ -56,7 +56,7 @@ impl ChangeStrategy for RandomStrategy { #[cfg(test)] mod tests { use super::*; - use crate::currency::USD; + use crate::currency::{USD, EUR}; use rand::SeedableRng; use rand::rngs::StdRng; @@ -127,4 +127,18 @@ mod tests { assert!(any_different, "random strategy should produce varied results across seeds"); } + + #[test] + fn eur_random_sums_correctly() { + for seed in 0..100 { + let mut strategy = seeded_strategy(seed); + let target = 263u32; // EUR has 2-cent coins and 20-cent coins — different structure + let breakdown = strategy.make_change(target, &EUR); + let total: u32 = breakdown.iter().map(|(d, c)| d.cents * c).sum(); + assert_eq!( + total, target, + "seed {seed}: EUR breakdown sums to {total}, expected {target}" + ); + } + } } From b5a22bbaac92108e6554fe50b72003dfdea7c300 Mon Sep 17 00:00:00 2001 From: george larson Date: Fri, 27 Feb 2026 00:25:49 +0000 Subject: [PATCH 12/19] Expand integration and property tests for EUR and error paths Integration: EUR end-to-end greedy/random, edge case file, unknown currency error, underpayment stderr message, malformed line with line number, mixed valid/invalid input processing. Property tests: EUR random sums, EUR greedy sums, EUR valid denominations only. --- tests/integration.rs | 205 +++++++++++++++++++++++++++++++++++++++++-- tests/proptest.rs | 39 +++++++- 2 files changed, 234 insertions(+), 10 deletions(-) diff --git a/tests/integration.rs b/tests/integration.rs index 324e6280..945b7f5e 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -7,6 +7,8 @@ fn cargo_bin() -> Command { cmd } +// ─── Sample input tests ───────────────────────────────────────────── + #[test] fn sample_input_greedy_lines() { // Lines where owed is NOT divisible by 3 should produce deterministic output. @@ -63,6 +65,107 @@ fn custom_divisor() { assert_eq!(lines[2], "1 dollar,2 quarters,1 dime,1 nickel,2 pennies"); } +#[test] +fn random_line_sums_correctly() { + // 3.33,5.00 -> 167 cents of change, owed 333 which is divisible by 3 + let output = cargo_bin() + .args(["sample_input.txt", "--seed", "42"]) + .output() + .expect("failed to run binary"); + + let stdout = String::from_utf8_lossy(&output.stdout); + let random_line = stdout.lines().nth(2).expect("expected 3 lines"); + + let total = parse_output_cents(random_line); + assert_eq!(total, 167, "random change should sum to 167 cents (got {total})"); +} + +// ─── Edge case tests ──────────────────────────────────────────────── + +#[test] +fn exact_payment_outputs_no_change() { + let output = cargo_bin() + .args(["sample_edge_cases.txt", "--divisor", "0"]) + .output() + .expect("failed to run binary"); + + let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec<&str> = stdout.lines().collect(); + + // 5.00,5.00 -> exact payment + assert_eq!(lines[0], "no change"); +} + +#[test] +fn edge_cases_greedy_output() { + let output = cargo_bin() + .args(["sample_edge_cases.txt", "--divisor", "0"]) + .output() + .expect("failed to run binary"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec<&str> = stdout.lines().collect(); + + assert_eq!(lines.len(), 6); + assert_eq!(lines[0], "no change"); // 5.00,5.00 + assert_eq!(lines[1], "3 quarters,2 dimes,4 pennies"); // 0.01,1.00 = 99c + assert_eq!(lines[2], "100 dollars"); // 100.00,200.00 = $100 + assert_eq!(lines[3], "3 pennies"); // 1.97,2.00 + assert_eq!(lines[4], "2 dollars"); // 3.00,5.00 + assert_eq!(lines[5], "1 quarter"); // 0.75,1.00 +} + +// ─── EUR end-to-end tests ─────────────────────────────────────────── + +#[test] +fn eur_greedy_output() { + let output = cargo_bin() + .args(["sample_eur.txt", "--currency", "EUR", "--divisor", "0"]) + .output() + .expect("failed to run binary"); + + assert!(output.status.success(), "stderr: {}", String::from_utf8_lossy(&output.stderr)); + let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec<&str> = stdout.lines().collect(); + + assert_eq!(lines.len(), 4); + assert_eq!(lines[0], "1 50 cent coin"); // 1.50,2.00 = 50c + assert_eq!(lines[1], "1 1 euro coin,1 50 cent coin,1 10 cent coin,1 5 cent coin,1 2 cent coin"); // 3.33,5.00 = 167c + assert_eq!(lines[2], "1 50 cent coin,1 10 cent coin,1 2 cent coin,1 1 cent coin"); // 0.37,1.00 = 63c + assert_eq!(lines[3], "1 2 euro coin,1 20 cent coin,1 2 cent coin,1 1 cent coin"); // 7.77,10.00 = 223c +} + +#[test] +fn eur_random_sums_correctly() { + // 3.33 -> 333, divisible by 3, so random with EUR + let output = cargo_bin() + .args(["sample_eur.txt", "--currency", "EUR", "--seed", "42"]) + .output() + .expect("failed to run binary"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let random_line = stdout.lines().nth(1).expect("expected 4 lines"); // line 2 is 3.33,5.00 + + let total = parse_eur_output_cents(random_line); + assert_eq!(total, 167, "EUR random change should sum to 167 cents (got {total})"); +} + +#[test] +fn unknown_currency_fails() { + let output = cargo_bin() + .args(["sample_input.txt", "--currency", "GBP"]) + .output() + .expect("failed to run binary"); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("Unknown currency"), "expected currency error, got: {stderr}"); +} + +// ─── Error handling tests ─────────────────────────────────────────── + #[test] fn missing_file_returns_nonzero_exit() { let output = cargo_bin() @@ -71,6 +174,8 @@ fn missing_file_returns_nonzero_exit() { .expect("failed to run binary"); assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("Error reading"), "expected file error message, got: {stderr}"); } #[test] @@ -87,22 +192,80 @@ fn no_args_shows_usage() { } #[test] -fn random_line_sums_correctly() { - // 3.33,5.00 -> 167 cents of change, owed 333 which is divisible by 3 - // Verify the random output sums to the correct total +fn underpayment_reports_error_to_stderr() { + // Write a temp file with an underpayment line + let dir = env!("CARGO_MANIFEST_DIR"); + let path = format!("{dir}/test_underpayment.txt"); + std::fs::write(&path, "5.00,3.00\n").unwrap(); + let output = cargo_bin() - .args(["sample_input.txt", "--seed", "42"]) + .arg(&path) + .output() + .expect("failed to run binary"); + + std::fs::remove_file(&path).ok(); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("paid"), "expected underpayment error, got: {stderr}"); + assert!(stderr.contains("less than"), "expected underpayment message, got: {stderr}"); +} + +#[test] +fn malformed_line_reports_error_with_line_number() { + let dir = env!("CARGO_MANIFEST_DIR"); + let path = format!("{dir}/test_malformed.txt"); + std::fs::write(&path, "2.12,3.00\nbad_line\n1.97,2.00\n").unwrap(); + + let output = cargo_bin() + .arg(&path) .output() .expect("failed to run binary"); + std::fs::remove_file(&path).ok(); + + // Should still process valid lines let stdout = String::from_utf8_lossy(&output.stdout); - let random_line = stdout.lines().nth(2).expect("expected 3 lines"); + assert_eq!(stdout.lines().count(), 2, "should output 2 valid lines"); - let total = parse_output_cents(random_line); - assert_eq!(total, 167, "random change should sum to 167 cents (got {total})"); + // Should report error for line 2 + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("line 2"), "expected line number in error, got: {stderr}"); + + // Should exit with error code + assert!(!output.status.success()); } -/// Parse an output line like "1 dollar,2 quarters,1 nickel,2 pennies" into total cents. +#[test] +fn mixed_valid_and_invalid_processes_all() { + let dir = env!("CARGO_MANIFEST_DIR"); + let path = format!("{dir}/test_mixed.txt"); + std::fs::write(&path, "1.00,2.00\nabc,def\n0.50,1.00\n").unwrap(); + + let output = cargo_bin() + .args([&path, "--divisor", "0"]) + .output() + .expect("failed to run binary"); + + std::fs::remove_file(&path).ok(); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + // Valid lines still produce output + let lines: Vec<&str> = stdout.lines().collect(); + assert_eq!(lines.len(), 2); + assert_eq!(lines[0], "1 dollar"); + assert_eq!(lines[1], "2 quarters"); + + // Bad line reported to stderr + assert!(stderr.contains("line 2"), "error should reference line 2"); + assert!(!output.status.success()); +} + +// ─── Helpers ──────────────────────────────────────────────────────── + +/// Parse a USD output line like "1 dollar,2 quarters,1 nickel,2 pennies" into total cents. fn parse_output_cents(line: &str) -> u32 { line.split(',') .map(|part| { @@ -111,7 +274,6 @@ fn parse_output_cents(line: &str) -> u32 { let count: u32 = count_str.parse().expect("expected numeric count"); let cents_per = match name { "dollar" | "dollars" => 100, - "half dollar" | "half dollars" => 50, "quarter" | "quarters" => 25, "dime" | "dimes" => 10, "nickel" | "nickels" => 5, @@ -122,3 +284,28 @@ fn parse_output_cents(line: &str) -> u32 { }) .sum() } + +/// Parse a EUR output line into total cents. +fn parse_eur_output_cents(line: &str) -> u32 { + // EUR format: "1 2 euro coin,2 50 cent coins,..." — split on comma, + // then the first token is the count, the rest is the denomination name. + line.split(',') + .map(|part| { + let part = part.trim(); + let (count_str, name) = part.split_once(' ').expect("expected 'N name' format"); + let count: u32 = count_str.parse().expect("expected numeric count"); + let cents_per = match name { + "2 euro coin" | "2 euro coins" => 200, + "1 euro coin" | "1 euro coins" => 100, + "50 cent coin" | "50 cent coins" => 50, + "20 cent coin" | "20 cent coins" => 20, + "10 cent coin" | "10 cent coins" => 10, + "5 cent coin" | "5 cent coins" => 5, + "2 cent coin" | "2 cent coins" => 2, + "1 cent coin" | "1 cent coins" => 1, + other => panic!("unknown EUR denomination: {other}"), + }; + count * cents_per + }) + .sum() +} diff --git a/tests/proptest.rs b/tests/proptest.rs index 52c36ef0..c6dfb356 100644 --- a/tests/proptest.rs +++ b/tests/proptest.rs @@ -2,7 +2,7 @@ use proptest::prelude::*; use rand::rngs::StdRng; use rand::SeedableRng; -use cash_register::currency::USD; +use cash_register::currency::{USD, EUR}; use cash_register::strategy::random::RandomStrategy; use cash_register::strategy::greedy::GreedyStrategy; use cash_register::strategy::ChangeStrategy; @@ -64,4 +64,41 @@ proptest! { let total_coins: u32 = breakdown.iter().map(|(_, c)| c).sum(); prop_assert!(total_coins <= cents, "greedy should use at most {cents} coins, used {total_coins}"); } + + // --- EUR property tests --- + + #[test] + fn eur_random_always_sums_to_target(cents in 1u32..10_000, seed in any::()) { + let rng = StdRng::seed_from_u64(seed); + let mut strategy = RandomStrategy::new(rng); + let breakdown = strategy.make_change(cents, &EUR); + + let total: u32 = breakdown.iter().map(|(d, c)| d.cents * c).sum(); + prop_assert_eq!(total, cents, "EUR random breakdown must sum to target"); + } + + #[test] + fn eur_greedy_always_sums_to_target(cents in 0u32..10_000) { + let mut strategy = GreedyStrategy; + let breakdown = strategy.make_change(cents, &EUR); + + let total: u32 = breakdown.iter().map(|(d, c)| d.cents * c).sum(); + prop_assert_eq!(total, cents, "EUR greedy breakdown must sum to target"); + } + + #[test] + fn eur_random_uses_only_valid_denominations(cents in 1u32..10_000, seed in any::()) { + let rng = StdRng::seed_from_u64(seed); + let mut strategy = RandomStrategy::new(rng); + let breakdown = strategy.make_change(cents, &EUR); + + let valid_values: Vec = EUR.denominations.iter().map(|d| d.cents).collect(); + for (denom, _) in &breakdown { + prop_assert!( + valid_values.contains(&denom.cents), + "denomination {} is not in EUR", + denom.cents + ); + } + } } From a30ba578f2182132ee6e5d7557b732954d632ab3 Mon Sep 17 00:00:00 2001 From: george larson Date: Fri, 27 Feb 2026 00:25:55 +0000 Subject: [PATCH 13/19] Document --currency flag and sample files in README Add EUR and edge-case sample output examples. Update usage line, flags section, and France extensibility answer to reference the now-functional --currency EUR flag. --- README.md | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 24dc3a0d..6c61a86e 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ cargo test ## Usage ``` -cash-register [--divisor N] [--seed N] +cash-register [--divisor N] [--seed N] [--currency USD|EUR] ``` **Input file**: Each line contains `owed,paid` as dollar amounts (e.g., `2.13,3.00`). Blank lines are skipped. @@ -25,12 +25,27 @@ $ cargo run -- sample_input.txt 3 quarters,1 dime,3 pennies 3 pennies 1 dollar,2 quarters,1 dime,1 nickel,2 pennies # random — your output will vary + +$ cargo run -- sample_edge_cases.txt --divisor 0 +no change +3 quarters,2 dimes,4 pennies +1 dollar +3 pennies +2 dollars +1 quarter + +$ cargo run -- sample_eur.txt --currency EUR --divisor 0 +1 50 cent coin +1 1 euro coin,1 50 cent coin,1 10 cent coin,1 5 cent coin,1 2 cent coin +1 50 cent coin,1 10 cent coin,1 2 cent coin,1 1 cent coin +1 2 euro coin,1 20 cent coin,1 2 cent coin,1 1 cent coin ``` ### Flags - `--divisor N` — Change which transactions get randomized denominations (default: 3). If `owed` in cents is divisible by N, the change is randomized. Use `--divisor 0` to disable randomization entirely. - `--seed N` — Seed the random number generator for reproducible output. Useful for testing. +- `--currency USD|EUR` — Select the currency denomination set (default: USD). ## The Problem @@ -94,7 +109,7 @@ Add a new strategy struct implementing `ChangeStrategy` (one file), then add a b > What might happen if sales closes a new client in France? -Add a `EUR` constant in `currency.rs` (already included as an example) and pass it instead of `USD`. The denomination table drives all formatting — singular/plural names, values, everything. One caveat: France uses commas as decimal separators (`2,13` not `2.13`), which conflicts with the comma-delimited input format. A real deployment would need a configurable delimiter or a different input format (e.g., TSV, JSON). +Pass `--currency EUR`. The EUR denomination table is already defined and wired into the CLI. Try it: `cargo run -- sample_eur.txt --currency EUR`. The denomination table drives all formatting — singular/plural names, values, everything. One caveat: France uses commas as decimal separators (`2,13` not `2.13`), which conflicts with the comma-delimited input format. A real deployment would need a configurable delimiter or a different input format (e.g., TSV, JSON). ## Testing From 0141ad27963a80671767ec0a17fbdddac686cf30 Mon Sep 17 00:00:00 2001 From: george larson Date: Fri, 27 Feb 2026 00:33:02 +0000 Subject: [PATCH 14/19] Add --verbose flag for human-readable transaction output Shows "Owed $2.12, Paid $3.00 -> 3 quarters,1 dime,3 pennies" with "(random)" label on randomized lines. Default output remains spec-compliant. Makes sample files self-explanatory when demoing. --- src/format.rs | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 12 +++++++--- 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/src/format.rs b/src/format.rs index fb7342d0..44757608 100644 --- a/src/format.rs +++ b/src/format.rs @@ -1,3 +1,4 @@ +use crate::parse::Transaction; use crate::strategy::Breakdown; /// Format a breakdown into the output string. @@ -26,6 +27,29 @@ pub fn format_breakdown(breakdown: &Breakdown) -> String { .join(",") } +/// Format cents as a dollar string: 213 -> "$2.13", 5 -> "$0.05". +fn cents_to_dollar_string(cents: u32) -> String { + format!("${}.{:02}", cents / 100, cents % 100) +} + +/// Format a transaction with its breakdown for verbose output. +/// +/// Example: "Owed $2.12, Paid $3.00 -> 3 quarters,1 dime,3 pennies" +/// With randomization: "Owed $3.33, Paid $5.00 -> 1 dollar,2 quarters (random)" +pub fn format_verbose( + transaction: &Transaction, + breakdown: &Breakdown, + is_random: bool, +) -> String { + let change = format_breakdown(breakdown); + let label = if is_random { " (random)" } else { "" }; + format!( + "Owed {}, Paid {} -> {change}{label}", + cents_to_dollar_string(transaction.owed_cents), + cents_to_dollar_string(transaction.paid_cents), + ) +} + #[cfg(test)] mod tests { use super::*; @@ -78,4 +102,42 @@ mod tests { assert!(!output.contains(", "), "output should not have spaces after commas"); assert_eq!(output, "3 quarters,1 dime,3 pennies"); } + + #[test] + fn cents_to_dollars_formatting() { + assert_eq!(cents_to_dollar_string(213), "$2.13"); + assert_eq!(cents_to_dollar_string(5), "$0.05"); + assert_eq!(cents_to_dollar_string(300), "$3.00"); + assert_eq!(cents_to_dollar_string(0), "$0.00"); + assert_eq!(cents_to_dollar_string(10000), "$100.00"); + } + + #[test] + fn verbose_greedy() { + let tx = Transaction { owed_cents: 212, paid_cents: 300, change_cents: 88 }; + let breakdown = vec![(quarter(), 3), (dime(), 1), (penny(), 3)]; + assert_eq!( + format_verbose(&tx, &breakdown, false), + "Owed $2.12, Paid $3.00 -> 3 quarters,1 dime,3 pennies", + ); + } + + #[test] + fn verbose_random() { + let tx = Transaction { owed_cents: 333, paid_cents: 500, change_cents: 167 }; + let breakdown = vec![(dollar(), 1), (quarter(), 2), (penny(), 17)]; + assert_eq!( + format_verbose(&tx, &breakdown, true), + "Owed $3.33, Paid $5.00 -> 1 dollar,2 quarters,17 pennies (random)", + ); + } + + #[test] + fn verbose_no_change() { + let tx = Transaction { owed_cents: 500, paid_cents: 500, change_cents: 0 }; + assert_eq!( + format_verbose(&tx, &Vec::new(), false), + "Owed $5.00, Paid $5.00 -> no change", + ); + } } diff --git a/src/main.rs b/src/main.rs index cb8826ba..ccaa8958 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,7 +6,7 @@ use rand::rngs::StdRng; use rand::SeedableRng; use cash_register::currency::{USD, EUR}; -use cash_register::format::format_breakdown; +use cash_register::format::{format_breakdown, format_verbose}; use cash_register::parse::parse_input; use cash_register::rules::make_change_for; @@ -14,7 +14,7 @@ fn main() { let args: Vec = env::args().collect(); if args.len() < 2 { - eprintln!("Usage: cash-register [--divisor N] [--seed N] [--currency USD|EUR]"); + eprintln!("Usage: cash-register [--divisor N] [--seed N] [--currency USD|EUR] [--verbose]"); process::exit(1); } @@ -22,6 +22,7 @@ fn main() { let divisor: u32 = parse_flag(&args, "--divisor").unwrap_or(3); let seed: Option = parse_flag(&args, "--seed"); let currency_name = parse_string_flag(&args, "--currency").unwrap_or("USD".to_string()); + let verbose = args.iter().any(|a| a == "--verbose"); let currency = match currency_name.to_uppercase().as_str() { "USD" => &USD, @@ -53,7 +54,12 @@ fn main() { match result { Ok(transaction) => { let breakdown = make_change_for(&transaction, currency, divisor, &mut rng); - println!("{}", format_breakdown(&breakdown)); + if verbose { + let is_random = divisor > 0 && transaction.owed_cents % divisor == 0; + println!("{}", format_verbose(&transaction, &breakdown, is_random)); + } else { + println!("{}", format_breakdown(&breakdown)); + } } Err(e) => { eprintln!("{e}"); From 2e1455f0c8457b3e41bd6c6649f4ae37f2585483 Mon Sep 17 00:00:00 2001 From: george larson Date: Fri, 27 Feb 2026 00:33:08 +0000 Subject: [PATCH 15/19] Add integration tests for --verbose output formatting Test verbose context lines, random labeling, edge cases (no change, large amounts), and EUR verbose output. Ensures verbose mode shows correct transaction details without breaking spec-format tests. --- tests/integration.rs | 69 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/tests/integration.rs b/tests/integration.rs index 945b7f5e..d635d593 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -263,6 +263,75 @@ fn mixed_valid_and_invalid_processes_all() { assert!(!output.status.success()); } +// ─── Verbose mode tests ───────────────────────────────────────────── + +#[test] +fn verbose_shows_transaction_context() { + let output = cargo_bin() + .args(["sample_input.txt", "--divisor", "0", "--verbose"]) + .output() + .expect("failed to run binary"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec<&str> = stdout.lines().collect(); + + assert_eq!(lines.len(), 3); + assert_eq!(lines[0], "Owed $2.12, Paid $3.00 -> 3 quarters,1 dime,3 pennies"); + assert_eq!(lines[1], "Owed $1.97, Paid $2.00 -> 3 pennies"); + assert_eq!(lines[2], "Owed $3.33, Paid $5.00 -> 1 dollar,2 quarters,1 dime,1 nickel,2 pennies"); +} + +#[test] +fn verbose_labels_random_lines() { + let output = cargo_bin() + .args(["sample_input.txt", "--seed", "42", "--verbose"]) + .output() + .expect("failed to run binary"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec<&str> = stdout.lines().collect(); + + // Lines 1 and 2 are greedy — no "(random)" label + assert!(!lines[0].contains("(random)"), "greedy line should not be labeled random"); + assert!(!lines[1].contains("(random)"), "greedy line should not be labeled random"); + // Line 3 (owed $3.33, divisible by 3) should be labeled random + assert!(lines[2].contains("(random)"), "random line should be labeled: {}", lines[2]); + assert!(lines[2].starts_with("Owed $3.33, Paid $5.00 -> ")); +} + +#[test] +fn verbose_edge_cases() { + let output = cargo_bin() + .args(["sample_edge_cases.txt", "--divisor", "0", "--verbose"]) + .output() + .expect("failed to run binary"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec<&str> = stdout.lines().collect(); + + assert_eq!(lines[0], "Owed $5.00, Paid $5.00 -> no change"); + assert_eq!(lines[1], "Owed $0.01, Paid $1.00 -> 3 quarters,2 dimes,4 pennies"); + assert_eq!(lines[2], "Owed $100.00, Paid $200.00 -> 100 dollars"); +} + +#[test] +fn verbose_eur() { + let output = cargo_bin() + .args(["sample_eur.txt", "--currency", "EUR", "--divisor", "0", "--verbose"]) + .output() + .expect("failed to run binary"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec<&str> = stdout.lines().collect(); + + assert_eq!(lines[0], "Owed $1.50, Paid $2.00 -> 1 50 cent coin"); + assert!(lines[1].starts_with("Owed $3.33, Paid $5.00 -> ")); +} + // ─── Helpers ──────────────────────────────────────────────────────── /// Parse a USD output line like "1 dollar,2 quarters,1 nickel,2 pennies" into total cents. From 38deab271bb1e8945c921155daa7122ad7ac82be Mon Sep 17 00:00:00 2001 From: george larson Date: Fri, 27 Feb 2026 00:33:15 +0000 Subject: [PATCH 16/19] Update README with verbose examples and correct test counts Lead sample output with --verbose so readers immediately see what each transaction does. Document all flags. Update test counts to 72 total (46 unit + 18 integration + 8 property-based). --- README.md | 49 ++++++++++++++++++++++++++----------------------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 6c61a86e..508b1383 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ cargo test ## Usage ``` -cash-register [--divisor N] [--seed N] [--currency USD|EUR] +cash-register [--divisor N] [--seed N] [--currency USD|EUR] [--verbose] ``` **Input file**: Each line contains `owed,paid` as dollar amounts (e.g., `2.13,3.00`). Blank lines are skipped. @@ -21,31 +21,34 @@ cash-register [--divisor N] [--seed N] [--currency USD|EUR] **Output**: One line per transaction showing the change denominations. ```bash -$ cargo run -- sample_input.txt -3 quarters,1 dime,3 pennies -3 pennies -1 dollar,2 quarters,1 dime,1 nickel,2 pennies # random — your output will vary - -$ cargo run -- sample_edge_cases.txt --divisor 0 -no change -3 quarters,2 dimes,4 pennies -1 dollar -3 pennies -2 dollars -1 quarter - -$ cargo run -- sample_eur.txt --currency EUR --divisor 0 -1 50 cent coin -1 1 euro coin,1 50 cent coin,1 10 cent coin,1 5 cent coin,1 2 cent coin -1 50 cent coin,1 10 cent coin,1 2 cent coin,1 1 cent coin -1 2 euro coin,1 20 cent coin,1 2 cent coin,1 1 cent coin +$ cargo run -- sample_input.txt --verbose +Owed $2.12, Paid $3.00 -> 3 quarters,1 dime,3 pennies +Owed $1.97, Paid $2.00 -> 3 pennies +Owed $3.33, Paid $5.00 -> 5 quarters,5 nickels,17 pennies (random) + +$ cargo run -- sample_edge_cases.txt --divisor 0 --verbose +Owed $5.00, Paid $5.00 -> no change +Owed $0.01, Paid $1.00 -> 3 quarters,2 dimes,4 pennies +Owed $100.00, Paid $200.00 -> 100 dollars +Owed $1.97, Paid $2.00 -> 3 pennies +Owed $3.00, Paid $5.00 -> 2 dollars +Owed $0.75, Paid $1.00 -> 1 quarter + +$ cargo run -- sample_eur.txt --currency EUR --verbose +Owed $1.50, Paid $2.00 -> 1 20 cent coin,2 10 cent coins,2 5 cent coins (random) +Owed $3.33, Paid $5.00 -> 1 50 cent coin,1 10 cent coin,7 5 cent coins (random) +Owed $0.37, Paid $1.00 -> 1 50 cent coin,1 10 cent coin,1 2 cent coin,1 1 cent coin +Owed $7.77, Paid $10.00 -> 1 2 euro coin,1 20 cent coin,1 2 cent coin,1 1 cent coin (random) ``` +Without `--verbose`, output matches the spec format exactly (`3 quarters,1 dime,3 pennies`). + ### Flags - `--divisor N` — Change which transactions get randomized denominations (default: 3). If `owed` in cents is divisible by N, the change is randomized. Use `--divisor 0` to disable randomization entirely. - `--seed N` — Seed the random number generator for reproducible output. Useful for testing. - `--currency USD|EUR` — Select the currency denomination set (default: USD). +- `--verbose` — Show transaction context alongside the change output. Labels random lines. ## The Problem @@ -114,8 +117,8 @@ Pass `--currency EUR`. The EUR denomination table is already defined and wired i ## Testing ```bash -cargo test # All 48 tests: unit + integration + property-based -cargo test --lib # Unit tests only (37 tests) -cargo test --test integration # Integration tests only (6 tests) -cargo test --test proptest # Property-based tests only (5 tests) +cargo test # All 72 tests: unit + integration + property-based +cargo test --lib # Unit tests only (46 tests) +cargo test --test integration # Integration tests only (18 tests) +cargo test --test proptest # Property-based tests only (8 tests) ``` From 87b553707b4813bcc9d4f6907ffc81415f473c0e Mon Sep 17 00:00:00 2001 From: george larson Date: Fri, 27 Feb 2026 00:45:17 +0000 Subject: [PATCH 17/19] Use correct currency symbol in verbose output, fix clippy warnings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EUR verbose now shows €, not $. Currency struct gains a symbol field that drives format_verbose(). Removed redundant parse_string_flag (parse_flag:: suffices). Applied is_multiple_of() per clippy suggestion. --- src/currency.rs | 94 +++++++++++++++++++++++++++++++++++-------- src/format.rs | 105 ++++++++++++++++++++++++++++++++++++++---------- src/main.rs | 19 ++++----- 3 files changed, 167 insertions(+), 51 deletions(-) diff --git a/src/currency.rs b/src/currency.rs index 6e0cd4dd..272a9999 100644 --- a/src/currency.rs +++ b/src/currency.rs @@ -6,35 +6,90 @@ pub struct Denomination { pub plural: &'static str, } -/// A currency configuration: a name and its denominations (largest first). +/// A currency configuration: a name, symbol, and denominations (largest first). #[derive(Debug, Clone)] pub struct Currency { pub name: &'static str, + pub symbol: &'static str, pub denominations: &'static [Denomination], } pub static USD: Currency = Currency { name: "USD", + symbol: "$", denominations: &[ - Denomination { cents: 100, singular: "dollar", plural: "dollars" }, - Denomination { cents: 25, singular: "quarter", plural: "quarters" }, - Denomination { cents: 10, singular: "dime", plural: "dimes" }, - Denomination { cents: 5, singular: "nickel", plural: "nickels" }, - Denomination { cents: 1, singular: "penny", plural: "pennies" }, + Denomination { + cents: 100, + singular: "dollar", + plural: "dollars", + }, + Denomination { + cents: 25, + singular: "quarter", + plural: "quarters", + }, + Denomination { + cents: 10, + singular: "dime", + plural: "dimes", + }, + Denomination { + cents: 5, + singular: "nickel", + plural: "nickels", + }, + Denomination { + cents: 1, + singular: "penny", + plural: "pennies", + }, ], }; pub static EUR: Currency = Currency { name: "EUR", + symbol: "€", denominations: &[ - Denomination { cents: 200, singular: "2 euro coin", plural: "2 euro coins" }, - Denomination { cents: 100, singular: "1 euro coin", plural: "1 euro coins" }, - Denomination { cents: 50, singular: "50 cent coin", plural: "50 cent coins" }, - Denomination { cents: 20, singular: "20 cent coin", plural: "20 cent coins" }, - Denomination { cents: 10, singular: "10 cent coin", plural: "10 cent coins" }, - Denomination { cents: 5, singular: "5 cent coin", plural: "5 cent coins" }, - Denomination { cents: 2, singular: "2 cent coin", plural: "2 cent coins" }, - Denomination { cents: 1, singular: "1 cent coin", plural: "1 cent coins" }, + Denomination { + cents: 200, + singular: "2 euro coin", + plural: "2 euro coins", + }, + Denomination { + cents: 100, + singular: "1 euro coin", + plural: "1 euro coins", + }, + Denomination { + cents: 50, + singular: "50 cent coin", + plural: "50 cent coins", + }, + Denomination { + cents: 20, + singular: "20 cent coin", + plural: "20 cent coins", + }, + Denomination { + cents: 10, + singular: "10 cent coin", + plural: "10 cent coins", + }, + Denomination { + cents: 5, + singular: "5 cent coin", + plural: "5 cent coins", + }, + Denomination { + cents: 2, + singular: "2 cent coin", + plural: "2 cent coins", + }, + Denomination { + cents: 1, + singular: "1 cent coin", + plural: "1 cent coins", + }, ], }; @@ -49,8 +104,10 @@ mod tests { assert!( window[0].cents > window[1].cents, "{} ({}) should be larger than {} ({})", - window[0].singular, window[0].cents, - window[1].singular, window[1].cents, + window[0].singular, + window[0].cents, + window[1].singular, + window[1].cents, ); } } @@ -58,7 +115,10 @@ mod tests { #[test] fn usd_smallest_denomination_is_one_cent() { let last = USD.denominations.last().unwrap(); - assert_eq!(last.cents, 1, "smallest denomination must be 1 cent for exact change"); + assert_eq!( + last.cents, 1, + "smallest denomination must be 1 cent for exact change" + ); } #[test] diff --git a/src/format.rs b/src/format.rs index 44757608..7c36dbaa 100644 --- a/src/format.rs +++ b/src/format.rs @@ -1,3 +1,4 @@ +use crate::currency::Currency; use crate::parse::Transaction; use crate::strategy::Breakdown; @@ -27,9 +28,9 @@ pub fn format_breakdown(breakdown: &Breakdown) -> String { .join(",") } -/// Format cents as a dollar string: 213 -> "$2.13", 5 -> "$0.05". -fn cents_to_dollar_string(cents: u32) -> String { - format!("${}.{:02}", cents / 100, cents % 100) +/// Format cents with a currency symbol: 213, "$" -> "$2.13". +fn format_amount(cents: u32, symbol: &str) -> String { + format!("{symbol}{}.{:02}", cents / 100, cents % 100) } /// Format a transaction with its breakdown for verbose output. @@ -39,14 +40,16 @@ fn cents_to_dollar_string(cents: u32) -> String { pub fn format_verbose( transaction: &Transaction, breakdown: &Breakdown, + currency: &Currency, is_random: bool, ) -> String { let change = format_breakdown(breakdown); let label = if is_random { " (random)" } else { "" }; + let sym = currency.symbol; format!( "Owed {}, Paid {} -> {change}{label}", - cents_to_dollar_string(transaction.owed_cents), - cents_to_dollar_string(transaction.paid_cents), + format_amount(transaction.owed_cents, sym), + format_amount(transaction.paid_cents, sym), ) } @@ -56,19 +59,35 @@ mod tests { use crate::currency::Denomination; fn penny() -> Denomination { - Denomination { cents: 1, singular: "penny", plural: "pennies" } + Denomination { + cents: 1, + singular: "penny", + plural: "pennies", + } } fn quarter() -> Denomination { - Denomination { cents: 25, singular: "quarter", plural: "quarters" } + Denomination { + cents: 25, + singular: "quarter", + plural: "quarters", + } } fn dime() -> Denomination { - Denomination { cents: 10, singular: "dime", plural: "dimes" } + Denomination { + cents: 10, + singular: "dime", + plural: "dimes", + } } fn dollar() -> Denomination { - Denomination { cents: 100, singular: "dollar", plural: "dollars" } + Denomination { + cents: 100, + singular: "dollar", + plural: "dollars", + } } #[test] @@ -99,45 +118,87 @@ mod tests { // "3 quarters,1 dime,3 pennies" — note: no spaces after commas let breakdown = vec![(quarter(), 3), (dime(), 1), (penny(), 3)]; let output = format_breakdown(&breakdown); - assert!(!output.contains(", "), "output should not have spaces after commas"); + assert!( + !output.contains(", "), + "output should not have spaces after commas" + ); assert_eq!(output, "3 quarters,1 dime,3 pennies"); } #[test] - fn cents_to_dollars_formatting() { - assert_eq!(cents_to_dollar_string(213), "$2.13"); - assert_eq!(cents_to_dollar_string(5), "$0.05"); - assert_eq!(cents_to_dollar_string(300), "$3.00"); - assert_eq!(cents_to_dollar_string(0), "$0.00"); - assert_eq!(cents_to_dollar_string(10000), "$100.00"); + fn format_amount_usd() { + assert_eq!(format_amount(213, "$"), "$2.13"); + assert_eq!(format_amount(5, "$"), "$0.05"); + assert_eq!(format_amount(300, "$"), "$3.00"); + assert_eq!(format_amount(0, "$"), "$0.00"); + assert_eq!(format_amount(10000, "$"), "$100.00"); + } + + #[test] + fn format_amount_eur() { + assert_eq!(format_amount(150, "€"), "€1.50"); + assert_eq!(format_amount(1, "€"), "€0.01"); } #[test] fn verbose_greedy() { - let tx = Transaction { owed_cents: 212, paid_cents: 300, change_cents: 88 }; + let tx = Transaction { + owed_cents: 212, + paid_cents: 300, + change_cents: 88, + }; let breakdown = vec![(quarter(), 3), (dime(), 1), (penny(), 3)]; assert_eq!( - format_verbose(&tx, &breakdown, false), + format_verbose(&tx, &breakdown, &crate::currency::USD, false), "Owed $2.12, Paid $3.00 -> 3 quarters,1 dime,3 pennies", ); } #[test] fn verbose_random() { - let tx = Transaction { owed_cents: 333, paid_cents: 500, change_cents: 167 }; + let tx = Transaction { + owed_cents: 333, + paid_cents: 500, + change_cents: 167, + }; let breakdown = vec![(dollar(), 1), (quarter(), 2), (penny(), 17)]; assert_eq!( - format_verbose(&tx, &breakdown, true), + format_verbose(&tx, &breakdown, &crate::currency::USD, true), "Owed $3.33, Paid $5.00 -> 1 dollar,2 quarters,17 pennies (random)", ); } #[test] fn verbose_no_change() { - let tx = Transaction { owed_cents: 500, paid_cents: 500, change_cents: 0 }; + let tx = Transaction { + owed_cents: 500, + paid_cents: 500, + change_cents: 0, + }; assert_eq!( - format_verbose(&tx, &Vec::new(), false), + format_verbose(&tx, &Vec::new(), &crate::currency::USD, false), "Owed $5.00, Paid $5.00 -> no change", ); } + + #[test] + fn verbose_eur_uses_euro_symbol() { + let tx = Transaction { + owed_cents: 150, + paid_cents: 200, + change_cents: 50, + }; + let breakdown = vec![( + Denomination { + cents: 50, + singular: "50 cent coin", + plural: "50 cent coins", + }, + 1, + )]; + assert_eq!( + format_verbose(&tx, &breakdown, &crate::currency::EUR, false), + "Owed €1.50, Paid €2.00 -> 1 50 cent coin", + ); + } } diff --git a/src/main.rs b/src/main.rs index ccaa8958..c2acde4b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,7 @@ use std::process; use rand::rngs::StdRng; use rand::SeedableRng; -use cash_register::currency::{USD, EUR}; +use cash_register::currency::{EUR, USD}; use cash_register::format::{format_breakdown, format_verbose}; use cash_register::parse::parse_input; use cash_register::rules::make_change_for; @@ -21,7 +21,7 @@ fn main() { let file_path = &args[1]; let divisor: u32 = parse_flag(&args, "--divisor").unwrap_or(3); let seed: Option = parse_flag(&args, "--seed"); - let currency_name = parse_string_flag(&args, "--currency").unwrap_or("USD".to_string()); + let currency_name: String = parse_flag(&args, "--currency").unwrap_or("USD".to_string()); let verbose = args.iter().any(|a| a == "--verbose"); let currency = match currency_name.to_uppercase().as_str() { @@ -55,8 +55,11 @@ fn main() { Ok(transaction) => { let breakdown = make_change_for(&transaction, currency, divisor, &mut rng); if verbose { - let is_random = divisor > 0 && transaction.owed_cents % divisor == 0; - println!("{}", format_verbose(&transaction, &breakdown, is_random)); + let is_random = divisor > 0 && transaction.owed_cents.is_multiple_of(divisor); + println!( + "{}", + format_verbose(&transaction, &breakdown, currency, is_random) + ); } else { println!("{}", format_breakdown(&breakdown)); } @@ -80,11 +83,3 @@ fn parse_flag(args: &[String], flag: &str) -> Option { .and_then(|i| args.get(i + 1)) .and_then(|v| v.parse().ok()) } - -/// Parse a `--flag value` pair as a string. -fn parse_string_flag(args: &[String], flag: &str) -> Option { - args.iter() - .position(|a| a == flag) - .and_then(|i| args.get(i + 1)) - .map(|v| v.clone()) -} From fed2a834eaf2f33a17902da179e4fb919a0dcb1b Mon Sep 17 00:00:00 2001 From: george larson Date: Fri, 27 Feb 2026 00:45:25 +0000 Subject: [PATCH 18/19] Apply cargo fmt across all source and test files Normalize formatting to match rustfmt defaults. No logic changes. --- src/parse.rs | 38 ++++++++------ src/rules.rs | 4 +- src/strategy/greedy.rs | 14 ++--- src/strategy/random.rs | 11 ++-- tests/integration.rs | 114 +++++++++++++++++++++++++++++++---------- tests/proptest.rs | 4 +- 6 files changed, 126 insertions(+), 59 deletions(-) diff --git a/src/parse.rs b/src/parse.rs index b48c13b2..31b649e3 100644 --- a/src/parse.rs +++ b/src/parse.rs @@ -55,26 +55,24 @@ pub fn parse_dollars_to_cents(s: &str) -> Result { pub fn parse_line(line: &str, line_number: usize) -> Result { let line = line.trim(); - let (owed_str, paid_str) = line.split_once(',').ok_or_else(|| { - CashRegisterError::MalformedLine { - line: line_number, - detail: format!("expected \"owed,paid\" but got \"{line}\""), - } - })?; - - let owed_cents = parse_dollars_to_cents(owed_str).map_err(|_| { - CashRegisterError::InvalidAmount { + let (owed_str, paid_str) = + line.split_once(',') + .ok_or_else(|| CashRegisterError::MalformedLine { + line: line_number, + detail: format!("expected \"owed,paid\" but got \"{line}\""), + })?; + + let owed_cents = + parse_dollars_to_cents(owed_str).map_err(|_| CashRegisterError::InvalidAmount { line: line_number, input: owed_str.trim().to_string(), - } - })?; + })?; - let paid_cents = parse_dollars_to_cents(paid_str).map_err(|_| { - CashRegisterError::InvalidAmount { + let paid_cents = + parse_dollars_to_cents(paid_str).map_err(|_| CashRegisterError::InvalidAmount { line: line_number, input: paid_str.trim().to_string(), - } - })?; + })?; if paid_cents < owed_cents { return Err(CashRegisterError::Underpayment { @@ -171,13 +169,19 @@ mod tests { #[test] fn parse_line_underpayment() { let result = parse_line("5.00,3.00", 1); - assert!(matches!(result, Err(CashRegisterError::Underpayment { .. }))); + assert!(matches!( + result, + Err(CashRegisterError::Underpayment { .. }) + )); } #[test] fn parse_line_missing_comma() { let result = parse_line("5.00", 1); - assert!(matches!(result, Err(CashRegisterError::MalformedLine { .. }))); + assert!(matches!( + result, + Err(CashRegisterError::MalformedLine { .. }) + )); } #[test] diff --git a/src/rules.rs b/src/rules.rs index ad517034..ba4eded1 100644 --- a/src/rules.rs +++ b/src/rules.rs @@ -20,7 +20,7 @@ pub fn make_change_for( return Vec::new(); } - if divisor > 0 && transaction.owed_cents % divisor == 0 { + if divisor > 0 && transaction.owed_cents.is_multiple_of(divisor) { RandomStrategy::new(rng).make_change(transaction.change_cents, currency) } else { GreedyStrategy.make_change(transaction.change_cents, currency) @@ -31,8 +31,8 @@ pub fn make_change_for( mod tests { use super::*; use crate::currency::USD; - use rand::SeedableRng; use rand::rngs::StdRng; + use rand::SeedableRng; fn tx(owed: u32, paid: u32) -> Transaction { Transaction { diff --git a/src/strategy/greedy.rs b/src/strategy/greedy.rs index c404f2f9..920fc43a 100644 --- a/src/strategy/greedy.rs +++ b/src/strategy/greedy.rs @@ -1,5 +1,5 @@ -use crate::currency::Currency; use super::{Breakdown, ChangeStrategy}; +use crate::currency::Currency; /// Greedy algorithm: use the fewest coins/bills possible. /// @@ -30,7 +30,7 @@ impl ChangeStrategy for GreedyStrategy { #[cfg(test)] mod tests { use super::*; - use crate::currency::{USD, EUR}; + use crate::currency::{EUR, USD}; #[test] fn sample_output_88_cents() { @@ -39,10 +39,7 @@ mod tests { let breakdown = strategy.make_change(88, &USD); let named: Vec<(&str, u32)> = breakdown.iter().map(|(d, c)| (d.singular, *c)).collect(); - assert_eq!( - named, - vec![("quarter", 3), ("dime", 1), ("penny", 3)], - ); + assert_eq!(named, vec![("quarter", 3), ("dime", 1), ("penny", 3)],); } #[test] @@ -107,7 +104,10 @@ mod tests { assert_eq!(total, 9999); // Should be 99 dollars, 3 quarters, 2 dimes, 4 pennies let named: Vec<(&str, u32)> = breakdown.iter().map(|(d, c)| (d.singular, *c)).collect(); - assert_eq!(named, vec![("dollar", 99), ("quarter", 3), ("dime", 2), ("penny", 4)]); + assert_eq!( + named, + vec![("dollar", 99), ("quarter", 3), ("dime", 2), ("penny", 4)] + ); } #[test] diff --git a/src/strategy/random.rs b/src/strategy/random.rs index c30b79ed..cbe8d8ce 100644 --- a/src/strategy/random.rs +++ b/src/strategy/random.rs @@ -1,7 +1,7 @@ use rand::Rng; -use crate::currency::Currency; use super::{Breakdown, ChangeStrategy}; +use crate::currency::Currency; /// Randomized change algorithm: pick random counts for each denomination. /// @@ -56,9 +56,9 @@ impl ChangeStrategy for RandomStrategy { #[cfg(test)] mod tests { use super::*; - use crate::currency::{USD, EUR}; - use rand::SeedableRng; + use crate::currency::{EUR, USD}; use rand::rngs::StdRng; + use rand::SeedableRng; fn seeded_strategy(seed: u64) -> RandomStrategy { RandomStrategy::new(StdRng::seed_from_u64(seed)) @@ -125,7 +125,10 @@ mod tests { counts != baseline_counts }); - assert!(any_different, "random strategy should produce varied results across seeds"); + assert!( + any_different, + "random strategy should produce varied results across seeds" + ); } #[test] diff --git a/tests/integration.rs b/tests/integration.rs index d635d593..52df06b2 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -77,7 +77,10 @@ fn random_line_sums_correctly() { let random_line = stdout.lines().nth(2).expect("expected 3 lines"); let total = parse_output_cents(random_line); - assert_eq!(total, 167, "random change should sum to 167 cents (got {total})"); + assert_eq!( + total, 167, + "random change should sum to 167 cents (got {total})" + ); } // ─── Edge case tests ──────────────────────────────────────────────── @@ -108,12 +111,12 @@ fn edge_cases_greedy_output() { let lines: Vec<&str> = stdout.lines().collect(); assert_eq!(lines.len(), 6); - assert_eq!(lines[0], "no change"); // 5.00,5.00 - assert_eq!(lines[1], "3 quarters,2 dimes,4 pennies"); // 0.01,1.00 = 99c - assert_eq!(lines[2], "100 dollars"); // 100.00,200.00 = $100 - assert_eq!(lines[3], "3 pennies"); // 1.97,2.00 - assert_eq!(lines[4], "2 dollars"); // 3.00,5.00 - assert_eq!(lines[5], "1 quarter"); // 0.75,1.00 + assert_eq!(lines[0], "no change"); // 5.00,5.00 + assert_eq!(lines[1], "3 quarters,2 dimes,4 pennies"); // 0.01,1.00 = 99c + assert_eq!(lines[2], "100 dollars"); // 100.00,200.00 = $100 + assert_eq!(lines[3], "3 pennies"); // 1.97,2.00 + assert_eq!(lines[4], "2 dollars"); // 3.00,5.00 + assert_eq!(lines[5], "1 quarter"); // 0.75,1.00 } // ─── EUR end-to-end tests ─────────────────────────────────────────── @@ -125,15 +128,28 @@ fn eur_greedy_output() { .output() .expect("failed to run binary"); - assert!(output.status.success(), "stderr: {}", String::from_utf8_lossy(&output.stderr)); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); let stdout = String::from_utf8_lossy(&output.stdout); let lines: Vec<&str> = stdout.lines().collect(); assert_eq!(lines.len(), 4); - assert_eq!(lines[0], "1 50 cent coin"); // 1.50,2.00 = 50c - assert_eq!(lines[1], "1 1 euro coin,1 50 cent coin,1 10 cent coin,1 5 cent coin,1 2 cent coin"); // 3.33,5.00 = 167c - assert_eq!(lines[2], "1 50 cent coin,1 10 cent coin,1 2 cent coin,1 1 cent coin"); // 0.37,1.00 = 63c - assert_eq!(lines[3], "1 2 euro coin,1 20 cent coin,1 2 cent coin,1 1 cent coin"); // 7.77,10.00 = 223c + assert_eq!(lines[0], "1 50 cent coin"); // 1.50,2.00 = 50c + assert_eq!( + lines[1], + "1 1 euro coin,1 50 cent coin,1 10 cent coin,1 5 cent coin,1 2 cent coin" + ); // 3.33,5.00 = 167c + assert_eq!( + lines[2], + "1 50 cent coin,1 10 cent coin,1 2 cent coin,1 1 cent coin" + ); // 0.37,1.00 = 63c + assert_eq!( + lines[3], + "1 2 euro coin,1 20 cent coin,1 2 cent coin,1 1 cent coin" + ); // 7.77,10.00 = 223c } #[test] @@ -149,7 +165,10 @@ fn eur_random_sums_correctly() { let random_line = stdout.lines().nth(1).expect("expected 4 lines"); // line 2 is 3.33,5.00 let total = parse_eur_output_cents(random_line); - assert_eq!(total, 167, "EUR random change should sum to 167 cents (got {total})"); + assert_eq!( + total, 167, + "EUR random change should sum to 167 cents (got {total})" + ); } #[test] @@ -161,7 +180,10 @@ fn unknown_currency_fails() { assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("Unknown currency"), "expected currency error, got: {stderr}"); + assert!( + stderr.contains("Unknown currency"), + "expected currency error, got: {stderr}" + ); } // ─── Error handling tests ─────────────────────────────────────────── @@ -175,7 +197,10 @@ fn missing_file_returns_nonzero_exit() { assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("Error reading"), "expected file error message, got: {stderr}"); + assert!( + stderr.contains("Error reading"), + "expected file error message, got: {stderr}" + ); } #[test] @@ -207,8 +232,14 @@ fn underpayment_reports_error_to_stderr() { assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("paid"), "expected underpayment error, got: {stderr}"); - assert!(stderr.contains("less than"), "expected underpayment message, got: {stderr}"); + assert!( + stderr.contains("paid"), + "expected underpayment error, got: {stderr}" + ); + assert!( + stderr.contains("less than"), + "expected underpayment message, got: {stderr}" + ); } #[test] @@ -230,7 +261,10 @@ fn malformed_line_reports_error_with_line_number() { // Should report error for line 2 let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("line 2"), "expected line number in error, got: {stderr}"); + assert!( + stderr.contains("line 2"), + "expected line number in error, got: {stderr}" + ); // Should exit with error code assert!(!output.status.success()); @@ -277,9 +311,15 @@ fn verbose_shows_transaction_context() { let lines: Vec<&str> = stdout.lines().collect(); assert_eq!(lines.len(), 3); - assert_eq!(lines[0], "Owed $2.12, Paid $3.00 -> 3 quarters,1 dime,3 pennies"); + assert_eq!( + lines[0], + "Owed $2.12, Paid $3.00 -> 3 quarters,1 dime,3 pennies" + ); assert_eq!(lines[1], "Owed $1.97, Paid $2.00 -> 3 pennies"); - assert_eq!(lines[2], "Owed $3.33, Paid $5.00 -> 1 dollar,2 quarters,1 dime,1 nickel,2 pennies"); + assert_eq!( + lines[2], + "Owed $3.33, Paid $5.00 -> 1 dollar,2 quarters,1 dime,1 nickel,2 pennies" + ); } #[test] @@ -294,10 +334,20 @@ fn verbose_labels_random_lines() { let lines: Vec<&str> = stdout.lines().collect(); // Lines 1 and 2 are greedy — no "(random)" label - assert!(!lines[0].contains("(random)"), "greedy line should not be labeled random"); - assert!(!lines[1].contains("(random)"), "greedy line should not be labeled random"); + assert!( + !lines[0].contains("(random)"), + "greedy line should not be labeled random" + ); + assert!( + !lines[1].contains("(random)"), + "greedy line should not be labeled random" + ); // Line 3 (owed $3.33, divisible by 3) should be labeled random - assert!(lines[2].contains("(random)"), "random line should be labeled: {}", lines[2]); + assert!( + lines[2].contains("(random)"), + "random line should be labeled: {}", + lines[2] + ); assert!(lines[2].starts_with("Owed $3.33, Paid $5.00 -> ")); } @@ -313,14 +363,24 @@ fn verbose_edge_cases() { let lines: Vec<&str> = stdout.lines().collect(); assert_eq!(lines[0], "Owed $5.00, Paid $5.00 -> no change"); - assert_eq!(lines[1], "Owed $0.01, Paid $1.00 -> 3 quarters,2 dimes,4 pennies"); + assert_eq!( + lines[1], + "Owed $0.01, Paid $1.00 -> 3 quarters,2 dimes,4 pennies" + ); assert_eq!(lines[2], "Owed $100.00, Paid $200.00 -> 100 dollars"); } #[test] fn verbose_eur() { let output = cargo_bin() - .args(["sample_eur.txt", "--currency", "EUR", "--divisor", "0", "--verbose"]) + .args([ + "sample_eur.txt", + "--currency", + "EUR", + "--divisor", + "0", + "--verbose", + ]) .output() .expect("failed to run binary"); @@ -328,8 +388,8 @@ fn verbose_eur() { let stdout = String::from_utf8_lossy(&output.stdout); let lines: Vec<&str> = stdout.lines().collect(); - assert_eq!(lines[0], "Owed $1.50, Paid $2.00 -> 1 50 cent coin"); - assert!(lines[1].starts_with("Owed $3.33, Paid $5.00 -> ")); + assert_eq!(lines[0], "Owed €1.50, Paid €2.00 -> 1 50 cent coin"); + assert!(lines[1].starts_with("Owed €3.33, Paid €5.00 -> ")); } // ─── Helpers ──────────────────────────────────────────────────────── diff --git a/tests/proptest.rs b/tests/proptest.rs index c6dfb356..15a7ba2c 100644 --- a/tests/proptest.rs +++ b/tests/proptest.rs @@ -2,9 +2,9 @@ use proptest::prelude::*; use rand::rngs::StdRng; use rand::SeedableRng; -use cash_register::currency::{USD, EUR}; -use cash_register::strategy::random::RandomStrategy; +use cash_register::currency::{EUR, USD}; use cash_register::strategy::greedy::GreedyStrategy; +use cash_register::strategy::random::RandomStrategy; use cash_register::strategy::ChangeStrategy; proptest! { From 74e5e9a588ebb967a80fd8271fe6dddf4c00ff84 Mon Sep 17 00:00:00 2001 From: george larson Date: Fri, 27 Feb 2026 00:45:30 +0000 Subject: [PATCH 19/19] =?UTF-8?q?Fix=20EUR=20examples=20in=20README=20to?= =?UTF-8?q?=20show=20=E2=82=AC=20symbol,=20update=20test=20counts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 508b1383..49238b71 100644 --- a/README.md +++ b/README.md @@ -35,10 +35,10 @@ Owed $3.00, Paid $5.00 -> 2 dollars Owed $0.75, Paid $1.00 -> 1 quarter $ cargo run -- sample_eur.txt --currency EUR --verbose -Owed $1.50, Paid $2.00 -> 1 20 cent coin,2 10 cent coins,2 5 cent coins (random) -Owed $3.33, Paid $5.00 -> 1 50 cent coin,1 10 cent coin,7 5 cent coins (random) -Owed $0.37, Paid $1.00 -> 1 50 cent coin,1 10 cent coin,1 2 cent coin,1 1 cent coin -Owed $7.77, Paid $10.00 -> 1 2 euro coin,1 20 cent coin,1 2 cent coin,1 1 cent coin (random) +Owed €1.50, Paid €2.00 -> 1 20 cent coin,2 10 cent coins,2 5 cent coins (random) +Owed €3.33, Paid €5.00 -> 1 50 cent coin,1 10 cent coin,7 5 cent coins (random) +Owed €0.37, Paid €1.00 -> 1 50 cent coin,1 10 cent coin,1 2 cent coin,1 1 cent coin +Owed €7.77, Paid €10.00 -> 1 2 euro coin,1 20 cent coin,1 2 cent coin,1 1 cent coin (random) ``` Without `--verbose`, output matches the spec format exactly (`3 quarters,1 dime,3 pennies`). @@ -117,8 +117,8 @@ Pass `--currency EUR`. The EUR denomination table is already defined and wired i ## Testing ```bash -cargo test # All 72 tests: unit + integration + property-based -cargo test --lib # Unit tests only (46 tests) +cargo test # All 74 tests: unit + integration + property-based +cargo test --lib # Unit tests only (48 tests) cargo test --test integration # Integration tests only (18 tests) cargo test --test proptest # Property-based tests only (8 tests) ```