Back to rust
January 2026
37 min read

Rust Weekly Exercises

Weekly coding exercises to practice and master Rust programming concepts
  • rust
  • exercises
  • practice

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) -> f64
  • grade(&self) -> char (A: ≥90, B: ≥80, C: ≥70, D: ≥60, F: <60)
  • highest(&self) -> f64
  • lowest(&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.rs may only import from lib.rs — it must not reach into internal submodules
  • All types used in main.rs must be publicly re-exported from lib.rs
  • geometry::stats functions 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.

  1. What happens in memory when you write let s2 = s1; where s1: String? Be specific about the stack and the heap.
  2. What is the difference between String and &str? When would you use each as a function parameter?
  3. What does the borrow checker rule out that C++ allows? Give a concrete example.
  4. What does #[derive(Debug, Clone)] do? What would you have to write manually if it didn’t exist?
  5. What is the difference between Vec<String> and Vec<&str>? Which is more expensive to construct and why?
  6. What does the ? operator do? Rewrite let x = some_result?; without using ?.
  7. Explain the difference between mod geometry; and use 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:

  1. Filter to only “completed” transactions
  2. Convert amounts from cents to dollars
  3. Group by category and sum
  4. Sort by total descending
  5. 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_static using impl Trait (one monomorphized function per concrete type)
  • fn pipeline_dynamic using Vec<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 conversion
  • source() chain that lets callers walk the error tree
  • A function that uses anyhow::Context to 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 maxRetries in JSON maps to max_retries: u32 in Rust
  • timeout_ms: Option<u64> is omitted from JSON when None (no "timeout_ms": null)
  • An enum LogLevel (Debug, Info, Warn, Error) serializes as lowercase strings
  • A Vec<String> field allowed_origins has 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 operation
  • Arc<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:

  1. What invariants you are manually guaranteeing in the unsafe version
  2. What would happen if those invariants were violated (be specific — use-after-free? undefined behavior? silent wrong result?)
  3. 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) -> Self
  • fn push(&mut self, value: T) -> bool — returns false if full
  • fn pop(&mut self) -> Option<T>
  • fn len(&self) -> usize
  • fn 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

WeekPhaseExercisesFocus
114Ownership, types, basic I/O
212Ownership deep dive (String/&str, borrow checker patterns)
314Structs, enums, pattern matching, modules
411Module system & visibility deep dive
514Collections, errors, generics intro
611Phase 1 review checkpoint
724Traits, generics, lifetimes, iterators, closures
822Lifetimes deep dive
923Smart pointers, trait objects, Rc/RefCell
1022Smart pointers & dispatch deep dive
1121Checkpoint (static vs dynamic dispatch)
1221Checkpoint (error architecture)
1321Checkpoint (serde round-trip prep)
1422Error handling & serde architecture deep dive
15–1621Checkpoint (serde round-trip)
1733Threading, atomics, channels
1832Threading deep dive (RwLock, select, cancellation)
1932Async/await, Tokio basics
2032Async deep dive (tokio::select!, broadcast channels)
2131Checkpoint (HTTP byte parsing)
2531Checkpoint (TCP echo server)
2631Checkpoint (sqlx compile-time queries)
3141Checkpoint (Gaussian CDF)
3241Checkpoint (Black-Scholes verification)
3341Checkpoint (Criterion benchmarking)
3841Checkpoint (ZIP + XML round trip)
4151Checkpoint (PDF page generation)
4551Checkpoint (DOCX XML parsing)
4951Checkpoint (PyO3 binding)
Total51

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.