Guide contents

Lesson 5 of 12

Expense Tracker CLI

We build expenses, a command-line tool that records spending in a JSON file:

Text
$ expenses add 12.50 food lunch with the team --date 2026-09-02
added #1
$ expenses list --month 2026-09
  #4  2026-09-05  utilities    $120.00  electricity
  #3  2026-09-03  food           $7.25  coffee beans
  #1  2026-09-02  food          $12.50  lunch with the team
  #2  2026-09-01  transport     $48.00  monthly bus pass
                         total $187.75
$ expenses report --month 2026-09
utilities    $120.00  ####################
transport     $48.00  ########
food          $19.75  ###

What it uses:

Part Topics
Money a newtype over cents, FromStr, Display, #[serde(transparent)]
storage serde, serde_json, std::fs, a missing file treated as empty, atomic save via rename
command line clap derive with subcommands and typed arguments (Money, NaiveDate)
queries chrono::NaiveDate, iterator filters, BTreeMap totals
errors anyhow::Result with .with_context(...)
Terminal
cargo new expenses
cd expenses
cargo add clap --features derive
cargo add serde --features derive
cargo add serde_json anyhow
cargo add chrono --features serde

Step 1: Money in cents

Floating point can't represent most decimal amounts exactly: 0.1 + 0.2 is 0.30000000000000004. Money is therefore stored as an integer number of cents, in a newtype so it can't be mixed up with other numbers. FromStr accepts what people type (12, 12.5, $12.50) and rejects everything else with a helpful message.

Rustcargo test
use std::fmt;
use std::str::FromStr;

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
pub struct Money(pub u64); // cents

impl FromStr for Money {
    type Err = String;

    fn from_str(text: &str) -> Result<Money, String> {
        let digits = text.trim().trim_start_matches('$');
        let (whole, fraction) = digits.split_once('.').unwrap_or((digits, ""));
        let all_digits = |s: &str| s.chars().all(|c| c.is_ascii_digit());
        if whole.is_empty() || fraction.len() > 2 || !all_digits(whole) || !all_digits(fraction) {
            return Err(format!("invalid amount {text:?}: use a number like 12.50"));
        }
        let whole: u64 = whole.parse().map_err(|_| format!("amount {text:?} is too large"))?;
        let cents: u64 = format!("{fraction:0<2}").parse().unwrap(); // "5" -> "50"
        whole.checked_mul(100).and_then(|c| c.checked_add(cents)).map(Money).ok_or(format!("amount {text:?} is too large"))
    }
}

impl fmt::Display for Money {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let text = format!("${}.{:02}", self.0 / 100, self.0 % 100);
        f.pad(&text) // respects width and alignment, e.g. {:>8}
    }
}

impl std::iter::Sum for Money {
    fn sum<I: Iterator<Item = Money>>(iter: I) -> Money {
        Money(iter.map(|m| m.0).sum())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_common_formats() {
        assert_eq!("12".parse(), Ok(Money(1200)));
        assert_eq!("12.5".parse(), Ok(Money(1250)));
        assert_eq!("$12.05".parse(), Ok(Money(1205)));
        assert_eq!("0.99".parse(), Ok(Money(99)));
    }

    #[test]
    fn rejects_bad_input() {
        for bad in ["", "abc", "1.234", "-3", "1,50", "$"] {
            assert!(bad.parse::<Money>().is_err(), "{bad:?} should be rejected");
        }
        assert!("99999999999999999999".parse::<Money>().unwrap_err().contains("too large"));
    }

    #[test]
    fn displays_with_two_decimals_and_padding() {
        assert_eq!(Money(705).to_string(), "$7.05");
        assert_eq!(format!("[{:>8}]", Money(1250)), "[  $12.50]");
    }

    #[test]
    fn sums() {
        let total: Money = [Money(150), Money(275)].into_iter().sum();
        assert_eq!(total, Money(425));
    }
}
Output
running 4 tests
test tests::displays_with_two_decimals_and_padding ... ok
test tests::parses_common_formats ... ok
test tests::rejects_bad_input ... ok
test tests::sums ... ok

test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

f.pad is what makes {:>8} work for a custom Display; write!(f, ...) would ignore the width.

Step 2: Storing the ledger as JSON

The whole ledger is one JSON file. Loading a file that doesn't exist yet gives an empty ledger, so the first add just works. Saving writes to a temporary file and then renames it over the real one: a rename is atomic on the same file system, so a crash in the middle of a save can't leave a half-written file behind.

#[serde(transparent)] stores Money(1250) as the plain number 1250 instead of [1250].

Rust
use anyhow::{Context, Result};
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::ErrorKind;
use std::path::Path;

#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
struct Money(u64);

#[derive(Debug, Clone, Serialize, Deserialize)]
struct Expense {
    id: u32,
    date: NaiveDate,
    amount: Money,
    category: String,
    note: String,
}

#[derive(Debug, Default, Serialize, Deserialize)]
struct Ledger {
    next_id: u32,
    expenses: Vec<Expense>,
}

impl Ledger {
    fn add(&mut self, date: NaiveDate, amount: Money, category: &str, note: &str) -> u32 {
        self.next_id += 1;
        let expense = Expense { id: self.next_id, date, amount, category: category.to_lowercase(), note: note.to_string() };
        self.expenses.push(expense);
        self.next_id
    }
}

fn load(path: &Path) -> Result<Ledger> {
    match fs::read_to_string(path) {
        Ok(text) => serde_json::from_str(&text).with_context(|| format!("{} is not a valid ledger", path.display())),
        Err(error) if error.kind() == ErrorKind::NotFound => Ok(Ledger::default()),
        Err(error) => Err(error).with_context(|| format!("cannot read {}", path.display())),
    }
}

fn save(path: &Path, ledger: &Ledger) -> Result<()> {
    let temp = path.with_extension("json.tmp");
    fs::write(&temp, serde_json::to_string_pretty(ledger)?).with_context(|| format!("cannot write {}", temp.display()))?;
    fs::rename(&temp, path).with_context(|| format!("cannot replace {}", path.display()))?;
    Ok(())
}

fn main() -> Result<()> {
    let path = std::env::temp_dir().join("expenses-step2.json");
    let _ = fs::remove_file(&path);

    let mut ledger = load(&path)?;
    println!("loaded {} expenses", ledger.expenses.len());
    let date = NaiveDate::from_ymd_opt(2026, 9, 2).unwrap();
    ledger.add(date, Money(1250), "Food", "lunch");
    save(&path, &ledger)?;

    println!("{}", fs::read_to_string(&path)?);
    println!("reloaded {} expenses", load(&path)?.expenses.len());

    fs::write(&path, "{ not json")?;
    println!("{:#}", load(&path).unwrap_err());
    Ok(())
}
Output
loaded 0 expenses
{
  "next_id": 1,
  "expenses": [
    {
      "id": 1,
      "date": "2026-09-02",
      "amount": 1250,
      "category": "food",
      "note": "lunch"
    }
  ]
}
reloaded 1 expenses
/tmp/expenses-step2.json is not a valid ledger: key must be a string at line 1 column 3

{:#} prints an anyhow error with its whole chain of causes on one line: our context first, then serde's message.

Step 3: The command line with clap

Each command is a variant of an enum deriving Subcommand, and its fields are the arguments. Because Money implements FromStr, clap parses and validates amounts itself; chrono::NaiveDate implements FromStr too. A small Month type handles --month 2026-09.

Cli::try_parse_from parses a list of strings instead of the real command line, which is how you test a clap interface.

Rust
use chrono::{Datelike, NaiveDate};
use clap::{Parser, Subcommand};
use std::path::PathBuf;
use std::str::FromStr;

#[derive(Debug, Clone, Copy, PartialEq)]
struct Money(u64);

impl FromStr for Money {
    type Err = String;

    fn from_str(text: &str) -> Result<Money, String> {
        let (whole, fraction) = text.split_once('.').unwrap_or((text, ""));
        let whole: u64 = whole.parse().map_err(|_| format!("invalid amount {text:?}"))?;
        let cents: u64 = format!("{fraction:0<2}").parse().map_err(|_| format!("invalid amount {text:?}"))?;
        Ok(Money(whole * 100 + cents))
    }
}

/// A calendar month, written like 2026-09.
#[derive(Debug, Clone, Copy, PartialEq)]
struct Month {
    year: i32,
    month: u32,
}

impl Month {
    fn contains(&self, date: NaiveDate) -> bool {
        date.year() == self.year && date.month() == self.month
    }
}

impl FromStr for Month {
    type Err = String;

    fn from_str(text: &str) -> Result<Month, String> {
        let date = NaiveDate::parse_from_str(&format!("{text}-01"), "%Y-%m-%d")
            .map_err(|_| format!("invalid month {text:?}: use YYYY-MM"))?;
        Ok(Month { year: date.year(), month: date.month() })
    }
}

#[derive(Debug, Parser)]
#[command(name = "expenses", about = "Track spending from the terminal")]
struct Cli {
    /// The JSON file that stores the ledger
    #[arg(long, default_value = "expenses.json")]
    file: PathBuf,

    #[command(subcommand)]
    command: Command,
}

#[derive(Debug, Subcommand)]
enum Command {
    /// Record an expense
    Add {
        amount: Money,
        category: String,
        /// What it was for
        note: Vec<String>,
        /// Defaults to today
        #[arg(long)]
        date: Option<NaiveDate>,
    },
    /// List expenses, newest first
    List {
        #[arg(long)]
        month: Option<Month>,
        #[arg(long)]
        category: Option<String>,
    },
    /// Total spending per category
    Report {
        #[arg(long)]
        month: Option<Month>,
    },
    /// Delete an expense by its number
    Remove { id: u32 },
}

fn main() {
    let attempts: [&[&str]; 5] = [
        &["expenses", "add", "12.50", "food", "lunch", "with", "the", "team", "--date", "2026-09-02"],
        &["expenses", "--file", "home.json", "list", "--month", "2026-09"],
        &["expenses", "remove", "3"],
        &["expenses", "add", "twelve", "food"],
        &["expenses", "list", "--month", "September"],
    ];
    for args in attempts {
        match Cli::try_parse_from(args) {
            Ok(cli) => println!("{:?} {:?}", cli.file, cli.command),
            Err(error) => println!("{}", error.to_string().lines().next().unwrap()),
        }
    }
    println!("{:?}", Month { year: 2026, month: 9 }.contains(NaiveDate::from_ymd_opt(2026, 9, 30).unwrap()));
}
Output
"expenses.json" Add { amount: Money(1250), category: "food", note: ["lunch", "with", "the", "team"], date: Some(2026-09-02) }
"home.json" List { month: Some(Month { year: 2026, month: 9 }), category: None }
"expenses.json" Remove { id: 3 }
error: invalid value 'twelve' for '<AMOUNT>': invalid amount "twelve"
error: invalid value 'September' for '--month <MONTH>': invalid month "September": use YYYY-MM
true

The /// comments become the help text: expenses --help and expenses add --help are generated from them.

Step 4: The complete tracker

The final program adds the list and report output. run takes the parsed Cli and any Write, so it can be driven by tests or by a demo. main parses the real command line; run with no arguments, it plays a short demo session against a temporary file instead, which is the output shown below.

Rust
use anyhow::{Context, Result, bail};
use chrono::{Datelike, Local, NaiveDate};
use clap::{Parser, Subcommand};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fmt;
use std::fs;
use std::io::{self, ErrorKind, Write};
use std::path::{Path, PathBuf};
use std::str::FromStr;

// ---------- money.rs ----------

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
#[serde(transparent)]
struct Money(u64);

impl FromStr for Money {
    type Err = String;

    fn from_str(text: &str) -> Result<Money, String> {
        let digits = text.trim().trim_start_matches('$');
        let (whole, fraction) = digits.split_once('.').unwrap_or((digits, ""));
        let all_digits = |s: &str| s.chars().all(|c| c.is_ascii_digit());
        if whole.is_empty() || fraction.len() > 2 || !all_digits(whole) || !all_digits(fraction) {
            return Err(format!("invalid amount {text:?}: use a number like 12.50"));
        }
        let whole: u64 = whole.parse().map_err(|_| format!("amount {text:?} is too large"))?;
        let cents: u64 = format!("{fraction:0<2}").parse().unwrap();
        whole.checked_mul(100).and_then(|c| c.checked_add(cents)).map(Money).ok_or(format!("amount {text:?} is too large"))
    }
}

impl fmt::Display for Money {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.pad(&format!("${}.{:02}", self.0 / 100, self.0 % 100))
    }
}

impl std::iter::Sum for Money {
    fn sum<I: Iterator<Item = Money>>(iter: I) -> Money {
        Money(iter.map(|m| m.0).sum())
    }
}

#[derive(Debug, Clone, Copy)]
struct Month {
    year: i32,
    month: u32,
}

impl Month {
    fn contains(&self, date: NaiveDate) -> bool {
        date.year() == self.year && date.month() == self.month
    }
}

impl FromStr for Month {
    type Err = String;

    fn from_str(text: &str) -> Result<Month, String> {
        let date = NaiveDate::parse_from_str(&format!("{text}-01"), "%Y-%m-%d")
            .map_err(|_| format!("invalid month {text:?}: use YYYY-MM"))?;
        Ok(Month { year: date.year(), month: date.month() })
    }
}

// ---------- store.rs ----------

#[derive(Debug, Clone, Serialize, Deserialize)]
struct Expense {
    id: u32,
    date: NaiveDate,
    amount: Money,
    category: String,
    note: String,
}

#[derive(Debug, Default, Serialize, Deserialize)]
struct Ledger {
    next_id: u32,
    expenses: Vec<Expense>,
}

impl Ledger {
    fn matching(&self, month: Option<Month>, category: Option<&str>) -> Vec<&Expense> {
        let mut found: Vec<&Expense> = self
            .expenses
            .iter()
            .filter(|e| month.is_none_or(|m| m.contains(e.date)))
            .filter(|e| category.is_none_or(|c| e.category.eq_ignore_ascii_case(c)))
            .collect();
        found.sort_by(|a, b| b.date.cmp(&a.date).then(b.id.cmp(&a.id)));
        found
    }
}

fn load(path: &Path) -> Result<Ledger> {
    match fs::read_to_string(path) {
        Ok(text) => serde_json::from_str(&text).with_context(|| format!("{} is not a valid ledger", path.display())),
        Err(error) if error.kind() == ErrorKind::NotFound => Ok(Ledger::default()),
        Err(error) => Err(error).with_context(|| format!("cannot read {}", path.display())),
    }
}

fn save(path: &Path, ledger: &Ledger) -> Result<()> {
    let temp = path.with_extension("json.tmp");
    fs::write(&temp, serde_json::to_string_pretty(ledger)?).with_context(|| format!("cannot write {}", temp.display()))?;
    fs::rename(&temp, path).with_context(|| format!("cannot replace {}", path.display()))?;
    Ok(())
}

// ---------- cli.rs ----------

#[derive(Debug, Parser)]
#[command(name = "expenses", about = "Track spending from the terminal")]
struct Cli {
    /// The JSON file that stores the ledger
    #[arg(long, default_value = "expenses.json")]
    file: PathBuf,

    #[command(subcommand)]
    command: Command,
}

#[derive(Debug, Subcommand)]
enum Command {
    /// Record an expense
    Add {
        amount: Money,
        category: String,
        /// What it was for
        note: Vec<String>,
        /// Defaults to today
        #[arg(long)]
        date: Option<NaiveDate>,
    },
    /// List expenses, newest first
    List {
        #[arg(long)]
        month: Option<Month>,
        #[arg(long)]
        category: Option<String>,
    },
    /// Total spending per category
    Report {
        #[arg(long)]
        month: Option<Month>,
    },
    /// Delete an expense by its number
    Remove { id: u32 },
}

// ---------- commands.rs ----------

fn run(cli: Cli, out: &mut impl Write) -> Result<()> {
    let mut ledger = load(&cli.file)?;
    match cli.command {
        Command::Add { amount, category, note, date } => {
            if amount == Money(0) {
                bail!("the amount must be more than zero");
            }
            ledger.next_id += 1;
            let date = date.unwrap_or_else(|| Local::now().date_naive());
            let expense = Expense { id: ledger.next_id, date, amount, category: category.to_lowercase(), note: note.join(" ") };
            ledger.expenses.push(expense);
            save(&cli.file, &ledger)?;
            writeln!(out, "added #{}", ledger.next_id)?;
        }
        Command::List { month, category } => {
            let found = ledger.matching(month, category.as_deref());
            if found.is_empty() {
                writeln!(out, "no expenses")?;
            }
            for e in &found {
                writeln!(out, "{:>4}  {}  {:<10} {:>9}  {}", format!("#{}", e.id), e.date, e.category, e.amount, e.note)?;
            }
            if found.len() > 1 {
                let total: Money = found.iter().map(|e| e.amount).sum();
                writeln!(out, "{:>38}", format!("total {total}"))?;
            }
        }
        Command::Report { month } => {
            let mut totals: BTreeMap<&str, Money> = BTreeMap::new();
            for e in ledger.matching(month, None) {
                let total = totals.entry(&e.category).or_default();
                total.0 += e.amount.0;
            }
            let mut rows: Vec<(&str, Money)> = totals.into_iter().collect();
            rows.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
            let largest = rows.first().map_or(1, |(_, m)| m.0.max(1));
            for (category, total) in rows {
                let bar = "#".repeat((total.0 * 20 / largest) as usize);
                writeln!(out, "{category:<10} {total:>9}  {bar}")?;
            }
        }
        Command::Remove { id } => {
            let Some(index) = ledger.expenses.iter().position(|e| e.id == id) else {
                bail!("there is no expense #{id}");
            };
            let removed = ledger.expenses.remove(index);
            save(&cli.file, &ledger)?;
            writeln!(out, "removed #{id} ({} {})", removed.amount, removed.category)?;
        }
    }
    Ok(())
}

// ---------- main.rs ----------

fn demo() -> Result<()> {
    let file = std::env::temp_dir().join("expenses-demo.json");
    let _ = fs::remove_file(&file);
    let file = file.to_str().context("temp path is not UTF-8")?.to_string();
    let session = [
        "add 12.50 food lunch with the team --date 2026-09-02",
        "add 48 transport monthly bus pass --date 2026-09-01",
        "add 7.25 food coffee beans --date 2026-09-03",
        "add 120 utilities electricity --date 2026-09-05",
        "add 30 food groceries --date 2026-08-30",
        "list --month 2026-09",
        "report --month 2026-09",
        "remove 2",
        "remove 42",
        "add 12,50 food typo",
        "list --category FOOD",
    ];
    let mut out = io::stdout();
    for line in session {
        writeln!(out, "$ expenses {line}")?;
        let args = ["expenses", "--file", &file].into_iter().chain(line.split(' '));
        let result = match Cli::try_parse_from(args) {
            Ok(cli) => run(cli, &mut out),
            Err(error) => Err(anyhow::anyhow!("{}", error.to_string().lines().next().unwrap_or_default())),
        };
        if let Err(error) = result {
            writeln!(out, "{error:#}")?;
        }
    }
    Ok(())
}

fn main() -> Result<()> {
    if std::env::args().len() == 1 {
        return demo();
    }
    run(Cli::parse(), &mut io::stdout())
}
Output
$ expenses add 12.50 food lunch with the team --date 2026-09-02
added #1
$ expenses add 48 transport monthly bus pass --date 2026-09-01
added #2
$ expenses add 7.25 food coffee beans --date 2026-09-03
added #3
$ expenses add 120 utilities electricity --date 2026-09-05
added #4
$ expenses add 30 food groceries --date 2026-08-30
added #5
$ expenses list --month 2026-09
  #4  2026-09-05  utilities    $120.00  electricity
  #3  2026-09-03  food           $7.25  coffee beans
  #1  2026-09-02  food          $12.50  lunch with the team
  #2  2026-09-01  transport     $48.00  monthly bus pass
                         total $187.75
$ expenses report --month 2026-09
utilities    $120.00  ####################
transport     $48.00  ########
food          $19.75  ###
$ expenses remove 2
removed #2 ($48.00 transport)
$ expenses remove 42
there is no expense #42
$ expenses add 12,50 food typo
error: invalid value '12,50' for '<AMOUNT>': invalid amount "12,50": use a number like 12.50
$ expenses list --category FOOD
  #3  2026-09-03  food           $7.25  coffee beans
  #1  2026-09-02  food          $12.50  lunch with the team
  #5  2026-08-30  food          $30.00  groceries
                          total $49.75

Option::is_none_or (stable since Rust 1.82) reads well for optional filters: "no month given, or the date is in that month".

Ideas for extending it

  • Monthly budgets per category, with a warning from add when a budget is exceeded.
  • import and export commands for CSV with the csv crate, which uses the same serde derives.
  • Store the data in SQLite with sqlx (see Databases with sqlx) once the JSON file gets large.
  • Put the ledger file in the platform's data directory with the directories crate instead of the current directory.

Summary

  • Money is an integer number of cents inside a newtype; FromStr and Display convert at the edges.
  • A missing file is an empty ledger; saving writes a temporary file and renames it, so the data file is never half-written.
  • clap's derive API turns an enum into subcommands, and any FromStr type becomes a validated argument.
  • run(cli, &mut impl Write) keeps the logic testable; main only decides where arguments and output come from.
  • anyhow with .with_context(...) produces errors like "expenses.json is not a valid ledger: expected ..." with no custom error types.

Next: Generic LRU Cache.