diff --git a/Cargo.toml b/Cargo.toml index cd80752..a161afd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,10 @@ members = [ "ch01-ownership/exercises", "ch02-error-handling", "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/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)"); + } +} 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()); + } +}