From 724c3cde5761f0bd0eb3d12bb3a60619b52e8a5b Mon Sep 17 00:00:00 2001 From: Shawn Hartsock Date: Sun, 12 Apr 2026 21:45:25 -0400 Subject: [PATCH 1/2] 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 364de6d5e14fd901cdd5dc0e03b9ae75ad776db8 Mon Sep 17 00:00:00 2001 From: hartsock Date: Mon, 13 Apr 2026 19:55:30 -0400 Subject: [PATCH 2/2] CI: auto-discover exercise crates for test exclusion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously hardcoded --exclude ch01-exercises. As more chapters land, each adds its own $chapter-exercises crate with todo!() stubs. Instead of appending --exclude flags per chapter, discover them from cargo metadata by the -exercises suffix. Exercise crates are still compiled by the clippy step, so broken stubs (syntax errors, wrong signatures) still fail CI — only the todo!() panics are tolerated. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/safety-gate.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/safety-gate.yml b/.github/workflows/safety-gate.yml index 13cd906..6854312 100644 --- a/.github/workflows/safety-gate.yml +++ b/.github/workflows/safety-gate.yml @@ -80,4 +80,15 @@ jobs: run: cargo clippy --workspace --all-targets -- -D warnings - name: Tests (excluding unsolved exercises) - run: cargo test --workspace --exclude ch01-exercises + run: | + # Auto-discover exercise crates (they contain todo!() stubs) + # and exclude them from the test run. Exercises are compiled by + # the clippy step above, so broken stubs still fail CI. + EXCLUDES=() + for crate in $(cargo metadata --no-deps --format-version 1 \ + | python3 -c "import json,sys; [print(p['name']) for p in json.load(sys.stdin)['packages']]" \ + | grep -- '-exercises$'); do + EXCLUDES+=(--exclude "$crate") + done + echo "Excluding: ${EXCLUDES[*]}" + cargo test --workspace "${EXCLUDES[@]}"