Project · after Level 2

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."

💡 Go build it now You know everything else already: 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}");
⚠️ Real-world money warning 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.

Stretch goals

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 clap and 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.