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
- Add a fourth arm:
Some("remove"). Parseargs[2]into ausizeexactly likedonedoes, then look upVec::remove— one new name, the opposite of.push()from the vectors lesson: it removes the item atindexand shifts everything after it down by one. Callsave_tasks(&tasks)?afterward, same as every other mutating command. - If you did the serde-and-json lesson's exercise and gave
Taskapriority: u8field — or add it now — calltasks.sort_by_key(|task| task.priority)right before theforloop inlist. The closures lesson'ssort_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.