Building a CLI: A To-Do List That Uses Everything

Building a CLI: A To-Do List That Uses Everything

The capstone of the core curriculum: a real, working command-line to-do list, task_cli. A Task struct with #[derive(Serialize, Deserialize)] from the serde-and-json lesson, load_tasks and save_tasks built from fs::read_to_string/fs::write and the env::var-style match from files-and-io, and a main() that dispatches add/list/done from env::args using the exact args.get(1).map(String::as_str) pattern the files-and-io lesson ended on. The list command is the iterators lesson's .enumerate() section, verbatim. Almost nothing new — everything used. Closes with a course-wide recap and an honest preview of the two ADVANCED sections ahead.

15 min read4 learning objectives

What You'll Learn

  • Start a new project with cargo new and add serde and serde_json as task_cli's only dependencies
  • Write load_tasks and save_tasks around fs::read_to_string/fs::write and serde_json::from_str/to_string_pretty, reusing the Ok(...)/Err(_) match shape from std::env::var
  • Dispatch on env::args with match and Option<&str> patterns, implementing add, list, and done as three arms of one match expression
  • Read a finished, multi-function program and identify which earlier lesson taught each individual piece — then preview what Smart Pointers & Concurrency adds next

Thirty-one lessons, and almost all of them — closures, iterators, even the modules lesson's examples — lived in one file, run with cargo run, output read straight off the screen. This lesson is different. task_cli is a real project: it reads a command from the terminal, keeps its data in a JSON file between runs, and uses — by name, on purpose — nearly everything this course has covered. Almost nothing new gets introduced here. Everything gets used.

A new project: task_cli

Start fresh: cargo new task_cli. Inside Cargo.toml's [dependencies] — the section the hello-cargo lesson named, the modules-and-crates lesson explained, and the serde-and-json lesson used — add the same two lines as last time:

No rand this time — [dependencies] lists what THIS project needs, and a to-do list has no use for random numbers. Two crates, both familiar, in a project that's never seen either of them before.

Loading tasks

Everything this program does starts with the same question: what's already in tasks.json? The first time it runs, the honest answer is "nothing — the file doesn't exist yet" — and that has to be a normal case, not an error:

Task is exactly the struct from the serde-and-json lesson — same two fields, same derive. load_tasks wraps fs::read_to_string in a match, and that match is the files-and-io lesson's env::var pattern again: Ok(value) => ..., Err(_) => ..., the wildcard standing in for "whatever went wrong, it doesn't change what we do." If the file exists, Ok(contents) holds its text, and serde_json::from_str(&contents)? parses it — the ? converting a serde_json::Error into Box<dyn Error>, same as last lesson. If it doesn't, Err(_) matches, and the function returns Ok(Vec::new()) — an empty list is a perfectly good answer to "what tasks exist so far?" on a brand-new project. load_tasks can still fail — a tasks.json that exists but isn't valid JSON would fail to parse — but "the file isn't there yet" isn't that failure.

Saving tasks

The mirror image:

to_string_pretty, then fs::write — both from the last two lessons, both still behind ?. The parameter type is &[Task], a slice reference — the exact type the generics lesson's largest asked for (fn largest<T: PartialOrd>(list: &[T]) -> &T). Calling save_tasks(&tasks) where tasks: Vec<Task> works for the same reason largest(&numbers) did back then: a &Vec<T> converts to &[T] automatically, because a Vec is, underneath, a slice that knows how to grow.

Putting it together: main

One struct, two helper functions — and now the part that ties them to the command line. Three commands, add, list, and done, dispatched from env::args exactly the way the files-and-io lesson set up. One more use line at the top of the same file, then main:

args.get(1).map(String::as_str) — the files-and-io lesson's exact closing line, args.get(1).map(String::as_str).unwrap_or("count.txt"), with the .unwrap_or(...) removed. Without it, the result is Option<&str>, and match takes it from there: Some("add"), Some("list"), and Some("done") are the same kind of pattern as 0, 1 | 2 | 3, and 4..=9 from the pattern-matching lesson — literal patterns — just &str literals instead of integers. _ catches everything else: no second word at all (None), or a word that isn't one of the three (Some("oops")) — both land on the same usage message, the same role Err(_) played for env::var.

Some("add") reuses args.get(2).map(String::as_str) — same chain as args.get(1), one position over — but ends in .unwrap_or("(empty)") instead of staying an Option. Forgetting a description doesn't crash the program; it just adds a task called "(empty)". Task { description: String::from(description), done: false } is the exact struct literal from the serde-and-json lesson, tasks.push(...) is the vectors lesson, and save_tasks(&tasks)? writes the new list to disk before the function says anything.

Some("list") is the iterators lesson's .enumerate() section, verbatim: tasks.iter().enumerate() borrows, pairs each Task with its position, and (index, task) destructures both. let mark = if task.done { "x" } else { " " }; is if as an expression — a value, assigned straight into a let — and task.done reaches a field through a reference exactly the way every &self method has all course long. println!("[{mark}] {index}: {}", task.description) mixes captured-identifier interpolation with a positional {} — the same mix the modules-and-crates lesson used for item.name.

Some("done") is the one place this program can panic on purpose. args[2].parse() — index first (the vectors lesson), then a method call that borrows through to &str and parses (the propagating-errors lesson) — and : usize is, once again, .collect()'s trick: the binding's type tells the otherwise-generic .parse() what to build. If args[2] doesn't exist — cargo run -- done with nothing after it — that's index out of bounds, the exact panic from the very first lesson on this topic, now triggered by what was typed at the command line instead of a number in the source — exactly what the files-and-io lesson predicted for args[1]. If index is too big for tasks, tasks[index] panics the same way, one line later. Compare this to add: a missing description got a harmless default, because "(empty)" is a perfectly good task description. There's no equally honest default for "which task did you mean" — so this one panics, on purpose, and that's the right call.

Try it

Six commands, one tasks.json, surviving every run in between. The [x]/[ ] markers are nothing more than the if expression in the list arm; the indices are .enumerate(); the persistence is load_tasks and save_tasks, called once each, every single time.

Quick exercise

  1. Add a fourth arm: Some("remove"). Parse args[2] into a usize exactly like done does, then look up Vec::remove — one new name, the opposite of .push() from the vectors lesson: it removes the item at index and shifts everything after it down by one. Call save_tasks(&tasks)? afterward, same as every other mutating command.
  2. If you did the serde-and-json lesson's exercise and gave Task a priority: u8 field — or add it now — call tasks.sort_by_key(|task| task.priority) right before the for loop in list. The closures lesson's sort_by_key, sorting your own struct for the first time. Watch the printed order change without touching anything else.

Read back through main one more time and count what's not a callback. struct and derive — the structs lesson, then serde-and-json. Vec, .push(), indexing, .iter() — vectors. match, literal patterns, Option, _ — pattern matching and option-and-result. Result, ?, Box<dyn Error> — propagating errors and traits. .enumerate(), .collect()'s type-annotation trick — iterators. env::args, env::var's match shape, fs::read_to_string, fs::write — files and I/O. Serialize, Deserialize, to_string_pretty, from_str — serde and JSON. Thirty-one lessons, one program, and every single one of them is in there, doing real work.

Two sections remain, and the tone is about to shift. Everything since lesson 1 has been about what to write — the right type, the right trait, the right method, called in the right order. Smart Pointers & Concurrency, starting next, is about something genuinely different: who owns a piece of data when more than one part of a program needs it, and what "at the same time" even means once a program can do more than one thing at once. It's the first section in this course marked ADVANCED — not because it's unfriendly, but because it's the first set of ideas Rust had to invent almost from scratch, with little to lean on from other languages. Box, Rc, and RefCell first; threads, channels, Mutex, and Arc after. Take a moment with task_cli before moving on — it's a complete, real program, and everything past this point builds on knowing, confidently, how it works.