Tip Splitter CLI 💸
A tool you will genuinely use at dinner. More importantly, it is a real command-line program: it takes arguments, validates them, does careful money math, and has tests proving it works.
📋 The spec
Build a command-line tool that runs like this:
$ cargo run -- 84.50 3
Bill: $84.50
Tip (18%): $15.21
Total: $99.71
Each of 3: $33.24
Requirements:
- Takes the bill amount and the number of people as command-line arguments.
- Accepts an optional third argument for the tip percentage; defaults to 18.
- Prints a clear breakdown with two decimal places.
- Refuses nonsense with a helpful message instead of crashing: missing arguments, non-numbers, zero people, negative bills.
- Puts the math in its own module with at least three tests that pass.
The one new thing: reading arguments
use std::env;
fn main() {
let args: Vec<String> = env::args().collect();
println!("{args:?}");
}
Run cargo run -- 84.50 3 and look closely at what prints.
args[0] is the program's own path, so your first real argument
is args[1]. That off-by-one trips up everyone once. The
-- tells Cargo "the rest is for my program, not for you."
parse, Result,
match, functions, modules, and tests. This is your first project
assembled entirely from parts you own.
Hints, in order of desperation
Hint 1: How should I structure this?
Separate the thinking from the talking. Pure math functions in one
module, all printing and argument reading in main:
src/
├── main.rs ← reads args, prints, handles errors
└── calc.rs ← total_with_tip(), split_between(), plus tests
Why bother? Because pure functions are trivially testable: no keyboard, no screen, just numbers in and numbers out. That separation is most of what "good architecture" means in practice.
Hint 2: What should the math functions look like?
// src/calc.rs
pub fn tip_amount(bill: f64, percent: f64) -> f64 {
bill * percent / 100.0
}
pub fn total_with_tip(bill: f64, percent: f64) -> f64 {
bill + tip_amount(bill, percent)
}
pub fn split_between(total: f64, people: u32) -> f64 {
total / people as f64
}
Remember pub, or main.rs cannot see them, and
mod calc; at the top of main.rs to pull the file in.
Hint 3: How do I validate the arguments?
Write one function that either produces good values or an error
message, and let main decide how to complain:
fn parse_args(args: &[String]) -> Result<(f64, u32, f64), String> {
if args.len() < 3 {
return Err(String::from("usage: tip <bill> <people> [tip percent]"));
}
let bill: f64 = args[1].parse()
.map_err(|_| format!("'{}' is not a valid amount", args[1]))?;
let people: u32 = args[2].parse()
.map_err(|_| format!("'{}' is not a whole number of people", args[2]))?;
let percent: f64 = match args.get(3) {
Some(p) => p.parse().map_err(|_| format!("'{p}' is not a percentage"))?,
None => 18.0,
};
if bill < 0.0 { return Err(String::from("a negative bill? lucky you")); }
if people == 0 { return Err(String::from("splitting between zero people is undefined")); }
Ok((bill, people, percent))
}
map_err is new but reads exactly as it sounds: if this is an
Err, replace the error with a friendlier one. Then ?
hands it upward.
Hint 4: How do I print money properly?
{:.2} forces two decimal places, and a width like
{:>8.2} right-aligns numbers into a tidy column:
println!("Bill: ${bill:.2}");
println!("Tip ({percent}%): ${tip:.2}");
f64 is fine for a dinner tool, but real financial software
never uses floats for money, because 0.1 + 0.2 is famously not exactly
0.3 in binary. Professionals store whole cents as integers. Worth
knowing now; not worth fixing tonight.
Hint 5: What should I test?
Test the interesting cases, not the obvious ones. Floats need a tolerance rather than exact equality:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn standard_tip_is_correct() {
assert!((total_with_tip(100.0, 18.0) - 118.0).abs() < 0.001);
}
#[test]
fn zero_tip_changes_nothing() {
assert!((total_with_tip(50.0, 0.0) - 50.0).abs() < 0.001);
}
#[test]
fn splitting_divides_evenly() {
assert!((split_between(90.0, 3) - 30.0).abs() < 0.001);
}
}
Run with cargo test. Watching three green lines appear
for code you designed is a genuinely good feeling.
Hint 6: Show me main.rs tying it together
// src/main.rs
mod calc;
use std::env;
use std::process;
fn main() {
let args: Vec<String> = env::args().collect();
let (bill, people, percent) = match parse_args(&args) {
Ok(values) => values,
Err(message) => {
eprintln!("⚠️ {message}");
process::exit(1);
}
};
let tip = calc::tip_amount(bill, percent);
let total = calc::total_with_tip(bill, percent);
let each = calc::split_between(total, people);
println!("Bill: ${bill:.2}");
println!("Tip ({percent:.0}%): ${tip:.2}");
println!("Total: ${total:.2}");
println!("Each of {people}: ${each:.2}");
}
Two professional details worth stealing: errors go to
eprintln! (stderr, so they do not pollute piped output), and
a failure exits with code 1, which is how other programs know something
went wrong.
You built a real tool 🎉
Run cargo build --release and look in
target/release/. That single file is a native executable you can
copy to any machine with the same operating system and just run, with no
Rust installed. That is a genuine advantage compiled languages have, and it
is now yours.
Make it yours
- Rounding up. Add a flag that rounds each person's share up to the nearest dollar and reports the extra as a bonus tip.
- Uneven splits. Accept per-person weights so the one who ordered lobster pays more.
- Interactive fallback. With no arguments, ask questions instead of printing usage (reuse your input skills from Project 1).
- A real argument parser. Try
cargo add clapand rebuild the interface with proper flags like--tip 20. Clap is what serious Rust CLIs use. - Integer cents. Take the float warning seriously and rewrite the math in whole cents. Notice how the tests change.