Lab: build a Rust notebook

The most efficient way to learn Rust as an orchestrator is to direct your agent through a small, real project. This lab is the project. You drive, the agent codes, the cargo gates keep both of you honest.

The project: a tiny notebook CLI. Five versions, each version teaching one bucket of concepts. By V5, you have a working rust-notebook binary that takes commands, persists to JSON, and exports HTML.

Setup

cargo new rust-notebook --bin
cd rust-notebook

Open the repo in your editor. Drop in a .gitignore (Cargo's cargo new already adds one). Add the cargo gates pre-commit hook from the cargo gates chapter.

V1: in-memory notes

Concepts: structs, vectors, loops, formatting.

Goal: A program that creates 3 sample notes in memory and prints them.

Prompt the agent:

"In src/main.rs, define a struct Note { id: u64, title: String, body: String }. In main, create three sample notes in a Vec<Note>, then print them. Derive Debug for Note so you can use {:?}. Run cargo run to verify."

After the diff: confirm Note is a public-ish struct with simple fields, Vec<Note> is local to main, and the print loop uses for note in &notes (borrow, not move).

V2: CLI arguments

Concepts: enums, match, argument parsing.

Goal: rust-notebook add "Title" "Body" / list / search "query"

Prompt:

"Add clap (derive feature) as a dependency. Define a Cli struct and a Command enum with Add { title, body }, List, Search { query } variants. Parse args, match on the command, and route to a function per variant. Move the sample notes into a fn sample_notes() -> Vec<Note>. For Search, do case-insensitive substring matching on title and body."

After the diff: enum has three variants (one each style: struct, unit, struct). match is exhaustive. No unwrap() on the parse.

V3: file storage

Concepts: Result, ?, file IO, serialization, error types.

Goal: Notes persist to ~/.rust-notebook/notes.json across runs.

Prompt:

"Add serde (derive feature) and serde_json and dirs. Derive Serialize, Deserialize on Note. Define a Notebook struct that owns Vec<Note> and methods load(path) -> Result<Self, NotebookError>, save(&self, path) -> Result<(), NotebookError>, add, list, search. Define NotebookError as a thiserror::Error enum with variants Io(#[from] std::io::Error), Json(#[from] serde_json::Error). Wire up main so commands load, mutate, save. Use dirs::config_dir() to find the storage path."

After the diff: error enum is concrete (no Box<dyn Error>, no anyhow in the library code). ? does the propagation. Zero unwrap() outside of main if at all.

V4: HTML export

Concepts: strings, file write, modules, templates, escaping.

Goal: rust-notebook export ~/notebook.html — a beautiful single-file HTML.

Prompt:

"Add an Export command to the CLI: Export { output: PathBuf }. Create a new module src/export.rs with pub fn render(notes: &[Note]) -> String. Build the HTML as a string: include <style> for a dark theme with rust orange accents, output each note as a card with title and body. Escape any HTML-unsafe characters in note content (use the html-escape crate or write a tiny escape function). Run cargo clippy -- -D warnings and fix anything it flags."

After the diff: a new module, the export function takes a borrow not a move, HTML escaping actually happens. Bonus: the agent might split rendering into helpers.

V5: tests and refactor

Concepts: unit tests, integration tests, lib.rs, error-path coverage.

Goal: Move core logic to a library, test it, harden it.

Prompt:

"Convert this into a library + binary. Move Note, Notebook, NotebookError, and export::render into src/lib.rs and submodules. Keep only argument parsing and dispatch in src/main.rs. Add unit tests for: adding a note, listing, searching (case-insensitive, substring), error path for missing file (returns Ok with empty notebook), error path for corrupted JSON (returns NotebookError::Json). Add an integration test in tests/cli.rs that uses assert_cmd to invoke the binary and check output. Run cargo fmt && cargo clippy -- -D warnings && cargo test and fix everything."

After the diff: lib.rs exists, the binary is thin. Every public function has a test. Each Err variant has a test that produces it.

What you have at V5

A real, small Rust binary that does something useful and demonstrates:

  • Structs, enums, match.
  • Result and thiserror.
  • File IO, serialization with serde.
  • Modules and library structure.
  • Unit and integration tests.
  • Cargo gates green.

The skill you have at V5 is not "I can write this from scratch." It is "I can direct an agent to build this end-to-end, review what it writes, and catch the failure modes." That is the orchestrator skill the whole site is about.

Going further

If V5 felt easy, add one of these:

  • V6: search ranking. TF-IDF or BM25 across titles and bodies. Adds an algorithm and tests for correctness, not just behavior.
  • V7: full-text indexing. A persistent inverted index in a separate file, rebuilt incrementally.
  • V8: HTTP server. Wrap the same library in axum. Now the same logic powers a CLI and an API.
  • V9: async file IO + watch mode. notify crate to live-refresh exports when a note changes.
  • V10: Tantivy or Lance. Integrate a real search/vector engine. Now you are touching the agent infra layer.

Pick what teaches you the bucket of Rust you are weakest on. The agent is willing.