From 437d6410cde1eea03047d0b2b72c36b05fb946b9 Mon Sep 17 00:00:00 2001 From: Shawn Hartsock Date: Sun, 12 Apr 2026 21:45:25 -0400 Subject: [PATCH 1/3] Add Chapter 2: Error Handling (Result, Option, ? operator) Maps Python's try/except/None to Rust's Result, Option, and the ? operator. Covers custom error types, Option chaining, iterator collect into Result, and the LBYL/EAFP/types comparison. 16 passing example tests, 5 exercises with todo!() stubs. Also fixes ch01 Logger exercise: replaced todo!() in Drop impl with a comment-only stub to avoid double-panic abort in the test runner. Co-Authored-By: Claude Opus 4.6 --- Cargo.toml | 2 + ch01-ownership/exercises/src/lib.rs | 4 +- ch02-error-handling/Cargo.toml | 6 + ch02-error-handling/README.md | 203 ++++++++++++++ ch02-error-handling/exercises/Cargo.toml | 6 + ch02-error-handling/exercises/src/lib.rs | 304 ++++++++++++++++++++ ch02-error-handling/src/lib.rs | 341 +++++++++++++++++++++++ 7 files changed, 865 insertions(+), 1 deletion(-) create mode 100644 ch02-error-handling/Cargo.toml create mode 100644 ch02-error-handling/README.md create mode 100644 ch02-error-handling/exercises/Cargo.toml create mode 100644 ch02-error-handling/exercises/src/lib.rs create mode 100644 ch02-error-handling/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index b8d47cf..cd80752 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,8 @@ resolver = "2" members = [ "ch01-ownership", "ch01-ownership/exercises", + "ch02-error-handling", + "ch02-error-handling/exercises", ] [workspace.package] diff --git a/ch01-ownership/exercises/src/lib.rs b/ch01-ownership/exercises/src/lib.rs index 1682c86..36274c2 100644 --- a/ch01-ownership/exercises/src/lib.rs +++ b/ch01-ownership/exercises/src/lib.rs @@ -142,7 +142,9 @@ impl Logger { impl Drop for Logger { fn drop(&mut self) { - todo!("Push \"logger:closed\" onto self.entries") + // TODO: Push "logger:closed" onto self.entries + // (We can't use todo!() here because panic in Drop aborts the process. + // Replace this comment block with your implementation.) } } diff --git a/ch02-error-handling/Cargo.toml b/ch02-error-handling/Cargo.toml new file mode 100644 index 0000000..e753c22 --- /dev/null +++ b/ch02-error-handling/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "ch02-error-handling" +version = "0.1.0" +edition.workspace = true +license.workspace = true +description = "Chapter 2: Error Handling — Result, Option, and the ? operator" diff --git a/ch02-error-handling/README.md b/ch02-error-handling/README.md new file mode 100644 index 0000000..3f800ef --- /dev/null +++ b/ch02-error-handling/README.md @@ -0,0 +1,203 @@ +# Chapter 2: Error Handling + +## The Big Idea + +Python uses exceptions for errors and `None` for missing values. Both are +invisible in function signatures — you only discover them by reading docs +(or hitting them at runtime). Rust makes errors and absence *part of the +type system*. A function that can fail returns `Result`. A function +that might have nothing returns `Option`. The compiler won't let you +ignore either one. + +This isn't just syntax sugar — it changes how you think about error paths. +In Python, error handling is something you bolt on after the fact. In Rust, +it's part of the design from the start. + +## Python Analogies + +### `Option` = The problem `None` was trying to solve + +```python +# Python: None is a valid value for any variable +def find_user(user_id): + if user_id in database: + return database[user_id] + return None + +user = find_user(42) +print(user.name) # AttributeError if user is None — runtime crash! +``` + +```rust +// Rust: Option forces you to handle the None case +fn find_user(user_id: u64) -> Option { + database.get(&user_id).cloned() +} + +let user = find_user(42); +// user.name // compile error! user is Option, not User + +// You must unwrap it explicitly: +match user { + Some(u) => println!("{}", u.name), + None => println!("User not found"), +} +``` + +**Key insight:** Python's `None` is a billion-dollar mistake (Tony Hoare's +words). Any variable can be `None`, and nothing forces you to check. Rust's +`Option` is a type — if a function returns `Option`, you *must* +handle the `None` case before you can use the `User`. The compiler enforces +what Python hopes you'll remember. + +### `Result` = `try/except` but visible in the signature + +```python +# Python: you can't tell from the signature that this function raises +def parse_config(path): + with open(path) as f: # might raise FileNotFoundError + data = json.load(f) # might raise JSONDecodeError + return Config(**data) # might raise TypeError +# Caller has to guess what to catch (or read the source) +``` + +```rust +// Rust: the signature tells you this function can fail, and how +fn parse_config(path: &str) -> Result { + let content = std::fs::read_to_string(path)?; // propagates io::Error + let data: Value = serde_json::from_str(&content)?; // propagates json Error + Config::from_value(data) // returns Result +} +// Caller knows exactly what can go wrong — it's in the type +``` + +**Key insight:** Python's exception system is powerful but invisible. Any +function can raise anything. Rust's `Result` makes failure a +first-class part of the return type. You can't accidentally ignore an error +because the compiler won't let you use the success value without handling +the error case first. + +### The `?` operator = Python's implicit exception propagation, but explicit + +```python +# Python: exceptions propagate automatically up the call stack +def load_settings(): + config = parse_config("settings.json") # if this raises, it bubbles up + return config.settings # caller never sees this line +``` + +```rust +// Rust: the ? operator propagates errors explicitly +fn load_settings() -> Result { + let config = parse_config("settings.json")?; // ? = "if Err, return it" + Ok(config.settings) +} +``` + +**Key insight:** In Python, every function call is an implicit `?` — errors +always propagate unless you catch them. In Rust, propagation is opt-in with +`?`. This means you can see *exactly* which calls in a function might cause +it to return early. No hidden control flow. + +### LBYL vs EAFP — Rust chooses neither (it chooses types) + +Python has two schools of error handling: + +```python +# LBYL: Look Before You Leap +if os.path.exists(path): + with open(path) as f: + data = f.read() +# Problem: file could be deleted between the check and the open (TOCTOU race) + +# EAFP: Easier to Ask Forgiveness than Permission +try: + with open(path) as f: + data = f.read() +except FileNotFoundError: + data = default_data +# Better, but you have to know which exception to catch +``` + +```rust +// Rust: the type system handles it — no LBYL/EAFP debate needed +match std::fs::read_to_string(path) { + Ok(data) => process(data), + Err(e) if e.kind() == ErrorKind::NotFound => use_default(), + Err(e) => return Err(e.into()), // propagate unexpected errors +} +``` + +**Key insight:** LBYL has race conditions. EAFP has invisible error types. +Rust's `match` on `Result` gives you exhaustive handling without either +problem — and the compiler tells you if you missed a case. + +### Custom error types = Custom exception classes + +```python +# Python custom exceptions +class ValidationError(Exception): + def __init__(self, field, message): + self.field = field + self.message = message + super().__init__(f"{field}: {message}") +``` + +```rust +// Rust custom errors — they're just enums +#[derive(Debug)] +enum ValidationError { + MissingField(String), + InvalidValue { field: String, message: String }, + TooLong { field: String, max: usize, actual: usize }, +} + +impl std::fmt::Display for ValidationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingField(name) => write!(f, "missing field: {name}"), + Self::InvalidValue { field, message } => write!(f, "{field}: {message}"), + Self::TooLong { field, max, actual } => + write!(f, "{field}: too long ({actual} > {max})"), + } + } +} +``` + +**Key insight:** Python exceptions are classes in a hierarchy. Rust errors +are enums with variants. The `match` statement on a Rust error enum is +exhaustive — the compiler ensures you handle every variant. Python's +`except` blocks are best-effort — you can always miss one. + +## Summary + +| Python | Rust | What Changes | +|--------|------|-------------| +| `None` (any variable) | `Option` | Absence is a type, not a surprise | +| `try/except` | `Result` | Errors visible in function signatures | +| Implicit propagation | `?` operator | You see where errors can escape | +| LBYL / EAFP debate | `match` on Result | Exhaustive handling, no race conditions | +| Exception class hierarchy | Error enums | Compiler checks exhaustiveness | +| `assert` / `raise` for bugs | `panic!` / `unreachable!` | Unrecoverable = crash, recoverable = Result | + +## The Panic Distinction + +One more thing Python developers need to know: Rust separates +*recoverable* errors from *unrecoverable* ones. + +- **Recoverable**: file not found, invalid input, network timeout → `Result` +- **Unrecoverable**: index out of bounds, violated invariant → `panic!` + +In Python, both are exceptions. In Rust, a `panic!` is a program bug — it +means something happened that *should never happen*. A `Result::Err` is an +expected failure — the system is working correctly by reporting it. + +Don't use `panic!` for things users might do wrong. Don't use `Result` for +things that indicate bugs. This distinction makes Rust programs much more +predictable than Python programs, where `KeyError` might mean "bad user +input" or "bug in your code" depending on context. + +## Next Steps + +Open `src/lib.rs` to see these concepts in working code, then try the +exercises in `exercises/`. diff --git a/ch02-error-handling/exercises/Cargo.toml b/ch02-error-handling/exercises/Cargo.toml new file mode 100644 index 0000000..92b2540 --- /dev/null +++ b/ch02-error-handling/exercises/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "ch02-exercises" +version = "0.1.0" +edition.workspace = true +license.workspace = true +description = "Exercises for Chapter 2: Error Handling" diff --git a/ch02-error-handling/exercises/src/lib.rs b/ch02-error-handling/exercises/src/lib.rs new file mode 100644 index 0000000..4408183 --- /dev/null +++ b/ch02-error-handling/exercises/src/lib.rs @@ -0,0 +1,304 @@ +//! # Chapter 2 Exercises: Error Handling +//! +//! Each exercise shows a Python snippet and asks you to write the Rust +//! equivalent. Replace the `todo!()` markers with working code. +//! +//! Run tests: `cargo test -p ch02-exercises` + +// These allows are intentional: exercise stubs have unused parameters +// and fields until the student fills in the todo!() markers. +#![allow(unused_variables, dead_code, clippy::ptr_arg)] + +use std::fmt; + +// ============================================================ +// Exercise 1: Option Basics +// ============================================================ +// +// Python version: +// ```python +// EXTENSIONS = { +// "rs": "Rust", +// "py": "Python", +// "js": "JavaScript", +// "ts": "TypeScript", +// } +// +// def language_for_extension(ext): +// return EXTENSIONS.get(ext) +// +// assert language_for_extension("rs") == "Rust" +// assert language_for_extension("go") is None +// ``` +// +// Implement a function that returns the language name for a file extension, +// or None if the extension is unknown. Use a match expression. + +pub fn language_for_extension(ext: &str) -> Option<&'static str> { + todo!("Match on ext: rs->Rust, py->Python, js->JavaScript, ts->TypeScript, _->None") +} + +// ============================================================ +// Exercise 2: Option Chaining +// ============================================================ +// +// Python version: +// ```python +// def describe_extension(ext): +// lang = language_for_extension(ext) +// if lang is not None: +// return f"{ext} is a {lang} file" +// return None +// +// assert describe_extension("py") == "py is a Python file" +// assert describe_extension("go") is None +// ``` +// +// Use Option::map to transform the value without unwrapping. + +pub fn describe_extension(ext: &str) -> Option { + todo!("Use language_for_extension and .map() to build the description string") +} + +// ============================================================ +// Exercise 3: Custom Error Type +// ============================================================ +// +// Python version: +// ```python +// class TemperatureError(Exception): pass +// +// def celsius_to_fahrenheit(celsius): +// if celsius < -273.15: +// raise TemperatureError(f"below absolute zero: {celsius}") +// return celsius * 9/5 + 32 +// +// assert celsius_to_fahrenheit(100) == 212.0 +// assert celsius_to_fahrenheit(0) == 32.0 +// # celsius_to_fahrenheit(-300) raises TemperatureError +// ``` +// +// 1. Define a TemperatureError enum with a BelowAbsoluteZero variant +// that carries the invalid value (f64). +// 2. Implement Display for it. +// 3. Implement celsius_to_fahrenheit returning Result. + +#[derive(Debug, PartialEq)] +pub enum TemperatureError { + // todo!(): Add a BelowAbsoluteZero variant that holds an f64 + BelowAbsoluteZero(f64), +} + +impl fmt::Display for TemperatureError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + todo!("Display: 'below absolute zero: '") + } +} + +pub fn celsius_to_fahrenheit(celsius: f64) -> Result { + todo!("Return Err if below -273.15, otherwise Ok(fahrenheit)") +} + +// ============================================================ +// Exercise 4: The ? Operator +// ============================================================ +// +// Python version: +// ```python +// def parse_pair(s): +// """Parse 'x,y' into a tuple of floats.""" +// parts = s.split(',') +// if len(parts) != 2: +// raise ValueError(f"expected 'x,y', got: {s}") +// x = float(parts[0]) # might raise ValueError +// y = float(parts[1]) # might raise ValueError +// return (x, y) +// +// assert parse_pair("3.5,7.2") == (3.5, 7.2) +// # parse_pair("oops") raises ValueError +// ``` +// +// Implement parse_pair. Use the provided PairError type and the ? operator +// to propagate errors from split and parse. + +#[derive(Debug, PartialEq)] +pub enum PairError { + BadFormat(String), + BadNumber(String), +} + +impl fmt::Display for PairError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::BadFormat(s) => write!(f, "expected 'x,y', got: {s}"), + Self::BadNumber(s) => write!(f, "not a valid number: {s}"), + } + } +} + +pub fn parse_pair(s: &str) -> Result<(f64, f64), PairError> { + todo!("Split on ',', check for exactly 2 parts, parse each as f64") +} + +// ============================================================ +// Exercise 5: Collecting Results from an Iterator +// ============================================================ +// +// Python version: +// ```python +// def parse_scores(lines): +// """Parse 'name:score' lines into a dict. +// +// Raises ValueError on malformed lines or non-integer scores. +// """ +// result = {} +// for line in lines: +// if ':' not in line: +// raise ValueError(f"missing ':' in: {line}") +// name, score_str = line.split(':', 1) +// score = int(score_str) # raises ValueError if not a number +// result[name] = score +// return result +// +// assert parse_scores(["alice:95", "bob:87"]) == {"alice": 95, "bob": 87} +// # parse_scores(["alice:95", "bad"]) raises ValueError +// ``` +// +// Implement parse_scores using iterators and .collect() to gather +// Result<(String, i32), ScoreError> into Result, ScoreError>. + +#[derive(Debug, PartialEq)] +pub enum ScoreError { + MissingColon(String), + InvalidScore(String), +} + +impl fmt::Display for ScoreError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingColon(s) => write!(f, "missing ':' in: {s}"), + Self::InvalidScore(s) => write!(f, "invalid score: {s}"), + } + } +} + +pub fn parse_scores(lines: &[&str]) -> Result, ScoreError> { + todo!("Iterate over lines, split each on ':', parse score, collect into Result") +} + +// ============================================================ +// Tests — do not modify below this line +// ============================================================ + +#[cfg(test)] +mod tests { + use super::*; + + // Exercise 1 + #[test] + fn ex1_known_extension() { + assert_eq!(language_for_extension("rs"), Some("Rust")); + assert_eq!(language_for_extension("py"), Some("Python")); + assert_eq!(language_for_extension("js"), Some("JavaScript")); + assert_eq!(language_for_extension("ts"), Some("TypeScript")); + } + + #[test] + fn ex1_unknown_extension() { + assert_eq!(language_for_extension("go"), None); + assert_eq!(language_for_extension(""), None); + } + + // Exercise 2 + #[test] + fn ex2_describe_known() { + assert_eq!( + describe_extension("py"), + Some("py is a Python file".to_string()) + ); + } + + #[test] + fn ex2_describe_unknown() { + assert_eq!(describe_extension("go"), None); + } + + // Exercise 3 + #[test] + fn ex3_valid_conversion() { + assert_eq!(celsius_to_fahrenheit(100.0), Ok(212.0)); + assert_eq!(celsius_to_fahrenheit(0.0), Ok(32.0)); + assert_eq!(celsius_to_fahrenheit(-40.0), Ok(-40.0)); // the crossover point! + } + + #[test] + fn ex3_below_absolute_zero() { + assert_eq!( + celsius_to_fahrenheit(-300.0), + Err(TemperatureError::BelowAbsoluteZero(-300.0)) + ); + } + + #[test] + fn ex3_exactly_absolute_zero_is_ok() { + assert!(celsius_to_fahrenheit(-273.15).is_ok()); + } + + #[test] + fn ex3_display() { + let err = TemperatureError::BelowAbsoluteZero(-300.0); + assert_eq!(err.to_string(), "below absolute zero: -300"); + } + + // Exercise 4 + #[test] + fn ex4_valid_pair() { + assert_eq!(parse_pair("3.5,7.2"), Ok((3.5, 7.2))); + } + + #[test] + fn ex4_negative_numbers() { + assert_eq!(parse_pair("-1.5,2.5"), Ok((-1.5, 2.5))); + } + + #[test] + fn ex4_bad_format() { + assert_eq!( + parse_pair("oops"), + Err(PairError::BadFormat("oops".to_string())) + ); + } + + #[test] + fn ex4_bad_number() { + assert!(matches!( + parse_pair("1.0,abc"), + Err(PairError::BadNumber(_)) + )); + } + + // Exercise 5 + #[test] + fn ex5_valid_scores() { + assert_eq!( + parse_scores(&["alice:95", "bob:87"]), + Ok(vec![("alice".to_string(), 95), ("bob".to_string(), 87),]) + ); + } + + #[test] + fn ex5_missing_colon() { + assert_eq!( + parse_scores(&["alice:95", "bad"]), + Err(ScoreError::MissingColon("bad".to_string())) + ); + } + + #[test] + fn ex5_invalid_score() { + assert_eq!( + parse_scores(&["alice:xyz"]), + Err(ScoreError::InvalidScore("xyz".to_string())) + ); + } +} diff --git a/ch02-error-handling/src/lib.rs b/ch02-error-handling/src/lib.rs new file mode 100644 index 0000000..75695c0 --- /dev/null +++ b/ch02-error-handling/src/lib.rs @@ -0,0 +1,341 @@ +//! # Chapter 2: Error Handling +//! +//! This module demonstrates Rust's error handling through examples that +//! map to familiar Python patterns. +//! +//! Run the tests: `cargo test -p ch02-error-handling` + +use std::fmt; +use std::num::ParseIntError; + +// --------------------------------------------------------------------------- +// 1. Option — Rust's answer to None +// --------------------------------------------------------------------------- + +/// Look up a value in a simple in-memory "database." +/// +/// Python equivalent: +/// ```python +/// def find_port(service_name): +/// ports = {"http": 80, "https": 443, "ssh": 22} +/// return ports.get(service_name) # returns None if missing +/// ``` +/// +/// The difference: Python returns None (any variable can be None). +/// Rust returns Option — the type *tells* you it might be absent. +pub fn find_port(service: &str) -> Option { + match service { + "http" => Some(80), + "https" => Some(443), + "ssh" => Some(22), + _ => None, + } +} + +/// Chaining Option operations with `map` and `and_then`. +/// +/// Python equivalent: +/// ```python +/// def port_as_string(service): +/// port = find_port(service) +/// if port is not None: +/// return f":{port}" +/// return None +/// ``` +/// +/// Rust's combinators let you avoid nested if-let / match blocks. +pub fn port_as_string(service: &str) -> Option { + find_port(service).map(|p| format!(":{p}")) +} + +/// Using `unwrap_or` — like Python's `value if value is not None else default`. +/// +/// Python equivalent: +/// ```python +/// def port_or_default(service): +/// return find_port(service) or 8080 +/// ``` +pub fn port_or_default(service: &str) -> u16 { + find_port(service).unwrap_or(8080) +} + +// --------------------------------------------------------------------------- +// 2. Result — Errors as values, not exceptions +// --------------------------------------------------------------------------- + +/// A custom error type — like a Python exception class, but an enum. +/// +/// Python equivalent: +/// ```python +/// class ParseError(Exception): pass +/// class OutOfRange(Exception): +/// def __init__(self, value, min_val, max_val): ... +/// ``` +#[derive(Debug, PartialEq)] +pub enum PortError { + /// The input string couldn't be parsed as a number. + NotANumber(String), + /// The number is outside the valid port range. + OutOfRange { value: i64, min: u16, max: u16 }, +} + +impl fmt::Display for PortError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NotANumber(s) => write!(f, "not a valid number: {s}"), + Self::OutOfRange { value, min, max } => { + write!(f, "port {value} out of range ({min}..{max})") + } + } + } +} + +/// Convert a ParseIntError into our custom PortError. +/// +/// This is like Python's `raise PortError(...) from original_exception`. +/// The `From` trait lets the `?` operator do this conversion automatically. +impl From for PortError { + fn from(e: ParseIntError) -> Self { + Self::NotANumber(e.to_string()) + } +} + +/// Parse a string as a valid port number (1-65535). +/// +/// Python equivalent: +/// ```python +/// def parse_port(s): +/// try: +/// value = int(s) +/// except ValueError: +/// raise ParseError(f"not a valid number: {s}") +/// if not (1 <= value <= 65535): +/// raise OutOfRange(value, 1, 65535) +/// return value +/// ``` +/// +/// The Rust version returns Result instead of raising — the caller can see +/// from the type signature that this function can fail. +pub fn parse_port(s: &str) -> Result { + let value: i64 = s + .parse() + .map_err(|_| PortError::NotANumber(s.to_string()))?; + + if !(1..=65535).contains(&value) { + return Err(PortError::OutOfRange { + value, + min: 1, + max: 65535, + }); + } + + Ok(value as u16) +} + +// --------------------------------------------------------------------------- +// 3. The ? operator — explicit propagation +// --------------------------------------------------------------------------- + +/// A network address: host + port. +#[derive(Debug, PartialEq)] +pub struct Address { + pub host: String, + pub port: u16, +} + +/// Parse "host:port" into an Address. +/// +/// Python equivalent: +/// ```python +/// def parse_address(s): +/// if ':' not in s: +/// raise ParseError("missing ':'") +/// host, port_str = s.rsplit(':', 1) +/// port = parse_port(port_str) # raises on bad port — propagates! +/// return Address(host=host, port=port) +/// ``` +/// +/// Notice each `?` in the Rust version. They mark *exactly* where the +/// function might return early with an error. No hidden control flow. +pub fn parse_address(s: &str) -> Result { + let (host, port_str) = s + .rsplit_once(':') + .ok_or_else(|| PortError::NotANumber("missing ':' separator".to_string()))?; + + let port = parse_port(port_str)?; // ? propagates PortError + + Ok(Address { + host: host.to_string(), + port, + }) +} + +// --------------------------------------------------------------------------- +// 4. Combining Option and Result +// --------------------------------------------------------------------------- + +/// Look up a service port, falling back to parsing a custom port string. +/// +/// Python equivalent: +/// ```python +/// def resolve_port(service_or_number): +/// port = find_port(service_or_number) +/// if port is not None: +/// return port +/// return parse_port(service_or_number) # might raise +/// ``` +/// +/// This shows how Option and Result interact: Option for "might not exist" +/// and Result for "might fail with a specific error." +pub fn resolve_port(service_or_number: &str) -> Result { + // If it's a known service, use that + if let Some(port) = find_port(service_or_number) { + return Ok(port); + } + // Otherwise try to parse as a number + parse_port(service_or_number) +} + +// --------------------------------------------------------------------------- +// 5. Iterating with Results — collect into Result +// --------------------------------------------------------------------------- + +/// Parse multiple port strings, failing on the first bad one. +/// +/// Python equivalent: +/// ```python +/// def parse_all_ports(strings): +/// return [parse_port(s) for s in strings] # raises on first bad one +/// ``` +/// +/// Rust's iterator + collect can gather Results into a single Result. +/// This is one of those "Rust lets you express something cleanly that +/// Python can't" moments. +pub fn parse_all_ports(strings: &[&str]) -> Result, PortError> { + strings.iter().map(|s| parse_port(s)).collect() +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // Option tests + + #[test] + fn option_some() { + assert_eq!(find_port("http"), Some(80)); + } + + #[test] + fn option_none() { + assert_eq!(find_port("gopher"), None); + } + + #[test] + fn option_map() { + assert_eq!(port_as_string("https"), Some(":443".to_string())); + assert_eq!(port_as_string("gopher"), None); + } + + #[test] + fn option_unwrap_or() { + assert_eq!(port_or_default("http"), 80); + assert_eq!(port_or_default("gopher"), 8080); + } + + // Result tests + + #[test] + fn result_ok() { + assert_eq!(parse_port("443"), Ok(443)); + } + + #[test] + fn result_not_a_number() { + assert_eq!( + parse_port("abc"), + Err(PortError::NotANumber("abc".to_string())) + ); + } + + #[test] + fn result_out_of_range() { + assert_eq!( + parse_port("99999"), + Err(PortError::OutOfRange { + value: 99999, + min: 1, + max: 65535, + }) + ); + } + + #[test] + fn result_zero_is_invalid() { + assert_eq!( + parse_port("0"), + Err(PortError::OutOfRange { + value: 0, + min: 1, + max: 65535, + }) + ); + } + + // ? operator tests + + #[test] + fn address_parse_ok() { + assert_eq!( + parse_address("localhost:8080"), + Ok(Address { + host: "localhost".to_string(), + port: 8080, + }) + ); + } + + #[test] + fn address_bad_port_propagates() { + assert!(parse_address("localhost:abc").is_err()); + } + + #[test] + fn address_missing_colon() { + assert!(parse_address("localhost").is_err()); + } + + // Option + Result interop + + #[test] + fn resolve_known_service() { + assert_eq!(resolve_port("ssh"), Ok(22)); + } + + #[test] + fn resolve_numeric_port() { + assert_eq!(resolve_port("3000"), Ok(3000)); + } + + #[test] + fn resolve_bad_string() { + assert!(resolve_port("not_a_service").is_err()); + } + + // Collecting Results + + #[test] + fn collect_all_ok() { + assert_eq!(parse_all_ports(&["80", "443", "22"]), Ok(vec![80, 443, 22])); + } + + #[test] + fn collect_fails_on_first_bad() { + let result = parse_all_ports(&["80", "bad", "443"]); + assert!(result.is_err()); + } +} From 006de00e8512cb6f951eb6c877ad7c61caee92f3 Mon Sep 17 00:00:00 2001 From: Shawn Hartsock Date: Sun, 12 Apr 2026 22:04:22 -0400 Subject: [PATCH 2/3] Add Chapter 3: Traits & Generics Maps Python's ABCs/Protocols/duck typing to Rust's trait system. Covers trait definition and implementation, generic functions with trait bounds, default methods, derived traits, operator overloading (Add, Sub, Display), static vs dynamic dispatch, and multiple trait bounds with where clauses. 16 passing example tests, 5 exercises with todo!() stubs covering HasArea trait, generic largest_area, Display for Temperature, Add for Money, and dynamic dispatch with Renderable. Co-Authored-By: Claude Opus 4.6 --- Cargo.toml | 2 + ch03-traits-and-generics/Cargo.toml | 6 + ch03-traits-and-generics/README.md | 274 +++++++++++ ch03-traits-and-generics/exercises/Cargo.toml | 6 + ch03-traits-and-generics/exercises/src/lib.rs | 373 ++++++++++++++ ch03-traits-and-generics/src/lib.rs | 456 ++++++++++++++++++ 6 files changed, 1117 insertions(+) create mode 100644 ch03-traits-and-generics/Cargo.toml create mode 100644 ch03-traits-and-generics/README.md create mode 100644 ch03-traits-and-generics/exercises/Cargo.toml create mode 100644 ch03-traits-and-generics/exercises/src/lib.rs create mode 100644 ch03-traits-and-generics/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index cd80752..0e188a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,8 @@ members = [ "ch01-ownership/exercises", "ch02-error-handling", "ch02-error-handling/exercises", + "ch03-traits-and-generics", + "ch03-traits-and-generics/exercises", ] [workspace.package] diff --git a/ch03-traits-and-generics/Cargo.toml b/ch03-traits-and-generics/Cargo.toml new file mode 100644 index 0000000..e9003e7 --- /dev/null +++ b/ch03-traits-and-generics/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "ch03-traits-and-generics" +version = "0.1.0" +edition.workspace = true +license.workspace = true +description = "Chapter 3: Traits & Generics — interfaces that compose" diff --git a/ch03-traits-and-generics/README.md b/ch03-traits-and-generics/README.md new file mode 100644 index 0000000..f3393ff --- /dev/null +++ b/ch03-traits-and-generics/README.md @@ -0,0 +1,274 @@ +# Chapter 3: Traits & Generics + +## The Big Idea + +Python has several ways to define "interfaces": abstract base classes (ABCs), +Protocols (structural typing), and plain duck typing. They all work at +runtime — you find out something doesn't implement the right method when +your program crashes. + +Rust has **traits**: named sets of behaviors that types can implement. Like +Python Protocols, they describe *what a type can do*. Unlike Python +Protocols, the compiler checks them *before your code runs*. Combined with +generics, traits let you write code that works with any type meeting certain +requirements — and the compiler generates specialized, zero-cost versions +for each concrete type. + +## Python Analogies + +### Traits = Protocols (but checked at compile time) + +```python +# Python Protocol (PEP 544) — structural typing +from typing import Protocol + +class Drawable(Protocol): + def draw(self) -> str: ... + +class Circle: + def draw(self) -> str: + return "○" + +class Square: + def draw(self) -> str: + return "□" + +def render(shape: Drawable) -> str: + return shape.draw() # type checker warns if .draw() is missing + # but the code still RUNS — crash at runtime +``` + +```rust +// Rust trait — the compiler enforces it +trait Drawable { + fn draw(&self) -> String; +} + +struct Circle; +struct Square; + +impl Drawable for Circle { + fn draw(&self) -> String { "○".to_string() } +} + +impl Drawable for Square { + fn draw(&self) -> String { "□".to_string() } +} + +fn render(shape: &dyn Drawable) -> String { + shape.draw() // compile error if .draw() is missing — guaranteed safe +} +``` + +**Key insight:** Python Protocols are advisory — mypy can warn you, but the +code runs regardless. Rust traits are mandatory — if a type claims to +implement a trait, the compiler verifies every required method exists and +has the right signature. + +### Generics = Type parameters (but monomorphized) + +```python +# Python generic with TypeVar +from typing import TypeVar, List + +T = TypeVar('T') + +def first(items: List[T]) -> T | None: + return items[0] if items else None + +# At runtime, T is erased — it's just a hint for type checkers +``` + +```rust +// Rust generic — the compiler generates specialized code for each type +fn first(items: &[T]) -> Option<&T> { + items.first() +} + +// When you call first::(&numbers), the compiler generates a +// version of `first` specifically for i32. No runtime cost. +// When you call first::(&names), it generates another version. +``` + +**Key insight:** Python generics are erased at runtime — `List[int]` and +`List[str]` are the same object. Rust generics are *monomorphized* — the +compiler generates specialized machine code for each concrete type. This +means generic Rust code runs as fast as hand-written specialized code. + +### Trait bounds = "T must support these operations" + +```python +# Python: you just hope T has the right methods +def largest(items: List[T]) -> T: + return max(items) # assumes T supports comparison — crashes if not +``` + +```rust +// Rust: the bound says exactly what T must support +fn largest(items: &[T]) -> Option<&T> { + items.iter().reduce(|a, b| if a >= b { a } else { b }) +} +// Won't compile if you call it with a type that can't be compared +``` + +**Key insight:** Python's duck typing means "try it and see." Rust's trait +bounds mean "prove it before we start." The compiler error message tells +you *exactly* which trait is missing. + +### Default methods = Mixin behavior + +```python +# Python: mixins or default method implementations +class Describable: + def name(self) -> str: + raise NotImplementedError + + def describe(self) -> str: + return f"I am {self.name()}" # default implementation using name() +``` + +```rust +// Rust: traits can have default methods +trait Describable { + fn name(&self) -> &str; // required — implementors must provide this + + fn describe(&self) -> String { // default — implementors get this for free + format!("I am {}", self.name()) + } +} +``` + +**Key insight:** This is exactly like Python mixins, but with a guarantee: +the required methods are enforced at compile time. No `NotImplementedError` +at runtime. + +### `impl Trait` = "something that implements this" + +```python +# Python: you write the Protocol in the type hint +def make_shape() -> Drawable: + return Circle() # caller knows it's Drawable, not necessarily Circle +``` + +```rust +// Rust: impl Trait in return position +fn make_shape() -> impl Drawable { + Circle // caller knows it's Drawable, compiler knows it's Circle +} +``` + +### Deriving = Auto-generating trait implementations + +```python +# Python: @dataclass auto-generates __eq__, __repr__, __hash__, etc. +from dataclasses import dataclass + +@dataclass +class Point: + x: float + y: float +# Automatically gets __eq__, __repr__, __init__, etc. +``` + +```rust +// Rust: #[derive] auto-generates trait implementations +#[derive(Debug, Clone, PartialEq)] +struct Point { + x: f64, + y: f64, +} +// Automatically gets Debug (like __repr__), Clone (like copy.deepcopy), +// and PartialEq (like __eq__) +``` + +**Key insight:** Python's `@dataclass` is a single decorator that generates +multiple methods. Rust's `#[derive]` lets you pick exactly which traits to +auto-implement. You get fine-grained control over what your type can do. + +### Operator overloading = `__add__`, `__mul__`, etc. + +```python +class Vector: + def __init__(self, x, y): + self.x = x + self.y = y + + def __add__(self, other): + return Vector(self.x + other.x, self.y + other.y) + + def __repr__(self): + return f"Vector({self.x}, {self.y})" +``` + +```rust +use std::ops::Add; + +#[derive(Debug, Clone, Copy)] +struct Vector { + x: f64, + y: f64, +} + +impl Add for Vector { + type Output = Self; + + fn add(self, other: Self) -> Self { + Self { + x: self.x + other.x, + y: self.y + other.y, + } + } +} +``` + +**Key insight:** Same concept, different mechanism. Python uses magic +methods (`__add__`). Rust uses trait implementations (`impl Add`). The +Rust version is more explicit about what the output type is (it could be +different from the input types). + +## Static vs Dynamic Dispatch + +One concept that doesn't exist in Python: Rust lets you choose between +*static dispatch* (generics, resolved at compile time) and *dynamic +dispatch* (trait objects, resolved at runtime). + +```rust +// Static dispatch — compiler generates specialized code for each type +// Fast (no indirection), but can't mix types in a collection +fn draw_static(shape: &impl Drawable) -> String { + shape.draw() +} + +// Dynamic dispatch — uses a vtable pointer at runtime +// Slight overhead, but can mix different types in a collection +fn draw_dynamic(shape: &dyn Drawable) -> String { + shape.draw() +} + +// Why this matters: you can have a Vec of mixed shapes +let shapes: Vec> = vec![ + Box::new(Circle), + Box::new(Square), +]; +``` + +Python always does dynamic dispatch (everything goes through `__dict__` +lookup). Rust makes you choose — and the default (generics/static) is +zero-cost. + +## Summary + +| Python | Rust | What Changes | +|--------|------|-------------| +| ABCs / Protocols | Traits | Compile-time enforcement | +| `TypeVar` / generics | `` generics | Monomorphized — zero runtime cost | +| Duck typing | Trait bounds | Explicit requirements, clear error messages | +| Mixins / default methods | Default trait methods | Same idea, enforced by compiler | +| `@dataclass` | `#[derive(...)]` | Pick exactly which behaviors to generate | +| `__add__`, `__repr__` | `impl Add`, `impl Display` | Operators are traits | +| Always dynamic dispatch | Static or dynamic dispatch | You choose the trade-off | + +## Next Steps + +Open `src/lib.rs` to see these concepts in working code, then try the +exercises in `exercises/`. diff --git a/ch03-traits-and-generics/exercises/Cargo.toml b/ch03-traits-and-generics/exercises/Cargo.toml new file mode 100644 index 0000000..04e8788 --- /dev/null +++ b/ch03-traits-and-generics/exercises/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "ch03-exercises" +version = "0.1.0" +edition.workspace = true +license.workspace = true +description = "Exercises for Chapter 3: Traits & Generics" diff --git a/ch03-traits-and-generics/exercises/src/lib.rs b/ch03-traits-and-generics/exercises/src/lib.rs new file mode 100644 index 0000000..35e4583 --- /dev/null +++ b/ch03-traits-and-generics/exercises/src/lib.rs @@ -0,0 +1,373 @@ +//! # Chapter 3 Exercises: Traits & Generics +//! +//! Each exercise shows a Python snippet and asks you to write the Rust +//! equivalent. Replace the `todo!()` markers with working code. +//! +//! Run tests: `cargo test -p ch03-exercises` + +// These allows are intentional: exercise stubs have unused parameters +// and fields until the student fills in the todo!() markers. +#![allow(unused_variables, dead_code)] + +use std::fmt; + +// ============================================================ +// Exercise 1: Define and Implement a Trait +// ============================================================ +// +// Python version: +// ```python +// class HasArea(Protocol): +// def area(self) -> float: ... +// +// class Rectangle: +// def __init__(self, width, height): +// self.width = width +// self.height = height +// def area(self): +// return self.width * self.height +// +// class Circle: +// def __init__(self, radius): +// self.radius = radius +// def area(self): +// return 3.14159265 * self.radius ** 2 +// ``` +// +// 1. Define a trait `HasArea` with a method `fn area(&self) -> f64` +// 2. Implement it for Rectangle and Circle (structs provided below) + +pub trait HasArea { + fn area(&self) -> f64; +} + +pub struct Rectangle { + pub width: f64, + pub height: f64, +} + +pub struct Circle { + pub radius: f64, +} + +impl HasArea for Rectangle { + fn area(&self) -> f64 { + todo!("width * height") + } +} + +impl HasArea for Circle { + fn area(&self) -> f64 { + todo!("PI * radius^2 — use std::f64::consts::PI") + } +} + +// ============================================================ +// Exercise 2: Generic Function with Trait Bound +// ============================================================ +// +// Python version: +// ```python +// def largest_area(shapes: list[HasArea]) -> float: +// return max(shape.area() for shape in shapes) +// +// assert largest_area([Rectangle(3, 4), Circle(1)]) == 12.0 +// ``` +// +// Write a generic function that finds the largest area in a slice. +// The bound: T must implement HasArea. + +pub fn largest_area(shapes: &[T]) -> Option { + todo!("Return the largest area, or None if the slice is empty") +} + +// ============================================================ +// Exercise 3: Display Trait (Python's __str__) +// ============================================================ +// +// Python version: +// ```python +// class Temperature: +// def __init__(self, celsius): +// self.celsius = celsius +// def __str__(self): +// return f"{self.celsius}°C" +// def __repr__(self): +// return f"Temperature(celsius={self.celsius})" +// ``` +// +// Implement Display for Temperature so that: +// format!("{}", temp) returns "23.5°C" +// +// Debug is already derived for you (__repr__ equivalent). + +#[derive(Debug, Clone, Copy)] +pub struct Temperature { + pub celsius: f64, +} + +impl Temperature { + pub fn new(celsius: f64) -> Self { + Self { celsius } + } + + pub fn to_fahrenheit(&self) -> f64 { + self.celsius * 9.0 / 5.0 + 32.0 + } +} + +impl fmt::Display for Temperature { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + todo!("Write celsius followed by °C") + } +} + +// ============================================================ +// Exercise 4: Operator Overloading +// ============================================================ +// +// Python version: +// ```python +// class Money: +// def __init__(self, cents): +// self.cents = cents +// def __add__(self, other): +// return Money(self.cents + other.cents) +// def __eq__(self, other): +// return self.cents == other.cents +// def __str__(self): +// return f"${self.cents / 100:.2f}" +// ``` +// +// Implement Add and Display for Money. PartialEq is already derived. + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Money { + pub cents: i64, +} + +impl Money { + pub fn new(cents: i64) -> Self { + Self { cents } + } + + pub fn from_dollars(dollars: f64) -> Self { + Self { + cents: (dollars * 100.0).round() as i64, + } + } +} + +impl std::ops::Add for Money { + type Output = Self; + + fn add(self, other: Self) -> Self { + todo!("Add the cents together") + } +} + +impl fmt::Display for Money { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + todo!("Format as $X.XX — dollars and cents with 2 decimal places") + } +} + +// ============================================================ +// Exercise 5: Trait with Default Method + Dynamic Dispatch +// ============================================================ +// +// Python version: +// ```python +// class Renderable: +// def render(self) -> str: +// raise NotImplementedError +// def render_with_border(self) -> str: +// content = self.render() +// width = max(len(line) for line in content.split('\n')) +// border = '+' + '-' * (width + 2) + '+' +// lines = [f"| {line:<{width}} |" for line in content.split('\n')] +// return '\n'.join([border] + lines + [border]) +// +// class TextBlock(Renderable): +// def __init__(self, text): self.text = text +// def render(self): return self.text +// +// class NumberBlock(Renderable): +// def __init__(self, value): self.value = value +// def render(self): return str(self.value) +// ``` +// +// 1. Implement `render()` for TextBlock and NumberBlock +// 2. The `render_with_border()` default method is provided +// 3. Implement `render_all` to work with mixed types (dynamic dispatch) + +pub trait Renderable { + /// Required: return the content to render. + fn render(&self) -> String; + + /// Default: wrap render() output in a border. + fn render_with_border(&self) -> String { + let content = self.render(); + let width = content.lines().map(|l| l.len()).max().unwrap_or(0); + let border = format!("+{}+", "-".repeat(width + 2)); + let body: Vec = content + .lines() + .map(|line| format!("| {: String { + todo!("Return self.text") + } +} + +impl Renderable for NumberBlock { + fn render(&self) -> String { + todo!("Return self.value as a string") + } +} + +/// Render all items in a mixed collection, one per line. +/// +/// Python equivalent: +/// ```python +/// def render_all(items: list[Renderable]) -> str: +/// return '\n'.join(item.render() for item in items) +/// ``` +/// +/// Hint: use `&[Box]` for the parameter type. +pub fn render_all(items: &[Box]) -> String { + todo!("Join each item's render() output with newlines") +} + +// ============================================================ +// Tests — do not modify below this line +// ============================================================ + +#[cfg(test)] +mod tests { + use super::*; + + // Exercise 1 + #[test] + fn ex1_rectangle_area() { + let r = Rectangle { + width: 3.0, + height: 4.0, + }; + assert!((r.area() - 12.0).abs() < f64::EPSILON); + } + + #[test] + fn ex1_circle_area() { + let c = Circle { radius: 1.0 }; + assert!((c.area() - std::f64::consts::PI).abs() < 1e-10); + } + + // Exercise 2 + #[test] + fn ex2_largest_area() { + let rects = vec![ + Rectangle { + width: 2.0, + height: 3.0, + }, + Rectangle { + width: 10.0, + height: 1.0, + }, + Rectangle { + width: 4.0, + height: 4.0, + }, + ]; + let largest = largest_area(&rects).unwrap(); + assert!((largest - 16.0).abs() < f64::EPSILON); + } + + #[test] + fn ex2_empty_returns_none() { + let empty: Vec = vec![]; + assert!(largest_area(&empty).is_none()); + } + + // Exercise 3 + #[test] + fn ex3_temperature_display() { + let t = Temperature::new(23.5); + assert_eq!(format!("{t}"), "23.5\u{00B0}C"); + } + + #[test] + fn ex3_temperature_debug() { + let t = Temperature::new(100.0); + assert_eq!(format!("{t:?}"), "Temperature { celsius: 100.0 }"); + } + + // Exercise 4 + #[test] + fn ex4_money_add() { + let a = Money::new(150); + let b = Money::new(250); + assert_eq!(a + b, Money::new(400)); + } + + #[test] + fn ex4_money_display() { + assert_eq!(format!("{}", Money::new(150)), "$1.50"); + assert_eq!(format!("{}", Money::new(7)), "$0.07"); + assert_eq!(format!("{}", Money::new(1000)), "$10.00"); + } + + #[test] + fn ex4_money_from_dollars() { + assert_eq!(Money::from_dollars(9.99), Money::new(999)); + } + + // Exercise 5 + #[test] + fn ex5_text_render() { + let t = TextBlock { + text: "hello".to_string(), + }; + assert_eq!(t.render(), "hello"); + } + + #[test] + fn ex5_number_render() { + let n = NumberBlock { value: 42.0 }; + assert_eq!(n.render(), "42"); + } + + #[test] + fn ex5_border() { + let t = TextBlock { + text: "hi".to_string(), + }; + let bordered = t.render_with_border(); + assert!(bordered.contains("+----+")); + assert!(bordered.contains("| hi |")); + } + + #[test] + fn ex5_render_all() { + let items: Vec> = vec![ + Box::new(TextBlock { + text: "hello".to_string(), + }), + Box::new(NumberBlock { value: 42.0 }), + ]; + assert_eq!(render_all(&items), "hello\n42"); + } +} diff --git a/ch03-traits-and-generics/src/lib.rs b/ch03-traits-and-generics/src/lib.rs new file mode 100644 index 0000000..cc1d8f0 --- /dev/null +++ b/ch03-traits-and-generics/src/lib.rs @@ -0,0 +1,456 @@ +//! # Chapter 3: Traits & Generics +//! +//! This module demonstrates Rust's trait system through examples that +//! map to familiar Python patterns. +//! +//! Run the tests: `cargo test -p ch03-traits-and-generics` + +use std::fmt; + +// --------------------------------------------------------------------------- +// 1. Defining and implementing traits +// --------------------------------------------------------------------------- + +/// A trait for things that can summarize themselves in one line. +/// +/// Python equivalent: +/// ```python +/// class Summarizable(Protocol): +/// def summary(self) -> str: ... +/// ``` +pub trait Summarizable { + fn summary(&self) -> String; +} + +/// A blog post. +#[derive(Debug, Clone)] +pub struct BlogPost { + pub title: String, + pub author: String, + pub word_count: usize, +} + +/// A code snippet. +#[derive(Debug, Clone)] +pub struct CodeSnippet { + pub language: String, + pub lines: usize, +} + +impl Summarizable for BlogPost { + fn summary(&self) -> String { + format!( + "\"{}\" by {} ({} words)", + self.title, self.author, self.word_count + ) + } +} + +impl Summarizable for CodeSnippet { + fn summary(&self) -> String { + format!("{} snippet ({} lines)", self.language, self.lines) + } +} + +// --------------------------------------------------------------------------- +// 2. Trait bounds and generics +// --------------------------------------------------------------------------- + +/// Print the summary of anything Summarizable. +/// +/// Python equivalent: +/// ```python +/// def print_summary(item: Summarizable) -> str: +/// return f">> {item.summary()}" +/// ``` +/// +/// The `impl Summarizable` syntax is sugar for ``. +/// The compiler generates a specialized version for each type you call +/// this with — no vtable, no runtime cost. +pub fn format_summary(item: &impl Summarizable) -> String { + format!(">> {}", item.summary()) +} + +/// Find the item with the longest summary. +/// +/// This shows a more complex trait bound: T must be both Summarizable +/// and Clone (because we need to return an owned copy). +/// +/// Python equivalent: +/// ```python +/// def longest_summary(items: list[Summarizable]) -> Summarizable: +/// return max(items, key=lambda x: len(x.summary())) +/// ``` +pub fn longest_summary(items: &[T]) -> Option { + items + .iter() + .max_by_key(|item| item.summary().len()) + .cloned() +} + +// --------------------------------------------------------------------------- +// 3. Default methods +// --------------------------------------------------------------------------- + +/// A trait with a required method and a default method. +/// +/// Python equivalent: +/// ```python +/// class Labeled: +/// def label(self) -> str: +/// raise NotImplementedError +/// +/// def display_label(self) -> str: +/// return f"[{self.label()}]" # default uses label() +/// ``` +pub trait Labeled { + /// Required — implementors must provide this. + fn label(&self) -> &str; + + /// Default — implementors get this for free, but can override it. + fn display_label(&self) -> String { + format!("[{}]", self.label()) + } +} + +#[derive(Debug)] +pub struct Tag { + name: String, +} + +impl Tag { + pub fn new(name: &str) -> Self { + Self { + name: name.to_string(), + } + } +} + +impl Labeled for Tag { + fn label(&self) -> &str { + &self.name + } + // display_label() uses the default implementation +} + +#[derive(Debug)] +pub struct Priority { + level: u8, + name: String, +} + +impl Priority { + pub fn new(level: u8, name: &str) -> Self { + Self { + level, + name: name.to_string(), + } + } +} + +impl Labeled for Priority { + fn label(&self) -> &str { + &self.name + } + + // Override the default to include the level + fn display_label(&self) -> String { + format!("[P{}:{}]", self.level, self.name) + } +} + +// --------------------------------------------------------------------------- +// 4. Deriving common traits +// --------------------------------------------------------------------------- + +/// A 2D point with derived traits. +/// +/// Python equivalent: +/// ```python +/// @dataclass(frozen=True) +/// class Point: +/// x: float +/// y: float +/// # Gets __eq__, __repr__, __hash__ automatically +/// ``` +/// +/// Rust's #[derive] is more granular — you pick exactly which traits. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Point { + pub x: f64, + pub y: f64, +} + +impl Point { + pub fn new(x: f64, y: f64) -> Self { + Self { x, y } + } + + pub fn distance_to(&self, other: &Self) -> f64 { + ((self.x - other.x).powi(2) + (self.y - other.y).powi(2)).sqrt() + } +} + +/// Display is the trait behind `format!("{}", point)`. +/// It's like Python's `__str__`. +impl fmt::Display for Point { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "({}, {})", self.x, self.y) + } +} + +// --------------------------------------------------------------------------- +// 5. Operator overloading via traits +// --------------------------------------------------------------------------- + +/// Implement Add for Point — like Python's `__add__`. +impl std::ops::Add for Point { + type Output = Self; + + fn add(self, other: Self) -> Self { + Self { + x: self.x + other.x, + y: self.y + other.y, + } + } +} + +/// Implement Sub for Point — like Python's `__sub__`. +impl std::ops::Sub for Point { + type Output = Self; + + fn sub(self, other: Self) -> Self { + Self { + x: self.x - other.x, + y: self.y - other.y, + } + } +} + +// --------------------------------------------------------------------------- +// 6. Dynamic dispatch with trait objects +// --------------------------------------------------------------------------- + +/// Render a mixed collection of Summarizable items. +/// +/// Python equivalent: +/// ```python +/// def render_feed(items: list[Summarizable]) -> list[str]: +/// return [f"- {item.summary()}" for item in items] +/// ``` +/// +/// In Rust, mixing different concrete types in one Vec requires dynamic +/// dispatch: `Box` or `&dyn Trait`. +pub fn render_feed(items: &[Box]) -> Vec { + items + .iter() + .map(|item| format!("- {}", item.summary())) + .collect() +} + +// --------------------------------------------------------------------------- +// 7. Multiple trait bounds — the "where" clause +// --------------------------------------------------------------------------- + +/// Format a labeled, summarizable item. +/// +/// Python equivalent: +/// ```python +/// def card(item): +/// # Assumes item has both .label() and .summary() +/// return f"{item.display_label()} {item.summary()}" +/// ``` +/// +/// The `where` clause is the same as inline bounds but more readable +/// when you have multiple constraints. +pub fn card(item: &T) -> String +where + T: Labeled + Summarizable, +{ + format!("{} {}", item.display_label(), item.summary()) +} + +/// A type that implements both Labeled and Summarizable. +#[derive(Debug, Clone)] +pub struct Article { + pub section: String, + pub title: String, + pub word_count: usize, +} + +impl Labeled for Article { + fn label(&self) -> &str { + &self.section + } +} + +impl Summarizable for Article { + fn summary(&self) -> String { + format!("{} ({} words)", self.title, self.word_count) + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // Trait basics + + #[test] + fn summarize_blog_post() { + let post = BlogPost { + title: "Ownership in Rust".to_string(), + author: "Alice".to_string(), + word_count: 1500, + }; + assert_eq!( + post.summary(), + "\"Ownership in Rust\" by Alice (1500 words)" + ); + } + + #[test] + fn summarize_code_snippet() { + let snippet = CodeSnippet { + language: "Rust".to_string(), + lines: 42, + }; + assert_eq!(snippet.summary(), "Rust snippet (42 lines)"); + } + + // Generic functions with trait bounds + + #[test] + fn format_summary_works() { + let post = BlogPost { + title: "Hello".to_string(), + author: "Bob".to_string(), + word_count: 100, + }; + assert_eq!(format_summary(&post), ">> \"Hello\" by Bob (100 words)"); + } + + #[test] + fn longest_summary_finds_longest() { + let snippets = vec![ + CodeSnippet { + language: "Python".to_string(), + lines: 10, + }, + CodeSnippet { + language: "Rust".to_string(), + lines: 1000, + }, + ]; + let longest = longest_summary(&snippets).unwrap(); + assert_eq!(longest.language, "Rust"); + } + + #[test] + fn longest_summary_empty_returns_none() { + let empty: Vec = vec![]; + assert!(longest_summary(&empty).is_none()); + } + + // Default methods + + #[test] + fn tag_uses_default_display_label() { + let tag = Tag::new("urgent"); + assert_eq!(tag.display_label(), "[urgent]"); + } + + #[test] + fn priority_overrides_display_label() { + let p = Priority::new(1, "critical"); + assert_eq!(p.display_label(), "[P1:critical]"); + } + + // Derived traits + + #[test] + fn point_equality() { + let a = Point::new(1.0, 2.0); + let b = Point::new(1.0, 2.0); + assert_eq!(a, b); + } + + #[test] + fn point_debug() { + let p = Point::new(3.0, 4.0); + assert_eq!(format!("{:?}", p), "Point { x: 3.0, y: 4.0 }"); + } + + #[test] + fn point_display() { + let p = Point::new(3.0, 4.0); + assert_eq!(format!("{p}"), "(3, 4)"); + } + + #[test] + fn point_copy() { + let a = Point::new(1.0, 2.0); + let b = a; // Copy, not move + assert_eq!(a, b); // both still valid + } + + // Operator overloading + + #[test] + fn point_add() { + let a = Point::new(1.0, 2.0); + let b = Point::new(3.0, 4.0); + assert_eq!(a + b, Point::new(4.0, 6.0)); + } + + #[test] + fn point_sub() { + let a = Point::new(5.0, 7.0); + let b = Point::new(2.0, 3.0); + assert_eq!(a - b, Point::new(3.0, 4.0)); + } + + #[test] + fn point_distance() { + let a = Point::new(0.0, 0.0); + let b = Point::new(3.0, 4.0); + assert!((a.distance_to(&b) - 5.0).abs() < f64::EPSILON); + } + + // Dynamic dispatch + + #[test] + fn render_mixed_feed() { + let items: Vec> = vec![ + Box::new(BlogPost { + title: "Hello".to_string(), + author: "Alice".to_string(), + word_count: 100, + }), + Box::new(CodeSnippet { + language: "Rust".to_string(), + lines: 50, + }), + ]; + let feed = render_feed(&items); + assert_eq!(feed.len(), 2); + assert!(feed[0].starts_with("- \"Hello\"")); + assert!(feed[1].starts_with("- Rust snippet")); + } + + // Multiple trait bounds + + #[test] + fn article_card() { + let article = Article { + section: "Tech".to_string(), + title: "Why Traits Matter".to_string(), + word_count: 2000, + }; + assert_eq!(card(&article), "[Tech] Why Traits Matter (2000 words)"); + } +} From 55ff4bc3f867b9b78f6f9b7236a5fb21f3200d19 Mon Sep 17 00:00:00 2001 From: Shawn Hartsock Date: Sun, 12 Apr 2026 22:17:32 -0400 Subject: [PATCH 3/3] Add Chapter 4: Content-Addressable Data Builds the concept of content-addressable data from first principles: BLAKE3 hashing, deterministic serialization with serde, a ContentAddressable trait with default methods, a content-addressed store with deduplication, verified retrieval, and composable Merkle-style content addressing. 17 passing example tests, 5 exercises building a mini CAS system from scratch (hash function, trait definition, Config implementation, content-addressed cache, and Merkle-chain snapshots). Co-Authored-By: Claude Opus 4.6 --- Cargo.toml | 2 + ch04-content-addressable/Cargo.toml | 11 + ch04-content-addressable/README.md | 178 ++++++++ ch04-content-addressable/exercises/Cargo.toml | 11 + ch04-content-addressable/exercises/src/lib.rs | 345 ++++++++++++++++ ch04-content-addressable/src/lib.rs | 384 ++++++++++++++++++ 6 files changed, 931 insertions(+) create mode 100644 ch04-content-addressable/Cargo.toml create mode 100644 ch04-content-addressable/README.md create mode 100644 ch04-content-addressable/exercises/Cargo.toml create mode 100644 ch04-content-addressable/exercises/src/lib.rs create mode 100644 ch04-content-addressable/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index 0e188a4..a161afd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,8 @@ members = [ "ch02-error-handling/exercises", "ch03-traits-and-generics", "ch03-traits-and-generics/exercises", + "ch04-content-addressable", + "ch04-content-addressable/exercises", ] [workspace.package] diff --git a/ch04-content-addressable/Cargo.toml b/ch04-content-addressable/Cargo.toml new file mode 100644 index 0000000..b0d4186 --- /dev/null +++ b/ch04-content-addressable/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "ch04-content-addressable" +version = "0.1.0" +edition.workspace = true +license.workspace = true +description = "Chapter 4: Content-Addressable Data — data that carries its own proof" + +[dependencies] +blake3 = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/ch04-content-addressable/README.md b/ch04-content-addressable/README.md new file mode 100644 index 0000000..9fbbb2e --- /dev/null +++ b/ch04-content-addressable/README.md @@ -0,0 +1,178 @@ +# Chapter 4: Content-Addressable Data + +## The Big Idea + +Most data is **location-addressed**: you find it by *where* it lives. +A file path, a URL, a database row ID — these all say "go to this place +and get whatever's there." The problem: the content at that location can +change without the address changing. You got a file from `/data/config.json` +— but is it the *same* file you got yesterday? + +**Content-addressed** data flips this: the address *is derived from the +content*. You hash the data, and the hash becomes the name. If the content +changes, the name changes. If two files have the same name, they have the +same content. The data carries its own proof of integrity. + +This isn't a Rust-specific idea — Git uses it (commits, trees, and blobs +are all content-addressed). Docker image layers use it. IPFS uses it. +But Rust's type system makes it particularly clean to express, because you +can use traits to make content-addressability a *property of the type +itself*, not something bolted on after the fact. + +## Python Analogies + +### Hashing = `hashlib` + +```python +import hashlib + +data = b"hello, world" +digest = hashlib.sha256(data).hexdigest() +print(digest) # deterministic: same input always gives same output +``` + +```rust +let data = b"hello, world"; +let hash = blake3::hash(data); +println!("{}", hash.to_hex()); // same idea, but BLAKE3 is faster +``` + +**Why BLAKE3?** SHA-256 is fine for most things, but BLAKE3 is: +- Faster (designed for modern CPUs, parallelizable) +- Just as secure for integrity checking +- Simpler API (no finalization step) + +### Deterministic serialization = `json.dumps(sort_keys=True)` + +```python +import json + +# Problem: dict ordering affects the hash +data1 = {"b": 2, "a": 1} +data2 = {"a": 1, "b": 2} + +# Python 3.7+ preserves insertion order, so these differ: +json.dumps(data1) # '{"b": 2, "a": 1}' +json.dumps(data2) # '{"a": 1, "b": 2}' + +# Fix: sort keys for deterministic output +json.dumps(data1, sort_keys=True) # '{"a": 1, "b": 2}' +json.dumps(data2, sort_keys=True) # '{"a": 1, "b": 2}' — same! +``` + +```rust +use serde::Serialize; +use serde_json; + +#[derive(Serialize)] +struct Data { + a: i32, + b: i32, +} + +// Rust structs have a fixed field order — no sorting needed. +// serde_json always serializes fields in declaration order. +let data = Data { a: 1, b: 2 }; +let json = serde_json::to_string(&data).unwrap(); +// Always: {"a":1,"b":2} — deterministic by construction +``` + +**Key insight:** Python dicts are inherently unordered (conceptually), +so you need `sort_keys=True` to get deterministic output. Rust structs +have a fixed field order defined at compile time. Determinism comes from +the type system, not from runtime flags. + +### Content identifiers = "the hash IS the name" + +```python +import hashlib, json + +def content_id(obj): + """Generate a content-based identifier for any JSON-serializable object.""" + canonical = json.dumps(obj, sort_keys=True, separators=(',', ':')) + return hashlib.sha256(canonical.encode()).hexdigest() + +doc1 = {"title": "Hello", "body": "World"} +doc2 = {"title": "Hello", "body": "World"} +doc3 = {"title": "Hello", "body": "Changed"} + +assert content_id(doc1) == content_id(doc2) # same content = same id +assert content_id(doc1) != content_id(doc3) # different content = different id +``` + +In Rust, we can make this a *trait* — a property of the type: + +```rust +trait ContentAddressable: Serialize { + fn content_id(&self) -> String { + let bytes = serde_json::to_vec(self).unwrap(); + blake3::hash(&bytes).to_hex().to_string() + } +} +``` + +Any type that implements `Serialize` can become `ContentAddressable` with +a single line. The trait carries the behavior, and the type system ensures +you can't call `content_id()` on something that can't be serialized. + +### Immutability = "frozen dataclass" + +```python +from dataclasses import dataclass + +@dataclass(frozen=True) +class Document: + title: str + body: str + # Can't modify after creation — mutations return new instances +``` + +```rust +// Rust values are immutable by default. No frozen flag needed. +#[derive(Debug, Clone, Serialize)] +struct Document { + title: String, + body: String, +} +// You'd need `mut` to modify — and the type system tracks it +``` + +## Why Content-Addressability Matters + +Content-addressed data gives you powerful properties for free: + +1. **Integrity**: If the hash matches, the data is uncorrupted. No trust + required — the data proves itself. + +2. **Deduplication**: Same content = same hash. Store it once, reference + it by hash from anywhere. + +3. **Caching**: Hash hasn't changed? Don't recompute. The hash is a + perfect cache key because it captures *exactly* what the content is. + +4. **Provenance**: Chain of hashes = audit trail. You can prove that data + at point B came from data at point A, because the hashes link them. + +5. **Concurrency safety**: Content-addressed data is inherently immutable. + You never update in place — you create a new version with a new hash. + No locks needed. + +These aren't theoretical benefits. Git uses content-addressing for its +entire object store. Docker uses it for image layers. Package managers +use it for dependency resolution. Any system that needs to answer "is +this the same data I saw before?" benefits from content-addressing. + +## Summary + +| Python | Rust | What Changes | +|--------|------|-------------| +| `hashlib.sha256()` | `blake3::hash()` | Faster, simpler API | +| `json.dumps(sort_keys=True)` | `serde_json::to_vec()` | Deterministic by struct definition | +| Manual hash functions | `ContentAddressable` trait | Hash behavior attached to the type | +| `@dataclass(frozen=True)` | Default immutability | No runtime flag needed | +| Content ID as a string | Content ID as a type | Type system tracks what's addressable | + +## Next Steps + +Open `src/lib.rs` to see these concepts in working code, then try the +exercises in `exercises/`. diff --git a/ch04-content-addressable/exercises/Cargo.toml b/ch04-content-addressable/exercises/Cargo.toml new file mode 100644 index 0000000..f4ab142 --- /dev/null +++ b/ch04-content-addressable/exercises/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "ch04-exercises" +version = "0.1.0" +edition.workspace = true +license.workspace = true +description = "Exercises for Chapter 4: Content-Addressable Data" + +[dependencies] +blake3 = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/ch04-content-addressable/exercises/src/lib.rs b/ch04-content-addressable/exercises/src/lib.rs new file mode 100644 index 0000000..7c95b30 --- /dev/null +++ b/ch04-content-addressable/exercises/src/lib.rs @@ -0,0 +1,345 @@ +//! # Chapter 4 Exercises: Content-Addressable Data +//! +//! These exercises build a mini content-addressable system from scratch. +//! Each exercise builds on the previous one. +//! +//! Run tests: `cargo test -p ch04-exercises` + +#![allow(unused_variables, dead_code)] + +use serde::Serialize; + +// ============================================================ +// Exercise 1: Hash Function +// ============================================================ +// +// Python version: +// ```python +// import hashlib +// def hash_content(data: bytes) -> str: +// return hashlib.blake2b(data, digest_size=32).hexdigest() +// ``` +// +// Use blake3 to hash bytes and return a hex string. + +pub fn hash_content(data: &[u8]) -> String { + todo!("Hash the data with blake3 and return the hex string") +} + +// ============================================================ +// Exercise 2: A ContentAddressable Trait +// ============================================================ +// +// Python version: +// ```python +// class ContentAddressable: +// def canonical_bytes(self) -> bytes: +// raise NotImplementedError +// +// def content_id(self) -> str: +// return hash_content(self.canonical_bytes()) +// ``` +// +// Define a trait with: +// - A required method `canonical_bytes(&self) -> Vec` +// - A default method `content_id(&self) -> String` that hashes the bytes + +pub trait ContentAddressable { + /// Required: return the canonical byte representation of this value. + fn canonical_bytes(&self) -> Vec; + + /// Default: hash the canonical bytes to produce a content identifier. + fn content_id(&self) -> String { + todo!("Call canonical_bytes, pass to hash_content") + } +} + +// ============================================================ +// Exercise 3: Implement ContentAddressable for a Config Struct +// ============================================================ +// +// Python version: +// ```python +// @dataclass(frozen=True) +// class Config: +// name: str +// version: int +// debug: bool +// +// def canonical_bytes(self): +// return json.dumps( +// {"name": self.name, "version": self.version, "debug": self.debug}, +// sort_keys=True, separators=(',', ':') +// ).encode() +// ``` +// +// Implement ContentAddressable for Config using serde_json serialization. + +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct Config { + pub name: String, + pub version: u32, + pub debug: bool, +} + +impl Config { + pub fn new(name: &str, version: u32, debug: bool) -> Self { + Self { + name: name.to_string(), + version, + debug, + } + } +} + +impl ContentAddressable for Config { + fn canonical_bytes(&self) -> Vec { + todo!("Serialize self to JSON bytes using serde_json::to_vec") + } +} + +// ============================================================ +// Exercise 4: Content-Addressed Cache +// ============================================================ +// +// Python version: +// ```python +// class ContentCache: +// def __init__(self): +// self._cache = {} +// +// def get_or_compute(self, key_obj, compute_fn): +// cid = key_obj.content_id() +// if cid not in self._cache: +// self._cache[cid] = compute_fn(key_obj) +// return self._cache[cid] +// ``` +// +// Build a cache that uses content IDs as keys. If two different Config +// objects have the same content, the computation should only run once. + +pub struct ContentCache { + cache: std::collections::HashMap, +} + +impl Default for ContentCache { + fn default() -> Self { + Self::new() + } +} + +impl ContentCache { + pub fn new() -> Self { + Self { + cache: std::collections::HashMap::new(), + } + } + + /// Get a cached result or compute it. + /// + /// If an entry with the same content_id already exists, return it. + /// Otherwise, call `compute` with the value, store the result, and return it. + pub fn get_or_compute( + &mut self, + value: &T, + compute: impl FnOnce(&T) -> String, + ) -> String { + todo!("Check if content_id is in cache; if not, compute and store") + } + + pub fn len(&self) -> usize { + self.cache.len() + } + + pub fn is_empty(&self) -> bool { + self.cache.is_empty() + } +} + +// ============================================================ +// Exercise 5: Merkle-Style Chaining +// ============================================================ +// +// Python version: +// ```python +// class Snapshot: +// def __init__(self, data, parent_cid=None): +// self.data = data +// self.parent_cid = parent_cid +// +// def canonical_bytes(self): +// obj = {"data": self.data, "parent": self.parent_cid} +// return json.dumps(obj, sort_keys=True, separators=(',', ':')).encode() +// +// def content_id(self): +// return hash_content(self.canonical_bytes()) +// ``` +// +// A Snapshot includes a reference to its parent's content ID, creating +// a chain. Changing any snapshot changes all descendant CIDs. +// This is the same principle behind Git commits and blockchain blocks. +// +// Implement ContentAddressable for Snapshot. + +#[derive(Debug, Clone, Serialize)] +pub struct Snapshot { + pub data: String, + pub parent_cid: Option, +} + +impl Snapshot { + pub fn root(data: &str) -> Self { + Self { + data: data.to_string(), + parent_cid: None, + } + } + + pub fn child(data: &str, parent: &impl ContentAddressable) -> Self { + Self { + data: data.to_string(), + parent_cid: Some(parent.content_id()), + } + } +} + +impl ContentAddressable for Snapshot { + fn canonical_bytes(&self) -> Vec { + todo!("Serialize self to JSON bytes") + } +} + +// ============================================================ +// Tests — do not modify below this line +// ============================================================ + +#[cfg(test)] +mod tests { + use super::*; + + // Exercise 1 + #[test] + fn ex1_hash_deterministic() { + assert_eq!(hash_content(b"hello"), hash_content(b"hello")); + } + + #[test] + fn ex1_hash_different_input() { + assert_ne!(hash_content(b"hello"), hash_content(b"world")); + } + + #[test] + fn ex1_hash_is_hex() { + let h = hash_content(b"test"); + assert!(h.chars().all(|c| c.is_ascii_hexdigit())); + assert_eq!(h.len(), 64); // BLAKE3 produces 256-bit = 64 hex chars + } + + // Exercise 2 + 3 + #[test] + fn ex3_config_content_id_deterministic() { + let cfg = Config::new("app", 1, false); + assert_eq!(cfg.content_id(), cfg.content_id()); + } + + #[test] + fn ex3_same_config_same_id() { + let cfg1 = Config::new("app", 1, false); + let cfg2 = Config::new("app", 1, false); + assert_eq!(cfg1.content_id(), cfg2.content_id()); + } + + #[test] + fn ex3_different_config_different_id() { + let cfg1 = Config::new("app", 1, false); + let cfg2 = Config::new("app", 2, false); + assert_ne!(cfg1.content_id(), cfg2.content_id()); + } + + #[test] + fn ex3_debug_flag_changes_id() { + let cfg1 = Config::new("app", 1, false); + let cfg2 = Config::new("app", 1, true); + assert_ne!(cfg1.content_id(), cfg2.content_id()); + } + + // Exercise 4 + #[test] + fn ex4_cache_computes_once() { + let mut cache = ContentCache::new(); + let cfg = Config::new("app", 1, false); + + let mut call_count = 0; + + let result1 = cache.get_or_compute(&cfg, |c| { + call_count += 1; + format!("computed-{}", c.name) + }); + + // Same content, different object — should hit cache + let cfg2 = Config::new("app", 1, false); + let result2 = cache.get_or_compute(&cfg2, |c| { + call_count += 1; + format!("computed-{}", c.name) + }); + + assert_eq!(result1, "computed-app"); + assert_eq!(result1, result2); + assert_eq!(call_count, 1); // computed only once! + assert_eq!(cache.len(), 1); + } + + #[test] + fn ex4_cache_different_content() { + let mut cache = ContentCache::new(); + + cache.get_or_compute(&Config::new("a", 1, false), |c| c.name.clone()); + cache.get_or_compute(&Config::new("b", 1, false), |c| c.name.clone()); + + assert_eq!(cache.len(), 2); + } + + // Exercise 5 + #[test] + fn ex5_root_snapshot() { + let root = Snapshot::root("initial"); + assert!(root.parent_cid.is_none()); + let cid = root.content_id(); + assert_eq!(cid.len(), 64); + } + + #[test] + fn ex5_child_includes_parent_cid() { + let root = Snapshot::root("initial"); + let child = Snapshot::child("update", &root); + assert_eq!(child.parent_cid, Some(root.content_id())); + } + + #[test] + fn ex5_chain_integrity() { + let root = Snapshot::root("v1"); + let child = Snapshot::child("v2", &root); + let grandchild = Snapshot::child("v3", &child); + + // Changing the root changes the entire chain + let alt_root = Snapshot::root("v1-tampered"); + let alt_child = Snapshot::child("v2", &alt_root); + let alt_grandchild = Snapshot::child("v3", &alt_child); + + assert_ne!(root.content_id(), alt_root.content_id()); + assert_ne!(child.content_id(), alt_child.content_id()); + assert_ne!(grandchild.content_id(), alt_grandchild.content_id()); + } + + #[test] + fn ex5_same_data_different_parent_different_cid() { + let root1 = Snapshot::root("a"); + let root2 = Snapshot::root("b"); + + let child1 = Snapshot::child("same-data", &root1); + let child2 = Snapshot::child("same-data", &root2); + + // Same child data but different parents = different CIDs + assert_ne!(child1.content_id(), child2.content_id()); + } +} diff --git a/ch04-content-addressable/src/lib.rs b/ch04-content-addressable/src/lib.rs new file mode 100644 index 0000000..4a796a3 --- /dev/null +++ b/ch04-content-addressable/src/lib.rs @@ -0,0 +1,384 @@ +//! # Chapter 4: Content-Addressable Data +//! +//! This module builds the concept of content-addressable data from first +//! principles: hashing, deterministic serialization, and a trait that +//! makes "data that carries its own proof of integrity" a property of +//! the type system. +//! +//! Run the tests: `cargo test -p ch04-content-addressable` + +use serde::Serialize; + +// --------------------------------------------------------------------------- +// 1. Basic hashing with BLAKE3 +// --------------------------------------------------------------------------- + +/// Hash raw bytes and return the hex string. +/// +/// Python equivalent: +/// ```python +/// import hashlib +/// def hash_bytes(data: bytes) -> str: +/// return hashlib.sha256(data).hexdigest() +/// ``` +/// +/// We use BLAKE3 instead of SHA-256: faster, parallelizable, same security +/// guarantees for integrity checking. +pub fn hash_bytes(data: &[u8]) -> String { + blake3::hash(data).to_hex().to_string() +} + +// --------------------------------------------------------------------------- +// 2. Deterministic serialization with serde +// --------------------------------------------------------------------------- + +/// A document with a title and body. +/// +/// The `#[derive(Serialize)]` makes this serializable with serde. +/// Unlike Python dicts, struct field order is fixed at compile time, +/// so serialization is deterministic without sort_keys=True. +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct Document { + pub title: String, + pub body: String, +} + +impl Document { + pub fn new(title: &str, body: &str) -> Self { + Self { + title: title.to_string(), + body: body.to_string(), + } + } +} + +/// Serialize to canonical JSON bytes (compact, no trailing newline). +/// +/// Python equivalent: +/// ```python +/// def canonical_json(obj) -> bytes: +/// return json.dumps(obj, sort_keys=True, separators=(',', ':')).encode() +/// ``` +pub fn canonical_json(value: &T) -> Vec { + serde_json::to_vec(value).expect("serialization should not fail for valid types") +} + +// --------------------------------------------------------------------------- +// 3. The ContentAddressable trait +// --------------------------------------------------------------------------- + +/// A trait for data that can identify itself by its content. +/// +/// Any type that implements `Serialize` can become content-addressable. +/// The content ID is a BLAKE3 hash of the canonical JSON representation. +/// +/// This is a default-method trait (like Chapter 3): you get `content_id()` +/// for free just by implementing Serialize and opting in. +/// +/// Python equivalent: +/// ```python +/// class ContentAddressable: +/// def content_id(self) -> str: +/// canonical = json.dumps(self.__dict__, sort_keys=True, separators=(',', ':')) +/// return hashlib.blake3(canonical.encode()).hexdigest() +/// ``` +pub trait ContentAddressable: Serialize { + /// Return the content-based identifier for this value. + /// + /// Same content always produces the same ID. Different content + /// (with overwhelming probability) produces a different ID. + fn content_id(&self) -> String { + let bytes = canonical_json(self); + hash_bytes(&bytes) + } + + /// Check whether two values have the same content, by comparing + /// their content IDs. + fn same_content(&self, other: &impl ContentAddressable) -> bool { + self.content_id() == other.content_id() + } +} + +// Implement ContentAddressable for Document — one line! +impl ContentAddressable for Document {} + +// --------------------------------------------------------------------------- +// 4. Content-addressed storage (a simple in-memory store) +// --------------------------------------------------------------------------- + +/// A simple content-addressed store: values are stored by their content ID. +/// +/// Python equivalent: +/// ```python +/// class ContentStore: +/// def __init__(self): +/// self._store = {} +/// +/// def put(self, data: bytes) -> str: +/// cid = hash_bytes(data) +/// self._store[cid] = data +/// return cid +/// +/// def get(self, cid: str) -> bytes | None: +/// return self._store.get(cid) +/// ``` +/// +/// The Rust version uses generics + trait bounds: the store only accepts +/// types that are ContentAddressable. The type system enforces this. +pub struct ContentStore { + store: std::collections::HashMap>, +} + +impl ContentStore { + pub fn new() -> Self { + Self { + store: std::collections::HashMap::new(), + } + } + + /// Store a value and return its content ID. + /// If the same content already exists, this is a no-op (deduplication!). + pub fn put(&mut self, value: &T) -> String { + let cid = value.content_id(); + let bytes = canonical_json(value); + self.store.entry(cid.clone()).or_insert(bytes); + cid + } + + /// Retrieve raw bytes by content ID. + pub fn get(&self, cid: &str) -> Option<&[u8]> { + self.store.get(cid).map(|v| v.as_slice()) + } + + /// Check if content exists without retrieving it. + pub fn contains(&self, cid: &str) -> bool { + self.store.contains_key(cid) + } + + /// Number of unique items stored. + pub fn len(&self) -> usize { + self.store.len() + } + + /// Whether the store is empty. + pub fn is_empty(&self) -> bool { + self.store.is_empty() + } +} + +impl Default for ContentStore { + fn default() -> Self { + Self::new() + } +} + +// --------------------------------------------------------------------------- +// 5. Verified retrieval — data that proves itself +// --------------------------------------------------------------------------- + +/// Retrieve and verify: fetch by CID, re-hash, confirm integrity. +/// +/// This is the key insight of content-addressed systems: the address +/// IS the checksum. If someone gives you a CID and data, you can verify +/// independently that the data matches — no trust required. +pub fn verify_content(claimed_cid: &str, data: &[u8]) -> bool { + let actual_cid = hash_bytes(data); + actual_cid == claimed_cid +} + +// --------------------------------------------------------------------------- +// 6. Composable content addressing — hashing a tree +// --------------------------------------------------------------------------- + +/// A collection of documents, itself content-addressable. +/// +/// When a collection is content-addressable, its CID depends on ALL +/// of its children. Change one document and the collection's CID changes. +/// This is how Git works: a tree hash includes all its blob hashes. +#[derive(Debug, Clone, Serialize)] +pub struct DocumentCollection { + pub name: String, + pub documents: Vec, +} + +impl DocumentCollection { + pub fn new(name: &str) -> Self { + Self { + name: name.to_string(), + documents: Vec::new(), + } + } + + pub fn add(&mut self, doc: Document) { + self.documents.push(doc); + } +} + +impl ContentAddressable for DocumentCollection {} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // Basic hashing + + #[test] + fn hash_is_deterministic() { + let data = b"hello, world"; + assert_eq!(hash_bytes(data), hash_bytes(data)); + } + + #[test] + fn different_data_different_hash() { + assert_ne!(hash_bytes(b"hello"), hash_bytes(b"world")); + } + + // Deterministic serialization + + #[test] + fn serialization_is_deterministic() { + let doc = Document::new("Hello", "World"); + assert_eq!(canonical_json(&doc), canonical_json(&doc)); + } + + #[test] + fn same_content_same_bytes() { + let doc1 = Document::new("Hello", "World"); + let doc2 = Document::new("Hello", "World"); + assert_eq!(canonical_json(&doc1), canonical_json(&doc2)); + } + + // ContentAddressable trait + + #[test] + fn content_id_is_deterministic() { + let doc = Document::new("Test", "Content"); + assert_eq!(doc.content_id(), doc.content_id()); + } + + #[test] + fn same_content_same_id() { + let doc1 = Document::new("Hello", "World"); + let doc2 = Document::new("Hello", "World"); + assert_eq!(doc1.content_id(), doc2.content_id()); + } + + #[test] + fn different_content_different_id() { + let doc1 = Document::new("Hello", "World"); + let doc2 = Document::new("Hello", "Changed"); + assert_ne!(doc1.content_id(), doc2.content_id()); + } + + #[test] + fn same_content_check() { + let doc1 = Document::new("Hello", "World"); + let doc2 = Document::new("Hello", "World"); + assert!(doc1.same_content(&doc2)); + } + + #[test] + fn different_content_check() { + let doc1 = Document::new("Hello", "World"); + let doc2 = Document::new("Goodbye", "World"); + assert!(!doc1.same_content(&doc2)); + } + + // Content store + + #[test] + fn store_and_retrieve() { + let mut store = ContentStore::new(); + let doc = Document::new("Test", "Data"); + let cid = store.put(&doc); + + assert!(store.contains(&cid)); + assert!(store.get(&cid).is_some()); + } + + #[test] + fn store_deduplicates() { + let mut store = ContentStore::new(); + let doc1 = Document::new("Hello", "World"); + let doc2 = Document::new("Hello", "World"); + + let cid1 = store.put(&doc1); + let cid2 = store.put(&doc2); + + assert_eq!(cid1, cid2); + assert_eq!(store.len(), 1); // only stored once! + } + + #[test] + fn store_different_content_separately() { + let mut store = ContentStore::new(); + store.put(&Document::new("A", "1")); + store.put(&Document::new("B", "2")); + assert_eq!(store.len(), 2); + } + + // Verified retrieval + + #[test] + fn verify_valid_content() { + let data = canonical_json(&Document::new("Test", "Data")); + let cid = hash_bytes(&data); + assert!(verify_content(&cid, &data)); + } + + #[test] + fn verify_tampered_content() { + let data = canonical_json(&Document::new("Test", "Data")); + let cid = hash_bytes(&data); + + let mut tampered = data.clone(); + tampered[0] = b'X'; + assert!(!verify_content(&cid, &tampered)); + } + + // Composable content addressing + + #[test] + fn collection_cid_includes_all_documents() { + let mut col1 = DocumentCollection::new("docs"); + col1.add(Document::new("A", "1")); + col1.add(Document::new("B", "2")); + + let mut col2 = DocumentCollection::new("docs"); + col2.add(Document::new("A", "1")); + col2.add(Document::new("B", "2")); + + assert_eq!(col1.content_id(), col2.content_id()); + } + + #[test] + fn collection_cid_changes_with_any_document() { + let mut col1 = DocumentCollection::new("docs"); + col1.add(Document::new("A", "1")); + col1.add(Document::new("B", "2")); + + let mut col2 = DocumentCollection::new("docs"); + col2.add(Document::new("A", "1")); + col2.add(Document::new("B", "CHANGED")); + + assert_ne!(col1.content_id(), col2.content_id()); + } + + #[test] + fn collection_cid_sensitive_to_order() { + let mut col1 = DocumentCollection::new("docs"); + col1.add(Document::new("A", "1")); + col1.add(Document::new("B", "2")); + + let mut col2 = DocumentCollection::new("docs"); + col2.add(Document::new("B", "2")); + col2.add(Document::new("A", "1")); + + // Order matters! Different order = different CID + assert_ne!(col1.content_id(), col2.content_id()); + } +}