The last lesson ended with a promise. The hello-cargo lesson added rand to Cargo.toml and called rand::thread_rng().gen_range(...) — one external crate, used once, before derive, traits, or modules meant anything yet. Here's the second one: serde. And the payoff isn't just "another crate" — it's that derive, the same #[derive(Debug)] you've written since the structs lesson, extends to crates you didn't write at all.
The second crate (or, more precisely, two)
Two new lines in Cargo.toml's [dependencies], alongside the rand line from the hello-cargo lesson:
serde and serde_json work as a pair. serde itself doesn't know anything about JSON — it defines two traits, Serialize and Deserialize, plus the machinery to implement them automatically. serde_json is the format: it knows how to turn anything that implements those two traits into JSON text, and back. (The split is deliberate — the same derived Serialize/Deserialize also works with other formats, like serde_yaml or bincode; today it's JSON.) { version = "1", features = ["derive"] } is TOML's inline-table syntax — a compact way to give one dependency more than one setting — and features = ["derive"] is the part that matters most: without it, serde ships only the Serialize/Deserialize traits themselves, with no way to implement them automatically. That feature flag is what makes the very next line possible.
#[derive(Serialize, Deserialize)]
Same struct-defining muscle as ever — and the same attribute as the structs lesson's #[derive(Debug)], just with two more names in the list:
#[derive(Debug)] told the compiler "generate a debug-printing implementation for this type." #[derive(Serialize, Deserialize)] says exactly the same kind of thing, twice more — except this time the traits being implemented aren't from std::fmt. They're serde::Serialize and serde::Deserialize, named in the use line above exactly the way every use has worked since the modules-and-crates lesson. derive never cared who defined the trait. It still doesn't.
serde_json::to_string(&task) takes a reference — the same borrow from the references-and-borrowing lesson, because turning a Task into JSON only needs to look at it — and returns Result<String, serde_json::Error>. .unwrap() for now, the option-and-result lesson's shortcut for "I know this works, don't make me handle it yet." Run this, and the output is one line: {"description":"Write lesson 31","done":false} — every field, by name, with its value, generated from a struct definition that mentions JSON exactly zero times.
Pretty-printing: to_string_pretty
One line of JSON is fine for a println!, but the moment this is heading into a file — which is exactly where this lesson is going — readable formatting starts to matter. Same Task, same derive, one word different:
Same two fields, same values — but spread across three lines with two-space indentation, the kind of output that's pleasant to open in a text editor. Nothing about Task or its derive changed; only which function turned it into text.
Back the other way: from_str
to_string and to_string_pretty both go one direction: struct to JSON. serde_json::from_str goes the other way — and the round trip fits in one block:
serde_json::from_str doesn't know what type to build — the function itself is generic, the same shape as .collect() in the iterators lesson. There, the type annotation on the binding was the only thing telling .collect() what to produce. Here it's the exact same move: let restored: Task = serde_json::from_str(&json).unwrap(); — the : Task is the entire instruction. Take it away, and the compiler has no way to know whether you wanted a Task, a Vec<i32>, or anything else that implements Deserialize. {restored:?} then prints it back with Debug — the same derive from the very first code block in this lesson, still doing its job. The output:
A whole file of tasks
Every piece is now on the table: a struct that turns into JSON and back, and — from the files-and-io lesson — fs::write and fs::read_to_string. Put them together, and a whole Vec<Task> becomes a file:
Two things changed quietly. First: Vec<Task>, not Task — and #[derive(Serialize, Deserialize)] was written exactly once, on Task, not on Vec. The moment Task implements Serialize, serde gives Vec<Task> (and a Vec of anything else that implements it) the same implementation automatically, for free — the same "one impl, every type that qualifies" shape as .to_string() arriving for free the moment a type implements Display, back in the writing-tests lesson.
Second: every ? here — after to_string_pretty, after fs::write, after fs::read_to_string, after from_str — sits inside main() -> Result<(), Box<dyn Error>>. Two of those four already worked this way: fs::write and fs::read_to_string return io::Error, which has converted into Box<dyn Error> since the traits lesson. serde_json::to_string_pretty and serde_json::from_str return a brand-new error type this course has never seen — serde_json::Error — and it slides into the same Box<dyn Error> with zero new code, because serde_json::Error implements Error too, and the standard library's blanket conversion from "any error type" into Box<dyn Error> doesn't care which crate defined it.
Run this, and tasks.json appears on disk — two tasks, formatted exactly like the pretty-printed output from earlier — and loaded prints back as [Task { description: "Write lesson 31", done: false }, Task { description: "Write lesson 32", done: false }]: the exact Vec you started with, having spent the time in between as a text file.
Quick exercise
- Add a third field to
Task—priority: u8— and give both tasks in the last block a value for it. Re-run it, then opentasks.jsonand look at what changed: every field you defined, with no edit to any line that mentionsto_string_pretty,fs::write,from_str, or anything else.deriveregenerated both implementations from the new struct definition; the rest of the program never needed to know anything changed. - Run the last block once, then change one task's
donetotruein the source (without deletingtasks.json) and run it again.fs::writeoverwrites unconditionally — the files-and-io lesson's word for it — sotasks.json, and theloadedit prints, always end up matching whatever was intasksthe moment the program last ran, regardless of what was on disk before.
Stack up what derive now covers: a printing implementation from the standard library (the structs lesson), and — today — two implementations from a crate added with one line in Cargo.toml. Same attribute, same syntax, same one-word instruction; derive simply doesn't care where the trait came from. And Box<dyn Error> just absorbed its third kind of error — io::Error and now serde_json::Error — without main changing at all, the traits lesson's blanket conversion working exactly as advertised for a crate that lesson never knew existed.
tasks.json isn't a toy. It's real, structured, persistent data — written by one run of a program and read by the next — and it's exactly what the final lesson in this course builds around: a small command-line to-do list. env::args, from last lesson, will choose what to do — add a task, list them, mark one done. Task and serde_json, from this lesson, are how the list survives between runs. And everything from structs through error handling supplies the rest: building a CLI.