diff --git a/Cargo.toml b/Cargo.toml index 7cbbc27..6f51cb5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,8 @@ members = [ "ch04-content-addressable/exercises", "ch05-cli-tools", "ch05-cli-tools/exercises", + "ch06-ffi-and-pyo3", + "ch06-ffi-and-pyo3/exercises", ] [workspace.package] diff --git a/ch06-ffi-and-pyo3/Cargo.toml b/ch06-ffi-and-pyo3/Cargo.toml new file mode 100644 index 0000000..3c2bc56 --- /dev/null +++ b/ch06-ffi-and-pyo3/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "ch06-ffi-and-pyo3" +version = "0.1.0" +edition.workspace = true +license.workspace = true +description = "Chapter 6: FFI & PyO3 — the bridge between Rust and Python" + +[dependencies] +blake3 = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/ch06-ffi-and-pyo3/README.md b/ch06-ffi-and-pyo3/README.md new file mode 100644 index 0000000..3d8e2e9 --- /dev/null +++ b/ch06-ffi-and-pyo3/README.md @@ -0,0 +1,183 @@ +# Chapter 6: FFI & PyO3 + +## The Big Idea + +You've spent five chapters learning Rust through the lens of Python. +Now the question becomes: do you have to *choose*? Can you use both? + +The answer is yes — and this is one of Rust's strongest practical +advantages. PyO3 lets you write Rust code that Python can call as if +it were a native Python module. No C glue code, no ctypes, no CFFI. +You write Rust, and Python sees a normal module with classes and functions. + +This chapter teaches the *design patterns* for building Python-callable +Rust libraries. The examples and exercises are pure Rust (no PyO3 +compilation required) — they teach you how to structure code at the +boundary so that when you're ready to add PyO3, the hard design work +is already done. + +## Why Rust + Python? + +Python is excellent for: +- Rapid prototyping and scripting +- Data science and ML ecosystems (numpy, pandas, scikit-learn) +- Glue code and orchestration + +Rust is excellent for: +- CPU-intensive computation +- Memory-safe systems code +- Guaranteed-correct data transformations + +The best architecture uses both: Python for the outer layer (user +interaction, orchestration, data pipeline wiring) and Rust for the +inner layer (hashing, parsing, validation, transformation). PyO3 +is the bridge. + +## The Boundary Design Pattern + +The most important concept in this chapter isn't a Rust feature — it's +a design pattern. When building a Rust library that Python will call, +you need **two layers**: + +``` +Python code + ↓ +┌─────────────────────────────┐ +│ Boundary layer (PyO3) │ ← Converts Python types ↔ Rust types +│ - Python-friendly API │ ← Error handling: Result → PyErr +│ - Accepts/returns Py types │ ← Owns the GIL interaction +└─────────────────────────────┘ + ↓ +┌─────────────────────────────┐ +│ Core layer (pure Rust) │ ← No PyO3 dependency +│ - Business logic │ ← Testable with cargo test +│ - Rust-idiomatic types │ ← Can be used from other Rust code too +└─────────────────────────────┘ +``` + +The boundary layer is thin: it converts types and handles errors. +The core layer is where the real work happens. This separation means: + +1. **Your core logic is testable without Python** — `cargo test` works +2. **Your core logic is reusable** — other Rust crates can use it directly +3. **The PyO3 layer is thin enough to be obvious** — easy to maintain +4. **You can change the Python API without changing the core** — and vice versa + +### Python equivalent + +```python +# This is like writing a C extension with ctypes, but much cleaner. +# Compare: + +# ctypes (painful) +import ctypes +lib = ctypes.CDLL('./libhash.so') +lib.hash_bytes.restype = ctypes.c_char_p +lib.hash_bytes.argtypes = [ctypes.c_char_p, ctypes.c_size_t] +result = lib.hash_bytes(b"hello", 5) + +# PyO3 (natural) +import my_rust_module +result = my_rust_module.hash_bytes(b"hello") # just works +``` + +## What PyO3 Code Looks Like + +You don't need to compile this — just read the pattern: + +```rust +// The CORE layer — pure Rust, no PyO3 +pub fn hash_bytes_core(data: &[u8]) -> String { + blake3::hash(data).to_hex().to_string() +} + +// The BOUNDARY layer — PyO3 wrapper +// (This is what you'd add when ready to build a Python module) +// +// use pyo3::prelude::*; +// +// #[pyfunction] +// fn hash_bytes(data: &[u8]) -> String { +// hash_bytes_core(data) // delegates to the core +// } +// +// #[pymodule] +// fn my_module(m: &Bound<'_, PyModule>) -> PyResult<()> { +// m.add_function(wrap_pyfunction!(hash_bytes, m)?)?; +// Ok(()) +// } +``` + +The pattern: **pure Rust core function** + **thin PyO3 wrapper that delegates**. + +## Type Mapping: Python ↔ Rust + +When designing the boundary, you need to know how types map: + +| Python type | Rust type (core) | PyO3 boundary | +|-------------|-----------------|---------------| +| `str` | `String` / `&str` | automatic | +| `bytes` | `Vec` / `&[u8]` | automatic | +| `int` | `i64` / `u64` | automatic | +| `float` | `f64` | automatic | +| `bool` | `bool` | automatic | +| `list[T]` | `Vec` | automatic | +| `dict[K,V]` | `HashMap` | automatic | +| `None` | `Option` → `None` | automatic | +| custom class | Rust struct | `#[pyclass]` + `#[pymethods]` | +| exception | `Result` | `PyResult` (auto-converts) | + +Most basic types convert automatically. Custom types need `#[pyclass]`. + +## Error Handling at the Boundary + +```rust +// Core: returns Result with a Rust error type +pub fn parse_config_core(json: &str) -> Result { + serde_json::from_str(json).map_err(ConfigError::Parse) +} + +// Boundary: converts to PyResult (which becomes a Python exception) +// +// #[pyfunction] +// fn parse_config(json: &str) -> PyResult { +// let config = parse_config_core(json) +// .map_err(|e| PyValueError::new_err(e.to_string()))?; +// Ok(PyConfig::from(config)) +// } +``` + +**Key insight:** Your core Rust code uses idiomatic `Result`. +The boundary layer converts Rust errors into Python exceptions. The +core never knows about Python. The boundary never knows about business +logic. + +## Summary + +| Concept | What It Means | +|---------|--------------| +| Two-layer pattern | Core (pure Rust) + Boundary (PyO3 wrapper) | +| Type mapping | Most Python types auto-convert to/from Rust | +| Error handling | `Result` in core → `PyResult` at boundary | +| `#[pyfunction]` | Expose a Rust function to Python | +| `#[pyclass]` | Expose a Rust struct as a Python class | +| `#[pymethods]` | Add methods to a `#[pyclass]` | +| `#[pymodule]` | Define the Python module entry point | +| maturin | Build tool that packages Rust as a Python wheel | + +## What to Do Next + +The exercises in this chapter focus on designing the **core layer** — +writing Rust code that's structured for Python interop without actually +requiring PyO3. When you're ready to build a real Python module: + +1. Install maturin: `pip install maturin` +2. Create a new project: `maturin init --bindings pyo3` +3. Move your core code in, write the thin boundary layer +4. Build: `maturin develop` (installs into your venv) +5. Import from Python: `import my_module` + +## Next Steps + +Open `src/lib.rs` to see the core-layer patterns in working code, +then try the exercises in `exercises/`. diff --git a/ch06-ffi-and-pyo3/exercises/Cargo.toml b/ch06-ffi-and-pyo3/exercises/Cargo.toml new file mode 100644 index 0000000..073d5bd --- /dev/null +++ b/ch06-ffi-and-pyo3/exercises/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "ch06-exercises" +version = "0.1.0" +edition.workspace = true +license.workspace = true +description = "Exercises for Chapter 6: FFI & PyO3" + +[dependencies] +blake3 = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/ch06-ffi-and-pyo3/exercises/src/lib.rs b/ch06-ffi-and-pyo3/exercises/src/lib.rs new file mode 100644 index 0000000..b09ca62 --- /dev/null +++ b/ch06-ffi-and-pyo3/exercises/src/lib.rs @@ -0,0 +1,393 @@ +//! # Chapter 6 Exercises: FFI & PyO3 +//! +//! These exercises practice the boundary design pattern: writing Rust +//! code structured for Python interop. Everything is pure Rust — no +//! PyO3 required. The focus is on API design at the language boundary. +//! +//! Run tests: `cargo test -p ch06-exercises` + +#![allow(unused_variables, dead_code)] + +use serde::{Deserialize, Serialize}; + +// ============================================================ +// Exercise 1: Python-Friendly Function Signatures +// ============================================================ +// +// Python version: +// ```python +// def word_count(text: str) -> dict[str, int]: +// """Count word frequencies in text.""" +// words = text.lower().split() +// counts = {} +// for word in words: +// counts[word] = counts.get(word, 0) + 1 +// return counts +// ``` +// +// Write a Rust function with Python-friendly types: +// - Input: &str (maps to Python str) +// - Output: HashMap (maps to dict[str, int]) + +pub fn word_count(text: &str) -> std::collections::HashMap { + todo!("Split text on whitespace, lowercase, count frequencies") +} + +// ============================================================ +// Exercise 2: Struct with Builder Pattern +// ============================================================ +// +// Python version: +// ```python +// class Record: +// def __init__(self, key: str, value: str, ttl: int | None = None): +// self.key = key +// self.value = value +// self.ttl = ttl +// +// def to_json(self) -> str: +// return json.dumps({"key": self.key, "value": self.value, "ttl": self.ttl}) +// +// @staticmethod +// def from_json(json_str: str) -> 'Record': +// data = json.loads(json_str) +// return Record(**data) +// ``` +// +// Build a Record struct with: +// - Serialize + Deserialize (for JSON roundtrip) +// - Builder method for optional TTL +// - to_json() and from_json() methods + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Record { + pub key: String, + pub value: String, + pub ttl: Option, +} + +impl Record { + pub fn new(key: &str, value: &str) -> Self { + todo!("Create a Record with ttl = None") + } + + pub fn with_ttl(self, ttl: u64) -> Self { + todo!("Return a new Record with the given TTL") + } + + pub fn to_json(&self) -> String { + todo!("Serialize to JSON string") + } + + pub fn from_json(json: &str) -> Result { + todo!("Deserialize from JSON, map error to String") + } +} + +// ============================================================ +// Exercise 3: Error Handling at the Boundary +// ============================================================ +// +// Python version: +// ```python +// class ValidationError(Exception): +// pass +// +// def validate_email(email: str) -> str: +// """Validate and normalize an email address. +// +// Returns the normalized email, or raises ValidationError. +// """ +// if '@' not in email: +// raise ValidationError("missing @") +// local, domain = email.rsplit('@', 1) +// if not local: +// raise ValidationError("empty local part") +// if not domain or '.' not in domain: +// raise ValidationError("invalid domain") +// return f"{local}@{domain.lower()}" +// ``` +// +// Write this as a Rust function returning Result. +// At the PyO3 boundary, this would become: +// ValidationError → PyValueError +// Ok(email) → str + +#[derive(Debug, PartialEq)] +pub enum ValidationError { + MissingAt, + EmptyLocal, + InvalidDomain(String), +} + +impl std::fmt::Display for ValidationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingAt => write!(f, "missing @"), + Self::EmptyLocal => write!(f, "empty local part"), + Self::InvalidDomain(d) => write!(f, "invalid domain: {d}"), + } + } +} + +pub fn validate_email(email: &str) -> Result { + todo!("Validate and normalize email: split on @, check parts, lowercase domain") +} + +// ============================================================ +// Exercise 4: Batch Processing with Results +// ============================================================ +// +// Python version: +// ```python +// def validate_emails(emails: list[str]) -> dict: +// """Validate multiple emails. +// +// Returns {"valid": [...], "errors": [{"email": ..., "error": ...}]} +// """ +// valid = [] +// errors = [] +// for email in emails: +// try: +// valid.append(validate_email(email)) +// except ValidationError as e: +// errors.append({"email": email, "error": str(e)}) +// return {"valid": valid, "errors": errors} +// ``` +// +// Write the Rust version. Use a struct for the result so it +// serializes cleanly to JSON for Python. + +#[derive(Debug, Serialize, PartialEq)] +pub struct BatchResult { + pub valid: Vec, + pub errors: Vec, +} + +#[derive(Debug, Serialize, PartialEq)] +pub struct BatchError { + pub email: String, + pub error: String, +} + +pub fn validate_emails(emails: &[&str]) -> BatchResult { + todo!("Validate each email, collect valid and errors separately") +} + +// ============================================================ +// Exercise 5: Content-Addressed Store with JSON Export +// ============================================================ +// +// Python version: +// ```python +// class KeyValueStore: +// def __init__(self): +// self._store = {} +// +// def put(self, key: str, value: str) -> str: +// """Store a value, return its content hash.""" +// cid = hash_content(value.encode()) +// self._store[cid] = {"key": key, "value": value} +// return cid +// +// def get(self, cid: str) -> dict | None: +// return self._store.get(cid) +// +// def export(self) -> str: +// """Export all entries as JSON.""" +// return json.dumps(list(self._store.values()), indent=2) +// ``` +// +// Build a KeyValueStore where: +// - put() stores entries by content hash of the value +// - get() retrieves by CID +// - export() returns JSON string (for Python to deserialize) + +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct Entry { + pub key: String, + pub value: String, + pub cid: String, +} + +pub struct KeyValueStore { + entries: std::collections::HashMap, +} + +impl KeyValueStore { + pub fn new() -> Self { + Self { + entries: std::collections::HashMap::new(), + } + } + + /// Store a key-value pair, return the content ID (hash of value bytes). + pub fn put(&mut self, key: &str, value: &str) -> String { + todo!("Hash the value, store an Entry, return the CID") + } + + /// Retrieve an entry by CID. + pub fn get(&self, cid: &str) -> Option<&Entry> { + todo!("Look up by CID") + } + + /// Export all entries as a JSON string. + pub fn export(&self) -> String { + todo!("Serialize all entries to JSON") + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +impl Default for KeyValueStore { + fn default() -> Self { + Self::new() + } +} + +// ============================================================ +// Tests — do not modify below this line +// ============================================================ + +#[cfg(test)] +mod tests { + use super::*; + + // Exercise 1 + #[test] + fn ex1_word_count() { + let counts = word_count("hello world hello"); + assert_eq!(counts["hello"], 2); + assert_eq!(counts["world"], 1); + } + + #[test] + fn ex1_word_count_case_insensitive() { + let counts = word_count("Hello HELLO hello"); + assert_eq!(counts["hello"], 3); + } + + #[test] + fn ex1_empty_string() { + let counts = word_count(""); + assert!(counts.is_empty()); + } + + // Exercise 2 + #[test] + fn ex2_record_new() { + let r = Record::new("name", "Alice"); + assert_eq!(r.key, "name"); + assert_eq!(r.value, "Alice"); + assert_eq!(r.ttl, None); + } + + #[test] + fn ex2_record_with_ttl() { + let r = Record::new("name", "Alice").with_ttl(3600); + assert_eq!(r.ttl, Some(3600)); + } + + #[test] + fn ex2_record_json_roundtrip() { + let original = Record::new("key", "value").with_ttl(60); + let json = original.to_json(); + let restored = Record::from_json(&json).unwrap(); + assert_eq!(original, restored); + } + + #[test] + fn ex2_record_from_invalid_json() { + assert!(Record::from_json("not json").is_err()); + } + + // Exercise 3 + #[test] + fn ex3_valid_email() { + assert_eq!( + validate_email("user@Example.COM"), + Ok("user@example.com".to_string()) + ); + } + + #[test] + fn ex3_missing_at() { + assert_eq!(validate_email("invalid"), Err(ValidationError::MissingAt)); + } + + #[test] + fn ex3_empty_local() { + assert_eq!( + validate_email("@example.com"), + Err(ValidationError::EmptyLocal) + ); + } + + #[test] + fn ex3_invalid_domain() { + assert!(matches!( + validate_email("user@nodot"), + Err(ValidationError::InvalidDomain(_)) + )); + } + + // Exercise 4 + #[test] + fn ex4_batch_validation() { + let result = validate_emails(&["good@example.com", "bad", "also@good.org"]); + assert_eq!(result.valid.len(), 2); + assert_eq!(result.errors.len(), 1); + assert_eq!(result.errors[0].email, "bad"); + } + + #[test] + fn ex4_all_valid() { + let result = validate_emails(&["a@b.com", "c@d.org"]); + assert_eq!(result.valid.len(), 2); + assert!(result.errors.is_empty()); + } + + // Exercise 5 + #[test] + fn ex5_put_and_get() { + let mut store = KeyValueStore::new(); + let cid = store.put("greeting", "hello"); + let entry = store.get(&cid).unwrap(); + assert_eq!(entry.key, "greeting"); + assert_eq!(entry.value, "hello"); + } + + #[test] + fn ex5_same_value_same_cid() { + let mut store = KeyValueStore::new(); + let cid1 = store.put("key1", "same-value"); + let cid2 = store.put("key2", "same-value"); + assert_eq!(cid1, cid2); + // Note: second put overwrites the first (same CID) + } + + #[test] + fn ex5_export_json() { + let mut store = KeyValueStore::new(); + store.put("name", "Alice"); + + let json = store.export(); + let parsed: Vec = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].key, "name"); + } + + #[test] + fn ex5_cid_is_hex() { + let mut store = KeyValueStore::new(); + let cid = store.put("k", "v"); + assert_eq!(cid.len(), 64); + assert!(cid.chars().all(|c| c.is_ascii_hexdigit())); + } +} diff --git a/ch06-ffi-and-pyo3/src/lib.rs b/ch06-ffi-and-pyo3/src/lib.rs new file mode 100644 index 0000000..92aa4d1 --- /dev/null +++ b/ch06-ffi-and-pyo3/src/lib.rs @@ -0,0 +1,439 @@ +//! # Chapter 6: FFI & PyO3 +//! +//! This module demonstrates the "core layer" pattern for Rust code that +//! will be called from Python. Everything here is pure Rust — no PyO3 +//! dependency. The design makes adding a PyO3 boundary layer trivial. +//! +//! Run the tests: `cargo test -p ch06-ffi-and-pyo3` + +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// 1. Core functions — Python-friendly signatures +// --------------------------------------------------------------------------- + +/// Hash bytes and return a hex string. +/// +/// This is the kind of function that maps perfectly to Python: +/// - Input: `&[u8]` maps to Python `bytes` +/// - Output: `String` maps to Python `str` +/// +/// PyO3 boundary would be: +/// ```text +/// #[pyfunction] +/// fn hash_bytes(data: &[u8]) -> String { +/// hash_bytes_core(data) +/// } +/// ``` +pub fn hash_bytes_core(data: &[u8]) -> String { + blake3::hash(data).to_hex().to_string() +} + +/// Parse a JSON string into a structured config. +/// +/// Input: `&str` (Python `str`) → Output: `Result` +/// +/// At the boundary, the Result becomes a Python exception: +/// ```text +/// #[pyfunction] +/// fn parse_config(json: &str) -> PyResult { +/// parse_config_core(json) +/// .map(PyConfig::from) +/// .map_err(|e| PyValueError::new_err(e.to_string())) +/// } +/// ``` +pub fn parse_config_core(json: &str) -> Result { + serde_json::from_str(json).map_err(|e| ConfigError::InvalidJson(e.to_string())) +} + +// --------------------------------------------------------------------------- +// 2. Data types designed for the boundary +// --------------------------------------------------------------------------- + +/// A configuration struct that maps cleanly to Python. +/// +/// All fields are types that PyO3 auto-converts: +/// - String ↔ str +/// - u32 ↔ int +/// - bool ↔ bool +/// - Vec ↔ list[str] +/// - Option ↔ Optional[str] +/// +/// PyO3 boundary would be: +/// ```text +/// #[pyclass] +/// struct PyConfig { +/// #[pyo3(get)] +/// name: String, +/// #[pyo3(get)] +/// version: u32, +/// ... +/// } +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Config { + pub name: String, + pub version: u32, + pub debug: bool, + pub tags: Vec, + pub description: Option, +} + +impl Config { + pub fn new(name: &str, version: u32) -> Self { + Self { + name: name.to_string(), + version, + debug: false, + tags: Vec::new(), + description: None, + } + } + + pub fn with_debug(mut self, debug: bool) -> Self { + self.debug = debug; + self + } + + pub fn with_tags(mut self, tags: Vec) -> Self { + self.tags = tags; + self + } + + pub fn with_description(mut self, desc: &str) -> Self { + self.description = Some(desc.to_string()); + self + } +} + +/// Error type for config parsing. +/// +/// At the PyO3 boundary, each variant maps to a different Python exception: +/// - InvalidJson → ValueError +/// - MissingField → KeyError +#[derive(Debug, PartialEq)] +pub enum ConfigError { + InvalidJson(String), + MissingField(String), +} + +impl std::fmt::Display for ConfigError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidJson(msg) => write!(f, "invalid JSON: {msg}"), + Self::MissingField(field) => write!(f, "missing field: {field}"), + } + } +} + +// --------------------------------------------------------------------------- +// 3. Builder pattern — Pythonic object construction +// --------------------------------------------------------------------------- + +/// A content-addressed document with a builder pattern. +/// +/// Python users expect keyword arguments: +/// ```python +/// doc = Document(title="Hello", body="World", tags=["greeting"]) +/// ``` +/// +/// Rust doesn't have keyword args, but the builder pattern serves +/// the same purpose — and PyO3 can map `#[new]` with keyword args: +/// ```text +/// #[pymethods] +/// impl PyDocument { +/// #[new] +/// #[pyo3(signature = (title, body, tags=vec![]))] +/// fn new(title: String, body: String, tags: Vec) -> Self { ... } +/// } +/// ``` +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct Document { + pub title: String, + pub body: String, + pub tags: Vec, +} + +impl Document { + pub fn new(title: &str, body: &str) -> Self { + Self { + title: title.to_string(), + body: body.to_string(), + tags: Vec::new(), + } + } + + pub fn with_tags(mut self, tags: Vec) -> Self { + self.tags = tags; + self + } + + /// Content ID — the hash of the serialized document. + pub fn content_id(&self) -> String { + let bytes = serde_json::to_vec(self).expect("document serialization should not fail"); + hash_bytes_core(&bytes) + } +} + +// --------------------------------------------------------------------------- +// 4. A service layer — the core business logic +// --------------------------------------------------------------------------- + +/// A document store that Python can interact with. +/// +/// At the boundary, this becomes a `#[pyclass]` with `#[pymethods]`. +/// The core logic stays here, testable without Python. +pub struct DocumentStore { + documents: std::collections::HashMap, +} + +impl DocumentStore { + pub fn new() -> Self { + Self { + documents: std::collections::HashMap::new(), + } + } + + /// Store a document, return its content ID. + pub fn put(&mut self, doc: Document) -> String { + let cid = doc.content_id(); + self.documents.insert(cid.clone(), doc); + cid + } + + /// Retrieve a document by content ID. + pub fn get(&self, cid: &str) -> Option<&Document> { + self.documents.get(cid) + } + + /// Search documents by title substring. + pub fn search(&self, query: &str) -> Vec<(&str, &Document)> { + let query_lower = query.to_lowercase(); + self.documents + .iter() + .filter(|(_, doc)| doc.title.to_lowercase().contains(&query_lower)) + .map(|(cid, doc)| (cid.as_str(), doc)) + .collect() + } + + /// List all content IDs. + pub fn list_ids(&self) -> Vec<&str> { + self.documents.keys().map(|s| s.as_str()).collect() + } + + /// Number of documents. + pub fn len(&self) -> usize { + self.documents.len() + } + + pub fn is_empty(&self) -> bool { + self.documents.is_empty() + } + + /// Export all documents as JSON (for Python interop). + /// + /// This pattern is common: Rust returns JSON, Python deserializes. + /// It's simpler than converting each field individually. + pub fn export_json(&self) -> String { + let entries: Vec<_> = self + .documents + .iter() + .map(|(cid, doc)| { + serde_json::json!({ + "cid": cid, + "title": doc.title, + "body": doc.body, + "tags": doc.tags, + }) + }) + .collect(); + serde_json::to_string_pretty(&entries).expect("export should not fail") + } +} + +impl Default for DocumentStore { + fn default() -> Self { + Self::new() + } +} + +// --------------------------------------------------------------------------- +// 5. Batch operations — processing lists +// --------------------------------------------------------------------------- + +/// Hash multiple items and return their CIDs. +/// +/// Python equivalent: +/// ```python +/// def hash_all(items: list[bytes]) -> list[str]: +/// return [hash_bytes(item) for item in items] +/// ``` +/// +/// At the boundary: Vec> (Python list[bytes]) → Vec (list[str]) +pub fn hash_all(items: &[&[u8]]) -> Vec { + items.iter().map(|item| hash_bytes_core(item)).collect() +} + +/// Verify a batch of (cid, data) pairs. +/// +/// Returns the indices of any items that fail verification. +/// Python can use this to report which items are corrupted. +pub fn verify_batch(pairs: &[(String, Vec)]) -> Vec { + pairs + .iter() + .enumerate() + .filter_map(|(i, (cid, data))| { + let actual = hash_bytes_core(data); + if actual != *cid { + Some(i) + } else { + None + } + }) + .collect() +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // Core functions + + #[test] + fn hash_bytes_returns_hex() { + let hash = hash_bytes_core(b"hello"); + assert_eq!(hash.len(), 64); + assert!(hash.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn parse_config_valid_json() { + let json = r#"{"name":"app","version":1,"debug":false,"tags":[],"description":null}"#; + let config = parse_config_core(json).unwrap(); + assert_eq!(config.name, "app"); + assert_eq!(config.version, 1); + } + + #[test] + fn parse_config_invalid_json() { + let result = parse_config_core("not json"); + assert!(result.is_err()); + } + + // Data types + + #[test] + fn config_builder() { + let config = Config::new("app", 1) + .with_debug(true) + .with_tags(vec!["prod".to_string()]) + .with_description("My app"); + + assert_eq!(config.name, "app"); + assert!(config.debug); + assert_eq!(config.tags, vec!["prod"]); + assert_eq!(config.description, Some("My app".to_string())); + } + + #[test] + fn config_roundtrip_json() { + let original = Config::new("test", 2).with_debug(true); + let json = serde_json::to_string(&original).unwrap(); + let restored: Config = serde_json::from_str(&json).unwrap(); + assert_eq!(original, restored); + } + + // Document + content addressing + + #[test] + fn document_content_id_deterministic() { + let doc1 = Document::new("Hello", "World"); + let doc2 = Document::new("Hello", "World"); + assert_eq!(doc1.content_id(), doc2.content_id()); + } + + #[test] + fn document_different_content_different_id() { + let doc1 = Document::new("Hello", "World"); + let doc2 = Document::new("Hello", "Changed"); + assert_ne!(doc1.content_id(), doc2.content_id()); + } + + // Document store + + #[test] + fn store_put_and_get() { + let mut store = DocumentStore::new(); + let doc = Document::new("Test", "Content"); + let cid = store.put(doc.clone()); + assert_eq!(store.get(&cid), Some(&doc)); + } + + #[test] + fn store_deduplicates() { + let mut store = DocumentStore::new(); + let cid1 = store.put(Document::new("A", "B")); + let cid2 = store.put(Document::new("A", "B")); + assert_eq!(cid1, cid2); + assert_eq!(store.len(), 1); + } + + #[test] + fn store_search() { + let mut store = DocumentStore::new(); + store.put(Document::new("Rust Guide", "Learn Rust")); + store.put(Document::new("Python Guide", "Learn Python")); + store.put(Document::new("Cooking 101", "Learn Cooking")); + + let results = store.search("guide"); + assert_eq!(results.len(), 2); + } + + #[test] + fn store_export_json() { + let mut store = DocumentStore::new(); + store.put(Document::new("Test", "Data")); + + let json = store.export_json(); + let parsed: Vec = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0]["title"], "Test"); + } + + // Batch operations + + #[test] + fn hash_all_items() { + let items: Vec<&[u8]> = vec![b"a", b"b", b"c"]; + let hashes = hash_all(&items); + assert_eq!(hashes.len(), 3); + assert_ne!(hashes[0], hashes[1]); + } + + #[test] + fn verify_batch_all_valid() { + let data = vec![b"hello".to_vec(), b"world".to_vec()]; + let pairs: Vec<(String, Vec)> = data + .iter() + .map(|d| (hash_bytes_core(d), d.clone())) + .collect(); + + assert!(verify_batch(&pairs).is_empty()); + } + + #[test] + fn verify_batch_detects_corruption() { + let pairs = vec![ + (hash_bytes_core(b"hello"), b"hello".to_vec()), + ("bad_hash".to_string(), b"world".to_vec()), + (hash_bytes_core(b"foo"), b"foo".to_vec()), + ]; + + let bad = verify_batch(&pairs); + assert_eq!(bad, vec![1]); + } +}