Project: a todo CLI
This page builds one small program from an empty folder to a tested command-line tool, todo. It does not teach the pieces again. Each step names the lesson or guide that does, and shows what the code and the compiler did in the pinned image (Rust 1.99.1, Linux ARM64). A first build on a fresh machine downloads the crates (needs network); the dependency versions are fixed by the Cargo.lock that comes with the project. The sessions leave out Cargo’s Compiling, Checking and Finished lines.
RememberPut the rules in src/lib.rs, keep src/main.rs thin (parse arguments, call the library, print), and test the library with unit tests and the binary with tests that run it.
todo/ ├── src/ │ ├── lib.rs the rules: Task and List │ ├── main.rs the command line │ └── store.rs reading and writing the JSON file ├── tests/ │ └── cli.rs tests that run the binary ├── Cargo.lock committed: pins every version └── Cargo.toml the four dependencies
[package]name = "todo"version = "0.1.0"edition = "2024"
[dependencies]anyhow = "1.0.104"clap = { version = "4.6.7", features = ["derive"] }serde = { version = "1.0.229", features = ["derive"] }serde_json = "1.0.154"cargo new todo makes the package, and cargo add writes the dependency lines (Using crates). There are four: clap for arguments, serde and serde_json for the file, anyhow for errors.
Why two crates in one package
Section titled “Why two crates in one package”A package with both src/lib.rs and src/main.rs builds a library and a program that uses it. The Rust Book gives the reason in its testing chapter: a project with only src/main.rs cannot have integration tests that use its functions, so “Rust projects that provide a binary have a straightforward src/main.rs file that calls logic that lives in the src/lib.rs file” (chapter 11.3). Project structure shows the layout in a larger package. Here it gives the split: everything that decides what a to-do list is goes in the library, and everything about the terminal goes in main.rs.
Step 1: the rules, with no input or output
Section titled “Step 1: the rules, with no input or output”//! A to-do list: the data and its rules, with no input or output.use std::fmt;
use anyhow::{Result, bail};use serde::{Deserialize, Serialize};
/// One thing to do.#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]pub struct Task { pub id: u32, pub text: String, pub priority: u8, pub done: bool,}
impl fmt::Display for Task { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { let mark = if self.done { 'x' } else { ' ' }; write!( f, "[{mark}] {:>2} p{} {}", self.id, self.priority, self.text ) }}
/// All the tasks, and the number the next one gets.#[derive(Debug, Default, PartialEq, Serialize, Deserialize)]pub struct List { next_id: u32, tasks: Vec<Task>,}
impl List { pub fn add(&mut self, text: &str, priority: u8) -> Result<&Task> { let text = text.trim(); if text.is_empty() { bail!("a task needs some text"); } if !(1..=3).contains(&priority) { bail!("priority must be 1, 2 or 3, not {priority}"); } self.next_id += 1; self.tasks.push(Task { id: self.next_id, text: text.to_string(), priority, done: false, }); Ok(self.tasks.last().expect("a task was just pushed")) }
pub fn complete(&mut self, id: u32) -> Result<&Task> { let Some(task) = self.tasks.iter_mut().find(|t| t.id == id) else { bail!("no task with id {id}"); }; task.done = true; Ok(task) }
pub fn remove(&mut self, id: u32) -> Result<Task> { let Some(pos) = self.tasks.iter().position(|t| t.id == id) else { bail!("no task with id {id}"); }; Ok(self.tasks.remove(pos)) }
pub fn tasks(&self) -> &[Task] { &self.tasks }}
#[cfg(test)]mod tests { use super::*;
#[test] fn ids_count_up_and_are_not_reused() { let mut list = List::default(); list.add("one", 2).unwrap(); let two = list.add("two", 2).unwrap().id; list.remove(two).unwrap(); assert_eq!(list.add("three", 2).unwrap().id, 3); }
#[test] fn completing_marks_the_task() { let mut list = List::default(); let id = list.add("one", 1).unwrap().id; assert!(list.complete(id).unwrap().done); assert!(list.tasks()[0].done); }
#[test] fn rules_are_enforced() { let mut list = List::default(); assert_eq!( list.add(" ", 2).unwrap_err().to_string(), "a task needs some text" ); assert_eq!( list.add("x", 4).unwrap_err().to_string(), "priority must be 1, 2 or 3, not 4" ); assert_eq!( list.complete(7).unwrap_err().to_string(), "no task with id 7" ); assert!(list.tasks().is_empty()); }
#[test] fn display_marks_done_tasks() { let mut list = List::default(); list.add("write", 1).unwrap(); list.complete(1).unwrap(); assert_eq!(list.tasks()[0].to_string(), "[x] 1 p1 write"); }}Running unittests src/lib.rs (target/debug/deps/todo-HASH) test tests::completing_marks_the_task ... ok test tests::display_marks_done_tasks ... ok test tests::ids_count_up_and_are_not_reused ... ok test tests::rules_are_enforced ... ok test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in X.XXs
This file is src/lib.rs after step 1. There is no file and no command line yet, so a test can build a List in memory and check it.
Listkeeps its fields private, so the only way to add a task in code isadd, which checks the rules: some text, a priority from 1 to 3 (Modules). That covers code that calls the library. A file read byloadin step 2 is not checked at all; the limits section runs an example.addandcompletereturnResult<&Task>: a reference into the list, which the caller can print and must drop before changing the list again (Borrowing).bail!andResultcome from anyhow, which fits an application: it holds any error and prints a message (Errors in real apps). A library that others depend on is often better off with its own error type; the “what to try next” list has that.let ... elsereturns early when the id is not found (Option).next_idonly goes up, so removing task 2 and adding another gives task 3. The first test checks it.- The two
derivelines for serde are there for step 2. The tests sit in a child module with#[cfg(test)](Modules).
Step 2: the file
Section titled “Step 2: the file”//! Reading the list from a JSON file and writing it back.use std::ffi::OsString;use std::fs;use std::io::ErrorKind;use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use crate::List;
/// A file that does not exist yet is an empty list; any other problem is an error.pub fn load(path: &Path) -> Result<List> { let text = match fs::read_to_string(path) { Ok(text) => text, Err(e) if e.kind() == ErrorKind::NotFound => return Ok(List::default()), Err(e) => return Err(e).with_context(|| format!("could not read {}", path.display())), }; serde_json::from_str(&text) .with_context(|| format!("{} is not a valid to-do file", path.display()))}
/// Write to `<path>.tmp`, then rename it over `path`.pub fn save(list: &List, path: &Path) -> Result<()> { let mut tmp: OsString = path.as_os_str().to_owned(); tmp.push(".tmp"); let tmp = PathBuf::from(tmp); let json = serde_json::to_string_pretty(list)?; fs::write(&tmp, json + "\n").with_context(|| format!("could not write {}", tmp.display()))?; fs::rename(&tmp, path).with_context(|| format!("could not replace {}", path.display()))?; Ok(())}
#[cfg(test)]mod tests { use super::*;
/// A folder under the system temp dir, removed again when dropped. struct TempDir(PathBuf);
impl TempDir { fn new(name: &str) -> Self { let dir = std::env::temp_dir().join(format!("todo-{}-{name}", std::process::id())); fs::create_dir_all(&dir).unwrap(); TempDir(dir) } fn file(&self, name: &str) -> PathBuf { self.0.join(name) } }
impl Drop for TempDir { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } }
#[test] fn a_saved_list_loads_back_equal() { let dir = TempDir::new("round-trip"); let path = dir.file("todo.json"); let mut list = List::default(); list.add("write the page", 1).unwrap(); list.add("run the tests", 2).unwrap(); list.complete(1).unwrap(); save(&list, &path).unwrap(); assert_eq!(load(&path).unwrap(), list); assert!(!dir.file("todo.json.tmp").exists()); }
#[test] fn a_missing_file_is_an_empty_list() { let dir = TempDir::new("missing"); assert_eq!(load(&dir.file("nope.json")).unwrap(), List::default()); }
#[test] fn garbage_is_an_error_that_names_the_file() { let dir = TempDir::new("garbage"); let path = dir.file("bad.json"); fs::write(&path, "{oops").unwrap(); let message = format!("{:#}", load(&path).unwrap_err()); assert!( message.contains("bad.json is not a valid to-do file"), "{message}" ); }}Running unittests src/lib.rs (target/debug/deps/todo-HASH) test store::tests::a_missing_file_is_an_empty_list ... ok test store::tests::a_saved_list_loads_back_equal ... ok test store::tests::garbage_is_an_error_that_names_the_file ... ok test tests::completing_marks_the_task ... ok test tests::display_marks_done_tasks ... ok test tests::ids_count_up_and_are_not_reused ... ok test tests::rules_are_enforced ... ok test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in X.XXs
lib.rs gains two lines to use it, mod store; and pub use store::{load, save};, so callers write todo::load. The two derives let serde turn a List into JSON text and back (serde guide), which is what to_string_pretty and from_str do.
- A file that does not exist is an empty list. Any other failure to read is an error, and the match on
ErrorKind::NotFoundis how the code tells them apart (Files and I/O). .with_context(...)wraps the cause with a message that names the file, so the user sees which file was bad, not only that the JSON was.savewrites<file>.tmpand then renames it over the real file. That is a shorter form of the idiom in Replacing a file safely, without itssync_all, so the page says nothing about a power cut. The rename happens after the write call returns, which is the order of the program and nothing more. No test here interrupts a write. The test only checks the temporary file is gone after a save.- The storage tests use a folder under
std::env::temp_dir()named with the process id, and aDropthat removes it, as in Temporary files that clean up.
Seven unit tests now cover the model and the file, and none of them needed a terminal.
Step 3: the command line
Section titled “Step 3: the command line”use std::path::PathBuf;
use anyhow::Result;use clap::{Parser, Subcommand};use todo::{List, load, save};
/// A to-do list kept in a JSON file#[derive(Parser)]#[command(version)]struct Cli { /// Where the list is stored #[arg(long, global = true, default_value = "todo.json")] file: PathBuf, #[command(subcommand)] command: Command,}
#[derive(Subcommand)]enum Command { /// Add a task Add { /// What to do text: String, /// 1 (high) to 3 (low) #[arg(short, long, default_value_t = 2)] priority: u8, }, /// Show the tasks List { /// Hide finished tasks #[arg(long)] open: bool, }, /// Mark a task as done Done { id: u32 }, /// Delete a task Remove { id: u32 },}
fn main() -> Result<()> { let cli = Cli::parse(); let mut list: List = load(&cli.file)?; match cli.command { Command::Add { text, priority } => { println!("added {}", list.add(&text, priority)?); save(&list, &cli.file)?; } Command::List { open } => { let shown: Vec<_> = list.tasks().iter().filter(|t| !open || !t.done).collect(); if shown.is_empty() { println!("no tasks"); } for task in shown { println!("{task}"); } } Command::Done { id } => { println!("finished {}", list.complete(id)?); save(&list, &cli.file)?; } Command::Remove { id } => { println!("removed {}", list.remove(id)?); save(&list, &cli.file)?; } } Ok(())}no tasks$ cargo run -- add "write the page" -p 1
added [ ] 1 p1 write the page$ cargo run -- add "run the tests"
added [ ] 2 p2 run the tests$ cargo run -- done 1
finished [x] 1 p1 write the page$ cargo run -- list
[x] 1 p1 write the page [ ] 2 p2 run the tests$ cargo run -- list --open
[ ] 2 p2 run the tests$ cargo run -- list --file todo.json
[x] 1 p1 write the page [ ] 2 p2 run the tests$ cat todo.json
{
"next_id": 2,
"tasks": [
{
"id": 1,
"text": "write the page",
"priority": 1,
"done": true
},
{
"id": 2,
"text": "run the tests",
"priority": 2,
"done": false
}
]
}$ cargo run -- --helpA to-do list kept in a JSON file
Usage: todo [OPTIONS] <COMMAND>
Commands:
add Add a task
list Show the tasks
done Mark a task as done
remove Delete a task
help Print this message or the help of the given subcommand(s)
Options:
--file <FILE> Where the list is stored [default: todo.json]
-h, --help Print help
-V, --version Print version$ cargo run -- add --helpAdd a task
Usage: todo add [OPTIONS] <TEXT>
Arguments:
<TEXT> What to do
Options:
--file <FILE> Where the list is stored [default: todo.json]
-p, --priority <PRIORITY> 1 (high) to 3 (low) [default: 2]
-h, --help Print helpEach cargo run above is a new process that reads todo.json, does one thing and writes the file back, which the cat shows. The derive attributes are the clap guide’s; what is specific here:
global = trueon--filelets it go before or after the subcommand (the session runslist --file todo.json), and it shows up in the help ofadd(the clap guide explains global flags).mainreturnsanyhow::Result<()>. If it returnsErr, the runtime printsError:and the error’s debug form and exits with status 1 (Errors,maincan return aResult). anyhow’s docs say that is the form you get by default whenfn mainreturns an error.- The
matchonCommandhas no_arm, so adding a command later without handling it is a compile error (Enums). mainis short glue. The rules it relies on are covered by the tests from steps 1 and 2.
Step 4: when things go wrong
Section titled “Step 4: when things go wrong”$ cargo run -- done 9 Error: no task with id 9 $ cargo run -- add "x" -p 5 Error: priority must be 1, 2 or 3, not 5 $ echo '{oops' > bad.json $ cargo run -- --file bad.json list Error: bad.json is not a valid to-do file Caused by: key must be a string at line 1 column 2 $ cargo run -- add error: the following required arguments were not provided: <TEXT> Usage: todo add <TEXT> For more information, try '--help'.
The first two messages come from the library’s bail!. The third shows the chain: the outer message names the file, and Caused by is serde_json’s own message. The last one is clap’s: it exits with status 2 for a usage error, our errors exit with 1. The first three stop before save is reached, so they wrote nothing; the fourth never gets past clap.
Step 5: tests that run the program
Section titled “Step 5: tests that run the program”//! Tests that run the real `todo` binary, the way a user does.use std::fs;use std::path::PathBuf;use std::process::{Command, Output};
/// A private folder for one test's list file, removed when the test ends.struct Sandbox { dir: PathBuf,}
impl Sandbox { fn new(name: &str) -> Self { let dir = std::env::temp_dir().join(format!("todo-cli-{}-{name}", std::process::id())); fs::create_dir_all(&dir).unwrap(); Sandbox { dir } }
fn file(&self) -> PathBuf { self.dir.join("todo.json") }
/// Run `todo --file <this sandbox's file> <args...>`. fn todo(&self, args: &[&str]) -> Output { Command::new(env!("CARGO_BIN_EXE_todo")) .arg("--file") .arg(self.file()) .args(args) .output() .expect("the binary starts") }}
impl Drop for Sandbox { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.dir); }}
fn stdout(out: &Output) -> String { String::from_utf8_lossy(&out.stdout).into_owned()}
fn stderr(out: &Output) -> String { String::from_utf8_lossy(&out.stderr).into_owned()}
#[test]fn a_new_list_is_empty() { let sb = Sandbox::new("empty"); let out = sb.todo(&["list"]); assert!(out.status.success()); assert_eq!(stdout(&out), "no tasks\n");}
#[test]fn added_tasks_are_listed_and_kept_between_runs() { let sb = Sandbox::new("add"); let out = sb.todo(&["add", "write the page", "-p", "1"]); assert!(out.status.success(), "{}", stderr(&out)); assert_eq!(stdout(&out), "added [ ] 1 p1 write the page\n"); assert_eq!(stdout(&sb.todo(&["list"])), "[ ] 1 p1 write the page\n");}
#[test]fn done_tasks_can_be_hidden() { let sb = Sandbox::new("done"); sb.todo(&["add", "one"]); sb.todo(&["add", "two"]); sb.todo(&["done", "1"]); assert_eq!(stdout(&sb.todo(&["list", "--open"])), "[ ] 2 p2 two\n");}
#[test]fn an_unknown_id_fails_with_a_message() { let sb = Sandbox::new("unknown-id"); let out = sb.todo(&["done", "9"]); assert_eq!(out.status.code(), Some(1)); assert!( stderr(&out).contains("no task with id 9"), "{}", stderr(&out) );}
#[test]fn a_bad_priority_is_refused_and_nothing_is_saved() { let sb = Sandbox::new("bad-priority"); let out = sb.todo(&["add", "x", "-p", "5"]); assert_eq!(out.status.code(), Some(1)); assert!(stderr(&out).contains("priority must be 1, 2 or 3, not 5")); assert!(!sb.file().exists());}
#[test]fn a_corrupt_file_is_reported_with_its_path() { let sb = Sandbox::new("corrupt"); fs::write(sb.file(), "{oops").unwrap(); let out = sb.todo(&["list"]); assert_eq!(out.status.code(), Some(1)); let err = stderr(&out); assert!(err.contains("todo.json is not a valid to-do file"), "{err}"); assert!(err.contains("Caused by"), "{err}");}
#[test]fn a_usage_error_exits_with_2() { let sb = Sandbox::new("usage"); let out = sb.todo(&["add"]); assert_eq!(out.status.code(), Some(2)); assert!(stderr(&out).contains("Usage:"));} Running unittests src/lib.rs (target/debug/deps/todo-HASH)
test store::tests::a_missing_file_is_an_empty_list ... ok
test store::tests::a_saved_list_loads_back_equal ... ok
test store::tests::garbage_is_an_error_that_names_the_file ... ok
test tests::completing_marks_the_task ... ok
test tests::display_marks_done_tasks ... ok
test tests::ids_count_up_and_are_not_reused ... ok
test tests::rules_are_enforced ... ok
test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in X.XXs
Running unittests src/main.rs (target/debug/deps/todo-HASH)
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in X.XXs
Running tests/cli.rs (target/debug/deps/cli-HASH)
test a_bad_priority_is_refused_and_nothing_is_saved ... ok
test a_corrupt_file_is_reported_with_its_path ... ok
test a_new_list_is_empty ... ok
test a_usage_error_exits_with_2 ... ok
test added_tasks_are_listed_and_kept_between_runs ... ok
test an_unknown_id_fails_with_a_message ... ok
test done_tasks_can_be_hidden ... ok
test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in X.XXs
Doc-tests todo
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in X.XXs$ for i in 1 2 3 4 5; do cargo test --test cli; done # default thread count; only the result linestest result: ok. 7 passed; 0 failed test result: ok. 7 passed; 0 failed test result: ok. 7 passed; 0 failed test result: ok. 7 passed; 0 failed test result: ok. 7 passed; 0 failed
Each file in tests/ is its own crate, built with the library and the binary. Cargo sets CARGO_BIN_EXE_<name> while compiling an integration test: “the absolute path to a binary target’s executable”, usable with the env! macro (Cargo environment variables). So the tests need only std::process::Command: no test-helper crate.
- Each test gets its own
Sandboxfolder and passes--fileinto it, so tests do not share a list file.--test-threads=1is in the command only so the lines print in the same order each time; the second step runs the tests five times with the default thread count and they passed each time (Testing). - They check what a user sees: standard output, standard error, and the exit status (
Some(1)for our errors,Some(2)for clap’s). a_bad_priority_is_refused_and_nothing_is_savedchecks the file does not exist afterwards.
Why bother when the unit tests pass? Here is a bug in main.rs that the library tests cannot see: the done arm forgets to call save. The first step below deletes that line, then both test sets run:
$ sed -i '/Command::Done/{n;n;d;}' src/main.rs # delete the save call in the Done arm 57: Command::Done { id } => { 58- println!("finished {}", list.complete(id)?); 59- } 60- Command::Remove { id } => { $ cargo test --lib # timings replaced by X.XXs test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in X.XXs $ cargo test --test cli # one test thread; timings, build hash and thread id masked Running tests/cli.rs (target/debug/deps/cli-HASH) running 7 tests test a_bad_priority_is_refused_and_nothing_is_saved ... ok test a_corrupt_file_is_reported_with_its_path ... ok test a_new_list_is_empty ... ok test a_usage_error_exits_with_2 ... ok test added_tasks_are_listed_and_kept_between_runs ... ok test an_unknown_id_fails_with_a_message ... ok test done_tasks_can_be_hidden ... FAILED failures: ---- done_tasks_can_be_hidden stdout ---- thread 'done_tasks_can_be_hidden' (N) panicked at tests/cli.rs:70:5: assertion `left == right` failed left: "[ ] 1 p2 one\n[ ] 2 p2 two\n" right: "[ ] 2 p2 two\n" note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace failures: done_tasks_can_be_hidden test result: FAILED. 6 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in X.XXs error: test failed, to rerun pass `--test cli`
The seven library tests still pass, because the library is fine. The CLI test that marks a task done and then lists the open ones fails, and its message shows both tasks still open. Finally, cargo fmt --check and cargo clippy --all-targets have nothing to say about the finished project:
$ cargo fmt --check $ cargo clippy --all-targets
(Empty output and a successful exit mean no complaints: cargo fmt --check printed nothing and clippy printed no warnings.)
Where each technique is taught
Section titled “Where each technique is taught”| In this project | Where it is explained |
|---|---|
lib.rs and main.rs in one package |
Project structure |
derive(Parser), subcommands, global flags |
clap |
derive(Serialize, Deserialize), serde_json |
serde |
anyhow::Result, bail!, .with_context |
Errors in real apps, Errors |
ErrorKind, temp files, replacing a file |
Files and I/O |
unit tests, tests/, Command |
Testing |
pub, private fields, mod, pub use |
Modules |
What this project does not do
Section titled “What this project does not do”Everything ran on one machine, Linux ARM64, so nothing here shows how fs::rename behaves on other systems. Limits of the code as written:
loadtrusts the file: it checks no text, no priority and no id. Changingnext_idback by hand makes the nextaddreuse an id, and a hand-written file with empty text and priority 9 loads and lists:
$ cargo run -- add "first" added [ ] 1 p2 first $ cargo run -- add "second" added [ ] 2 p2 second $ sed -i 's/"next_id": 2/"next_id": 0/' todo.json # edit the file by hand $ cargo run -- add "third" added [ ] 1 p2 third $ cargo run -- list [ ] 1 p2 first [ ] 2 p2 second [ ] 1 p2 third $ echo '{"next_id":1,"tasks":[{"id":1,"text":"","priority":9,"done":false}]}' > odd.json $ cargo run -- --file odd.json list [ ] 1 p9
- Two
todoprocesses at the same moment could each read the old list, and the second save would overwrite the first. Nothing here locks the file (not run). - The
.tmpfile would be left behind ifrenamefailed (not run).
What to try next
Section titled “What to try next”- Close the id hole. Make
loadreturn an error when two tasks share an id ornext_idis not above every id, and add a unit test that feeds it such a file. - Give the library its own error type. Replace
anyhowinlib.rswith an enum written withthiserror, keeping anyhow inmain.rsandstore.rs(it needscargo add thiserror, which needs network and changesCargo.lock). Therules_are_enforcedtest can then match on a variant instead of a message (app-errors). - Read the file name from the environment. clap can read an argument from an environment variable when its
envfeature is on (see theenvattribute in the derive reference). Decide which wins, the flag or the variable, and write a test for it (environment variables). - Add a command. Add
edit <id> <text>to the enum and watch the compiler list what is missing. Then write its unit test and its CLI test before the implementation. - Split into a workspace. Move the library to its own package and keep the binary as the second (project structure).
Read more
Section titled “Read more”- The Rust Book, chapter 12.3: the same split (thin
main, logic in a library) in a longer example - The Rust Book, chapter 11.3: unit tests, integration tests, and why binary crates are tested through a library
- The Cargo Book: targets: what
src/lib.rs,src/main.rsandtests/mean to Cargo - anyhow’s
Errorand clap’s derive tutorial, for the versions this page pins
Learn Rust is an independent project, not affiliated with the Rust Project or the Rust Foundation. Code and text are under the MIT licence.Source on GitHub.