Lesson 5 of 12
Expense Tracker CLI
We build expenses, a command-line tool that records spending in a JSON file:
$ 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(...) |
cargo new expenses
cd expenses
cargo add clap --features derive
cargo add serde --features derive
cargo add serde_json anyhow
cargo add chrono --features serdeStep 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.
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));
}
}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.00sf.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].
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(())
}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.
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()));
}"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
trueThe /// 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.
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())
}$ 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.75Option::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
addwhen a budget is exceeded. importandexportcommands for CSV with thecsvcrate, 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
directoriescrate instead of the current directory.
Summary
- Money is an integer number of cents inside a newtype;
FromStrandDisplayconvert 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
FromStrtype becomes a validated argument. run(cli, &mut impl Write)keeps the logic testable;mainonly decides where arguments and output come from.anyhowwith.with_context(...)produces errors like "expenses.json is not a valid ledger: expected ..." with no custom error types.
Next: Generic LRU Cache.
