Rust Weekly Exercises — Understanding Checkpoints
Companion to: The Rust Learning Program (1-Year Guide)
Ashwin Hebbar | March 2026
Each week has 3–5 small, focused programs designed to test whether you’ve internalized that week’s concepts. These are NOT project work — they are short exercises (15–60 minutes each) meant for weekday evenings.
Rules:
- Write each program from scratch. No copy-paste.
- No AI assistance. These are diagnostic — if you can’t do them, you know what to revisit.
- Each exercise includes sample input/output so you can verify correctness.
- If an exercise takes you more than 60 minutes, stop and re-read the relevant Book chapter.
Week 1 — Ownership, Variables, Basic Types
Book Chapters 1–4
Exercise 1.1: Temperature Converter
Write a program that reads a temperature value and a unit from the command line and converts between Celsius and Fahrenheit.
Input (command line args):
cargo run -- 100 C
Expected Output:
100°C = 212°F
Input:
cargo run -- 32 F
Expected Output:
32°F = 0°C
Input:
cargo run -- 72 F
Expected Output:
72°F = 22.22°C
What it tests: Basic types (f64), command line args (std::env::args), string comparison, formatted output with println!.
Exercise 1.2: Ownership Transfer Debugger
The following code does NOT compile. Your task: explain why in a comment, then fix it in two different ways (one using .clone(), one using references).
fn main() {
let greeting = String::from("Hello, Rust!");
let length = calculate_length(greeting);
println!("{} has length {}", greeting, length);
}
fn calculate_length(s: String) -> usize {
s.len()
}
Expected Output (after fix):
Hello, Rust! has length 12
What it tests: Understanding ownership transfer (move semantics), the difference between passing by value vs. by reference, when .clone() is appropriate.
Exercise 1.3: FizzBuzz with Ownership
Write FizzBuzz (1 to 50), but store results in a Vec<String>. Then write a function summarize(results: &[String]) -> (usize, usize, usize) that returns how many “Fizz”, “Buzz”, and “FizzBuzz” entries there are — without taking ownership of the Vec.
Expected Output:
1
2
Fizz
4
Buzz
Fizz
...
Buzz
Summary: Fizz=10, Buzz=6, FizzBuzz=3
What it tests: Vec<String>, borrowing with &[String], iterators with .filter(), counting patterns.
Exercise 1.4: Word Frequency Counter
Read a string literal (hardcoded), split it into words, count the frequency of each word (case-insensitive), and print the top 5 most frequent words.
Input (hardcoded string):
let text = "the quick brown fox jumps over the lazy dog the fox the dog the quick quick";
Expected Output:
the: 5
quick: 3
fox: 2
dog: 2
over: 1
What it tests: HashMap<String, usize>, .split_whitespace(), .to_lowercase(), sorting a Vec of tuples, .entry().or_insert().
Week 2 — Ownership Deep Dive
Revisiting Chapters 3–4 with intent
Exercise 2.1: The Borrow Checker Obstacle Course
The following six snippets each fail to compile. For each: (a) explain the error in a comment, (b) fix it with the minimal change, and (c) write a one-sentence rule that prevents this class of error.
// Snippet A
let mut v = vec![1, 2, 3];
let first = &v[0];
v.push(4);
println!("{}", first);
// Snippet B
let s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s;
println!("{} {}", r1, r2);
// Snippet C
let r;
{
let x = 5;
r = &x;
}
println!("{}", r);
// Snippet D
fn first_word(s: String) -> &str {
&s[0..5]
}
// Snippet E
let mut data = vec![1, 2, 3];
for n in &data {
data.push(*n * 2);
}
// Snippet F
let s1 = String::from("hello");
let s2 = s1;
println!("{}", s1);
What it tests: Every distinct borrow checker rule in isolation. If you can explain all six, you own borrowing.
Exercise 2.2: String Conversions
Write a function fn normalize(input: &str) -> String that: trims whitespace, converts to lowercase, replaces spaces with underscores, and removes any character that is not alphanumeric or underscore.
Then write a function fn longest_normalized<'a>(a: &'a str, b: &'a str) -> &'a str that returns the longer of the two input strings (not the normalized version — the original).
Input:
normalize(" Hello, World! 2026 ") // → "hello_world_2026"
normalize(" Rust Programming ") // → "rust___programming"
longest_normalized("hello", "world!") // → "world!" (same length, return second)
longest_normalized("hi", "there") // → "there"
Expected Output:
"hello_world_2026"
"rust___programming"
Longest: "world!"
Longest: "there"
What it tests: &str vs String, string method chaining (.trim(), .to_lowercase(), .chars(), .filter(), .collect()), lifetime annotations with concrete need.
Week 3 — Structs, Enums, Pattern Matching
Book Chapters 5–7
Exercise 3.1: Shape Area Calculator
Define an enum Shape with variants Circle(f64), Rectangle(f64, f64), and Triangle(f64, f64, f64) (three sides). Implement a method area(&self) -> f64 using pattern matching. For the triangle, use Heron’s formula.
Input (hardcoded):
let shapes = vec![
Shape::Circle(5.0),
Shape::Rectangle(4.0, 6.0),
Shape::Triangle(3.0, 4.0, 5.0),
];
Expected Output:
Circle(r=5.0): area = 78.5398
Rectangle(4.0 x 6.0): area = 24.0000
Triangle(3.0, 4.0, 5.0): area = 6.0000
Total area: 108.5398
What it tests: Enums with data, impl blocks, match expressions, Heron’s formula: √(s(s-a)(s-b)(s-c)).
Exercise 3.2: Student Grade Processor
Define a Student struct with name: String, scores: Vec<f64>. Implement:
average(&self) -> f64grade(&self) -> char(A: ≥90, B: ≥80, C: ≥70, D: ≥60, F: <60)highest(&self) -> f64lowest(&self) -> f64
Process a list of students and print a formatted report.
Input (hardcoded):
let students = vec![
Student::new("Alice", vec![92.0, 88.0, 95.0, 90.0]),
Student::new("Bob", vec![75.0, 82.0, 68.0, 71.0]),
Student::new("Charlie", vec![55.0, 62.0, 48.0, 70.0]),
Student::new("Diana", vec![98.0, 95.0, 100.0, 97.0]),
];
Expected Output:
Student Report
==============
Name | Avg | Grade | High | Low
----------|-------|-------|-------|------
Alice | 91.25 | A | 95.00 | 88.00
Bob | 74.00 | C | 82.00 | 68.00
Charlie | 58.75 | F | 70.00 | 48.00
Diana | 97.50 | A | 100.00| 95.00
Class average: 80.38
What it tests: Structs with methods, Vec<f64> operations, formatted table output with padding, iteration over structs.
Exercise 3.3: Expression Evaluator
Define an enum for a simple arithmetic expression tree:
enum Expr {
Num(f64),
Add(Box<Expr>, Box<Expr>),
Mul(Box<Expr>, Box<Expr>),
Neg(Box<Expr>),
}
Implement fn eval(expr: &Expr) -> f64 using recursive pattern matching and fn display(expr: &Expr) -> String that shows the expression with parentheses.
Input (hardcoded):
// Represents: (3 + 4) * -(2 + 5)
let expr = Expr::Mul(
Box::new(Expr::Add(Box::new(Expr::Num(3.0)), Box::new(Expr::Num(4.0)))),
Box::new(Expr::Neg(Box::new(Expr::Add(Box::new(Expr::Num(2.0)), Box::new(Expr::Num(5.0)))))),
);
Expected Output:
Expression: ((3 + 4) * -(2 + 5))
Result: -49
What it tests: Recursive enums, Box<T> for heap allocation (why it’s needed — can you explain?), recursive pattern matching, tree traversal.
Exercise 3.4: Module Organization
Refactor Exercise 3.1 (Shape Area Calculator) into a proper module structure:
src/
main.rs
shapes/
mod.rs (re-exports)
circle.rs
rectangle.rs
triangle.rs
Each shape should be its own struct (not an enum variant) and implement a trait HasArea. main.rs should use the module without knowing internal structure.
Expected Output: Same as Exercise 3.1, but the code is modular.
What it tests: Module system, mod, pub, use, trait definition, trait implementation across files.
Week 4 — Module System & Visibility Deep Dive
Consolidating Chapters 5–7 and the module system
Week 4 Checkpoint: Module Refactor
Take your Week 3 Shape Area Calculator (Exercise 3.1) and refactor it into a proper multi-file crate with the following structure:
src/
main.rs (uses the library, prints shapes)
lib.rs (re-exports public API)
geometry/
mod.rs (re-exports submodules)
shapes.rs (Shape enum + area logic)
stats.rs (total_area, largest_shape functions)
formatting/
mod.rs
table.rs (formats shapes as a table)
Rules:
main.rsmay only import fromlib.rs— it must not reach into internal submodules- All types used in
main.rsmust be publicly re-exported fromlib.rs geometry::statsfunctions must borrow shapes, not take ownership
Expected Output: Same as Week 3 Exercise 3.1, but the code is split across 6 files with clean visibility boundaries.
What it tests: mod, pub, pub use, super::, crate::, the difference between mod (declare a module) and use (bring into scope), visibility rules (pub(crate) vs pub).
Week 5 — Collections, Error Handling, Generics Intro
Book Chapters 8–10
Exercise 3.1: Robust CSV Line Parser
Write a function parse_csv_line(line: &str) -> Result<Vec<String>, ParseError> that handles:
- Simple comma-separated values
- Quoted fields with commas inside:
"hello, world" - Escaped quotes inside quoted fields:
"she said ""hello""" - Mismatched quotes (return an error)
Input/Output pairs:
Input: "Alice,30,Engineer"
Output: Ok(["Alice", "30", "Engineer"])
Input: "Alice,\"New York, NY\",Engineer"
Output: Ok(["Alice", "New York, NY", "Engineer"])
Input: "Alice,\"She said \"\"hi\"\"\",30"
Output: Ok(["Alice", "She said \"hi\"", "30"])
Input: "Alice,\"unclosed quote,30"
Output: Err(ParseError::UnmatchedQuote { position: 6 })
What it tests: Result<T, E>, custom error types with enum ParseError, character-by-character string processing, state machine logic.
Exercise 3.2: Generic Statistics
Write a generic function stats<T: Into<f64> + Copy>(data: &[T]) -> Stats that works with &[i32], &[f64], and &[u64]. Return a Stats struct with mean, median, mode, standard deviation, min, max.
Input:
let integers: Vec<i32> = vec![4, 2, 7, 2, 9, 4, 2, 8, 1, 4];
let floats: Vec<f64> = vec![1.5, 2.3, 1.5, 4.7, 2.3, 1.5];
Expected Output:
Integer stats:
Mean: 4.30, Median: 4.00, Mode: 2
Std Dev: 2.67, Min: 1, Max: 9
Float stats:
Mean: 2.30, Median: 1.90, Mode: 1.50
Std Dev: 1.14, Min: 1.50, Max: 4.70
What it tests: Generic functions, trait bounds (Into<f64> + Copy), sorting for median, HashMap for mode, mathematical computation.
Exercise 3.3: File Word Counter with Error Handling
Write a program that takes a file path as a command line argument, reads the file, and reports: total lines, total words, total characters, and the 5 longest words. Handle all errors gracefully with custom error types.
Input file (test.txt):
The quick brown fox jumps over the lazy dog.
Rust programming is extraordinarily satisfying.
Systems programming requires understanding fundamentals.
Expected Output:
File: test.txt
Lines: 3
Words: 19
Characters: 133
5 Longest Words:
1. extraordinarily (15 chars)
2. understanding (13 chars)
3. fundamentals (12 chars)
4. programming (11 chars)
5. programming (11 chars)
Input (nonexistent file):
cargo run -- nonexistent.txt
Expected Output:
Error: could not read file 'nonexistent.txt': No such file or directory (os error 2)
What it tests: std::fs::read_to_string, Result propagation with ?, custom error types, Display trait implementation for errors.
Exercise 3.4: Mini Inventory System
Build a small inventory system using HashMap<String, Item> where Item has name, quantity, price. Implement add, remove, update quantity, search by name (partial match), and print sorted by value (quantity × price).
Read commands from stdin in a loop.
Sample Session:
> add laptop 5 999.99
Added: laptop (5 @ $999.99)
> add mouse 50 29.99
Added: mouse (50 @ $29.99)
> add keyboard 30 79.99
Added: keyboard (30 @ $79.99)
> search key
Found: keyboard (30 @ $79.99, total value: $2399.70)
> update laptop 3
Updated: laptop quantity 5 -> 3
> list
Inventory (sorted by total value):
laptop | 3 x $999.99 = $2999.97
keyboard | 30 x $79.99 = $2399.70
mouse | 50 x $29.99 = $1499.50
Total inventory value: $6899.17
> remove mouse
Removed: mouse
> quit
Goodbye!
What it tests: HashMap operations (.entry(), .get(), .remove()), stdin reading loop, parsing user commands, sorting, formatted output.
Week 6 — Phase 1 Review & csvq Checkpoint
Buffer week — no new concepts. Consolidate and verify.
Week 6 Checkpoint: Explain It Out Loud
This is not a coding exercise. Answer each question in writing (a few sentences each). If you cannot answer from memory, you have found what to re-read.
- What happens in memory when you write
let s2 = s1;wheres1: String? Be specific about the stack and the heap. - What is the difference between
Stringand&str? When would you use each as a function parameter? - What does the borrow checker rule out that C++ allows? Give a concrete example.
- What does
#[derive(Debug, Clone)]do? What would you have to write manually if it didn’t exist? - What is the difference between
Vec<String>andVec<&str>? Which is more expensive to construct and why? - What does the
?operator do? Rewritelet x = some_result?;without using?. - Explain the difference between
mod geometry;anduse geometry::Shape;. What does each one do?
What it tests: Verbal understanding of Phase 1 concepts. The goal is fluency — you should answer these without looking anything up. If any answer takes you more than 2 minutes, re-read the relevant chapter.
Week 7 — Traits, Generics, Lifetimes (Part 1)
Book Chapters 10–13
Exercise 4.1: Trait-Based Formatter
Define a trait Reportable with methods title(&self) -> &str, summary(&self) -> String, and priority(&self) -> u8. Implement it for three different structs: Bug, Feature, Task.
Write a generic function fn print_report<T: Reportable>(items: &[T]) that prints a formatted report sorted by priority.
Input (hardcoded):
let bugs = vec![
Bug { id: 1, title: "Login crash".into(), severity: "critical".into() },
Bug { id: 2, title: "Typo in footer".into(), severity: "low".into() },
Bug { id: 3, title: "Memory leak".into(), severity: "high".into() },
];
Expected Output:
Bug Report (sorted by priority)
================================
[P1] #1 Login crash
Critical severity bug affecting user authentication
[P2] #3 Memory leak
High severity bug affecting system stability
[P5] #2 Typo in footer
Low severity bug affecting display
What it tests: Trait definition and implementation, generic functions with trait bounds, sorting with custom comparators.
Exercise 4.2: The Lifetime Puzzle
Each of these three functions has a lifetime problem. For each: explain the error, draw a diagram of the lifetimes involved (as a comment), and fix it.
Puzzle A:
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() { x } else { y }
}
Puzzle B:
fn first_word(s: &String) -> &str {
let bytes = s.as_bytes();
for (i, &byte) in bytes.iter().enumerate() {
if byte == b' ' {
return &s[0..i];
}
}
&s[..]
}
fn main() {
let word;
{
let s = String::from("hello world");
word = first_word(&s);
}
println!("First word: {}", word);
}
Puzzle C:
struct Config {
name: &str,
value: &str,
}
fn create_config() -> Config {
let name = String::from("timeout");
let value = String::from("30");
Config { name: &name, value: &value }
}
Expected: For each puzzle, a commented explanation of why it fails, a corrected version, and a brief ASCII diagram of lifetimes.
What it tests: Lifetime annotations, dangling references, struct lifetimes, the relationship between lifetimes and ownership.
Exercise 4.3: Iterator Chain Pipeline
Given a list of transactions, use iterator chains (no explicit loops) to:
- Filter to only “completed” transactions
- Convert amounts from cents to dollars
- Group by category and sum
- Sort by total descending
- Format and print
Input (hardcoded):
let transactions = vec![
Transaction { id: 1, amount_cents: 5000, category: "food".into(), status: "completed".into() },
Transaction { id: 2, amount_cents: 12000, category: "tech".into(), status: "completed".into() },
Transaction { id: 3, amount_cents: 3000, category: "food".into(), status: "pending".into() },
Transaction { id: 4, amount_cents: 8500, category: "food".into(), status: "completed".into() },
Transaction { id: 5, amount_cents: 25000, category: "tech".into(), status: "completed".into() },
Transaction { id: 6, amount_cents: 4500, category: "transport".into(), status: "completed".into() },
Transaction { id: 7, amount_cents: 15000, category: "tech".into(), status: "failed".into() },
];
Expected Output:
Spending by Category (completed only)
======================================
tech: $370.00
food: $135.00
transport: $45.00
Total: $550.00
What it tests: .iter().filter().map().fold() chains, closures, HashMap accumulation via iterators, no explicit for loops allowed.
Exercise 4.4: Closure Sorter
Write a function fn sort_by<T, F>(data: &mut Vec<T>, key: F) where F: Fn(&T, &T) -> std::cmp::Ordering. Then use it with three different closures to sort a list of employees by: (a) name alphabetically, (b) salary descending, (c) department then name.
Input (hardcoded):
let mut employees = vec![
Employee { name: "Charlie".into(), department: "Engineering".into(), salary: 95000 },
Employee { name: "Alice".into(), department: "Marketing".into(), salary: 85000 },
Employee { name: "Bob".into(), department: "Engineering".into(), salary: 105000 },
Employee { name: "Diana".into(), department: "Marketing".into(), salary: 92000 },
Employee { name: "Eve".into(), department: "Engineering".into(), salary: 88000 },
];
Expected Output:
Sorted by name:
Alice (Marketing, $85000)
Bob (Engineering, $105000)
Charlie (Engineering, $95000)
Diana (Marketing, $92000)
Eve (Engineering, $88000)
Sorted by salary (descending):
Bob (Engineering, $105000)
Charlie (Engineering, $95000)
Diana (Marketing, $92000)
Eve (Engineering, $88000)
Alice (Marketing, $85000)
Sorted by department, then name:
Bob (Engineering, $105000)
Charlie (Engineering, $95000)
Eve (Engineering, $88000)
Alice (Marketing, $85000)
Diana (Marketing, $92000)
What it tests: Generic functions with closure bounds (Fn), closure syntax, multi-key sorting with .then().
Week 8 — Lifetimes Deep Dive
Crust of Rust: Lifetime Annotations + targeted practice
Exercise 8.1: Struct Lifetime Annotations
Define a struct Highlight<'a> that holds a reference to a substring of a larger string. Implement a method fn contains(&self, needle: &str) -> bool and a method fn as_str(&self) -> &'a str.
Write a function fn find_first_highlight<'a>(text: &'a str, keywords: &[&str]) -> Option<Highlight<'a>> that returns the first keyword found in the text as a Highlight.
Input:
let document = String::from("The Rust programming language is blazingly fast.");
let keywords = vec!["Python", "Rust", "Go"];
Expected Output:
Found: "Rust" at position 4
Contains "programming": false
As str: "Rust"
What it tests: Lifetime annotations on structs, implementing methods on structs with lifetimes, lifetime elision rules (know when you can omit vs must annotate), why 'a connects the struct to the source data.
Exercise 8.2: Multiple Lifetime Parameters
Write a function fn merge_strings<'a, 'b>(a: &'a str, b: &'b str, take_first: bool) -> &'a str — and then explain why this signature would be wrong for -> &str without annotations and why it could not return &'b str when take_first is true.
Then write a variant fn longest_with_context<'a>(first: &'a str, second: &'a str, announcement: &str) -> &'a str and explain why announcement doesn’t need a lifetime parameter matching 'a.
What it tests: Why lifetime parameters exist (they’re constraints, not declarations of duration), when multiple parameters are needed vs when elision works, the concept that lifetimes describe relationships, not durations.
Week 9 — Smart Pointers, Trait Objects, Advanced Patterns
Book Chapters 14–17
Exercise 5.1: Linked List
Implement a singly linked list using Box<T>:
enum List<T> {
Cons(T, Box<List<T>>),
Nil,
}
Implement: push_front, len, to_vec, and Display (prints as 1 -> 2 -> 3 -> Nil).
Expected Output:
List: 3 -> 2 -> 1 -> Nil
Length: 3
As vec: [3, 2, 1]
What it tests: Box<T> for recursive types, why Box is needed (explain the size issue), recursive methods, Display trait.
Exercise 5.2: Dynamic Dispatch Zoo
Define a trait Animal with methods name(&self) -> &str, sound(&self) -> &str, and legs(&self) -> u8. Create five different animal structs. Store them in a Vec<Box<dyn Animal>> and iterate to print info.
Then write a function fn loudest(animals: &[Box<dyn Animal>]) -> &dyn Animal that returns the animal whose sound string is longest.
Expected Output:
Zoo Inventory:
Dog says "woof" and has 4 legs
Cat says "meow" and has 4 legs
Parrot says "squawk" and has 2 legs
Snake says "hiss" and has 0 legs
Elephant says "trumpet" and has 4 legs
Loudest animal: Elephant (sound: "trumpet", 7 chars)
What it tests: dyn Trait, Box<dyn Trait>, dynamic vs static dispatch (explain the difference in a comment), trait object safety rules.
Exercise 5.3: Reference Counting Graph
Build a simple directed graph where nodes can have multiple parents (shared ownership) using Rc<RefCell<Node>>:
struct Node {
value: String,
children: Vec<Rc<RefCell<Node>>>,
}
Build a small org chart where some people report to multiple managers. Print the tree and count total references to each node.
Input (hardcoded):
CEO
├── VP Engineering
│ ├── Team Lead A
│ │ └── Developer X (shared)
│ └── Team Lead B
│ └── Developer X (shared)
└── VP Product
└── Developer X (shared)
Expected Output:
Org Chart:
CEO (1 reference)
VP Engineering (1 reference)
Team Lead A (1 reference)
Developer X (3 references)
Team Lead B (1 reference)
Developer X (already listed)
VP Product (1 reference)
Developer X (already listed)
Developer X is shared by 3 managers
What it tests: Rc<T> for shared ownership, RefCell<T> for interior mutability, Rc::strong_count(), tree traversal with shared nodes.
Week 10 — Smart Pointers & Dispatch Deep Dive
Crust of Rust: Smart Pointers + Dispatch & Fat Pointers
Exercise 10.1: Interior Mutability Pattern
Build a simple in-memory cache using HashMap<String, Rc<RefCell<CacheEntry>>> where CacheEntry has a value: String and an access_count: u32. Multiple parts of the code should hold Rc references to the same entries and be able to read/increment the access count without taking ownership.
Input:
// Simulate two "consumers" sharing the same cache entries
let cache = Cache::new();
cache.insert("key1", "value_one");
cache.insert("key2", "value_two");
let consumer_a = cache.get("key1"); // returns Rc<RefCell<CacheEntry>>
let consumer_b = cache.get("key1"); // same entry, now 2 Rc references
// Both consumers read independently
consumer_a.borrow().print();
consumer_b.borrow_mut().increment_access();
consumer_a.borrow().print(); // should show updated access count
Expected Output:
key1: "value_one" (accessed 0 times)
key1: "value_one" (accessed 1 times)
Total Rc refs to key1: 2 (cache holds 1, consumer_a holds 1, consumer_b holds 1 = 3 total)
What it tests: Rc<T> for shared ownership, RefCell<T> for interior mutability, Rc::strong_count(), why Rc<RefCell<T>> is the single-threaded shared mutable state pattern, and why Arc<Mutex<T>> is its thread-safe counterpart.
Exercise 10.2: Static vs Dynamic Dispatch Benchmark
Create a trait Transform with fn apply(&self, input: f64) -> f64. Implement it for Double, Square, Negate, AddConstant(f64).
Write two pipeline functions:
fn pipeline_staticusingimpl Trait(one monomorphized function per concrete type)fn pipeline_dynamicusingVec<Box<dyn Transform>>
Apply each to 1,000,000 values. Measure and compare.
Expected Output:
Static pipeline: ran in 2.1ms (470M ops/sec)
Dynamic pipeline: ran in 8.7ms (114M ops/sec)
Dynamic overhead: 4.1x slower
Explanation: static dispatch inlines the calls (zero overhead); dynamic dispatch
goes through a vtable pointer indirection on every call.
What it tests: The concrete, measurable cost of dynamic dispatch, why you choose dyn Trait (flexibility, heterogeneous collections) vs impl Trait (performance, monomorphization), vtable layout understanding.
Weeks 11–13 — schemaguard Build Weeks
No separate exercises — you’re building the project. But do these quick checks at the end of each week.
Week 11 Checkpoint: Trait Dispatch Drill
Write a small program that demonstrates the difference between static and dynamic dispatch. Create a trait Validator with fn validate(&self, value: &str) -> bool. Implement it for EmailValidator, PhoneValidator, LengthValidator.
Show both:
fn validate_static(v: &impl Validator, input: &str) -> bool
fn validate_dynamic(v: &dyn Validator, input: &str) -> bool
Print the results, and in comments, explain: which generates more code (monomorphization)? Which allows a Vec of mixed validators? Why?
Input:
let input = "alice@example.com";
Expected Output:
Static dispatch:
EmailValidator("alice@example.com"): true
PhoneValidator("alice@example.com"): false
LengthValidator(5)("alice@example.com"): true
Dynamic dispatch (mixed vec):
[EmailValidator] "alice@example.com": true
[PhoneValidator] "alice@example.com": false
[LengthValidator(5)] "alice@example.com": true
Week 12 Checkpoint: Error Handling Architecture
Write a small library with three modules, each defining its own error type. Create a top-level error type that wraps all three using thiserror. Demonstrate the full error chain.
Expected Output:
Operation failed: database error: connection refused to localhost:5432
Caused by: IO error: Connection refused (os error 111)
Error chain:
0: database error: connection refused to localhost:5432
1: IO error: Connection refused (os error 111)
What it tests: thiserror derive macros, #[from] for error conversion, source() chain, real-world error architecture patterns.
Week 14 — Error Handling & Serde Deep Dive
thiserror, anyhow, and serde in depth before building logr
Exercise 14.1: Full Error Hierarchy
Build a small multi-layer application with three modules (storage, parser, api), each with its own error type. Then define a top-level AppError that wraps all three using thiserror. Demonstrate:
#[from]for automatic conversionsource()chain that lets callers walk the error tree- A function that uses
anyhow::Contextto add context at the boundary - A clean display message at each level
Expected Output:
api error: failed to process user request for id=42
caused by: parser error: invalid JSON at line 3: missing field `email`
caused by: serde_json error: missing field `email` at line 3 column 1
Full chain:
[0] api: failed to process user request for id=42
[1] parser: invalid JSON at line 3: missing field `email`
[2] serde_json::Error: missing field `email` at line 3 column 1
What it tests: thiserror derive macros, #[error(...)], #[from], #[source], difference between thiserror (library errors) and anyhow (application errors), error display chains.
Exercise 14.2: Advanced Serde
Define a Config struct that serializes to/from JSON with these requirements:
- A field named
maxRetriesin JSON maps tomax_retries: u32in Rust timeout_ms: Option<u64>is omitted from JSON whenNone(no"timeout_ms": null)- An enum
LogLevel(Debug,Info,Warn,Error) serializes as lowercase strings - A
Vec<String>fieldallowed_originshas a custom deserializer that also accepts a single string (not just an array) - A
created_at: DateTime<Utc>field uses RFC 3339 format
Expected Round-Trip:
{"maxRetries": 3, "logLevel": "warn", "allowedOrigins": "example.com", "createdAt": "2026-03-15T10:00:00Z"}
Deserializes, then re-serializes to:
{"maxRetries": 3, "logLevel": "warn", "allowedOrigins": ["example.com"], "createdAt": "2026-03-15T10:00:00Z"}
What it tests: #[serde(rename)], #[serde(skip_serializing_if)], #[serde(rename_all = "camelCase")], custom deserializers with #[serde(deserialize_with)], chrono serde support.
Weeks 15–16 — logr Build Weeks
Weeks 15–16 Checkpoint: Serde Round-Trip Test
Write a program that defines a complex nested struct, serializes it to JSON, deserializes it back, and verifies equality. Include: Option<T>, Vec<T>, HashMap<String, T>, chrono::DateTime, and an enum with data variants.
Expected Output:
Original: Config { name: "production", features: {"auth": true, "logging": false}, ... }
JSON size: 247 bytes
Restored: Config { name: "production", features: {"auth": true, "logging": false}, ... }
Round-trip: PASS (original == restored)
What it tests: serde derives, #[serde(rename)], #[serde(skip_serializing_if)], chrono serde support, equality derivation.
Week 17 — Threading Fundamentals
Book Chapters 16, 20
Exercise 17.1: Parallel Sum
Split a large vector into N chunks, sum each chunk in a separate thread, then combine. Measure speedup vs single-threaded.
Input (generated):
let data: Vec<u64> = (1..=10_000_000).collect();
Expected Output:
Single-threaded sum: 50000005000000 (took 45ms)
4-thread parallel sum: 50000005000000 (took 14ms)
8-thread parallel sum: 50000005000000 (took 9ms)
Speedup (4 threads): 3.21x
Speedup (8 threads): 5.00x
What it tests: std::thread::spawn, Arc<Vec<T>> for shared read-only data, JoinHandle, timing with std::time::Instant.
Exercise 17.2: Thread-Safe Counter
Three different threads each increment a shared counter 1,000,000 times. Use Arc<Mutex<u64>>. Then do the same with Arc<AtomicU64>. Compare timing.
Expected Output:
Mutex counter: 3000000 (took 285ms)
Atomic counter: 3000000 (took 42ms)
Atomic is 6.8x faster
What it tests: Arc<Mutex<T>>, AtomicU64, Ordering::Relaxed, why atomics are faster for simple operations.
Exercise 17.3: Producer-Consumer with Channels
Spawn 3 producer threads and 1 consumer thread. Producers send Message { producer_id, value, timestamp } over an mpsc::channel. Consumer collects all messages and prints stats.
Expected Output:
Received 3000 messages in 125ms
Producer 0: 1000 messages, avg value: 502.3
Producer 1: 1000 messages, avg value: 498.7
Producer 2: 1000 messages, avg value: 501.1
Messages arrived in order per producer: true
Messages interleaved across producers: true
What it tests: mpsc::channel(), thread::spawn with move closures, collecting and analyzing concurrent output.
Week 18 — Threading Deep Dive
Arc<RwLock<T>>, Condvar, thread pool patterns
Exercise 18.1: RwLock vs Mutex Benchmark
Implement a shared counter that is read 99% of the time and written 1% of the time. Compare:
Arc<Mutex<u64>>— exclusive lock on every operationArc<RwLock<u64>>— shared readers, exclusive writers
Spawn 8 threads, each doing 100,000 operations at the 99/1 read/write ratio.
Expected Output:
Mutex (8 threads, 800k ops, 99% reads): 312ms
RwLock (8 threads, 800k ops, 99% reads): 89ms
RwLock is 3.5x faster for read-heavy workloads
Note: if write ratio were 50%, the results would be closer.
What it tests: Arc<RwLock<T>> API, .read() vs .write(), understanding when RwLock wins over Mutex (read-heavy workloads), poisoned lock handling.
Exercise 18.2: Thread Pool From Scratch
Implement a minimal thread pool with:
- A fixed number of worker threads (configurable)
- A job queue using
Arc<Mutex<VecDeque<Box<dyn FnOnce() + Send>>>> - A
submit(f: impl FnOnce() + Send + 'static)method - Graceful shutdown: all submitted jobs complete before the pool drops
Input:
let pool = ThreadPool::new(4);
for i in 0..20 {
pool.submit(move || {
println!("Job {} running on thread {:?}", i, std::thread::current().id());
std::thread::sleep(Duration::from_millis(50));
});
}
// pool drops here — waits for all 20 jobs to finish
Expected Output:
Job 0 running on thread ThreadId(2)
Job 1 running on thread ThreadId(3)
...
All 20 jobs completed. Pool shut down cleanly.
What it tests: Mutex<VecDeque<...>> as a work queue, Condvar for blocking workers when queue is empty (or a channel-based approach), Box<dyn FnOnce() + Send> as a job type, graceful shutdown with Arc reference counting.
Week 19 — Async/Await Fundamentals
Tokio Tutorial Part 1
Exercise 19.1: Async Sleeper
Spawn 5 Tokio tasks, each sleeping for a random duration (100–500ms), then printing their ID and duration. All 5 should run concurrently (total time ≈ max sleep, not sum).
Expected Output:
Task 3 completed in 120ms
Task 1 completed in 210ms
Task 5 completed in 280ms
Task 2 completed in 350ms
Task 4 completed in 490ms
All 5 tasks completed in 491ms (concurrent, not 1450ms sequential)
What it tests: tokio::spawn, tokio::time::sleep, tokio::time::Instant, understanding concurrency vs parallelism in async.
Exercise 19.2: Async Channel Pipeline
Build a 3-stage async pipeline using tokio::sync::mpsc:
- Stage 1: Generate numbers 1–100
- Stage 2: Filter to only primes
- Stage 3: Square each prime and collect
Each stage is a tokio::spawn task connected by channels.
Expected Output:
Pipeline result: [4, 9, 25, 49, 121, 169, 289, 361, 529, 625, ...]
Primes found: 25
Sum of squared primes: 208065
Pipeline completed in 3ms
What it tests: tokio::sync::mpsc, async task pipelines, channel closing semantics (when does the receiver know the sender is done?).
Week 20 — Async Deep Dive
Tokio Tutorial Part 2 + tokio::select! + cancellation patterns
Exercise 20.1: tokio::select! and Race Conditions
Write an async function fn fetch_with_timeout(url: &str, timeout_ms: u64) -> Result<String, &'static str> that races an HTTP fetch (simulated with tokio::time::sleep returning a fake body) against a timeout. Whichever completes first wins; the other is cancelled.
Then write fn first_available(sources: Vec<String>) -> Result<String, &'static str> that races all sources and returns the first to succeed.
Expected Output:
Fetch with 100ms timeout, source takes 50ms: Ok("response body")
Fetch with 100ms timeout, source takes 200ms: Err("timeout")
Racing 3 sources (delays: 300ms, 150ms, 80ms):
Winner: source 3 (80ms)
Sources 1 and 2 cancelled
What it tests: tokio::select!, understanding that the non-selected branches are dropped (cancelled), tokio::time::timeout, why async cancellation is clean in Rust vs callback hell in other languages.
Exercise 20.2: Broadcast Channel — Fan-Out
Build a system where one producer broadcasts messages to N subscribers using tokio::sync::broadcast. Each subscriber filters for messages it cares about and accumulates its own stats. The producer sends 1000 messages and then signals done.
Expected Output:
Subscriber A (filter: even numbers): received 500 messages, sum = 249500
Subscriber B (filter: divisible by 3): received 334 messages, sum = 166833
Subscriber C (filter: > 900): received 99 messages, sum = 94599
All subscribers completed. No messages lost.
What it tests: tokio::sync::broadcast, Receiver::recv(), RecvError::Lagged (what happens when a slow subscriber falls behind), the difference between mpsc (one consumer) and broadcast (many consumers).
Weeks 21–23 — HTTP Server Build Weeks
Week 21 Checkpoint: Raw Byte Parsing
Write a function that parses a raw HTTP request string (as bytes) into a structured type. This is preparation for your HTTP server project.
Input:
let raw = b"GET /api/users?page=2 HTTP/1.1\r\nHost: localhost:8080\r\nContent-Type: application/json\r\n\r\n";
Expected Output:
Method: GET
Path: /api/users
Query: page=2
Version: HTTP/1.1
Headers:
Host: localhost:8080
Content-Type: application/json
Body: (empty)
What it tests: Byte slice parsing, split on \r\n, str::from_utf8, extracting query parameters, structured output from unstructured bytes.
Weeks 24–28 — airpost Build Weeks
Week 25 Checkpoint: Tokio TCP Echo Server
Write a Tokio-based TCP echo server that handles multiple concurrent connections. Each connection logs the client address and echoes back received data in uppercase.
Test with:
echo "hello rust" | nc localhost 8080
Expected Output (server log):
Listening on 0.0.0.0:8080
New connection from 127.0.0.1:54321
Received: "hello rust"
Sent: "HELLO RUST"
Connection from 127.0.0.1:54321 closed
What it tests: TcpListener::bind, tokio::spawn per connection, AsyncReadExt, AsyncWriteExt, graceful connection handling.
Week 26 Checkpoint: sqlx Compile-Time Queries
Write a small program that creates a PostgreSQL table, inserts data, and queries it using sqlx::query! (compile-time checked). Deliberately introduce a typo in a column name and verify the compiler catches it.
Expected Output (correct version):
Inserted 3 ticks
Query: SELECT * FROM ticks WHERE symbol = 'AAPL'
Results:
AAPL | 187.50 | 2026-03-15T14:30:00Z | 200
AAPL | 187.75 | 2026-03-15T14:30:01Z | 150
Expected Output (typo version):
error[E0425]: cannot find column `symboll` in table `ticks`
--> src/main.rs:15:5
What it tests: sqlx setup with DATABASE_URL, compile-time query verification, async PostgreSQL operations, understanding why this is transformative compared to Python ORMs.
Weeks 29–30 — Unsafe Rust Study Weeks
Study weeks with diagnostic exercises. No project work this week — all evenings go to these.
Week 29 Exercise: Raw Pointer Arithmetic
Write a function fn sum_slice_unsafe(data: &[i64]) -> i64 that sums the slice using raw pointer arithmetic instead of an iterator. Then write the identical function using safe Rust. Benchmark both with criterion.
In a code comment block, document:
- What invariants you are manually guaranteeing in the unsafe version
- What would happen if those invariants were violated (be specific — use-after-free? undefined behavior? silent wrong result?)
- Whether the performance difference is measurable in the benchmark output
Input (hardcoded):
let data: Vec<i64> = (1..=1_000_000).collect();
Expected Output (correctness check):
sum_slice_safe(1..=1_000_000): 500000500000 ✓
sum_slice_unsafe(1..=1_000_000): 500000500000 ✓
Results match.
Expected Output (criterion benchmark):
sum_safe time: [312 µs 315 µs 318 µs]
sum_unsafe time: [311 µs 314 µs 317 µs]
(The expected outcome is that performance is identical — the compiler was already doing this. Document this. Unsafe is a cost-benefit decision, not a performance cheat code.)
What it tests: Raw pointer arithmetic (ptr::add, ptr::read), the obligations you take on when writing unsafe, criterion benchmarking setup, and the concrete understanding that unsafe is rarely faster when the compiler can already see the same access pattern.
Week 30 Exercise: Ring Buffer with MaybeUninit<T>
Implement a fixed-capacity ring buffer using MaybeUninit<T> and raw indices. The type must work correctly for all T: Copy. Required operations:
fn new(capacity: usize) -> Selffn push(&mut self, value: T) -> bool— returnsfalseif fullfn pop(&mut self) -> Option<T>fn len(&self) -> usizefn is_empty(&self) -> bool
Write tests that verify correct behavior with i32 and f64. Then write one additional test — a #[test] with a comment explaining what unsafe assumption the implementation relies on and what would break if that assumption were violated. The comment is required; the test must still pass.
Expected Output (test run):
running 5 tests
test push_and_pop ... ok
test push_full_returns_false ... ok
test pop_empty_returns_none ... ok
test wraps_around_correctly ... ok
test unsafe_invariant_documentation ... ok
test result: ok. 5 passed; 0 failed
What it tests: MaybeUninit<T> (why it exists and what “initialized” means at the type system level), ring buffer index arithmetic with wrapping, understanding that MaybeUninit prevents the compiler from assuming the memory is valid until you say so, and the discipline of documenting unsafe invariants in test code.
(This exercise prepares you for the Rustonomicon’s “Implementing Vec” chapter — a ring buffer is simpler than Vec but teaches the same core concepts.)
Weeks 31–35 — riskbook Build Weeks
Week 31 Checkpoint: Gaussian CDF Implementation
Implement the cumulative normal distribution function N(x) from scratch using the Abramowitz & Stegun rational approximation (or Horner polynomial). Test against known values.
Expected Output:
N(0.0) = 0.500000 (expected: 0.500000) ✓
N(1.0) = 0.841345 (expected: 0.841345) ✓
N(-1.0) = 0.158655 (expected: 0.158655) ✓
N(1.96) = 0.975002 (expected: 0.975002) ✓
N(2.326) = 0.989976 (expected: 0.990000) ✓
N(-2.326) = 0.010024 (expected: 0.010000) ✓
Max error across 1000 test points: 1.2e-7
What it tests: Mathematical function implementation, floating-point precision, numerical approximation methods, testing against known values.
Week 32 Checkpoint: Black-Scholes Verification
Implement full Black-Scholes pricing and verify against known option prices. Test put-call parity: C - P = S - K*e^(-rT).
Expected Output:
Option Pricing (S=100, K=100, r=0.05, σ=0.20, T=1.0):
Call price: 10.4506 (expected: 10.4506) ✓
Put price: 5.5735 (expected: 5.5735) ✓
Put-call parity check: C - P = 4.8771, S - K*e^(-rT) = 4.8771 ✓
Greeks:
Delta(call): 0.6368 (expected: 0.6368) ✓
Gamma: 0.0188 (expected: 0.0188) ✓
Theta(call): -6.414 (expected: -6.414) ✓
Vega: 37.524 (expected: 37.524) ✓
What it tests: Mathematical implementation accuracy, put-call parity as a correctness invariant, financial domain understanding.
Week 33 Checkpoint: Criterion Benchmarking
Benchmark your Black-Scholes implementation. Compute option prices for 10,000 positions and measure throughput. Compare with and without compiler optimizations.
Expected Output:
black_scholes/single_option time: [45.2 ns 45.8 ns 46.5 ns]
black_scholes/1000_options time: [48.3 µs 49.1 µs 50.0 µs]
black_scholes/10000_options time: [485 µs 492 µs 501 µs]
Throughput: ~20 million option pricings per second
What it tests: criterion setup, statistical benchmarking (understanding confidence intervals), cargo build --release vs debug performance difference.
Weeks 37–40 — xlsxfmt Build Weeks
Week 38 Checkpoint: ZIP + XML Round Trip
Write a program that opens a .xlsx file (which is a ZIP), lists all files inside it, reads xl/sharedStrings.xml, parses it with quick-xml, modifies a string, writes it back, and saves the ZIP. Verify the file still opens in Excel/LibreOffice.
Expected Output:
Files in report.xlsx:
[Content_Types].xml (423 bytes)
_rels/.rels (590 bytes)
xl/workbook.xml (812 bytes)
xl/worksheets/sheet1.xml (2,456 bytes)
xl/sharedStrings.xml (345 bytes)
xl/styles.xml (4,123 bytes)
Shared strings:
0: "Revenue"
1: "Q1"
2: "Q2"
3: "Total"
Replacing "Revenue" with "Income"...
Saved to report_modified.xlsx
Verification: modified file has 6 entries, sharedStrings contains "Income" ✓
What it tests: zip crate for reading/writing ZIP archives, quick-xml for XML parsing, byte-level file manipulation, XLSX format understanding.
Weeks 41–52 — docforge Capstone Build Weeks
Week 41 Checkpoint: PDF Page Generation
Using printpdf, generate a single PDF page with: a title (large, bold), a horizontal line, a table with 3 columns and 5 rows with borders, and colored text. Verify it looks correct when opened.
Expected Output (file): A PDF file with a visible title, divider line, and formatted table.
Console Output:
Generated test_page.pdf (1 page, 14.2 KB)
Title: "Monthly Report" at (50, 750) in 24pt bold
Table: 3 columns x 5 rows at (50, 650), cell padding 5pt
Colors used: #000000 (text), #4472C4 (header bg), #D9E2F3 (alt row bg)
What it tests: printpdf API, coordinate system understanding (PDF origin is bottom-left), font loading, drawing primitives (lines, rectangles), text rendering.
Week 45 Checkpoint: DOCX XML Parser
Write a program that opens a .docx file, extracts word/document.xml, and prints all text content with its formatting attributes (bold, italic, font size, color).
Expected Output:
Paragraphs in document.docx:
[1] Style: Heading1, Text: "Project Overview"
Runs: [("Project Overview", bold=true, size=28pt, color=#2E74B5)]
[2] Style: Normal, Text: "This document describes the architecture of the system."
Runs: [("This document describes the ", bold=false, size=11pt, color=#000000),
("architecture", bold=true, size=11pt, color=#000000),
(" of the system.", bold=false, size=11pt, color=#000000)]
[3] Style: Normal, Text: ""
(empty paragraph - line break)
What it tests: OOXML document structure, XML namespaces in quick-xml, run-level formatting extraction, understanding how Word stores formatted text.
Week 49 Checkpoint: PyO3 Binding Smoke Test
Create a minimal PyO3 module that exposes a convert(input_path: str, output_path: str) -> bool function. Build and install it. Call from Python.
Python test:
import docforge
result = docforge.convert("test.xlsx", "output.pdf")
print(f"Conversion {'succeeded' if result else 'failed'}")
Expected Output:
Conversion succeeded
What it tests: PyO3 project setup with maturin, #[pyfunction] macros, building a wheel, installing in a Python environment, type conversion across FFI.
Summary: Exercise Count by Week
| Week | Phase | Exercises | Focus |
|---|---|---|---|
| 1 | 1 | 4 | Ownership, types, basic I/O |
| 2 | 1 | 2 | Ownership deep dive (String/&str, borrow checker patterns) |
| 3 | 1 | 4 | Structs, enums, pattern matching, modules |
| 4 | 1 | 1 | Module system & visibility deep dive |
| 5 | 1 | 4 | Collections, errors, generics intro |
| 6 | 1 | 1 | Phase 1 review checkpoint |
| 7 | 2 | 4 | Traits, generics, lifetimes, iterators, closures |
| 8 | 2 | 2 | Lifetimes deep dive |
| 9 | 2 | 3 | Smart pointers, trait objects, Rc/RefCell |
| 10 | 2 | 2 | Smart pointers & dispatch deep dive |
| 11 | 2 | 1 | Checkpoint (static vs dynamic dispatch) |
| 12 | 2 | 1 | Checkpoint (error architecture) |
| 13 | 2 | 1 | Checkpoint (serde round-trip prep) |
| 14 | 2 | 2 | Error handling & serde architecture deep dive |
| 15–16 | 2 | 1 | Checkpoint (serde round-trip) |
| 17 | 3 | 3 | Threading, atomics, channels |
| 18 | 3 | 2 | Threading deep dive (RwLock, select, cancellation) |
| 19 | 3 | 2 | Async/await, Tokio basics |
| 20 | 3 | 2 | Async deep dive (tokio::select!, broadcast channels) |
| 21 | 3 | 1 | Checkpoint (HTTP byte parsing) |
| 25 | 3 | 1 | Checkpoint (TCP echo server) |
| 26 | 3 | 1 | Checkpoint (sqlx compile-time queries) |
| 31 | 4 | 1 | Checkpoint (Gaussian CDF) |
| 32 | 4 | 1 | Checkpoint (Black-Scholes verification) |
| 33 | 4 | 1 | Checkpoint (Criterion benchmarking) |
| 38 | 4 | 1 | Checkpoint (ZIP + XML round trip) |
| 41 | 5 | 1 | Checkpoint (PDF page generation) |
| 45 | 5 | 1 | Checkpoint (DOCX XML parsing) |
| 49 | 5 | 1 | Checkpoint (PyO3 binding) |
| Total | 51 |
Every exercise answered correctly without AI means one more concept you own permanently. Every exercise you struggle with tells you exactly what to re-study. Trust the compiler. Trust the process.