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 19dc8ea9ba432af1063e2104d89431eb15629938 Mon Sep 17 00:00:00 2001 From: Shawn Hartsock Date: Mon, 13 Apr 2026 07:24:17 -0400 Subject: [PATCH 2/3] Add Chapter 0: Getting Started (toolchain setup and hello world) Covers rustup/cargo installation, side-by-side Python vs Rust syntax for variables, functions, strings, control flow, collections, and iterators. Includes a Cargo command cheat sheet mapping to Python equivalents. 11 passing example tests, 5 exercises (multiply, string format, fizzbuzz, iterator map, and a score summarizer). Also adds Ch0 to the course README table of contents. Co-Authored-By: Claude Opus 4.6 --- Cargo.toml | 2 + README.md | 1 + ch00-getting-started/Cargo.toml | 6 + ch00-getting-started/README.md | 312 ++++++++++++++++++++++ ch00-getting-started/exercises/Cargo.toml | 6 + ch00-getting-started/exercises/src/lib.rs | 184 +++++++++++++ ch00-getting-started/src/lib.rs | 230 ++++++++++++++++ 7 files changed, 741 insertions(+) create mode 100644 ch00-getting-started/Cargo.toml create mode 100644 ch00-getting-started/README.md create mode 100644 ch00-getting-started/exercises/Cargo.toml create mode 100644 ch00-getting-started/exercises/src/lib.rs create mode 100644 ch00-getting-started/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index cd80752..25c5322 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,8 @@ [workspace] resolver = "2" members = [ + "ch00-getting-started", + "ch00-getting-started/exercises", "ch01-ownership", "ch01-ownership/exercises", "ch02-error-handling", diff --git a/README.md b/README.md index 7d0291e..d0836a3 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ demanding the user carry it for them. | # | Title | Python Concept | Rust Concept | |---|-------|---------------|--------------| +| 0 | **Getting Started** | `pip`, `venv`, `python hello.py` | `cargo`, `rustup`, `cargo run` | | 1 | **Ownership** | Reference counting, `del`, `with` blocks | Ownership, borrowing, lifetimes, RAII | | 2 | **Error Handling** | `try/except`, `None`, `LBYL vs EAFP` | `Result`, `Option`, `?` operator | | 3 | **Traits & Generics** | ABCs, Protocols, duck typing | Traits, generics, trait bounds | diff --git a/ch00-getting-started/Cargo.toml b/ch00-getting-started/Cargo.toml new file mode 100644 index 0000000..37160d2 --- /dev/null +++ b/ch00-getting-started/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "ch00-getting-started" +version = "0.1.0" +edition.workspace = true +license.workspace = true +description = "Chapter 0: Getting Started — toolchain setup and hello world" diff --git a/ch00-getting-started/README.md b/ch00-getting-started/README.md new file mode 100644 index 0000000..29a1229 --- /dev/null +++ b/ch00-getting-started/README.md @@ -0,0 +1,312 @@ +# Chapter 0: Getting Started + +## Apologies in advance + +Yes, we're doing hello world. If you've written Python for years, this +will feel patronizing. Bear with it — the point isn't the program, it's +the *toolchain*. Python's toolchain (interpreter, pip, venv) and Rust's +toolchain (compiler, cargo, crates) solve similar problems in very +different ways, and understanding the Rust toolchain early will save you +hours of confusion later. + +Think of this chapter as the equivalent of teaching someone who's always +driven automatic how the clutch works before taking them on a mountain +road. You already know how to drive. You just need to learn where the +new pedals are. + +## Installing Rust + +### The short version + +```bash +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +``` + +Then restart your shell (or `source ~/.cargo/env`). + +### What you just installed + +| Tool | Python equivalent | What it does | +|------|------------------|-------------| +| `rustup` | `pyenv` | Manages Rust toolchain versions | +| `rustc` | `python` | The compiler (you rarely call it directly) | +| `cargo` | `pip` + `venv` + `setuptools` + `pytest` | Build, deps, test, run — everything | + +That last one is the important one. In Python, you need separate tools +for package management (pip), virtual environments (venv), building +(setuptools/poetry/hatch), testing (pytest), and formatting (black). +In Rust, `cargo` does all of these. One tool. + +### Verify it works + +```bash +rustc --version # should print rustc 1.XX.X +cargo --version # should print cargo 1.XX.X +``` + +## Python vs Rust: Side by side + +### Hello world + +```python +# hello.py +print("Hello, world!") +``` + +```bash +$ python hello.py +Hello, world! +``` + +```rust +// src/main.rs +fn main() { + println!("Hello, world!"); +} +``` + +```bash +$ cargo run +Hello, world! +``` + +**What's different:** +- Rust needs a `main()` function — no top-level code +- `println!` has an exclamation mark because it's a *macro* (don't worry + about why yet — it's so the compiler can check your format string) +- `cargo run` compiles *and* runs — no separate compile step needed + +### Variables + +```python +name = "World" +count = 42 +pi = 3.14 +active = True +``` + +```rust +let name = "World"; // &str (string slice — like a view) +let count = 42; // i32 (32-bit integer — Rust picks a default) +let pi = 3.14; // f64 (64-bit float) +let active = true; // bool +``` + +**What's different:** +- `let` instead of bare assignment +- Semicolons at the end of statements +- Types are inferred (like Python) but *fixed* (unlike Python) +- Variables are **immutable by default** — use `let mut` to make mutable + +```python +count = 0 +count += 1 # fine in Python +``` + +```rust +let count = 0; +// count += 1; // compile error! count is not mutable + +let mut count = 0; +count += 1; // this works +``` + +**Key insight:** Python variables are always mutable. Rust variables are +immutable by default. This seems restrictive, but it catches an entire +class of bugs — accidental mutation — at compile time. + +### Functions + +```python +def greet(name: str) -> str: + return f"Hello, {name}!" +``` + +```rust +fn greet(name: &str) -> String { + format!("Hello, {name}!") +} +``` + +**What's different:** +- `fn` instead of `def` +- Types are mandatory in function signatures (not optional hints) +- `&str` is a *borrowed* string (read-only view), `String` is an *owned* + string (you'll learn the difference in Chapter 1) +- `format!` instead of f-strings — same idea, different syntax +- No `return` keyword needed — the last expression is the return value + (though `return` works if you want it) + +### Collections + +```python +# List +numbers = [1, 2, 3] +numbers.append(4) + +# Dict +scores = {"alice": 95, "bob": 87} +scores["carol"] = 91 +``` + +```rust +// Vec (growable array — like Python list) +let mut numbers = vec![1, 2, 3]; +numbers.push(4); + +// HashMap (like Python dict) +use std::collections::HashMap; +let mut scores = HashMap::new(); +scores.insert("alice", 95); +scores.insert("bob", 87); +scores.insert("carol", 91); +``` + +**What's different:** +- `vec![]` macro creates a Vec (Rust's growable array) +- `push` instead of `append` +- HashMap needs `use` to import it (like Python's `from collections import`) +- Both need `mut` because we're modifying them + +### Control flow + +```python +# If/else +if score >= 90: + grade = "A" +elif score >= 80: + grade = "B" +else: + grade = "C" + +# For loop +for item in items: + print(item) + +# While +while count > 0: + count -= 1 +``` + +```rust +// If/else — nearly identical, but no colons and requires braces +let grade = if score >= 90 { + "A" +} else if score >= 80 { + "B" +} else { + "C" +}; // note: if/else is an expression — it returns a value! + +// For loop +for item in &items { + println!("{item}"); +} + +// While +while count > 0 { + count -= 1; +} +``` + +**Key insight:** In Rust, `if/else` is an *expression* that returns a +value. You can assign its result to a variable. Python recently added +something similar with the walrus operator, but Rust's version is more +general. + +### String formatting + +```python +name = "World" +print(f"Hello, {name}!") # f-string +print("Hello, {}!".format(name)) # .format() +print("Value: %d" % 42) # % formatting +``` + +```rust +let name = "World"; +println!("Hello, {name}!"); // inline variable (Rust 1.58+) +println!("Hello, {}!", name); // positional +println!("Hello, {n}!", n = name); // named +println!("Pi is approximately {:.2}", 3.14159); // format spec +``` + +Same idea, slightly different syntax. The `!` on `println!` means it's +a macro — the compiler checks your format string at compile time. If you +write `println!("{}")` without an argument, that's a *compile error*, not +a runtime crash. + +## Project structure + +### Python + +``` +my_project/ + my_project/ + __init__.py + main.py + tests/ + test_main.py + requirements.txt + setup.py / pyproject.toml +``` + +### Rust + +``` +my_project/ + src/ + main.rs (for binaries) + lib.rs (for libraries) + tests/ (integration tests) + Cargo.toml (like pyproject.toml — deps, metadata, everything) +``` + +`Cargo.toml` is the single source of truth. No `requirements.txt` vs +`setup.py` vs `pyproject.toml` confusion. One file. + +## The compile-run cycle + +Python: **write → run → see error → fix** + +```bash +python my_script.py # runs immediately, crashes at runtime on errors +``` + +Rust: **write → compile → see error → fix → run** + +```bash +cargo run # compiles first, then runs — catches errors before running +cargo check # just checks for errors without building (faster) +cargo build # builds without running +cargo test # builds and runs tests +``` + +This feels slower at first. But the errors you catch at compile time are +errors you'll *never* see at runtime. No more "this worked in dev but +crashes in production because a variable was None." + +## Cargo is your new best friend + +| Command | What it does | Python equivalent | +|---------|-------------|-------------------| +| `cargo new my_project` | Create new project | `mkdir` + `pyproject.toml` | +| `cargo run` | Build and run | `python main.py` | +| `cargo test` | Run tests | `pytest` | +| `cargo check` | Type check (fast) | `mypy` | +| `cargo build` | Build binary | N/A (Python is interpreted) | +| `cargo build --release` | Optimized build | N/A | +| `cargo add serde` | Add dependency | `pip install serde` | +| `cargo clippy` | Lint | `ruff check` | +| `cargo fmt` | Format | `black` | +| `cargo doc --open` | Generate docs | `sphinx` / `pdoc` | + +## Next Steps + +If `cargo test -p ch00-getting-started` passes, your toolchain works. +Open `src/lib.rs` for annotated examples, then try the exercises in +`exercises/`. + +When you're ready: Chapter 1 is where the real fun starts — ownership +is the concept that makes Rust *Rust*, and it's the one thing Python +developers find most surprising. diff --git a/ch00-getting-started/exercises/Cargo.toml b/ch00-getting-started/exercises/Cargo.toml new file mode 100644 index 0000000..b4444af --- /dev/null +++ b/ch00-getting-started/exercises/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "ch00-exercises" +version = "0.1.0" +edition.workspace = true +license.workspace = true +description = "Exercises for Chapter 0: Getting Started" diff --git a/ch00-getting-started/exercises/src/lib.rs b/ch00-getting-started/exercises/src/lib.rs new file mode 100644 index 0000000..bae5a9f --- /dev/null +++ b/ch00-getting-started/exercises/src/lib.rs @@ -0,0 +1,184 @@ +//! # Chapter 0 Exercises: Getting Started +//! +//! These exercises are deliberately simple — they're here to make sure +//! your toolchain works and you're comfortable with the basic syntax. +//! If you can do these, you're ready for Chapter 1. +//! +//! Run tests: `cargo test -p ch00-exercises` + +#![allow(unused_variables, dead_code)] + +// ============================================================ +// Exercise 1: A Simple Function +// ============================================================ +// +// Python version: +// ```python +// def multiply(a, b): +// return a * b +// +// assert multiply(3, 4) == 12 +// assert multiply(-2, 5) == -10 +// ``` +// +// Write the Rust equivalent. + +pub fn multiply(a: i32, b: i32) -> i32 { + todo!("Return a * b") +} + +// ============================================================ +// Exercise 2: String Formatting +// ============================================================ +// +// Python version: +// ```python +// def describe(name, age): +// return f"{name} is {age} years old" +// +// assert describe("Alice", 30) == "Alice is 30 years old" +// ``` +// +// Use format!() — Rust's equivalent of f-strings. + +pub fn describe(name: &str, age: u32) -> String { + todo!("Return \"{name} is {age} years old\" using format!()") +} + +// ============================================================ +// Exercise 3: Conditional Logic +// ============================================================ +// +// Python version: +// ```python +// def fizzbuzz(n): +// if n % 15 == 0: +// return "FizzBuzz" +// elif n % 3 == 0: +// return "Fizz" +// elif n % 5 == 0: +// return "Buzz" +// else: +// return str(n) +// ``` +// +// Write the Rust version. Remember: if/else is an expression. + +pub fn fizzbuzz(n: u32) -> String { + todo!("Return FizzBuzz, Fizz, Buzz, or the number as a string") +} + +// ============================================================ +// Exercise 4: Working with Vec +// ============================================================ +// +// Python version: +// ```python +// def double_all(numbers): +// return [n * 2 for n in numbers] +// +// assert double_all([1, 2, 3]) == [2, 4, 6] +// ``` +// +// Use .iter(), .map(), and .collect() — Rust's version of list +// comprehensions. + +pub fn double_all(numbers: &[i32]) -> Vec { + todo!("Return a new Vec with each number doubled") +} + +// ============================================================ +// Exercise 5: Putting It Together +// ============================================================ +// +// Python version: +// ```python +// def summarize_scores(scores): +// """Given a list of scores, return a summary string. +// +// >>> summarize_scores([85, 92, 78, 95, 88]) +// '5 scores, min 78, max 95, avg 87.6' +// """ +// n = len(scores) +// if n == 0: +// return "no scores" +// lo = min(scores) +// hi = max(scores) +// avg = sum(scores) / n +// return f"{n} scores, min {lo}, max {hi}, avg {avg:.1}" +// ``` +// +// Write the Rust version. You'll need: +// - .len() for count +// - .iter().min() and .iter().max() (these return Option!) +// - .iter().sum::() for sum +// - format!("{:.1}", value) for one decimal place + +pub fn summarize_scores(scores: &[i32]) -> String { + todo!("Return summary string, or \"no scores\" if empty") +} + +// ============================================================ +// Tests — do not modify below this line +// ============================================================ + +#[cfg(test)] +mod tests { + use super::*; + + // Exercise 1 + #[test] + fn ex1_multiply() { + assert_eq!(multiply(3, 4), 12); + assert_eq!(multiply(-2, 5), -10); + assert_eq!(multiply(0, 100), 0); + } + + // Exercise 2 + #[test] + fn ex2_describe() { + assert_eq!(describe("Alice", 30), "Alice is 30 years old"); + assert_eq!(describe("Bob", 0), "Bob is 0 years old"); + } + + // Exercise 3 + #[test] + fn ex3_fizzbuzz() { + assert_eq!(fizzbuzz(1), "1"); + assert_eq!(fizzbuzz(3), "Fizz"); + assert_eq!(fizzbuzz(5), "Buzz"); + assert_eq!(fizzbuzz(15), "FizzBuzz"); + assert_eq!(fizzbuzz(30), "FizzBuzz"); + assert_eq!(fizzbuzz(7), "7"); + } + + // Exercise 4 + #[test] + fn ex4_double_all() { + assert_eq!(double_all(&[1, 2, 3]), vec![2, 4, 6]); + assert_eq!(double_all(&[]), Vec::::new()); + assert_eq!(double_all(&[-1, 0, 1]), vec![-2, 0, 2]); + } + + // Exercise 5 + #[test] + fn ex5_summarize() { + assert_eq!( + summarize_scores(&[85, 92, 78, 95, 88]), + "5 scores, min 78, max 95, avg 87.6" + ); + } + + #[test] + fn ex5_empty() { + assert_eq!(summarize_scores(&[]), "no scores"); + } + + #[test] + fn ex5_single() { + assert_eq!( + summarize_scores(&[100]), + "1 scores, min 100, max 100, avg 100.0" + ); + } +} diff --git a/ch00-getting-started/src/lib.rs b/ch00-getting-started/src/lib.rs new file mode 100644 index 0000000..92ac7ec --- /dev/null +++ b/ch00-getting-started/src/lib.rs @@ -0,0 +1,230 @@ +//! # Chapter 0: Getting Started +//! +//! Welcome! This module covers the absolute basics: variables, functions, +//! control flow, and collections. If you've written Python, you already +//! understand the *concepts* — this is just the new syntax. +//! +//! Run the tests: `cargo test -p ch00-getting-started` + +// --------------------------------------------------------------------------- +// 1. Variables and types +// --------------------------------------------------------------------------- + +/// In Python you'd write: `def add(a: int, b: int) -> int: return a + b` +/// +/// In Rust, types in function signatures are mandatory (not just hints). +/// The compiler uses them to catch bugs before your code runs. +pub fn add(a: i32, b: i32) -> i32 { + a + b // no `return` needed — the last expression is the return value +} + +/// Demonstrate that variables are immutable by default. +/// +/// Python equivalent: +/// ```python +/// def counting_demo(): +/// count = 0 +/// count += 1 +/// count += 1 +/// return count # 2 +/// ``` +pub fn counting_demo() -> i32 { + let mut count = 0; // `mut` makes it mutable — without this, count += 1 won't compile + count += 1; + count += 1; + count +} + +// --------------------------------------------------------------------------- +// 2. String formatting +// --------------------------------------------------------------------------- + +/// Python: `f"Hello, {name}!"` +/// Rust: `format!("Hello, {name}!")` +/// +/// The `!` means `format!` is a macro. The compiler checks the format +/// string at compile time — mismatched args are a compile error, not +/// a runtime crash. +pub fn greet(name: &str) -> String { + format!("Hello, {name}!") +} + +/// Python: `f"Pi is approximately {pi:.2f}"` +/// Rust: `format!("Pi is approximately {pi:.2}")` +pub fn format_pi() -> String { + let pi = std::f64::consts::PI; + format!("Pi is approximately {pi:.2}") +} + +// --------------------------------------------------------------------------- +// 3. Control flow — if/else as an expression +// --------------------------------------------------------------------------- + +/// In Python, if/else is a statement. In Rust, it's an expression +/// that returns a value. +/// +/// Python equivalent: +/// ```python +/// def letter_grade(score): +/// if score >= 90: +/// return "A" +/// elif score >= 80: +/// return "B" +/// elif score >= 70: +/// return "C" +/// else: +/// return "F" +/// ``` +pub fn letter_grade(score: u32) -> &'static str { + if score >= 90 { + "A" + } else if score >= 80 { + "B" + } else if score >= 70 { + "C" + } else { + "F" + } +} + +// --------------------------------------------------------------------------- +// 4. Collections — Vec and HashMap +// --------------------------------------------------------------------------- + +/// Python: `[x**2 for x in range(1, n+1)]` +/// +/// Rust has iterators and `collect()` — the equivalent of list +/// comprehensions, but more composable. +pub fn squares(n: u32) -> Vec { + (1..=n).map(|x| x * x).collect() +} + +/// Python: `sum(items)` +/// +/// Rust iterators have `.sum()` built in. +pub fn sum_items(items: &[i32]) -> i32 { + items.iter().sum() +} + +/// Python: `[x for x in items if x > threshold]` +/// +/// Rust: `.filter()` + `.collect()` +pub fn filter_above(items: &[i32], threshold: i32) -> Vec { + items.iter().copied().filter(|&x| x > threshold).collect() +} + +/// Count word frequencies — like Python's `collections.Counter`. +/// +/// Python equivalent: +/// ```python +/// from collections import Counter +/// def word_frequencies(text): +/// return dict(Counter(text.lower().split())) +/// ``` +pub fn word_frequencies(text: &str) -> std::collections::HashMap { + let mut counts = std::collections::HashMap::new(); + for word in text.split_whitespace() { + let lower = word.to_lowercase(); + *counts.entry(lower).or_insert(0) += 1; + } + counts +} + +// --------------------------------------------------------------------------- +// 5. Iterators — Rust's superpower +// --------------------------------------------------------------------------- + +/// Python: `", ".join(items)` +/// Rust: `items.join(", ")` +/// +/// Straightforward — but Rust's version works on slices of &str, not +/// arbitrary iterables. For more complex joins, use iterators. +pub fn join_words(words: &[&str]) -> String { + words.join(", ") +} + +/// Python: `list(zip(keys, values))` +/// +/// Rust has `.zip()` on iterators. +pub fn zip_to_pairs(keys: &[&str], values: &[i32]) -> Vec<(String, i32)> { + keys.iter() + .zip(values.iter()) + .map(|(k, v)| (k.to_string(), *v)) + .collect() +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_add() { + assert_eq!(add(2, 3), 5); + assert_eq!(add(-1, 1), 0); + } + + #[test] + fn test_counting() { + assert_eq!(counting_demo(), 2); + } + + #[test] + fn test_greet() { + assert_eq!(greet("World"), "Hello, World!"); + assert_eq!(greet("Rustacean"), "Hello, Rustacean!"); + } + + #[test] + fn test_format_pi() { + assert_eq!(format_pi(), "Pi is approximately 3.14"); + } + + #[test] + fn test_letter_grade() { + assert_eq!(letter_grade(95), "A"); + assert_eq!(letter_grade(85), "B"); + assert_eq!(letter_grade(75), "C"); + assert_eq!(letter_grade(50), "F"); + assert_eq!(letter_grade(90), "A"); // boundary + } + + #[test] + fn test_squares() { + assert_eq!(squares(5), vec![1, 4, 9, 16, 25]); + assert_eq!(squares(0), vec![]); + } + + #[test] + fn test_sum() { + assert_eq!(sum_items(&[1, 2, 3, 4]), 10); + assert_eq!(sum_items(&[]), 0); + } + + #[test] + fn test_filter() { + assert_eq!(filter_above(&[1, 5, 3, 8, 2], 3), vec![5, 8]); + } + + #[test] + fn test_word_frequencies() { + let counts = word_frequencies("the cat sat on the mat"); + assert_eq!(counts["the"], 2); + assert_eq!(counts["cat"], 1); + assert_eq!(counts["mat"], 1); + } + + #[test] + fn test_join_words() { + assert_eq!(join_words(&["one", "two", "three"]), "one, two, three"); + } + + #[test] + fn test_zip() { + let pairs = zip_to_pairs(&["a", "b"], &[1, 2]); + assert_eq!(pairs, vec![("a".to_string(), 1), ("b".to_string(), 2)]); + } +} From 254057f6d212a3775e5a9df4718b8bf80ef7987e Mon Sep 17 00:00:00 2001 From: Shawn Hartsock Date: Mon, 13 Apr 2026 07:38:28 -0400 Subject: [PATCH 3/3] Redesign CI pipeline for course repo structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old pipeline ran `cargo test --workspace` which fails because exercise crates contain todo!() stubs that panic at runtime. That's by design — students fill them in. New pipeline has 5 jobs, all meaningful: 1. secrets-scan — gitleaks on full history 2. forbidden-patterns — grep for private infra leaks 3. lint — cargo fmt + clippy on entire workspace (exercises have #![allow] for expected warnings from stubs) 4. test-examples — run tests on chapter crates only (not exercises), using cargo metadata to discover crates dynamically 5. build-exercises — compile exercise test binaries without running them, catching type errors and broken scaffolding Also adds rust-cache for faster CI runs and updates branch protection to require all 5 jobs. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/safety-gate.yml | 90 ++++++++++++++++++++++++++++--- 1 file changed, 84 insertions(+), 6 deletions(-) diff --git a/.github/workflows/safety-gate.yml b/.github/workflows/safety-gate.yml index ab58e2c..a64f95c 100644 --- a/.github/workflows/safety-gate.yml +++ b/.github/workflows/safety-gate.yml @@ -1,4 +1,5 @@ -# HOOK PARITY: This workflow mirrors .pre-commit-config.yaml. +# HOOK PARITY: This workflow mirrors .pre-commit-config.yaml (safety checks) +# and the local dev workflow (Rust checks). # When editing this file, update the pre-commit config to match. name: Safety Gate @@ -13,6 +14,9 @@ permissions: contents: read jobs: + # --------------------------------------------------------------- + # Safety: secrets and forbidden patterns + # --------------------------------------------------------------- secrets-scan: name: Scan for secrets runs-on: ubuntu-latest @@ -58,8 +62,11 @@ jobs: fi echo "No forbidden patterns found." - rust-checks: - name: Rust lint and test + # --------------------------------------------------------------- + # Rust: lint the entire workspace (examples + exercises) + # --------------------------------------------------------------- + lint: + name: Lint (fmt + clippy) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -68,11 +75,82 @@ jobs: with: components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - name: Format check run: cargo fmt --all -- --check - - name: Clippy + - name: Clippy (full workspace) run: cargo clippy --workspace --all-targets -- -D warnings - - name: Tests - run: cargo test --workspace + # --------------------------------------------------------------- + # Rust: test examples (chapter crates, NOT exercise crates) + # + # Exercise crates contain todo!() stubs that panic at runtime. + # That's by design — students fill them in. We verify exercises + # COMPILE (via clippy above) but only RUN tests on the example + # crates where all tests should pass. + # --------------------------------------------------------------- + test-examples: + name: Test examples + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - name: Discover and test example crates + run: | + # Find all chapter crates that are NOT exercise crates. + # Convention: exercise crates live in ch*/exercises/ + EXAMPLE_CRATES=$( + cargo metadata --no-deps --format-version 1 \ + | python3 -c " + import json, sys + meta = json.load(sys.stdin) + for pkg in meta['packages']: + if 'exercises' not in pkg['manifest_path']: + print(f'-p {pkg[\"name\"]}', end=' ') + " + ) + + echo "Testing: $EXAMPLE_CRATES" + # shellcheck disable=SC2086 + cargo test $EXAMPLE_CRATES + + # --------------------------------------------------------------- + # Rust: verify exercise crates BUILD (compile + link) + # + # This catches broken test code, missing imports, or type errors + # in the exercise scaffolding — without running the todo!() stubs. + # --------------------------------------------------------------- + build-exercises: + name: Build exercises (no run) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - name: Build exercise test binaries + run: | + # Build test binaries for exercise crates without running them. + # This verifies the test code compiles against the todo!() stubs. + EXERCISE_CRATES=$( + cargo metadata --no-deps --format-version 1 \ + | python3 -c " + import json, sys + meta = json.load(sys.stdin) + for pkg in meta['packages']: + if 'exercises' in pkg['manifest_path']: + print(f'-p {pkg[\"name\"]}', end=' ') + " + ) + + echo "Building (no run): $EXERCISE_CRATES" + # shellcheck disable=SC2086 + cargo test --no-run $EXERCISE_CRATES