Learn Rust Series (#14) - Mini Project: A Command-Line To-Do App
Learn Rust Series (#14) - Mini Project: A Command-Line To-Do App
What will I learn
- You will learn how to read command-line arguments with
std::env::args; - how to read and write a text file with
std::fs, handling the "file does not exist yet" case cleanly; - how to structure a small program as a set of small functions with clear ownership;
- how the
?operator threads I/O errors up tomain; - how everything from the first thirteen episodes combines into a real, working tool you can actually use.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Rust toolchain (via rustup, from rustup.rs);
- The previous thirteen episodes, especially error handling, collections and pattern matching;
- The ambition to learn systems programming from the ground up.
Difficulty
- Beginner
Curriculum (of the Learn Rust Series):
- Learn Rust Series (#1) - Introduction to Rust
- Learn Rust Series (#2) - Variables, Types, Functions
- Learn Rust Series (#3) - Ownership & Borrowing
- Learn Rust Series (#4) - Control Flow & Pattern Matching
- Learn Rust Series (#5) - Structs & Enums
- Learn Rust Series (#6) - Error Handling
- Learn Rust Series (#7) - Collections
- Learn Rust Series (#8) - Traits & Generics
- Learn Rust Series (#9) - Modules & Crates
- Learn Rust Series (#10) - Lifetimes
- Learn Rust Series (#11) - Closures & the Iterator Trait
- Learn Rust Series (#12) - Smart Pointers: Box, Rc & RefCell
- Learn Rust Series (#13) - Concurrency: Threads, Channels, Arc & Mutex
- Learn Rust Series (#14) - Mini Project: A Command-Line To-Do App (this post)
Learn Rust Series (#14) - Mini Project: A Command-Line To-Do App
Thirteen episodes in, and every one of them has been a single idea in isolation: here is ownership, here is pattern matching, here is the iterator trait, here is a mutex. That is how you learn the vocabulary of a language. But nobody hires you to write a match in a vacuum -- they hire you to ship a thing, and a thing is always a dozen of these ideas leaning on each other at once. So today we build something real: a small to-do app you drive from the terminal, like todo add "buy milk", todo list and todo done 0. It stores your tasks in a plain text file so they survive between runs, which means it is a genuinely useful little tool and not just a toy that forgets everything the moment it exits.
Here is the part I want you to notice as we go: almost nothing in this episode is new. That is the whole point. This project is the first thirteen episodes finally working as a team -- Option at the edges, a match guard from episode 4, the ? operator from episode 6, slices and Vec from episode 7, closures and iterator adapters from episode 11. We will build it up one small piece at a time, explain why each piece looks the way it does, and then bolt the whole thing together at the end so you can copy-paste-and-run it. As always, though, we start by clearing last episode's homework ;-)
Solutions to Episode 13 Exercises
Episode 13 was concurrency -- threads, channels, Arc and Mutex. There were three exercises, and here is how each one lands with full code you can paste and run.
Exercise 1 asked you to spawn three threads, each printing its own number (0, 1 and 2), collecting the join handles in a Vec and then joining them all in a second loop:
use std::thread;
fn main() {
let handles: Vec<_> = (0..3)
.map(|n| thread::spawn(move || println!("thread {n}")))
.collect();
for h in handles {
h.join().unwrap();
}
}
The key insight is the two separate loops. We spawn all three threads first and stash their handles in the Vec, and only then do we go back and join each one. If you had joined inside the first loop instead, you would have waited for thread 0 to completely finish before even starting thread 1, which quietly serialises the whole thing and defeats the purpose. Spawn-then-join is the pattern. Run it a handful of times and the numbers will print in a different order most runs -- that reordering is real parallelism, not a bug.
Exercise 2 wanted a channel where a spawned thread sends the numbers 1 through 5, and the main thread receives them, sums them, and prints the total:
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
for i in 1..=5 {
tx.send(i).unwrap();
}
});
let total: i32 = rx.iter().sum();
println!("total: {total}"); // 15
}
The neat trick here is rx.iter().sum(). Because the receiver is an iterator that ends automatically when the sending end drops, we can hand it straight to sum() -- one of the consuming adapters from episode 11 -- and let it drain the channel and add everything up in a single expression. No manual loop, no running total variable, no off-by-one. When the spawned thread finishes its for loop, tx goes out of scope, the channel closes, the iterator ends, and sum returns 15.
Exercise 3 was the trickier one: take the Arc<Mutex<i32>> counter from the episode and turn it into an Arc<Mutex<Vec<i32>>>, then spawn five threads that each lock the mutex and push their own id into the shared vector:
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let ids = Arc::new(Mutex::new(Vec::new()));
let handles: Vec<_> = (0..5).map(|id| {
let ids = Arc::clone(&ids);
thread::spawn(move || ids.lock().unwrap().push(id))
}).collect();
for h in handles {
h.join().unwrap();
}
let mut collected = ids.lock().unwrap().clone();
collected.sort();
println!("{collected:?}"); // [0, 1, 2, 3, 4]
}
Every thread clones the Arc to get its own owning handle, locks the mutex, pushes one id, and drops the guard (releasing the lock) at the end of the closure. Because the pushes happen in whatever order the scheduler feels like, the vector arrives unsorted, so we sort() it before printing to get a stable, predictable [0, 1, 2, 3, 4]. Note that we clone() the inner vector out while holding the lock, then release it before sorting and printing -- a small habit of holding the lock for as short a time as possible. Right, homework cleared. On to our project!
Reading the arguments
Every command-line program starts the same way: figuring out what the user actually typed. Rust hands you those arguments through std::env::args, an iterator whose first item is always the program's own name (the path it was launched with) and whose remaining items are the words the user supplied. We collect the whole lot into a Vec<String> so we can index into it, and then we dispatch on the first real argument:
use std::env;
fn main() {
let args: Vec<String> = env::args().collect();
match args.get(1).map(|s| s.as_str()) {
Some("add") => println!("would add: {:?}", args.get(2)),
Some("list") => println!("would list"),
Some("done") => println!("would mark done: {:?}", args.get(2)),
_ => println!("usage: todo [add | list | done ]"),
}
}
There is a small but important decision hiding in args.get(1). It would be tempting to write args[1] -- shorter, reads nicely -- but indexing a Vec out of bounds panics, and if the user runs the bare todo with no command at all, there is no args[1] and the program crashes with an ugly backtrace. Using get(1) instead returns an Option<&String>: Some when the argument exists, None when it does not, and we simply match on it. This is the exact same "reach for Option at the untrusted edges of your program" habit we have leaned on since episode 6, and command-line input is about as untrusted an edge as you will find. Build that reflex early -- indexing is for data you know is there, get is for data you hope is there.
The .map(|s| s.as_str()) in the middle is a small convenience so we can match against plain string literals like "add" rather than String values. args.get(1) gives us an Option<&String>, and mapping as_str over it turns that into an Option<&str>, which lines up neatly with the Some("add") patterns. If you come from Python, this whole block is the moral equivalent of reading sys.argv and doing an if/elif chain on argv[1] -- except in Python, argv[1] on an empty command line throws an IndexError at runtime that you have to remember to guard against, whereas here the compiler will not even let you forget the "no command given" case, because match on an Option must handle None (or a catch-all _) or it will not compile.
Loading tasks from a file
Now for persistence, which is what makes this tool actually worth using. We store each task on its own line in a text file, so loading means reading the file and splitting it into lines. There is one wrinkle that trips up almost everyone the first time: on the very first run the file does not exist yet, and that is not an error -- it simply means "you have no tasks yet". A naive program would crash on that missing file; a good one treats it as an empty list. So we handle that one specific case and forward every other kind of I/O error up to the caller:
use std::fs;
use std::io;
fn load_tasks(path: &str) -> io::Result<Vec<String>> {
match fs::read_to_string(path) {
Ok(contents) => Ok(contents.lines().map(|l| l.to_string()).collect()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Vec::new()),
Err(e) => Err(e),
}
}
Look closely at the middle arm, because it is doing something rather elegant. Err(e) if e.kind() == io::ErrorKind::NotFound is a match guard -- that little if after the pattern -- exactly the feature we met back in episode 4. It says "match an Err, but only when the error's kind is specifically NotFound", and in that one recoverable case we quietly return an empty Vec and pretend nothing was wrong. Any other error -- a permissions problem, a bad disk, a file that exists but cannot be read -- falls through to the final Err(e) => Err(e) arm and gets passed straight back to whoever called us. That is the correct posture for error handling: be specific about the failures you can genuinely recover from, and forward everything else honestly rather than swallowing it. The contents.lines() part is an iterator over the lines of the file (an iterator again, of course), and .map(|l| l.to_string()).collect() turns each borrowed &str line into an owned String and gathers them into the Vec<String> we promised to return.
Saving tasks back
Saving is the mirror image of loading, and it is delightfully short. We glue the lines back together with newlines between them and write the whole lot to disk in one go. fs::write takes a path and some bytes, creates or truncates the file, and returns an io::Result<()> -- so we can simply hand its result straight back as our own return value:
use std::fs;
use std::io;
fn save_tasks(path: &str, tasks: &[String]) -> io::Result<()> {
fs::write(path, tasks.join("\n"))
}
Notice the parameter type: &[String], a slice, and not &Vec<String>. This is a deliberate, idiomatic choice. A function that only needs to read a list of strings should ask for the most general read-only view it can, and a slice is exactly that -- any contiguous run of String values, whether it came from a Vec, an array, or part of a larger buffer. Because of deref coercion (a mechanism we will study properly in a couple of episodes), you can pass a &Vec<String> wherever a &[String] is wanted and Rust converts it for you automatically, so you lose nothing at the call site while gaining flexibility in the signature. tasks.join("\n") is the counterpart to the lines() we used when loading: it stitches the slice back into a single String with a newline between each entry, ready to be written out.
The three operations
The actual to-do logic -- the part a user would say is "the program" -- is almost embarrassingly small. Adding pushes a new formatted line onto the list, marking something done rewrites the little checkbox on one line, and listing prints every task with its index so the user knows which number to pass to done:
fn add_task(tasks: &mut Vec<String>, text: &str) {
tasks.push(format!("[ ] {text}"));
}
fn mark_done(tasks: &mut Vec<String>, index: usize) {
if let Some(task) = tasks.get_mut(index) {
*task = task.replacen("[ ]", "[x]", 1);
}
}
fn list_tasks(tasks: &[String]) {
if tasks.is_empty() {
println!("no tasks yet");
return;
}
for (i, task) in tasks.iter().enumerate() {
println!("{i}: {task}");
}
}
Every one of these three functions borrows precisely what it needs, and no more. add_task and mark_done take a &mut Vec<String> because they genuinely change the list -- one grows it, the other edits an element in place -- while list_tasks takes a read-only &[String] because it only ever reads. Those ownership annotations are not bureaucratic ceremony; they are a checked contract. The compiler will refuse to let list_tasks accidentally mutate the list, and it guarantees that when add_task holds its mutable borrow, nothing else is reading the list at the same time. You get to state your intent in the type, and then the type is enforced for free.
A couple of details worth pausing on. In mark_done, tasks.get_mut(index) returns an Option<&mut String> -- there is that "reach for Option at the edges" habit again, because the user might pass an index that does not exist, and get_mut gives us None rather than panicking on a bad number. We only touch the task inside if let Some(task) = ..., so an out-of-range index is silently and safely a no-op. Then task.replacen("[ ]", "[x]", 1) swaps the empty checkbox for a ticked one, and the 1 means "replace at most one occurrence" so we do not accidentally maul a task whose text happens to contain [ ]. Over in list_tasks, tasks.iter().enumerate() pairs each task with its position -- enumerate is the iterator adapter that hands you (index, item) tuples -- which is how the user gets those handy numbers to reference.
The whole program
Now we wire the pieces together into a single main. The trick that ties it all up is the return type: main returns io::Result<()>, which means we are allowed to use the ? operator inside it. Every time we call load_tasks(path)? or save_tasks(path, &tasks)?, that little ? says "if this returned an Err, stop right here and bubble the error up out of main; otherwise unwrap the Ok and carry on". That is the whole error-propagation story from episode 6, now doing real work in a real program:
use std::env;
use std::fs;
use std::io;
fn load_tasks(path: &str) -> io::Result<Vec<String>> {
match fs::read_to_string(path) {
Ok(contents) => Ok(contents.lines().map(|l| l.to_string()).collect()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Vec::new()),
Err(e) => Err(e),
}
}
fn save_tasks(path: &str, tasks: &[String]) -> io::Result<()> {
fs::write(path, tasks.join("\n"))
}
fn add_task(tasks: &mut Vec<String>, text: &str) {
tasks.push(format!("[ ] {text}"));
}
fn mark_done(tasks: &mut Vec<String>, index: usize) {
if let Some(task) = tasks.get_mut(index) {
*task = task.replacen("[ ]", "[x]", 1);
}
}
fn list_tasks(tasks: &[String]) {
if tasks.is_empty() {
println!("no tasks yet");
return;
}
for (i, task) in tasks.iter().enumerate() {
println!("{i}: {task}");
}
}
fn main() -> io::Result<()> {
let path = "tasks.txt";
let args: Vec<String> = env::args().collect();
let mut tasks = load_tasks(path)?;
match args.get(1).map(|s| s.as_str()) {
Some("add") => {
let text = args.get(2).cloned().unwrap_or_default();
add_task(&mut tasks, &text);
save_tasks(path, &tasks)?;
println!("added: {text}");
}
Some("done") => {
if let Some(i) = args.get(2).and_then(|s| s.parse::<usize>().ok()) {
mark_done(&mut tasks, i);
save_tasks(path, &tasks)?;
println!("marked {i} done");
} else {
println!("usage: todo done ");
}
}
Some("list") | None => list_tasks(&tasks),
Some(other) => println!("unknown command: {other}"),
}
Ok(())
}
Read main top to bottom and you will see the shape of nearly every command-line tool ever written: load the state, look at what the user asked for, do the one thing, save the state, report back. The add arm uses args.get(2).cloned().unwrap_or_default() so that a missing task text falls back to an empty string in stead of panicking -- cloned turns the Option<&String> into an Option<String>, and unwrap_or_default supplies an empty String when nothing was given. The done arm is where a few episodes really stack up: args.get(2) gives an Option, .and_then(|s| s.parse::<usize>().ok()) tries to parse that argument into a number and yields None if it was missing or unparseable, and only if we get a real Some(i) do we go ahead and mark it done. If the user typed todo done banana, the parse fails, we drop into the else, and print a usage hint rather than crashing. And Some("list") | None => list_tasks(&tasks) uses an or-pattern so that both an explicit list command and the bare todo with no arguments show the list, which is a friendly default.
Drop that into a fresh cargo new todo project's src/main.rs and take it for a spin: cargo run -- add "learn rust", then cargo run -- add "build things", then cargo run -- list, and finally cargo run -- done 0. Everything after the -- is handed to your program rather than to cargo itself, which is how you feed arguments to a binary you are running through cargo. You now have a persistent to-do list in well under seventy lines of code, and here is the thing I want to leave you with: every single line of it is checked by the compiler. There is no path through this program that reads past the end of the vector, no path that crashes on a missing file, no path that silently ignores a write failure. Try building the same tool in a language without these guarantees and honestly count the number of ways it could have blown up on a missing file, a bad index, or a number that would not parse. Rust made you handle all of them, not by nagging, but by refusing to compile until you had ;-)
What did we actually build?
- We read command-line input with
std::env::args, collected it into aVec<String>, and dispatched on it with amatch-- usinggetrather than indexing so untrusted input can never panic us. - We persisted state to disk with
std::fs, treating a missing file as an empty list via a match guard onio::ErrorKind::NotFound, while forwarding every other error honestly. - We split the work into small functions that each borrow exactly what they need --
&mut Vec<String>to mutate,&[String]to read -- turning "what is this function allowed to touch" into a compiler-checked contract. - We let
mainreturnio::Result<()>so the?operator could thread I/O errors up and out with almost no ceremony.
None of those bullet points is a new feature. They are episodes 4, 6, 7 and 11, finally pulling together into one honest little program. That is what real Rust feels like: not exotic tricks, but a handful of solid habits reinforcing each other until the compiler is quietly guarding your back on every line. Next time we start looking at how traits let us abstract over behaviour rather than concrete types -- the beginning of Rust's answer to polymorphism -- but one step at a time.
Exercises
Three exercises, gentle to chewier as always. Full solutions open the next episode -- have a real go at them first, because typing this stuff yourself is where it actually sticks.
- Add a
clearcommand that wipes the list by deleting the file (look atfs::remove_file), and make it treat an already-missing file as "already clear" rather than an error -- reuse theNotFoundmatch-guard idea fromload_tasks. - Make
listhide completed tasks (the ones starting with[x]) by default, but show everything when the user passes a second argumentall, as intodo list all. Use afilteron the iterator rather than anifinside the loop. - Replace the
[ ]/[x]string trick with a properstruct Task { done: bool, text: String }. Write one function that serialises aTaskinto a single line and another that parses a line back into aTask, and thread them throughload_tasksandsave_tasks. This is a real taste of hand-rolled serialisation.
Bedankt voor het meebouwen, en tot de volgende keer! ;-)
Leave Learn Rust Series (#14) - Mini Project: A Command-Line To-Do App to:
Read more #stem posts
Best Posts From scipio
We have not curated any of scipio's posts yet. But you can encourage our curation team to review posts by visiting them regularly and by referring other readers. Because we give priority to frequently read content.
More Posts From scipio
- Learn Rust Series (#15) - Trait Objects & Dynamic Dispatch
- Learn AI Series (#145) - Neuro-Symbolic AI
- Learn Zig Series (#125) - Mini Project: Search Engine - Inverted Index
- Learn Rust Series (#14) - Mini Project: A Command-Line To-Do App
- Learn AI Series (#144) - Few-Shot and Zero-Shot Learning
- Learn Zig Series (#124) - Consistent Hashing
- Learn Rust Series (#13) - Concurrency: Threads, Channels, Arc & Mutex
- Learn AI Series (#143) - Continual Learning
- Learn Zig Series (#123) - LRU Cache
- Learn Rust Series (#12) - Smart Pointers: Box, Rc & RefCell