Capstone: Building a CLI Tool

Course 3 · Ch 6
Capstone: Building a CLI Tool
A real task tracker combining ownership, error handling, traits, and crates — everything this three-course track built

One small, real command-line application, combining ownership, error handling, traits, and crates from across all three Rust courses.

The Project: A Task Tracker CLI

Add, list, complete, and remove tasks, persisted to a JSON file between runs, with subcommands parsed by the clap crate.

Project Setup (Cargo.toml)

[dependencies] clap = { version = "4.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0"

The Course 2 Chapter 6 payoff — real, external crates pulled in from crates.io, exactly as covered there.

The Task Struct & Custom Error Type

#[derive(serde::Serialize, serde::Deserialize)] struct Task { id: u32, description: String, completed: bool, } #[derive(Debug)] enum TaskError { NotFound(u32), Io(std::io::Error), } impl std::fmt::Display for TaskError { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { TaskError::NotFound(id) => write!(f, "no task with id {}", id), TaskError::Io(e) => write!(f, "file error: {}", e), } } } impl std::error::Error for TaskError {}

Course 1 Chapter 7's error handling and Course 3 Chapter 1's trait implementation, combined directly: a real error type implementing Display and std::error::Error.

A Storage Trait for Persistence

trait Storage { fn load(&self) -> Result<Vec<Task>, TaskError>; fn save(&self, tasks: &[Task]) -> Result<(), TaskError>; } struct JsonFileStorage { path: String } impl Storage for JsonFileStorage { fn load(&self) -> Result<Vec<Task>, TaskError> { let contents = std::fs::read_to_string(&self.path).unwrap_or_default(); if contents.is_empty() { return Ok(vec![]); } serde_json::from_str(&contents).map_err(|e| TaskError::Io(e.into())) } fn save(&self, tasks: &[Task]) -> Result<(), TaskError> { let json = serde_json::to_string_pretty(tasks).unwrap(); std::fs::write(&self.path, json).map_err(TaskError::Io) } }

Course 2 Chapter 2's trait basics chapter, now used for a genuine abstraction — a storage backend that could, in principle, be swapped for anything else implementing Storage.

CLI Parsing with clap

#[derive(clap::Parser)] struct Cli { #[command(subcommand)] command: Commands, } #[derive(clap::Subcommand)] enum Commands { Add { description: String }, List, Complete { id: u32 }, Remove { id: u32 }, }

#[derive(clap::Parser)] and #[derive(clap::Subcommand)] are exactly the derive procedural macros Course 3 Chapter 4 explained — seen here in genuine, practical use, generating a complete argument parser from a plain struct/enum definition.

Tying It Together: main()

fn main() -> Result<(), TaskError> { let cli = Cli::parse(); let storage = JsonFileStorage { path: "tasks.json".into() }; let mut tasks = storage.load()?; match cli.command { Commands::Add { description } => { let id = tasks.len() as u32 + 1; tasks.push(Task { id, description, completed: false }); } Commands::List => { for t in &tasks { println!("[{}] {} - {}", t.id, if t.completed { "x" } else { " " }, t.description); } } Commands::Complete { id } => { let task = tasks.iter_mut().find(|t| t.id == id).ok_or(TaskError::NotFound(id))?; task.completed = true; } Commands::Remove { id } => { tasks.retain(|t| t.id != id); } } storage.save(&tasks)?; Ok(()) }

? propagates errors all the way up to main's own Result return type — Course 1 Chapter 7's error-handling philosophy, in its natural home: a real CLI tool that fails with a clean message instead of a panic when something goes wrong.

What This Capstone Demonstrates

FeatureWhere It Appears
Ownership (Course 1)Task struct, Vec<Task> passed and mutated throughout main()
Error Handling (Course 1, Ch.7)TaskError, Result<T, TaskError>, ? propagation to main
Traits (Course 2 Ch.2, Course 3 Ch.1)The Storage trait and its JsonFileStorage implementation
Crates (Course 2, Ch.6)clap and serde as real Cargo.toml dependencies
Procedural Macros (Course 3, Ch.4)#[derive(Parser)], #[derive(Subcommand)], #[derive(Serialize)]

The Bigger Picture

This closes the loop back to Course 1 Chapter 1's founding question: how does Rust deliver memory safety without a garbage collector? This small tool, working end to end, demonstrates the full arc — compile-time-verified ownership and borrowing, zero-cost abstractions (generics, iterators), and a real, practical ecosystem (crates.io, clap, serde) — Rust's whole pitch, delivered in working code rather than left as an abstract claim.

Natural Extensions From Here
A realistic next step, not required: trait objects (dyn Storage, Chapter 1) for supporting multiple storage backends interchangeably at runtime; async I/O (Chapter 3) if storage became a network service instead of a local file; unsafe (Chapter 2) only if genuine FFI were ever needed. This capstone is a real, usable starting point, not a toy that stops mattering once the course ends.
Don't unwrap() in Real CLI Logic
A real tool's main logic should propagate errors with ? and let main's own Result return type produce a clean failure message — not unwrap() its way through file I/O or parsing and crash with a raw panic on the first bad input. Course 1 Chapter 7's guidance, now shown in the exact context it was always meant for.

Coding Challenges

Challenge 1

Add a Commands::Clear variant that removes all tasks, and wire it into main()'s match block, following the existing variants as a template.

📄 View solution
Challenge 2

Write a #[cfg(test)] mod tests block (Course 2, Chapter 7) with a unit test verifying that Commands::Complete correctly sets a task's completed field to true when given a valid id, and that an invalid id produces a TaskError::NotFound via #[should_panic] or explicit Result matching.

📄 View solution
Challenge 3

Write a short reflection: pick one feature from EACH of the three Rust courses (Fundamentals, Intermediate, Advanced) that you found most valuable, and explain in a sentence each why it changed how you'd approach writing code, in Rust or otherwise.

📄 View solution

Chapter 6 Quick Reference

  • A real CLI tool combines this track's material naturally: ownership, Result-based error handling, traits for abstraction, and real crates.io dependencies
  • clap (with the derive feature) — declarative CLI parsing via #[derive(Parser)]/#[derive(Subcommand)]
  • serde/serde_json — struct-to-JSON serialization via #[derive(Serialize, Deserialize)]
  • A custom error enum implementing Display + std::error::Error is the idiomatic foundation for a real Result-based API
  • The Storage trait demonstrates trait-based abstraction with a genuine, practical payoff — not just a teaching example
  • fn main() -> Result<(), E> lets ? propagate all the way to the program's own exit, replacing panics with clean error output

🎉 Rust Advanced Complete — 6 / 6 chapters — Full Rust Track Complete

From cargo and ownership through borrowing, structs, enums, error handling, and collections (Fundamentals) — through lifetimes, traits, generics, smart pointers, concurrency, modules, and testing (Intermediate) — to advanced traits, unsafe code, async, macros, performance, and this capstone (Advanced): 21 chapters across three courses, one coherent design philosophy from start to finish. This completes the entire Rust track on the site.