# Set up Rust Install Rust, find your way around a Cargo project, and run your first example. Canonical: https://rust.robertdevore.com/course/01-start/ Author: Robert DeVore Technical baseline: Rust 1.98.1, edition 2024; verified 2026-09-06. ## What you will build You will build **Fieldnotes**, a command-line tool that reads event records and reports counts. A record looks like `WARN disk almost full`. The final tool accepts a file or standard input, reports malformed records with line numbers, rejects oversized records, and works without loading the entire file. Each stage makes a specific improvement to that program. You need a terminal and a text editor, but no Rust experience. If you are new to programming, take your time with the next lesson: you will use functions, conditions, and loops throughout the course. Allow roughly 25–40 hours for reading and practice. Read in sequence on your first pass. Run the example, predict the effect of a change, then try the exercise before opening its solution. Compiler drills are meant to fail. All other examples and checks should pass. Keep your exercise work on a local branch so you can compare it with the reference implementation. ## Install and locate the tools Install Rust through [the official installer](https://rust-lang.org/tools/install/). On Windows, follow its instructions for the MSVC build tools; on macOS, a missing linker generally means the Xcode command-line tools are absent. Linux distributions may require their compiler/linker package. Use the installer instructions for your platform rather than copying a shell command you have not read. Clone the [course repository](https://github.com/robertdevore/rust.robertdevore.com), enter it, and run: ```sh git clone https://github.com/robertdevore/rust.robertdevore.com.git cd rust.robertdevore.com rustc --version cargo --version cargo run --locked --example 01_setup ``` The repository selects Rust **1.98.1**, edition **2024**, through `rust-toolchain.toml`. Rustup installs the pinned compiler if necessary. The edition selects the language compatibility rules for a package. The compiler version selects the program that compiles it. Choosing an edition does not freeze the standard library. This course does not change your global default toolchain. ## Read the project before editing `Cargo.toml` describes a package and its dependencies. `Cargo.lock` records the dependency resolution we verified. `src/lib.rs` is the library target; `examples/` holds executable learning targets; `crates/` holds the final application and the isolated unsafe lab. `target/` is generated output and should not be committed. `cargo check` type-checks quickly without producing a finished executable. `cargo build` creates one. `cargo run` builds and runs a selected target. `--locked` refuses to change dependency resolution, which makes an unexpected dependency update visible. ### Run the example ```sh cargo run --locked --example 01_setup ``` ```rust fn main() { println!("Rust workshop ready."); } ``` [View the tested source](https://github.com/robertdevore/rust.robertdevore.com/blob/main/examples/01_setup.rs) `fn main()` is the entry point. Braces enclose its body. `println!` is a macro; the exclamation mark distinguishes macro invocation from an ordinary function call. A semicolon ends this statement. You do not need to understand macro implementation to use this one. ## Exercise Change the greeting, run it, then run `cargo check --workspace --all-targets --locked`. Explain why changing text should not change the lockfile. Create a separate scratch project using `cargo new first-record`; identify the files that Cargo created.
Solution and acceptance check Any greeting is valid. The executable must print the new text once and exit successfully. The lockfile stays unchanged because source text is not dependency metadata. A new binary package has a manifest and `src/main.rs`; generated Git files depend on the surrounding repository and Cargo configuration. Never put the scratch project inside another workspace without understanding membership.
## Check your understanding If the editor says a name is invalid but `cargo check` succeeds, check that rust-analyzer uses this repository's toolchain and workspace. Start with the compiler output: anyone using the same toolchain can check it. See [Cargo's first steps](https://doc.rust-lang.org/cargo/getting-started/first-steps.html) and the [Edition Guide](https://doc.rust-lang.org/edition-guide/editions/index.html). --- # Values, functions, and control flow Write functions, choose between cases, and handle numbers that reach their limits. Canonical: https://rust.robertdevore.com/course/02-values/ Author: Robert DeVore Technical baseline: Rust 1.98.1, edition 2024; verified 2026-09-06. ## Write your first function Our first question is whether a request took less than 100 milliseconds. A function gives that rule a name. `milliseconds: u32` names a parameter and its unsigned 32-bit integer type. `-> &'static str` says the result is a reference to text stored for the duration of the program; here both answers are string literals. We will unpack references later. ### Run the example ```sh cargo run --locked --example 02_values ``` ```rust fn classify(milliseconds: u32) -> &'static str { if milliseconds < 100 { "fast" } else { "slow" } } fn main() { let samples = [12, 100, 340]; for value in samples { println!("{value}: {}", classify(value)); } assert_eq!(classify(100), "slow"); assert_eq!(u8::MAX.checked_add(1), None); } ``` [View the tested source](https://github.com/robertdevore/rust.robertdevore.com/blob/main/examples/02_values.rs) `if` produces a value, so both branches must agree on its type. The final expression in the function has no semicolon: its value is returned. Add a semicolon and the block instead evaluates to `()`, the unit value, unless an explicit return supplies the function result. This is a common first compiler error. `let samples = [12, 100, 340]` creates an array. A `for` loop visits its values. Formatting captures `value` by name; `{}` receives the following expression. `assert_eq!` checks a fact and panics if it is false. These assertions check that the example does what we expect. ## Bindings and changes A binding is immutable unless declared `mut`. Use `mut` when you intend to change the binding. Shadowing with another `let` creates a new binding; it can have a different type. Mutation updates an existing place and preserves its type. Neither operation is an ownership workaround: moving a non-`Copy` value still matters, as the next lesson shows. Booleans are `true` and `false`; conditions do not treat arbitrary integers as booleans. Use `&&`, `||`, and `!` to combine predicates. A range `0..3` excludes 3; `0..=3` includes it. A `match` chooses among patterns and must cover every possible input, either explicitly or with a fallback. Prefer it when several distinct cases deserve separate handling. ## Choose a numeric policy An integer type has a finite range. Do not write a production counter assuming overflow can never happen. Ordinary arithmetic can panic when overflow checks are enabled, while unchecked release arithmetic generally wraps. Select `checked_add` for an explicit failure, `saturating_add` for a deliberate upper bound, or `wrapping_add` for modular arithmetic. Choose the behavior your program needs. A passing debug build does not settle that choice. `usize` is useful for indexing and sizes in memory; its width depends on the target. It is not automatically the right type for a portable file format. Floating-point values cannot represent many real numbers exactly. If you use them, decide how to handle equality, rounding during sums, infinity, and NaN. Our capstone uses integer counts. ## Exercise Change the classifier to return `fast` below 100, `acceptable` from 100 through 499, and `slow` at 500 or above. Add assertions for 99, 100, 499, and 500. Then deliberately put a semicolon after the final `if` expression and read the error before removing it.
Solution and acceptance check Use `if milliseconds < 100 { "fast" } else if milliseconds < 500 { "acceptable" } else { "slow" }`. The four tests exercise both sides of each boundary. The return annotation demands text; the extra semicolon produces unit. Fix the value-producing expression, not the return type, because the function's job is to classify.
See [primitive numeric types](https://doc.rust-lang.org/std/primitive.u32.html), [control flow](https://doc.rust-lang.org/book/ch03-05-control-flow.html), and [statements and expressions](https://doc.rust-lang.org/reference/statements-and-expressions.html). --- # Ownership, places, and moves Learn what moves, what gets copied, and when a borrow is enough. Canonical: https://rust.robertdevore.com/course/03-ownership/ Author: Robert DeVore Technical baseline: Rust 1.98.1, edition 2024; verified 2026-09-06. ## A value and the place holding it A **place** is a location a program can refer to: a local variable, a field, an indexed element, or a dereferenced pointer. A **value** is what the location currently holds. Moving a value transfers it out of a place. The heap allocation it owns may stay at the same address. `String` owns and tracks a UTF-8 buffer. If two independent strings both tried to free that buffer, the program could free the same memory twice. Moving a `String` makes the source unavailable until reinitialized. The new owner normally drops the value when its drop scope ends. ### Run the example ```sh cargo run --locked --example 03_ownership ``` ```rust fn label(text: &str) -> usize { text.len() } fn main() { let original = String::from("WARN retry"); let bytes = label(&original); let stored = original; assert_eq!(bytes, 10); assert_eq!(stored, "WARN retry"); } ``` [View the tested source](https://github.com/robertdevore/rust.robertdevore.com/blob/main/examples/03_ownership.rs) `label(&original)` lends access, so it does not take ownership of the string. `let stored = original` does transfer the value. No second character buffer is needed. The compiler may optimize away the move itself. A move in Rust source does not promise a particular machine instruction. ## Copy is a type property Some types implement `Copy`: using their values in a move-like context implicitly copies them and leaves the source usable. Integers are familiar examples. Heap versus stack is not the rule: references can be copied, and a stack-resident struct can own a non-`Copy` resource. A type implementing `Drop` cannot also implement `Copy`. `Clone` requests duplication explicitly, but its meaning depends on the type. Cloning a `String` duplicates its text; cloning an `Rc` adds an owner to shared data. Before adding `.clone()` to fix an error, ask what the caller needs: ownership, a temporary borrow, or a separate copy it can change. ## Follow destruction, not just allocation Values are normally dropped when their drop scope ends; moving transfers that responsibility. Fields can sometimes be moved separately, leaving a partially moved struct whose remaining fields are still usable. A type with a destructor restricts such moves because its destructor expects an intact value. Leaking memory with safe operations is possible: Rust does not promise that every destructor always runs. Never base an unsafe abstraction's soundness solely on a caller eventually calling `drop`. The early model “one owner” describes ordinary owning values, not a ban on shared ownership. `Rc` and `Arc` coordinate multiple owning handles through a reference count. They do not automatically make the contained data mutable or thread-safe. ## Compiler drill **Intentionally fails on Rust 1.98.1.** Run `rustc --edition=2024 drills/moved.rs` from the repository. ```rust fn main() { let original = String::from("record"); let stored = original; println!("{original} {stored}"); } ```
Actual compiler diagnostic · Rust 1.98.1 ```text error[E0382]: borrow of moved value: `original` --> drills/moved.rs:4:16 | 2 | let original = String::from("record"); | -------- move occurs because `original` has type `String`, which does not implement the `Copy` trait 3 | let stored = original; | -------- value moved here 4 | println!("{original} {stored}"); | ^^^^^^^^ value borrowed here after move | help: consider cloning the value if the performance cost is acceptable | 3 | let stored = original.clone(); | ++++++++ error: aborting due to 1 previous error For more information about this error, try `rustc --explain E0382`. ```
The diagnostic identifies the earlier move and the later use. Repair the design before considering duplication. Can the recipient borrow? Can the original caller stop using the value? Are two independently editable strings actually required? ## Exercise Repair the drill without cloning. Then make a second version in which two independent strings are deliberately required and justify the clone in one sentence. In each version, identify which binding is responsible for dropping which allocation.
Solution and acceptance check For shared read-only access, use `let stored = &original`; printing both is then valid. Alternatively print only the new owner after a move. For two independently editable strings, use `let stored = original.clone()` and mutate one to show the other remains unchanged. The two allocations each have an owner and are each dropped once in ordinary execution.
Sources: [places and moves](https://doc.rust-lang.org/reference/expressions.html#place-expressions-and-value-expressions), [destructors](https://doc.rust-lang.org/reference/destructors.html), and [`Copy`](https://doc.rust-lang.org/std/marker/trait.Copy.html). --- # Borrowing and reborrowing Use shared and exclusive references, and see when a borrow ends. Canonical: https://rust.robertdevore.com/course/04-borrowing/ Author: Robert DeVore Technical baseline: Rust 1.98.1, edition 2024; verified 2026-09-06. ## Access has a duration A reference gives access to another value without taking ownership of that value. `&T` provides shared access; `&mut T` provides exclusive access subject to reborrowing. The distinction controls who may access the value, not just whether a method writes to it. While a shared reference remains usable, other code cannot change ordinary data in ways that violate that reference. An exclusive reference prevents conflicting access through other paths. This does not mean only one pointer can exist. Later lessons cover interior mutability and the rules unsafe code must follow. ### Run the example ```sh cargo run --locked --example 04_borrowing ``` ```rust fn append(text: &mut String) { text.push('!'); } fn main() { let mut text = String::from("ready"); let view = &text; assert_eq!(view, "ready"); append(&mut text); let exclusive = &mut text; append(&mut *exclusive); assert_eq!(exclusive, "ready!!"); } ``` [View the tested source](https://github.com/robertdevore/rust.robertdevore.com/blob/main/examples/04_borrowing.rs) The last use of `view` occurs before `append`. The compiler therefore permits the mutation without waiting for the closing brace of `main`. This is called non-lexical lifetime analysis: the compiler follows where you use a borrow, not just where its variable was declared. The second call passes `&mut *exclusive`. It temporarily **reborrows** through the exclusive reference. During that smaller borrow, the original reference cannot be used for conflicting access. After the call, the original reference becomes usable again. Function calls commonly insert reborrows implicitly; writing one explicitly helps you see the relationship. ## Why a push can invalidate a reference A vector or string may need a larger allocation when it grows. A reference into its old allocation could then point at freed storage. But the access rules do more than prevent reallocation: even a mutation that fits in existing capacity may conflict with a live shared borrow. Reserving capacity is not permission to violate a reference's access contract. The borrow checker reasons about places with varying precision. It can understand disjoint struct fields and many slice splits exposed through safe APIs. For arbitrary indices, it needs to know that they differ. Use a library method that checks this. A borrow-checking error may mean the compiler needs a clearer way to see that the accesses are separate. ## Compiler drill **Intentionally fails on Rust 1.98.1.** Run `rustc --edition=2024 drills/alias.rs` from the repository. ```rust fn main() { let mut text = String::from("record"); let shared = &text; text.push('!'); println!("{shared}"); } ```
Actual compiler diagnostic · Rust 1.98.1 ```text error[E0502]: cannot borrow `text` as mutable because it is also borrowed as immutable --> drills/alias.rs:4:5 | 3 | let shared = &text; | ----- immutable borrow occurs here 4 | text.push('!'); | ^^^^^^^^^^^^^^ mutable borrow occurs here 5 | println!("{shared}"); | ------ immutable borrow later used here error: aborting due to 1 previous error For more information about this error, try `rustc --explain E0502`. ```
Read the spans in order: creation of the shared borrow, attempted conflicting mutation, and later use keeping the shared borrow live. Moving the print earlier may end the borrow requirement before mutation. Cloning changes ownership and allocation; it is often unnecessary here. ## Exercise Fix the drill by changing operation order. Then take a mutable slice of `[1, 2, 3, 4]`, call `split_at_mut(2)`, and change both halves. Explain why the two returned slices can coexist.
Solution and acceptance check Print `shared` before `text.push('!')`. The earlier print reads `record`, and the owned string ends as `record!`. For the slice, bind `(left, right)` from `split_at_mut(2)`, then update `left[0]` and `right[0]`. The function's contract and implementation establish nonoverlapping element ranges; no element has two independent exclusive access paths. The unsafe lab later examines that boundary.
Sources: [borrowing](https://doc.rust-lang.org/book/ch04-02-references-and-borrowing.html), [borrow-checking internals](https://rustc-dev-guide.rust-lang.org/borrow_check.html), and [`split_at_mut`](https://doc.rust-lang.org/std/primitive.slice.html#method.split_at_mut). --- # Strings, slices, and collections Work with UTF-8 text and choose when collections should own or borrow data. Canonical: https://rust.robertdevore.com/course/05-text/ Author: Robert DeVore Technical baseline: Rust 1.98.1, edition 2024; verified 2026-09-06. ## Text is not an array of letters A `String` owns text. A `str` is a dynamically sized sequence of UTF-8 bytes, usually accessed through `&str`. Our parser will accept `&str` because it only reads a record. A caller can pass a string literal, a slice of an owned string, or other borrowed text without first allocating another string. ### Run the example ```sh cargo run --locked --example 05_text ``` ```rust fn main() { let text = "café"; assert_eq!(text.len(), 5); assert_eq!(text.chars().count(), 4); assert_eq!(text.get(..3), Some("caf")); assert_eq!(text.get(..4), None); let mut rows = vec![String::from("INFO ok")]; rows.push("WARN retry".into()); let sizes: Vec<_> = rows.iter().map(|row| row.len()).collect(); assert_eq!(sizes, [7, 10]); } ``` [View the tested source](https://github.com/robertdevore/rust.robertdevore.com/blob/main/examples/05_text.rs) The final character in `café` occupies two UTF-8 bytes. `len()` reports bytes, while `chars()` iterates Unicode scalar values. Neither necessarily reports user-perceived grapheme clusters: combining marks and multi-scalar emoji make that distinction visible. A byte range must end at UTF-8 boundaries. `get` returns `None` for an invalid range; direct slicing can panic. There is no general constant-time “character at index” operation on UTF-8 text. ## Choose a collection `Vec` owns a growable contiguous sequence. `[T; N]` has a fixed length as part of its type. `&[T]` borrows a sequence without requiring a particular owning container. An interface taking a slice is often more useful than one taking `&Vec`. A `HashMap` provides key-based lookup without a stable iteration order. A `BTreeMap` maintains keys in sorted order. If output must always have the same order, sort it or use an ordered collection. Our three severity counters do not need any map at all: three named fields make their meaning clear. `iter()` borrows elements, `iter_mut()` allows mutation of elements under an exclusive collection borrow, and `into_iter()` consumes the value on which it is called. The receiver matters: consuming an owned vector yields its elements, while iterating a borrowed collection yields references. Look at the iterator's `Item` type when unsure. ## Limit how much input you store `read_to_string` is convenient when inputs are intentionally small. It is not a bounded-memory solution for arbitrary files. Likewise, “read one line at a time” can still allocate an enormous buffer if an input contains no newline. The capstone checks a byte limit as it reads each record, before the buffer can grow too large. A vector's capacity is reserved storage, not initialized elements. Its length describes initialized values available to safe callers. Never treat spare capacity as initialized data. Changing the length with unsafe code requires proving that the new elements are valid and initialized. Our application does not need this technique. ## Exercise Collect only records beginning with `WARN `, using borrowed slices rather than new `String` values. Test with `WARNING ignored` to prove the separator matters. Then explain what must stay alive while your collected slices are used.
Solution and acceptance check Use an iterator over the input records and filter with `starts_with("WARN ")`. Collect `&str` items. Only `WARN retry` should match among `INFO ok`, `WARN retry`, and `WARNING ignored`. The backing input strings must remain valid and cannot be mutated in ways conflicting with those borrows. If the result must outlive the input, owning strings may be the correct design.
Sources: [`str`](https://doc.rust-lang.org/std/primitive.str.html), [`Vec`](https://doc.rust-lang.org/std/vec/struct.Vec.html), and [collections](https://doc.rust-lang.org/std/collections/index.html). --- # Structs, enums, and program states Represent your data with structs and enums, and reject invalid states. Canonical: https://rust.robertdevore.com/course/06-model/ Author: Robert DeVore Technical baseline: Rust 1.98.1, edition 2024; verified 2026-09-06. ## Use an enum for alternatives A struct groups fields that exist together. An enum chooses one of several variants. If a job is either queued, running with an attempt number, or finished with a byte count, an enum associates each piece of data with the state that needs it. ### Run the example ```sh cargo run --locked --example 06_model ``` ```rust #[derive(Debug, PartialEq, Eq)] enum Job { Queued, Running { attempt: u32 }, Finished { bytes: usize }, } fn describe(job: &Job) -> String { match job { Job::Queued => "queued".into(), Job::Running { attempt } => format!("attempt {attempt}"), Job::Finished { bytes } => format!("{bytes} bytes"), } } fn main() { assert_eq!(describe(&Job::Queued), "queued"); assert_eq!(describe(&Job::Running { attempt: 2 }), "attempt 2"); assert_eq!(describe(&Job::Finished { bytes: 8 }), "8 bytes"); } ``` [View the tested source](https://github.com/robertdevore/rust.robertdevore.com/blob/main/examples/06_model.rs) This avoids independent booleans such as `is_running` and `is_finished` that allow contradictory combinations. `match` asks you to consider every variant. Adding a new variant then reveals the places that need a decision. A catch-all arm can be appropriate, but using one everywhere gives up some of that useful compiler feedback. Pattern matching a borrowed enum lets you inspect it without taking its owned fields. Match ergonomics can insert reference bindings; inspect inferred types before adding `.clone()` to a pattern-related error. Use `if let` when only one alternative matters and the others genuinely require no action. ## Keep invariants close to construction Methods in an `impl` block operate on the type. A constructor named `new` is a convention, not a special language feature. `&self` borrows the receiver; `&mut self` borrows it exclusively; `self` consumes it. Choose based on the operation. Finishing a builder can consume it; reading a count should not. Private fields let a constructor validate inputs and keep later code from bypassing checks. Public fields are a promise that callers can construct and change values directly. For a simple data transfer record that may be fine. When a type must enforce a rule, keep its fields private and provide methods that preserve that rule. Derived traits such as `Debug`, `PartialEq`, and `Eq` are useful for inspection and tests. `Debug` is not a stable wire format. Choose a defined file format for saved data so a change to debug output cannot corrupt it. ## Start with the simpler type Do not invent a generic state-machine framework for three local variants. Typestate uses different types for different states, so the compiler can reject operations that do not fit the current state. It also adds types and makes collections of mixed states harder to manage. Start with an enum unless you need those extra checks. Our event parser uses `Level` and `Event<'a>`. `Level` has three variants; the event pairs a level with borrowed text. The parser validates spelling. The summary exposes named counts because callers need to inspect a plain result, not participate in a complex protocol. ## Exercise Add a `Failed { reason: String }` variant. Update the description without cloning the reason. Then consider whether a public `attempt: u32` can represent an invalid zero attempt for your application.
Solution and acceptance check Match `Job::Failed { reason }` through `&Job` and format a message using the borrowed reason. Add an assertion that includes the reason. If attempts start at one, use a validated constructor or a nonzero integer type; if zero means “not attempted”, document it. Types should express a real rule rather than an assumed one.
Sources: [enums and matching](https://doc.rust-lang.org/book/ch06-00-enums.html), [visibility](https://doc.rust-lang.org/reference/visibility-and-privacy.html), and [API guidelines](https://rust-lang.github.io/api-guidelines/). --- # Errors as part of the interface Use Option, Result, and useful error messages to handle different kinds of failure. Canonical: https://rust.robertdevore.com/course/07-errors/ Author: Robert DeVore Technical baseline: Rust 1.98.1, edition 2024; verified 2026-09-06. ## Four different situations A parser can successfully find no optional value, reject malformed input, fail to read its source, or encounter an internal programming bug. Those are different outcomes. `Option` represents a value or absence. `Result` represents success or an error with information. A panic is appropriate for some violated programmer assumptions, not as the default response to a bad line in a user file. ### Run the example ```sh cargo run --locked --example 07_errors ``` ```rust fn main() -> Result<(), Box> { let event = audit_core::parse("WARN retry")?; assert_eq!(event.message, "retry"); assert!(audit_core::parse("???").is_err()); Ok(()) } ``` [View the tested source](https://github.com/robertdevore/rust.robertdevore.com/blob/main/examples/07_errors.rs) The `?` operator returns early on error, converting it through the relevant `From` implementation when the surrounding result uses a different error type. On success it extracts the value. It does not log, retry, or decide whether the operation should be attempted again. ## Give callers useful distinctions The parser defines `MissingSeparator`, `UnknownLevel`, and `EmptyMessage`. A caller can match those variants without parsing an English sentence. `Display` provides a human-facing description. `std::error::Error` connects nested causes: the final reader error includes a line number and keeps the parser error as its source. A concrete error enum often fits a reusable library. A boxed error can be convenient at an application boundary where the main job is reporting and exiting. `thiserror` can derive repetitive error implementations, while application-oriented libraries can add context. You still need to decide which failures callers should distinguish. Our capstone defines its few error types directly, keeping the core free of dependencies. Avoid returning only “operation failed”. Useful context includes which operation failed and, where appropriate, which record. Do not echo secret input or entire untrusted records into logs. A line number and error category are sufficient for our tool. ## Decide what happens after an error The capstone returns no partial summary on the first invalid record. This prevents users from mistaking partial counts for a complete result. An alternative “skip bad records” mode would need explicit counts of rejected rows, documentation, and tests. Silently ignoring errors changes the meaning of the result. `unwrap` and `expect` are reasonable in tests when failure must fail the test. In application paths, justify why the error is impossible or handle it. An `expect` message should explain the assumption that failed. Do not use it to dismiss an error that can occur during normal use. File creation, network reads, and output writes can fail after earlier work has succeeded. ## Exercise Run the example with an empty message, an unknown level, and a missing separator. Inspect the actual `ParseError` variants. Add a test asserting each variant. Explain why a retry will not fix malformed record syntax.
Solution and acceptance check `WARN ` yields `EmptyMessage`, `DEBUG x` yields `UnknownLevel`, and `WARN` yields `MissingSeparator`. Retrying the same bytes leaves the syntax unchanged. A read interruption may merit a different policy, but that is an I/O decision. Keep parser tests independent from filesystem behavior so failure categories stay clear.
Sources: [`Result`](https://doc.rust-lang.org/std/result/), [`Error`](https://doc.rust-lang.org/std/error/trait.Error.html), and [thiserror's maintainer documentation](https://github.com/dtolnay/thiserror). --- # Stage build: the first event counter Build a working event counter with loops, matching, and borrowed text. Canonical: https://rust.robertdevore.com/course/08-first-build/ Author: Robert DeVore Technical baseline: Rust 1.98.1, edition 2024; verified 2026-09-06. ## Build brief Write a program that counts the three event levels in a fixed, trusted multiline string. Print the counts in INFO, WARN, ERROR order. Keep it small enough that you can explain every line. Before reading the reference, write down the input, the output, and the boundary cases. Our fixture has exactly three records. A production parser is not required yet, but an unknown level must not be accidentally counted as INFO. ### Run the example ```sh cargo run --locked --example 08_stage_one ``` ```rust fn main() { let input = "INFO started\nWARN retry\nERROR stopped"; let mut counts = [0u32; 3]; for line in input.lines() { match line.split_once(' ') { Some(("INFO", _)) => counts[0] += 1, Some(("WARN", _)) => counts[1] += 1, Some(("ERROR", _)) => counts[2] += 1, _ => panic!("invalid built-in fixture"), } } assert_eq!(counts, [1, 1, 1]); println!("{counts:?}"); } ``` [View the tested source](https://github.com/robertdevore/rust.robertdevore.com/blob/main/examples/08_stage_one.rs) ## Walk through the state The input is borrowed static text. `lines()` gives views into it, so the loop does not need to allocate one string per record. The array stores three counters. `split_once(' ')` separates the first token from the rest. The match increments one count or panics on an invalid built-in fixture. The panic is acceptable only because the input is part of this test-like example and a malformed fixture is a programmer mistake. Do not copy that policy into a tool that reads user files. Stage two replaces it with a typed error and a bounded reader. Counters are `u32` here because the fixture is tiny. That does not establish a production overflow policy. The completed library uses checked `u64` counts. The fixed input makes this safe for the example. It would not be enough for arbitrary user input. ## Acceptance criteria 1. The original fixture prints `[1, 1, 1]` and its assertion passes. 2. Adding a second WARN line changes only the middle count. 3. An unknown level is visibly rejected. 4. The parser does not clone the full input or individual records. 5. You can identify the lifetime of the input and of each line view. ## Exercise Start from a blank example file and rebuild the counter. Add an empty-input case and repeated levels. Then replace array positions with a local struct containing `info`, `warn`, and `error`. Decide whether the named fields make the code easier to read.
Solution and acceptance check Initialize every count to zero before iterating. Empty input visits no records and keeps the counts zero. Each level updates exactly one field. A struct avoids remembering what index 1 means; for an interface used by several functions, that clarity usually wins. Compare with `Summary` in the capstone core after finishing your version.
## Review before moving on Can your program distinguish an empty file from a blank record? `lines()` handles line terminators but a blank line within the input is still a record with no level. Decide whether it should be rejected. Can a level contain spaces? Our format says no. Can a message contain spaces? Yes: splitting once preserves the remainder. Commit the working counter and its tests with a message that explains what it does. Generated binaries and your entire `target` directory do not belong in history. The reference source remains available through the repository if an experiment goes wrong. Read the contracts for [`str::lines`](https://doc.rust-lang.org/std/primitive.str.html#method.lines) and [`split_once`](https://doc.rust-lang.org/std/primitive.str.html#method.split_once) to check your assumptions. --- # Lifetimes describe relationships Connect borrowed results to their inputs without trying to extend the life of data. Canonical: https://rust.robertdevore.com/course/09-lifetimes/ Author: Robert DeVore Technical baseline: Rust 1.98.1, edition 2024; verified 2026-09-06. ## A borrowed result needs a source Our parser returns a message slice inside its input. The return value does not own the message bytes. You can use the slice only while the input remains valid. A lifetime annotation expresses a relationship between borrows; it does not allocate storage, keep an owner alive, or change how long a local variable exists. ### Run the example ```sh cargo run --locked --example 09_lifetimes ``` ```rust fn message(line: &str) -> Option<&str> { line.split_once(' ').map(|(_, message)| message) } fn choose<'a>(left: &'a str, right: &'a str) -> &'a str { if left.len() >= right.len() { left } else { right } } fn main() { let input = String::from("INFO ready"); assert_eq!(message(&input), Some("ready")); assert_eq!(choose(&input, "WARN x"), "INFO ready"); } ``` [View the tested source](https://github.com/robertdevore/rust.robertdevore.com/blob/main/examples/09_lifetimes.rs) `message` needs no written lifetime parameter because the elision rules connect its single borrowed input to its borrowed output. `choose` has two possible sources, so its signature gives both inputs and the output a named relationship. At a call, the compiler can select a duration over which both inputs are valid. It does not require their owners to have identical scopes. The function's implementation still has to meet its signature. Adding `'static` to a return type cannot keep a local string alive. The compiler rejects returning a reference to the local allocation that will be dropped at function exit. ## References inside structs `Event<'a>` stores `&'a str`. The struct is useful while the input borrow is valid. If events need to be stored after reusing the input buffer, choose owned text or a representation with an owner that remains alive. Decide whether the data should be borrowed before adding lifetime parameters. A `'static` bound on a type means the type does not contain borrows that expire sooner; it does not mean a value must live forever. An owned `String` can satisfy such a bound and still be dropped at the end of a short task. A `&'static str` specifically refers to text valid for the program's duration, such as a string literal. Variance and higher-ranked bounds become relevant when you build more sophisticated generic interfaces. For now, identify the owner of each value and the source of each returned reference. This often reveals the problem before you write a lifetime annotation. ## Compiler drill **Intentionally fails on Rust 1.98.1.** Run `rustc --edition=2024 drills/dangling.rs` from the repository. ```rust fn label() -> &'static str { let text = String::from("record"); &text } fn main() { println!("{}", label()); } ```
Actual compiler diagnostic · Rust 1.98.1 ```text error[E0515]: cannot return reference to local variable `text` --> drills/dangling.rs:3:5 | 3 | &text | ^^^^^ returns a reference to data owned by the current function error: aborting due to 1 previous error For more information about this error, try `rustc --explain E0515`. ```
Repair it by returning an owned `String`, or by taking text from a caller and returning a view of that text. Choose based on which part of the program should own the text. Leaking the string to obtain a static reference is not an ordinary parser design. ## Exercise Write a function that returns the part before the first space as `Option<&str>`. Test no separator, a leading space, and a normal record. Then explain why returning this view from a temporary input string to a longer-lived caller must be rejected.
Solution and acceptance check Use `line.split_once(' ').map(|(level, _)| level)`. No separator gives `None`; a leading space gives an empty slice; `INFO ready` gives `Some("INFO")`. The empty slice is a parsing choice to validate later. The returned reference cannot remain usable after the input allocation is dropped, and annotations cannot change that fact.
Sources: [lifetime elision](https://doc.rust-lang.org/reference/lifetime-elision.html), [trait and lifetime bounds](https://doc.rust-lang.org/reference/trait-bounds.html), and [lifetime syntax](https://doc.rust-lang.org/book/ch10-03-lifetime-syntax.html). --- # Traits and reusable interfaces Use standard traits, associated types, and static or dynamic dispatch. Canonical: https://rust.robertdevore.com/course/10-traits/ Author: Robert DeVore Technical baseline: Rust 1.98.1, edition 2024; verified 2026-09-06. ## Write to more than one destination A report should write to a file, terminal, or test buffer without duplicating formatting logic. `std::io::Write` already describes that capability. The destination changes, but the formatting code stays the same. ### Run the example ```sh cargo run --locked --example 10_traits ``` ```rust use std::io::{self, Write}; fn report(mut destination: impl Write, count: usize) -> io::Result<()> { writeln!(destination, "records={count}") } fn main() -> io::Result<()> { let mut buffer = Vec::new(); report(&mut buffer, 3)?; assert_eq!(buffer, b"records=3\n"); let erased: &mut dyn Write = &mut buffer; report(erased, 4)?; assert!(buffer.ends_with(b"records=4\n")); Ok(()) } ``` [View the tested source](https://github.com/robertdevore/rust.robertdevore.com/blob/main/examples/10_traits.rs) `impl Write` in a parameter is a convenient way to accept a concrete type implementing the trait. It uses static dispatch in this setting. A generic `W: Write` with a named parameter is useful when several arguments or return positions need to refer to the same type. Use a named type parameter when you need to refer to it elsewhere in the signature. The second call goes through `&mut dyn Write`. A trait object erases the concrete implementation behind a reference and dispatches through a vtable. The reference itself does not require a heap allocation; `Box` would introduce an owning allocation. Static dispatch may enable inlining but can increase generated code across instantiations. Dynamic dispatch can reduce duplication but introduces indirection. Measure where those tradeoffs matter. ## Associated types carry a relationship `Iterator` has an associated `Item` type. Each iterator implementation specifies what one step yields. A trait parameter instead permits the same implementing type to participate in different instantiations of the trait when the rules allow it. Select the design that matches the relationship rather than treating associated types as interchangeable syntax. Trait implementations obey coherence rules, including restrictions on implementing an external trait for external types. A local wrapper, often called a newtype, lets you define an implementation for your own type. Give the wrapper a clear purpose; unnecessary wrappers make callers do more work. ## Dyn compatibility is a checked contract Not every trait can form a trait object. A method with generic type parameters or a hidden return type can need information that the object interface does not provide. The Reference calls this **dyn compatibility**; older material often says object safety. Native async trait methods support static dispatch on the stable compiler used here. Calling them directly through `dyn Trait` is still a separate limitation. A compiler's inability to prove a bound is not automatically a mathematical impossibility. The next-generation trait solver is evolving. Keep a minimal reproduction and a pinned compiler when explaining a limitation; do not turn it into a timeless rule about Rust. ## Exercise Use `report` with two counts and assert the exact bytes in a `Vec`. Add a writer that deliberately returns an I/O error and verify that `report` propagates it. Explain why a custom `ReportSink` trait adds no useful capability to this example.
Solution and acceptance check The buffer must contain `records=3\nrecords=4\n`. A failing `Write::write` implementation should make `report` return `Err`; do not swallow it. The standard trait already supports the real variation and integrates with existing types. Add a new trait only if it describes behavior that `Write` does not.
Sources: [`Write`](https://doc.rust-lang.org/std/io/trait.Write.html), [dyn compatibility](https://doc.rust-lang.org/reference/items/traits.html#dyn-compatibility), and [coherence](https://doc.rust-lang.org/reference/items/implementations.html#trait-implementation-coherence). --- # Iterators and closures Follow iterator items, closure captures, and errors through your code. Canonical: https://rust.robertdevore.com/course/11-iterators/ Author: Robert DeVore Technical baseline: Rust 1.98.1, edition 2024; verified 2026-09-06. ## An iterator describes a sequence of steps An iterator produces an `Option` each time `next` is called. Many adaptors, including `map` and `filter`, are lazy: constructing them does not perform the whole computation. A consumer such as `collect`, `sum`, or a `for` loop drives the steps. ### Run the example ```sh cargo run --locked --example 11_iterators ``` ```rust fn main() { let rows = ["INFO a", "WARN b", "WARN c"]; let warnings: Vec<_> = rows .iter() .copied() .filter(|row| row.starts_with("WARN ")) .collect(); assert_eq!(warnings, ["WARN b", "WARN c"]); let parsed: Result, _> = rows.iter().map(|row| audit_core::parse(row)).collect(); assert_eq!(parsed.unwrap().len(), 3); } ``` [View the tested source](https://github.com/robertdevore/rust.robertdevore.com/blob/main/examples/11_iterators.rs) `rows.iter()` yields references to array elements, so each item is `&&str`. `copied()` copies the small shared reference to give `&str`; it does not duplicate the text allocation. `filter` itself passes a reference to its item to the predicate. Check the item type at each step so you know which references to dereference. Collecting an iterator of `Result` values into `Result, _>` stops at the first error. This chooses to stop on the first failure. Collecting all errors requires a different design and often a different output type. A concise iterator chain is good only when its policy remains clear. ## Closures capture what they need A closure is an anonymous function-like value that can capture its environment. It may borrow shared data, borrow it mutably, or consume captured values depending on its body. `move` requests ownership capture; it does not by itself imply the closure can only be called once. The operations performed by the body determine whether it implements `Fn`, `FnMut`, or only `FnOnce`. A closure that consumes a captured string by returning it can generally run only once. A closure that owns a string but only reads its length can run repeatedly. This distinction matters when passing closures to iterators and spawning tasks. ## Prefer understandable data flow A loop is often clearer when you must update several counters, attach a line number to an error, and maintain a record-size limit. Use whichever is easier to follow. Both can compile efficiently; measure before claiming one is faster. Avoid collecting merely to iterate immediately again when a streaming consumer would do. Conversely, collecting is sensible when you need random access or multiple passes. If a borrow makes the chain difficult to write, check who needs to own the data before cloning the collection. ## Exercise Change the fixture to include `BOGUS bad` between two valid records. Show that collecting into `Result, _>` returns the parser error. Then implement an explicit loop that counts valid records and errors separately, and write down how that changes the tool's contract.
Solution and acceptance check The first version returns `UnknownLevel`, with no successful vector. The second can retain two valid records and one rejection count. Report the rejected record too, so users know the counts are incomplete. Use a `match` on each result to make that policy visible.
Sources: [`Iterator`](https://doc.rust-lang.org/std/iter/trait.Iterator.html), [closure capture](https://doc.rust-lang.org/reference/types/closure.html), and [`FromIterator` for `Result`](https://doc.rust-lang.org/std/result/enum.Result.html#impl-FromIterator%3CResult%3CA,+E%3E%3E-for-Result%3CV,+E%3E). --- # Stage build: a bounded streaming library Build a library that parses records as it reads them and limits buffer growth. Canonical: https://rust.robertdevore.com/course/12-reader/ Author: Robert DeVore Technical baseline: Rust 1.98.1, edition 2024; verified 2026-09-06. ## Build brief Turn the first counter into a reusable library. It must accept any `BufRead`, preserve structured errors, and retain at most 4096 bytes for an unfinished record. Empty input succeeds. LF and CRLF records are accepted. A final record without a newline is accepted. Stop at the first malformed record and return an error, not a partial summary. ### Run the example ```sh cargo run --locked --example 12_stage_two ``` ```rust use std::io::Cursor; fn main() -> Result<(), audit_core::AuditError> { let summary = audit_core::summarize(Cursor::new("INFO started\nWARN retry\nERROR stopped\n"))?; assert_eq!((summary.info, summary.warn, summary.error), (1, 1, 1)); println!("{summary:?}"); Ok(()) } ``` [View the tested source](https://github.com/robertdevore/rust.robertdevore.com/blob/main/examples/12_stage_two.rs) The example calls the completed library with an in-memory cursor. Read `crates/audit-core/src/lib.rs` alongside this lesson; it is the complete reference, not pseudocode. ## Why the reader and parser are separate `parse(&str)` handles syntax and returns a borrowed `Event`. `summarize(impl BufRead)` reads the input and adds a line number to parser errors. The parser does not know about files; the reader does not need to own a path. Tests can choose tiny buffer capacities to simulate fragmented input without relying on a real filesystem. The reader calls `fill_buf` to inspect available bytes, finds the first newline if any, checks whether adding those bytes exceeds the record limit, copies the allowed portion, then calls `consume`. It must stop using the borrowed reader buffer before the next mutable reader operation. The borrow ends before the reader advances its buffer. ## Check the limit before copying Calling `read_line` into a `String` and checking its length afterward can already have allocated excessive memory. Our loop checks `end > MAX_LINE_BYTES - bytes.len()` before extending. The invariant is that `bytes.len()` never exceeds the limit, making the subtraction safe. The input reader may have its own buffer; the guarantee concerns the library's retained record storage, not all memory used by an arbitrary `BufRead` implementation. UTF-8 can cross buffer boundaries. Decode only when you have a complete record or reach the end of the input. Reject invalid UTF-8 with a line number. The limit includes a newline when present, so the boundary is explicit and testable rather than a vague “roughly 4 KB”. ## Exercise Implement a simpler version first, then test reader capacities from 1 through 16. Include `INFO café\n`, a final unterminated record, a record exactly at the limit, and one byte beyond it. Inject an I/O failure. Ensure none of those errors produce a successful partial summary.
Solution and acceptance check Use `BufReader::with_capacity` over a `Cursor` to force boundary splits. The UTF-8 input must parse regardless of where the two-byte character crosses a read. An exactly 4096-byte record is accepted; 4097 bytes are rejected before extension. A custom failing `Read` wrapped in `BufReader` preserves an `AuditError::Io`. The library's tests provide executable reference cases.
## Review the API Accepting `BufRead` lets the same function read files, stdin, and test buffers. The message borrow avoids allocation during parsing. Errors distinguish syntax from I/O. Three counters avoid a dynamic map. No trait framework, async runtime, or shared mutex is necessary for this stage. Sources: [`BufRead`](https://doc.rust-lang.org/std/io/trait.BufRead.html), [`Cursor`](https://doc.rust-lang.org/std/io/struct.Cursor.html), and [`from_utf8`](https://doc.rust-lang.org/std/str/fn.from_utf8.html). --- # Cargo, modules, and dependency boundaries Understand packages, modules, features, and the dependencies your program builds. Canonical: https://rust.robertdevore.com/course/13-cargo/ Author: Robert DeVore Technical baseline: Rust 1.98.1, edition 2024; verified 2026-09-06. ## Package, crate, module, workspace A package has a manifest and may contain multiple targets. A crate is a compilation unit, such as a library or binary target. Modules organize names inside a crate; adding `mod parser` is not the same as adding a package dependency. A workspace coordinates packages, shares a lockfile and target directory, and can inherit selected metadata. Our workspace has a lesson package, a core library, a CLI, and an unsafe lab. The CLI depends on the core through a path dependency. The core does not depend on the website, Tokio, or the examples. You can test and install the CLI without building the website. ### Run the example ```sh cargo run --locked --example 13_cargo ``` ```rust fn main() { assert_eq!(env!("CARGO_PKG_NAME"), "rust-course-examples"); println!( "package={} version={}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION") ); } ``` [View the tested source](https://github.com/robertdevore/rust.robertdevore.com/blob/main/examples/13_cargo.rs) ## Resolver and feature decisions The workspace explicitly selects resolver `3`, appropriate for this edition-2024 course. Virtual workspaces should declare their resolver because there may be no root package edition from which to infer one. The resolver considers compiler compatibility, but you still need to test on the oldest compiler you support. Features should usually add capabilities. Dependency features are unified in relevant parts of the graph, so a feature that disables safety checks or removes functionality can surprise a downstream user when another dependency enables it. Test default features, no default features, and supported combinations when a library actually offers those configurations. The lesson package enables only Tokio's runtime, macros, channels, time, and test utilities. The synchronous CLI has no Tokio dependency. `cargo tree -e features` shows why a feature is enabled; guessing from one manifest is insufficient when multiple dependency paths exist. ## Update dependencies with care Commit the lockfile for this course and application. Run `cargo update` intentionally, inspect the diff and release notes, and rerun checks. `--locked` prevents an unnoticed re-resolution during verification. It does not make source registries permanently available or guarantee that dependencies have no vulnerabilities. Build scripts and procedural macros execute on the build machine. Review the code and maintenance status of dependencies, including the dependencies they bring in. A small handwritten error enum is appropriate here; Serde becomes useful when a real structured format is introduced. Add a dependency when it solves a problem your project has. ## Exercise Run `cargo metadata --no-deps --format-version 1` and identify the CLI's library dependency. Run `cargo tree -p audit-cli` and `cargo tree -p rust-course-examples -e features`. Explain why building the CLI need not bring Tokio into its binary.
Solution and acceptance check The CLI graph contains the CLI and local core. Tokio belongs to the example package's graph. Packages in the same workspace do not automatically depend on each other. A shared lockfile may list packages a particular target does not use.
For release profiles, begin with Cargo defaults. Benchmark before adding LTO, reducing codegen units, or changing panic behavior. Build caching is an optimization; cache keys need to account for toolchains and dependencies, and correctness must not depend on a warm cache. Sources: [workspaces](https://doc.rust-lang.org/cargo/reference/workspaces.html), [resolver](https://doc.rust-lang.org/cargo/reference/resolver.html), [features](https://doc.rust-lang.org/cargo/reference/features.html), and [profiles](https://doc.rust-lang.org/cargo/reference/profiles.html). --- # Tests, compiler errors, and verification tools Test expected behavior and failures, then use compiler checks and Clippy to catch more mistakes. Canonical: https://rust.robertdevore.com/course/14-testing/ Author: Robert DeVore Technical baseline: Rust 1.98.1, edition 2024; verified 2026-09-06. ## Test what callers need A test that repeats the implementation can miss a mistake in what the function is supposed to do. Our reader promises to handle records split across arbitrary buffering boundaries. Testing many tiny buffer capacities challenges that promise directly. One test with a large input buffer would not check those splits. ### Run the example ```sh cargo run --locked --example 14_testing ``` ```rust fn main() { for capacity in 1..=16 { let input = std::io::BufReader::with_capacity( capacity, std::io::Cursor::new("INFO café\nWARN retry"), ); let got = audit_core::summarize(input).unwrap(); assert_eq!((got.info, got.warn), (1, 1)); } assert!(audit_core::parse("WARN ").is_err()); } ``` [View the tested source](https://github.com/robertdevore/rust.robertdevore.com/blob/main/examples/14_testing.rs) Unit tests sit close to private implementation details. Integration tests exercise the public surface as a separate crate or process. Documentation tests make public examples executable. Use each kind of test to catch different mistakes. ## A useful verification sequence ```sh cargo fmt --all -- --check cargo check --workspace --all-targets --locked cargo test --workspace --locked cargo clippy --workspace --all-targets --locked -- -D warnings npm run verify:rust ``` Formatting produces consistent layout. The compiler checks types, ownership constraints, and other static rules. Tests exercise selected behavior. Clippy identifies suspicious patterns and suggests improvements. You still need to review the requirements and rules those tools cannot check. Clippy cannot decide whether your design fits the problem. The default useful lints are a starting point; enabling every restriction lint can create contradictory advice. If a suppression is needed, scope it narrowly and explain the non-obvious reason. This repository treats warnings as failures for its pinned validation toolchain, so compiler upgrades include an intentional lint review. ## Intentionally rejected programs The drill runner invokes rustc on each small input, captures structured diagnostics, and checks that exactly the intended error code appears. It also stores rustc's actual rendered explanation. This catches an example that still fails but for the wrong reason. Absolute temporary paths are avoided by invoking the compiler from the repository root. A diagnostic snapshot describes one compiler version. Wording may change while the language rule remains the same. Do not promise every learner's editor displays identical text. Ask them to compare the error code, source spans, and ownership relationship first. ## Exercise Add a test for an error on the second record, then a test for an invalid UTF-8 final record. Make each fail by temporarily breaking the relevant behavior; restore the code afterward. Explain what observing a failure tells you about the test.
Solution and acceptance check The second-line syntax failure must carry line 2. Invalid UTF-8 must produce the dedicated error, not replacement characters or a panic. A mutation that breaks the behavior should fail the corresponding test; otherwise the test may not actually inspect the claimed contract. Temporarily breaking the code helps you check that the test can catch the mistake it claims to cover.
Concurrency later adds schedule-sensitive behavior, and unsafe code adds obligations that ordinary tests cannot exhaust. Miri and deterministic async time tests complement this baseline. None proves all possible executions correct. Sources: [Cargo test](https://doc.rust-lang.org/cargo/commands/cargo-test.html), [rustc JSON diagnostics](https://doc.rust-lang.org/rustc/json.html), and [Clippy usage](https://doc.rust-lang.org/stable/clippy/usage.html). --- # Interior mutability and shared ownership Choose between reference counting, runtime borrow checks, and locks. Canonical: https://rust.robertdevore.com/course/15-interior/ Author: Robert DeVore Technical baseline: Rust 1.98.1, edition 2024; verified 2026-09-06. ## Shared does not always mean unchanging Ordinary data behind `&T` cannot be mutated while the shared reference's contract applies. Interior-mutability types let you change data through a shared reference under defined rules. They rely on `UnsafeCell` internally, but each safe abstraction supplies its own rules. `UnsafeCell` itself does not prevent data races or allow competing `&mut` references. ### Run the example ```sh cargo run --locked --example 15_interior ``` ```rust use std::{cell::RefCell, rc::Rc}; fn main() { let data = Rc::new(RefCell::new(vec![1])); let other = Rc::clone(&data); { let mut guard = other.borrow_mut(); guard.push(2); assert!(data.try_borrow().is_err()); } assert_eq!(*data.borrow(), [1, 2]); assert_eq!(Rc::strong_count(&data), 2); } ``` [View the tested source](https://github.com/robertdevore/rust.robertdevore.com/blob/main/examples/15_interior.rs) `Rc` gives two owning handles to the same allocation. `RefCell` tracks borrows at runtime. While the mutable guard exists, `try_borrow()` reports a conflict. After that guard is dropped, the shared borrow succeeds. Leaving the block drops the guard and ends its borrow. `borrow()` and `borrow_mut()` panic on a runtime conflict; their `try_` variants return an error. Use the `try_` variants when your program should handle a borrow conflict rather than panic. A runtime check does not mean the compiler has stopped enforcing all safety: the guard types and library implementation cooperate to preserve access rules. ## Pick the narrowest mechanism `Cell` can replace a value without handing out ordinary references to its interior. `RefCell` supports checked borrows within a thread. `Mutex` coordinates access across threads. `Rc` is not a thread-safe reference counter; `Arc` is, but that does not make every `T` thread-safe. `Arc>` is not a substitute for a synchronized mutation abstraction. Reference-counted cycles can leak memory. Use weak references or redesign ownership where a graph needs back-links. A leak is a resource bug even when it does not violate memory safety. Rust's safety model does not imply every resource is eventually reclaimed. ## Apply this to the course project The event counter owns its summary and reads records sequentially. It does not need a reference-counted mutable summary. Passing `&mut Summary` to a function already states the intended exclusive update. Add a cell or lock only when a real relationship requires shared access with mutation. If a compiler error appears because two components both want ownership, first consider transferring ownership at a message boundary. If they only read immutable data, an ordinary borrow or `Arc` may be enough. Each mechanism adds behavior you need to understand and test. ## Exercise Keep the mutable `RefCell` guard alive and call `try_borrow` from the other handle. Assert failure. Drop the guard explicitly and assert success. Explain why cloning the `Rc` does not create a second vector.
Solution and acceptance check The first attempt returns `Err`; the second sees `[1, 2]`. Both handles refer to the same cell and its borrow state. `Rc::clone` increases the owning handle count. To copy the data itself, you would need to clone the vector separately.
Sources: [`RefCell`](https://doc.rust-lang.org/std/cell/struct.RefCell.html), [`UnsafeCell`](https://doc.rust-lang.org/std/cell/struct.UnsafeCell.html), and [`Rc`](https://doc.rust-lang.org/std/rc/). --- # Threads, Send, and Sync Move or borrow data across threads, then handle completion and failure. Canonical: https://rust.robertdevore.com/course/16-threads/ Author: Robert DeVore Technical baseline: Rust 1.98.1, edition 2024; verified 2026-09-06. ## Pass data to a thread A thread can outlive the function that started it. Ordinary `thread::spawn` therefore requires a closure and result satisfying `Send + 'static`. Owned data can satisfy that bound without living forever. A borrowed local cannot simply be sent into a potentially longer-lived thread. Scoped threads give a different guarantee: they finish before the scope returns. This permits borrowing data that remains valid for that scope. ### Run the example ```sh cargo run --locked --example 16_threads ``` ```rust fn main() { let rows = ["INFO a", "WARN b", "WARN c", "ERROR d"]; let total = std::thread::scope(|scope| { let left = scope.spawn(|| rows[..2].iter().filter(|r| r.starts_with("WARN ")).count()); let right = scope.spawn(|| rows[2..].iter().filter(|r| r.starts_with("WARN ")).count()); left.join().unwrap() + right.join().unwrap() }); assert_eq!(total, 2); } ``` [View the tested source](https://github.com/robertdevore/rust.robertdevore.com/blob/main/examples/16_threads.rs) Two threads read disjoint ranges of the same immutable array. They return counts, and the parent combines them. No lock is needed because there is no shared mutation. `join` also reports a worker panic. The example uses `unwrap` so a panic fails the example. In an application, decide how to report that failure. ## What Send and Sync mean `Send` means a value can safely be transferred to another thread. `Sync` means a shared reference to the type can safely be transferred between threads. More precisely, `T: Sync` relates to `&T: Send`. These are unsafe traits to implement manually; normally let their automatic derivation from fields describe your type. `Rc` is not `Send` because its reference count is not synchronized. `Arc` synchronizes ownership bookkeeping. Sharing mutable inner data still needs an appropriate mechanism and trait bounds. Using `Arc` alone does not make access to the data safe. ## Compiler drill **Intentionally fails on Rust 1.98.1.** Run `rustc --edition=2024 drills/send.rs` from the repository. ```rust fn main() { let value = std::rc::Rc::new(1); std::thread::spawn(move || println!("{value}")); } ```
Actual compiler diagnostic · Rust 1.98.1 ```text error[E0277]: `Rc` cannot be sent between threads safely --> drills/send.rs:3:24 | 3 | std::thread::spawn(move || println!("{value}")); | ------------------ -------^^^^^^^^^^^^^^^^^^^^ | | | | | `Rc` cannot be sent between threads safely | | within this `{closure@drills/send.rs:3:24: 3:31}` | required by a bound introduced by this call | = help: within `{closure@drills/send.rs:3:24: 3:31}`, the trait `Send` is not implemented for `Rc` note: required because it's used within this closure --> drills/send.rs:3:24 | 3 | std::thread::spawn(move || println!("{value}")); | ^^^^^^^ note: required by a bound in `spawn` --> /rustc/48a229ceaefd4985c50990b14116b6d856af0985/library/std/src/thread/functions.rs:125:0 error: aborting due to 1 previous error For more information about this error, try `rustc --explain E0277`. ```
The compiler traces the requirement from the thread closure to the captured `Rc`. For immutable shared data, `Arc` may be the right correction. If the parent no longer needs the data, moving an ordinary owned value may be simpler. Do not add a mutex when no thread mutates anything. ## Correctness extends beyond data races Rust's type system and sound libraries rule out many data races in safe code, but not deadlocks, starvation, lost application updates, or wrong ordering of business events. The OS or runtime schedules threads. Rust does not promise that a new thread runs immediately or that threads run in creation order. Parallel work can also be slower. Thread creation, communication, cache effects, and small inputs can dominate useful computation. Our tiny fixture demonstrates ownership, not a performance win. Benchmark a realistic workload before choosing worker counts. ## Exercise Repair the drill twice: once by moving a plain integer owner, and once by using `Arc` for immutable sharing. Then explain why the scoped example requires neither an atomic reference count nor a mutex.
Solution and acceptance check The move-only version captures an owned value. The shared version clones an `Arc` handle before spawning and joins the thread. Scoped borrows are valid because the scope waits for workers, and shared immutable access does not require a lock. Each version should terminate and produce the same value without ignoring a join result.
Sources: [`thread::scope`](https://doc.rust-lang.org/std/thread/fn.scope.html), [`Send`](https://doc.rust-lang.org/std/marker/trait.Send.html), and [`Sync`](https://doc.rust-lang.org/std/marker/trait.Sync.html). --- # Channels, locks, and shutdown Pass work through channels, protect shared data, and shut down without deadlocks. Canonical: https://rust.robertdevore.com/course/17-synchronization/ Author: Robert DeVore Technical baseline: Rust 1.98.1, edition 2024; verified 2026-09-06. ## Give the state a clear owner A producer creates work; a consumer receives it and keeps the running total. A channel transfers each message to the consumer. This avoids multiple workers mutating the same summary and makes the shutdown condition visible. ### Run the example ```sh cargo run --locked --example 17_sync ``` ```rust use std::{sync::mpsc::sync_channel, thread}; fn main() { let (sender, receiver) = sync_channel(2); let worker = thread::spawn(move || { for value in [1, 2, 3] { sender.send(value).unwrap(); } }); let total: i32 = receiver.iter().sum(); worker.join().unwrap(); assert_eq!(total, 6); } ``` [View the tested source](https://github.com/robertdevore/rust.robertdevore.com/blob/main/examples/17_sync.rs) The synchronous channel has capacity two. Sending can block when the queue is full. The consumer keeps receiving until every sender is dropped. Here the producer's sender is dropped on return, so iteration ends and joining is safe. If the main thread retained an unused sender clone, the receiver could wait forever. If the main thread joined a blocked producer before draining the full channel, the program could deadlock. Both bugs can occur in fully safe Rust. Write down who closes the queue and who drains it before choosing the order of waits. ## When a mutex is appropriate A short update to a genuinely shared data structure can fit `Arc>`. The lock stays held for as long as its guard lives. Copy or move out the small result you need and release the guard before unrelated slow work. Avoid calling arbitrary callbacks while holding a lock unless the contract explicitly covers reentrancy and lock ordering. Standard mutex poisoning indicates that a panic may have interrupted a protected operation. It is advisory and not a soundness mechanism. Recovery must examine application invariants; blindly calling `into_inner` is not evidence the state is usable. Likewise, an unpoisoned mutex is not proof that the algorithm is correct. Acquire multiple locks in a consistent order where possible. A read-write lock can help some read-heavy patterns, but it is not automatically faster and can introduce different contention behavior. Draw who owns the data and measure contention before choosing a more complex lock. ## What each layer controls The language and standard library describe what data can be safely accessed and what synchronization establishes. The OS chooses which runnable threads execute. Hardware may reorder operations within constraints imposed by the language memory model. Application architecture decides whether messages can be retried or discarded. A program that works under one schedule may still fail under another. ## Exercise Draw the channel's close-and-drain sequence. Add a producer error path that returns early and confirm the consumer still terminates. Then describe how you would report an error without treating a partial aggregate as complete.
Solution and acceptance check An early return drops that sender; if no senders remain, receiving eventually ends after queued messages drain. Joining the producer reveals whether it completed successfully. Return a status with the partial total, or reject the result. A disconnected channel alone does not prove the producer processed every intended item.
Sources: [`sync_channel`](https://doc.rust-lang.org/std/sync/mpsc/fn.sync_channel.html), [`Mutex`](https://doc.rust-lang.org/std/sync/struct.Mutex.html), and [message-passing concurrency](https://doc.rust-lang.org/book/ch16-02-message-passing.html). --- # Atomics and memory ordering Use atomic counters and understand why sharing other data needs more than a flag. Canonical: https://rust.robertdevore.com/course/18-atomics/ Author: Robert DeVore Technical baseline: Rust 1.98.1, edition 2024; verified 2026-09-06. ## Increment a shared counter Several threads increment one atomic counter. Each read-modify-write is atomic, so updates to that counter are not lost. We do not use the counter to announce that unrelated memory is ready. ### Run the example ```sh cargo run --locked --example 18_atomics ``` ```rust use std::sync::atomic::{AtomicUsize, Ordering}; fn main() { let count = AtomicUsize::new(0); std::thread::scope(|s| { for _ in 0..4 { s.spawn(|| { for _ in 0..100 { count.fetch_add(1, Ordering::Relaxed); } }); } }); assert_eq!(count.load(Ordering::Relaxed), 400); } ``` [View the tested source](https://github.com/robertdevore/rust.robertdevore.com/blob/main/examples/18_atomics.rs) `Relaxed` is enough for the count's own atomic updates in this example. The scoped threads finish before the final assertion, giving the necessary completion synchronization. If the main thread read while workers were still running, it could observe an intermediate count. The counter does not mean “all workers are finished” by itself. ## Publication is a different problem Imagine one thread writes a payload and then sets a ready flag. Another waits for ready and reads the payload. A relaxed flag does not, by itself, establish the ordering needed to publish unrelated non-atomic memory safely. A release operation paired with an acquire operation that observes it, or an appropriate release sequence, can establish a happens-before relationship. You must also show that every access to the payload follows the memory model's rules. `SeqCst` adds a global order for sequentially consistent atomic operations, subject to the model's rules. It does not turn a multi-step algorithm into a transaction, prevent deadlocks, or make ordinary conflicting memory accesses acceptable. Stronger ordering can simplify a proof, but it does not replace one. We will not build a lock-free queue here. Besides memory ordering, it needs rules for ownership and memory reclamation, protection against values changing and changing back unnoticed (the ABA problem), and an argument that operations make progress under every allowed schedule. ## Keep layers separate The Rust atomic API specifies language-level behavior and follows a memory-model framework documented by the standard library. Supported atomic widths depend on the target. OS scheduling and hardware instructions also affect speed and progress. Rust's safety guarantees do not settle those questions. An algorithm can be data-race-free and still produce an incorrect result. Two individually atomic operations may interleave with another thread between them. An atomic operation does not make the surrounding sequence of steps atomic. Even a sequence of atomics may need a mutex to implement the intended invariant simply. ## Exercise Change the number of workers and increments, compute the expected total, and assert it after joining. Then explain why replacing a future payload-ready flag with `Relaxed` would require a different argument from this counter.
Solution and acceptance check With six workers and 250 increments, the final count is 1500. Choose a size that cannot wrap the target's `usize`. The result follows from atomic updates and completion synchronization. Publishing a payload asks whether other memory writes become visible and whether conflicting access is excluded; the count-only proof does not answer that question.
For a deeper treatment, read the [standard atomic documentation](https://doc.rust-lang.org/std/sync/atomic/) and Mara Bos's [memory-ordering chapter](https://mara.nl/atomics/memory-ordering.html). These support the distinction between atomicity and synchronization; they do not constitute an endorsement of this course. --- # Futures before runtimes Learn how polling and wakeups work before adding an async runtime. Canonical: https://rust.robertdevore.com/course/19-futures/ Author: Robert DeVore Technical baseline: Rust 1.98.1, edition 2024; verified 2026-09-06. ## When a future starts running An `async` block produces a future. Calling an async function also creates a future. Its body runs when the future is polled. A future describes a computation that may be incomplete. The `Future` trait connects it to a poller through `poll`, `Context`, and `Poll`. ### Run the example ```sh cargo run --locked --example 19_futures ``` ```rust use std::{ future::Future, pin::Pin, task::{Context, Poll, Waker}, }; fn main() { let mut future = std::pin::pin!(async { 21 * 2 }); let mut context = Context::from_waker(Waker::noop()); assert_eq!(Pin::as_mut(&mut future).poll(&mut context), Poll::Ready(42)); } ``` [View the tested source](https://github.com/robertdevore/rust.robertdevore.com/blob/main/examples/19_futures.rs) This future completes immediately, so one poll with a no-op waker is sufficient. This example has **no executor** to schedule later polls. A future that returns `Pending` must arrange a wakeup when it may make progress. Repeatedly polling it in a tight loop would waste CPU, and never polling it again would stall it. Do not adapt this demonstration into a general runtime. ## Suspension stores state Conceptually, the compiler transforms an async body into a state machine containing what it needs across suspension points. A future can therefore contain owned values and borrows. Its size and whether it is `Send` depend on the state it may hold. A non-`Send` value retained across an await can prevent moving the future between worker threads. `.await` drives another future as part of the current task. If it is not ready, the current future may yield `Pending` to its caller. If it is already ready, execution may continue immediately. An `.await` is not a promise of a scheduler yield, a new task, or parallel execution. After a future returns `Ready`, callers must not assume polling it again is supported. Polling it again may panic or produce no useful result, though it must still obey Rust's safety rules. A runtime tracks completion so ordinary application code need not manually manage this state. ## Cancellation belongs to ownership Dropping a pending future commonly cancels that instance of the computation by dropping its stored state. It does not undo an external effect that already occurred. A request may have reached a server even if the caller stopped awaiting its response. Decide what cancellation means for your application. A retry may need a request ID or a transaction to avoid repeating an external change. The runtime manages a spawned task until it finishes or is cancelled. Dropping its join handle is not necessarily the same as dropping the future inside it. Tokio, for example, detaches a task when its handle is dropped. Always read the runtime's contract before treating a handle as a cancellation guard. ## Exercise Explain why the example's no-op waker is valid for this immediately ready future but not for a timer. Identify what data would have to survive if an async function borrowed a string before a wait and used it afterward.
Solution and acceptance check The immediate future needs no later wakeup. A timer may return `Pending` and must notify a real executor when time advances. The borrowed string's owner must remain valid while the future can use the borrow; the future itself carries the reference or equivalent state. Moving work into async syntax does not erase those ownership requirements.
Sources: [`Future`](https://doc.rust-lang.org/std/future/trait.Future.html), [await expressions](https://doc.rust-lang.org/reference/expressions/await-expr.html), and [async blocks](https://doc.rust-lang.org/reference/expressions/block-expr.html#async-blocks). --- # Tokio tasks, blocking, and cancellation Run Tokio tasks, limit queued work, and handle blocking calls and cancellation. Canonical: https://rust.robertdevore.com/course/20-async/ Author: Robert DeVore Technical baseline: Rust 1.98.1, edition 2024; verified 2026-09-06. ## What Tokio provides Rust supplies async syntax. `Future` defines polling. An executor schedules tasks. Tokio supplies an executor plus timers, channels, and I/O integration. Your application defines limits, retries, state ownership, and shutdown. Tokio is one runtime for Rust async code; it does not define the language feature. ### Run the example ```sh cargo run --locked --example 20_async ``` ```rust #[tokio::main(flavor = "current_thread")] async fn main() -> Result<(), Box> { let (sender, mut receiver) = tokio::sync::mpsc::channel(2); let producer = tokio::spawn(async move { for id in 0..4 { sender.send(id).await?; } Ok::<(), tokio::sync::mpsc::error::SendError>(()) }); let mut sum = 0; while let Some(id) = receiver.recv().await { sum += id; } producer.await??; assert_eq!(sum, 6); Ok(()) } ``` [View the tested source](https://github.com/robertdevore/rust.robertdevore.com/blob/main/examples/20_async.rs) The macro builds a current-thread Tokio runtime. A producer task sends four values into a bounded channel. The consumer drains it, and the producer's sender is dropped at completion. The join handle reports whether the task failed and contains the producer's own result. In `await??`, the first `?` checks the task result; the second checks the producer's result. ## Limit queued work A channel with capacity two creates backpressure: a send can wait for space. This bounds queued messages, not the size of each message or the total number of tasks elsewhere in the program. A production design must bound those separately. Tokio schedules cooperatively. A long computation or blocking filesystem call inside a task can occupy the runtime thread and prevent other tasks from progressing. Async syntax alone does not make a function nonblocking. Use an appropriate async API, a dedicated worker, or `spawn_blocking` with a deliberate concurrency limit. A started blocking task generally cannot be aborted just by aborting its async handle. ## Check what cancellation leaves behind Selecting between two futures drops a losing future in common patterns. Ask what progress it may have made and whether retrying loses or duplicates work. Tokio's `mpsc::Receiver::recv` has documented cancellation behavior useful in select loops. Other operations, such as reading an exact number of bytes, may have consumed partial input before cancellation. A timeout is not rollback. Dropping a `JoinHandle` detaches the task; call `abort` when appropriate and await the handle to observe completion. Abortion takes effect when the task yields control, and destructors are part of cleanup. If cleanup needs async work, write a shutdown step that awaits it. Ordinary `Drop` cannot await. ## Exercise Write a deterministic timeout test using Tokio's paused test clock. Make a receiver wait on an empty channel while a sender still exists; advance time and assert a timeout. Then drop the sender and assert that `recv` returns `None`. Use the repository's async tests as a reference after trying it.
Solution and acceptance check Use `#[tokio::test(start_paused = true)]` with a current-thread test runtime and `tokio::time::timeout`. Paused time lets the runtime advance to the next timer when idle, avoiding fragile wall-clock sleeps. Retaining a sender prevents “channel closed” from winning first. Once the final sender is dropped, the receiver terminates. These are different outcomes and should have separate assertions.
Sources: [Tokio channels](https://tokio.rs/tokio/tutorial/channels), [shutdown](https://tokio.rs/tokio/topics/shutdown), [JoinHandle](https://docs.rs/tokio/latest/tokio/task/struct.JoinHandle.html), and Alice Ryhl's [blocking explanation](https://ryhl.io/blog/async-what-is-blocking/). --- # Pin and async traits Learn what pinning protects and how to return futures through a trait object. Canonical: https://rust.robertdevore.com/course/21-pin/ Author: Robert DeVore Technical baseline: Rust 1.98.1, edition 2024; verified 2026-09-06. ## What Pin keeps in place Some values become address-sensitive, including futures that may contain references into their own suspended state. `Pin

` constrains how the pointee accessed through pointer `P` may be moved. It does not mean the pointer handle itself cannot move, and it does not automatically allocate anything. For `T: Unpin`, moving the value does not violate a pinning invariant, so many pin restrictions become irrelevant. For a `!Unpin` value, safe APIs prevent operations that would violate the pin contract. The contract also involves destruction and storage validity; it is stronger than “do not call mem::swap right now”. ## Use safe constructors first `pin!` pins a value in local storage. `Box::pin` owns a pinned allocation whose handle can move. Most async application code can use these or runtime helpers without implementing unsafe projection. Accessing a pinned struct's fields requires knowing which fields are structurally pinned. Do not use `get_unchecked_mut` just to bypass a compiler error. ### Run the example ```sh cargo run --locked --example 21_pin ``` ```rust use std::{future::Future, pin::Pin}; trait Lookup { fn get(&self) -> Pin + '_>>; } struct Fixed(usize); impl Lookup for Fixed { fn get(&self) -> Pin + '_>> { Box::pin(async move { self.0 }) } } #[tokio::main(flavor = "current_thread")] async fn main() { let lookup = Fixed(42); let service: &dyn Lookup = &lookup; assert_eq!(service.get().await, 42); } ``` [View the tested source](https://github.com/robertdevore/rust.robertdevore.com/blob/main/examples/21_pin.rs) The interface returns a future in a pinned `Box`. The trait object hides its concrete type. Its lifetime is tied to `&self`, so it can borrow the service. The example uses a local executor and does not require `Send`. An API intended for movable spawned tasks may need `Send` on the returned future and appropriate bounds on the captured data. ## Calling async methods through trait objects Native `async fn` in traits works on stable Rust for supported static-dispatch cases. That does not imply those methods can be dispatched directly through `dyn Trait`. Returning an explicitly erased future is a stable alternative, with allocation and indirection costs. The `async-trait` crate automates a related transformation; use it when you need this kind of interface. **Intentionally fails on Rust 1.98.1.** Run `rustc --edition=2024 drills/async_dyn.rs` from the repository. ```rust trait Fetch { async fn get(&self) -> usize; } fn consume(_: &dyn Fetch) {} fn main() {} ```

Actual compiler diagnostic · Rust 1.98.1 ```text error[E0038]: the trait `Fetch` is not dyn compatible --> drills/async_dyn.rs:2:16 | 2 | fn consume(_: &dyn Fetch) {} | ^^^^^^^^^ `Fetch` is not dyn compatible | note: for a trait to be dyn compatible it needs to allow building a vtable for more information, visit --> drills/async_dyn.rs:1:24 | 1 | trait Fetch { async fn get(&self) -> usize; } | ----- ^^^ ...because method `get` is `async` | | | this trait is not dyn compatible... = help: consider moving `get` to another trait error: aborting due to 1 previous error For more information about this error, try `rustc --explain E0038`. ```
The drill captures the current direct-dyn rejection. This shows what the pinned compiler supports today, not what Rust will support forever. Current async and field-projection initiatives aim to improve these awkward edges. ## Exercise Explain why moving the `Box` handle is compatible with pinning its allocated future. Then remove the trait object entirely and call a concrete async method. Compare what the two designs require from allocation and the public signature.
Solution and acceptance check The pointee stays in its allocation when the owning pointer moves. A concrete async method can return its compiler-generated future without the explicit box and vtable used here. Prefer the concrete method unless callers need to work with different implementations through one interface. Do not claim a speedup without measuring the relevant workload.
Sources: [`std::pin`](https://doc.rust-lang.org/std/pin/), [trait rules](https://doc.rust-lang.org/reference/items/traits.html#dyn-compatibility), and [the async trait RFC](https://rust-lang.github.io/rfcs/3185-static-async-fn-in-trait.html). --- # Stage build: process records with async tasks Send records through a bounded queue and keep one task in charge of the counts. Canonical: https://rust.robertdevore.com/course/22-async-build/ Author: Robert DeVore Technical baseline: Rust 1.98.1, edition 2024; verified 2026-09-06. ## Build brief Use the parsing core in a small async program. One producer sends owned records through a queue. One consumer owns the summary. At normal completion, the queue closes, the consumer drains it, and the producer's result is checked. Keep the synchronous file CLI as the final product: async is useful to study, but it does not automatically improve sequential local file processing. ### Run the example ```sh cargo run --locked --example 22_stage_three ``` ```rust #[tokio::main(flavor = "current_thread")] async fn main() -> Result<(), Box> { let (tx, mut rx) = tokio::sync::mpsc::channel(2); let producer = tokio::spawn(async move { for line in ["INFO ready", "WARN retry", "ERROR stop"] { tx.send(String::from(line)).await?; } Ok::<_, tokio::sync::mpsc::error::SendError>(()) }); let mut summary = audit_core::Summary::default(); while let Some(line) = rx.recv().await { summary.record(&audit_core::parse(&line)?)?; } producer.await??; assert_eq!((summary.info, summary.warn, summary.error), (1, 1, 1)); Ok(()) } ``` [View the tested source](https://github.com/robertdevore/rust.robertdevore.com/blob/main/examples/22_stage_three.rs) Each `String` moves into the queue. The consumer borrows it to parse an `Event`, updates the summary, and then drops the record. The event never escapes the record's lifetime. The consumer owns the counts, so it needs no shared mutable state, reference counting, or mutex. ## What the queue limit covers The queue holds at most two pending messages. This fixture also has a fixed number and size of messages. If you replace it with network input, a bounded queue alone does not cap individual record size, number of connections, or producer-side accumulation. Apply the byte limit before a producer allocates an arbitrarily large record. The example stops on a parse error through `?`. At that point the receiver is dropped, pending sends fail, and the short-lived runtime shuts down on return. That is enough for a finite teaching fixture. A long-running service needs code that checks how each task ends and decides whether to finish queued work, cancel it, or restart the task. Do not mistake this demonstration for a complete production server. ## Cancellation exercise Extend the pipeline so the producer can wait for a shutdown signal. Specify whether already accepted messages are drained. Use a `JoinSet` or explicit task handles to observe completion; do not detach tasks by losing their handles. Add a test in which a shutdown arrives while the queue is full.
Solution and acceptance check A valid policy closes admission, drops all producers' senders, drains already queued work, and awaits producer completion. A different policy may discard queued work, but the result must say it is incomplete. In the full-queue test, the consumer must keep making progress or cancellation must release the blocked send. Use deterministic synchronization rather than hoping a sleep creates the intended state.
## Review the ownership graph The producer owns each record before sending; the channel owns it while queued; the consumer owns it while parsing. The summary remains local to the consumer. The parser has no runtime dependency and is reused unchanged. Because the parser accepts `&str`, it works here without changes. The synchronous reader remains separate behind `BufRead`. If you introduce multiple consumers, decide how totals are merged and how failures affect completeness. A mutex is one possible choice, but local summaries returned to an aggregator can keep the critical path simpler. Choose based on how much work you have and what should happen when part of it fails. Sources: [Tokio shared state](https://tokio.rs/tokio/tutorial/shared-state), [bounded mpsc](https://docs.rs/tokio/latest/tokio/sync/mpsc/), and [JoinSet](https://docs.rs/tokio/latest/tokio/task/struct.JoinSet.html). --- # Unsafe boundaries and soundness Review a small unsafe function, explain its safety rules, and check it with Miri. Canonical: https://rust.robertdevore.com/course/23-unsafe/ Author: Robert DeVore Technical baseline: Rust 1.98.1, edition 2024; verified 2026-09-06. ## Unsafe does not suspend Rust's rules Unsafe operations have requirements the compiler cannot completely verify. An unsafe block makes the programmer responsible for meeting those requirements; undefined behavior remains forbidden. A safe API is sound only if no allowed safe caller can trigger undefined behavior through it. The lab reimplements a tiny slice split for study. Production application code should use the standard `split_at_mut`. The separate lab lets us study unsafe code while forbidding it in the application. ```rust //! Study-only unsafe boundary. In application code prefer `slice::split_at_mut`. /// Split at a checked index, preserving exclusive access to disjoint elements. /// /// Panics if `mid > slice.len()`. Safe callers have no extra obligations. pub fn split(slice: &mut [T], mid: usize) -> (&mut [T], &mut [T]) { let len = slice.len(); assert!(mid <= len, "split index exceeds length"); let ptr = slice.as_mut_ptr(); // SAFETY: ptr comes from a valid exclusive slice. mid <= len keeps the // offset in the allocation or one-past. Both ranges contain initialized T, // are aligned/non-null even when empty, and partition the original range. // Returned lifetimes are tied to slice; the original borrow cannot be used // while these reborrows are live. ZSTs have disjoint logical elements even // when their addresses coincide. No ownership or destructor is duplicated. unsafe { ( std::slice::from_raw_parts_mut(ptr, mid), std::slice::from_raw_parts_mut(ptr.add(mid), len - mid), ) } } #[cfg(test)] mod tests { use super::*; #[test] fn partitions() { for mid in 0..=4 { let mut data = [0; 4]; let (a, b) = split(&mut data, mid); a.fill(1); b.fill(2); assert_eq!(data[..mid], vec![1; mid]); assert_eq!(data[mid..], vec![2; 4 - mid]); } } #[test] fn empty_and_zst() { let mut empty: [u8; 0] = []; assert!(split(&mut empty, 0).0.is_empty()); let mut z = [(); 4]; let (a, b) = split(&mut z, 2); assert_eq!((a.len(), b.len()), (2, 2)); } #[test] fn drops_once() { use std::rc::Rc; let token = Rc::new(()); { let mut data = [token.clone(), token.clone()]; let (a, b) = split(&mut data, 1); std::mem::swap(&mut a[0], &mut b[0]); } assert_eq!(Rc::strong_count(&token), 1); } #[test] #[should_panic(expected = "split index exceeds length")] fn rejects_oob() { split(&mut [1], 2); } } ``` [View the complete source](https://github.com/robertdevore/rust.robertdevore.com/blob/main/crates/unsafe-lab/src/lib.rs) ## The safety argument **What must remain true?** Both returned slices cover initialized elements from the original slice, stay within its allocation, retain valid alignment and provenance, and refer to disjoint logical element ranges. Their references cannot outlive the original borrow. **Who maintains it?** The caller supplies a valid exclusive slice through a safe Rust type. The function checks the split index before pointer arithmetic. Its implementation constructs exactly the two ranges. Borrow checking relates the returned lifetimes to the input. **Which safe callers are allowed?** Any valid mutable slice, including empty slices, zero-sized element types, and types with destructors. A split at either endpoint is valid. An out-of-range index must panic before unsafe operations. Safe callers must not need to follow extra, undocumented rules. **How is the unsafe code kept small?** One unsafe block follows a checked boundary. The raw pointer does not escape. No allocation is freed and no element ownership is duplicated. The application never depends on this study implementation. **What would make it unsound?** Removing the bounds check, overlapping nonempty element ranges, inventing a longer return lifetime, accepting an arbitrary unvalidated pointer, or allowing conflicting access through the original slice while the returned borrows are live. ## Aliasing is more than addresses Zero-sized elements can share an address without representing overlapping stored bytes. A simplistic “different addresses means safe” argument would fail to cover them. Likewise, `UnsafeCell` relaxes shared-reference immutability for its contents but does not erase exclusive-reference rules or synchronize concurrent accesses. The Reference explicitly notes that exact aliasing rules remain an active specification area. Miri checks executions under its models. A passing run does not prove all executions safe, and its models are not the final language specification. We test ordinary elements, endpoints, empty input, zero-sized elements, and drop behavior, then inspect the invariant argument separately. ## Exercise Run the isolated lab under Miri: ```sh rustup toolchain install nightly-2026-09-05 --profile minimal --component miri cargo +nightly-2026-09-05 miri test -p unsafe-lab ``` Explain why no destructor may run twice and why a safe caller using `mem::forget` must not invalidate the abstraction. Do not execute deliberately undefined examples in a normal production process.
Solution and acceptance check The function returns borrows, not new owners of elements. Dropping a slice reference does not drop its elements. The original owner retains destruction responsibility. Forgetting a reference does not produce conflicting access by itself; the implementation does not rely on its destructor running. The tests check selected cases. The safety argument must cover every allowed safe caller.
Sources: [undefined behavior](https://doc.rust-lang.org/reference/behavior-considered-undefined.html), [Rustonomicon](https://doc.rust-lang.org/nomicon/), [`from_raw_parts_mut`](https://doc.rust-lang.org/std/slice/fn.from_raw_parts_mut.html), and [Miri](https://github.com/rust-lang/miri). --- # Measure performance and resource use Measure speed and memory use before changing how the program works. Canonical: https://rust.robertdevore.com/course/24-performance/ Author: Robert DeVore Technical baseline: Rust 1.98.1, edition 2024; verified 2026-09-06. ## A measurement starts with a question Does parsing allocate? Does input reading dominate runtime? Does parallelism help on representative files? Each question needs its own test. “Rust is fast” does not answer any of them. ### Run the example ```sh cargo run --locked --example 23_performance ``` ```rust use std::{hint::black_box, time::Instant}; fn main() { let input = "INFO small message"; let start = Instant::now(); for _ in 0..10_000 { black_box(audit_core::parse(black_box(input)).unwrap()); } println!( "10,000 parses: {:?}; one sample, not a benchmark conclusion", start.elapsed() ); } ``` [View the tested source](https://github.com/robertdevore/rust.robertdevore.com/blob/main/examples/23_performance.rs) The example shows how to time code and use `black_box`. One timing result is not enough to draw a conclusion. Startup, optimization, CPU frequency, background work, and tiny sample size can dominate. `black_box` is a best-effort optimization barrier useful in experiments, not a mathematical guarantee about generated code. ## Design the experiment Run optimized builds when evaluating optimized production behavior. Record compiler version, target, profile, input distribution, hardware, and sample count. Make sure both versions produce the same output and handle errors the same way. Include empty input, typical records, long records near the limit, and malformed data if those occur in the workload. Separate warm-up from samples where appropriate. Report a distribution rather than one favorable run. Change one significant variable at a time. If you claim lower memory use, measure it or prove a specific bound and state precisely which allocations the bound covers. Our parser borrows message text rather than allocating a new string. That is visible in its interface and implementation. The reader keeps one bounded record vector, while the caller's buffering layer can have its own allocation. A borrowed design still pays for reading, validation, and counting; avoiding one allocation does not mean the whole program is zero-cost. ## Optimize the bottleneck A profile may show that filesystem I/O dominates, in which case replacing an iterator with a loop is unlikely to matter. It may show formatting costs, in which case current standard-library APIs deserve review before adding a crate. Rust 1.98 introduced integer buffer formatting APIs, but our three-line-sized summary does not justify optimizing that path without evidence. Monomorphization can improve inlining and also increase compile time or code size. Dynamic dispatch has indirection costs and can reduce duplication. `Arc::clone` updates an atomic count; `String::clone` copies text. Treat these as different operations. Check whether unnecessary copies or allocations cause the slowdown before reaching for unsafe code. ## Exercise Compare parsing borrowed records with a version that creates a new owned message for each record. Preserve outputs and error handling. Record at least several repeated optimized runs and explain which part of the measurement is noisy. Do not commit a machine-specific speed claim as a universal fact.
Solution and acceptance check Keep input bytes and loop counts identical, consume results so they cannot trivially disappear, and record toolchain/profile. The owned version allocates new message strings, but the timing difference may vary. A useful conclusion states the tested workload and uncertainty. If the difference is below noise, report that rather than selecting the fastest run.
Sources: [`black_box`](https://doc.rust-lang.org/std/hint/fn.black_box.html), [Cargo profiles](https://doc.rust-lang.org/cargo/reference/profiles.html), and [Rust 1.98 release notes](https://blog.rust-lang.org/2026/08/20/Rust-1.98.0/). --- # Stable Rust and work in progress Tell stable features apart from nightly experiments and plans that may change. Canonical: https://rust.robertdevore.com/course/25-horizon/ Author: Robert DeVore Technical baseline: Rust 1.98.1, edition 2024; verified 2026-09-06. ## The version this course uses This course was verified on **2026-09-06** against **Rust 1.98.1**, edition **2024**. The September point release fixes a vtable-generation miscompilation in 1.98.0, so the course pins the corrected version. The compiler version matters when you reproduce the examples and diagnostics. A feature can have both a stable subset and an active extension. The following table classifies the specific claim being made. A project goal means work is planned. It does not guarantee a stable release or a date you can rely on. | Area | Today | On the horizon / status | | --- | --- | --- | | Async functions in traits | Static-dispatch use is STABLE | Native async dyn dispatch is an ACTIVE PROJECT GOAL; our direct-dyn drill still fails | | Pin | Safe pinning APIs are STABLE | Field projections and broader pin/reborrow ergonomics are ACTIVE PROJECT GOALS with EXPERIMENTAL work | | Borrow checking | Stable checks remain the course baseline | Polonius alpha is NIGHTLY work and an ACTIVE PROJECT GOAL; do not assume all lending patterns work on stable | | Trait solver | Stable compilation is the contract used here | Global next-solver rollout is NIGHTLY; the August 21 announcement is not stable rollout | | Const generics | Array lengths parameterized by integers are STABLE | Full const generics and const trait capabilities are evolving; const trait implementation support is NIGHTLY | | Reference counting | Explicit `Rc::clone` and `Arc::clone` are STABLE | Reborrow and smart-pointer ergonomics are ACTIVE PROJECT GOALS | | Allocation | `GlobalAlloc` and normal collections are STABLE | The general allocator API remains NIGHTLY; Allocators 1.0 is an ACTIVE PROJECT GOAL | | Unsafe fields | Private fields plus documented invariants are the stable idiom | `unsafe_fields` is NIGHTLY; it does not replace a safety argument | | Safety contracts | Written invariants and checked unsafe boundaries remain required | Primitive ownership assertions are an ACTIVE PROJECT GOAL, not a stable verification guarantee | | Formal specification | Reference contracts guide production work | a-mir-formality and executable specification work are EXPERIMENTAL / ACTIVE PROJECT GOALS | | C/C++ interop | Explicit ABI boundaries and maintained bindings are usable today | Seamless interop and problem-space mapping are ACTIVE PROJECT GOALS | | Standard-library rebuilding | Normal prebuilt std targets are the baseline | Cargo `-Z build-std` is NIGHTLY | | Build speed | Stable Cargo profiles and incremental builds are available | Fast Builds is an ACTIVE PROJECT GOAL roadmap; measure local changes | | Semver tooling | Cargo's compatibility guidance applies today | cargo-semver-checks is ecosystem tooling, not a compiler proof of all compatibility | ### Run the example ```sh cargo run --locked --example 24_const ``` ```rust fn first(values: &[u8; N]) -> Option { values.first().copied() } fn main() { assert_eq!(first(&[3, 4]), Some(3)); assert_eq!(first(&[]), None); } ``` [View the tested source](https://github.com/robertdevore/rust.robertdevore.com/blob/main/examples/24_const.rs) This small array example uses stable const generics, which do not let arbitrary trait methods run at compile time. **Intentionally fails on Rust 1.98.1.** Run `rustc --edition=2024 drills/const_trait.rs` from the repository. ```rust trait Compute { const fn compute(&self) -> usize; } fn main() {} ```
Actual compiler diagnostic · Rust 1.98.1 ```text error[E0379]: functions in traits cannot be declared const --> drills/const_trait.rs:1:17 | 1 | trait Compute { const fn compute(&self) -> usize; } | ^^^^^- | | | functions in traits cannot be const | help: remove the `const` error: aborting due to 1 previous error For more information about this error, try `rustc --explain E0379`. ```
The August program update also records two discontinued efforts: the specific **Experimental language specification** goal and **Continue Experimentation with Pin Ergonomics**. Treat those goal labels as HISTORICAL / SUPERSEDED, not active promises. Field-projection work and the newer end-to-end executable specification goal continue on their own terms. Cargo’s linting-system goal is reported complete; this course uses current manifest lints and Clippy rather than describing that completed goal as future work. See the [July–August program update](https://blog.rust-lang.org/inside-rust/2026/08/31/program-management-2026-jul-aug/) and [ownership-contract goal](https://github.com/rust-lang/goals/issues/734). ## How to update an explanation First reproduce behavior on the exact stable compiler. Then read the Reference, stabilization record, and relevant goal or tracking issue. If the compiler rejects a safe pattern because its analysis is conservative, describe the currently supported expression and the limitation. Distinguish a limit in the compiler's analysis from a rule of the language. Work on an easier alternative does not make an existing feature deprecated. Historical descriptions need dates. Proposed designs can change or fail to ship. The core exercises avoid nightly language features; only the optional unsafe verification toolchain uses nightly for Miri. ## Exercise Choose one row, follow its official source, and identify what evidence would justify changing its status to STABLE. Record the compiler release and stabilization reference rather than editing the label based on a social post.
Solution and acceptance check A stable release note or merged stabilization record plus a successful minimal stable probe is strong evidence. A goal marked accepted, a nightly demo, or a merged RFC alone is insufficient. Preserve the older verification date in the research history and rerun affected compiler drills.
Sources: [release fix](https://blog.rust-lang.org/2026/09/03/Rust-1.98.1/), [2026 goals](https://goals.rust-lang.org/2026/goals.html), [Polonius](https://goals.rust-lang.org/2026/polonius.html), [next-solver rollout](https://blog.rust-lang.org/2026/08/21/enabling-next-solver-on-nightly/), [unstable Cargo](https://doc.rust-lang.org/cargo/reference/unstable.html), [unsafe fields](https://doc.rust-lang.org/unstable-book/language-features/unsafe-fields.html), [const traits](https://doc.rust-lang.org/unstable-book/language-features/const-trait-impl.html), and [allocator API](https://doc.rust-lang.org/std/alloc/trait.Allocator.html). --- # FFI, serialization, and public contracts Define ownership, data formats, and errors when calling code outside Rust. Canonical: https://rust.robertdevore.com/course/26-interop/ Author: Robert DeVore Technical baseline: Rust 1.98.1, edition 2024; verified 2026-09-06. ## The other side has its own rules A foreign function interface (FFI) lets Rust call code that may follow different rules. `extern "C"` selects an ABI; it does not validate pointers, lengths, ownership, or lifetimes. `repr(C)` specifies a C-compatible layout scheme for the annotated type, but does not make every contained Rust type appropriate to expose to C. The following example calls a C-ABI function defined in the same Rust program. It is a portable ABI-boundary exercise, not a demonstration of building or linking an external C library. The byte pointer is not dereferenced because the function only counts a provided length. ```rust extern "C" fn record_length(_bytes: *const u8, len: usize) -> usize { // This function never dereferences or retains the pointer. len } fn main() { let record = b"INFO ready"; assert_eq!(record_length(record.as_ptr(), record.len()), 10); } ``` [View the complete source](https://github.com/robertdevore/rust.robertdevore.com/blob/main/examples/25_ffi.rs) A real external declaration needs an unsafe extern block in edition 2024. When adding an actual C library, consult its headers and platform ABI, specify how memory is allocated and released, and test the build on supported targets. Avoid passing `String`, Rust enums with unspecified layout, or trait objects as if their representation were a stable C contract. ## Write down ownership in both directions For each pointer parameter, ask whether null is allowed, which allocation it belongs to, how many initialized elements are accessible, whether mutation is permitted, and how long the callee retains it. For each returned pointer, identify who frees it and with which allocator. Two libraries can each be correct on their own but crash when they disagree about these rules. Unwinding across an ABI boundary needs an explicit contract. A panic must not accidentally unwind through an incompatible foreign frame. Catching a panic works only if it unwinds. It cannot recover from an abort or undefined behavior. Do not use panic catching to make invalid pointers acceptable. C++ has additional challenges around object lifetimes, exceptions, templates, and ownership. Bridge libraries can enforce some of these rules, but you still need to check the foreign code. Current Rust interop initiatives are improving this space; the course's application deliberately needs no FFI. ## A file format is also an interface Our plain-text format has exact level spelling, UTF-8 messages, and a record-size rule. Adding JSON should use a maintained serializer such as Serde with an explicitly versioned schema. Derived serialization saves repetitive code. You still need to decide how to handle unknown fields, missing fields, and incompatible changes. Never persist `Debug` output as a stable format. ## Exercise Write a contract for a hypothetical foreign function receiving bytes. Specify pointer validity, length, retention, mutability, ownership, and error reporting. Then identify which checks a safe wrapper could perform and which require trusting the foreign implementation.
Solution and acceptance check A slice can provide initialized byte storage and a valid length for the call. The wrapper can pass its pointer and length without transferring ownership. The contract must forbid retaining the pointer beyond the borrow unless a separate ownership protocol exists. The wrapper cannot prove arbitrary foreign code obeys that promise; implementation review and testing remain necessary.
Sources: [external blocks](https://doc.rust-lang.org/reference/items/external-blocks.html), [type layout](https://doc.rust-lang.org/reference/type-layout.html), [FFI unwinding](https://doc.rust-lang.org/nomicon/ffi.html#ffi-and-unwinding), [Serde](https://serde.rs/), and [interop work](https://goals.rust-lang.org/2026/interop-problem-map.html). --- # Capstone: ship Fieldnotes Combine the parser and reader into a command-line tool you can install and use. Canonical: https://rust.robertdevore.com/course/27-capstone/ Author: Robert DeVore Technical baseline: Rust 1.98.1, edition 2024; verified 2026-09-06. ## Product brief Fieldnotes is the course's completed event-audit tool, packaged as `audit-cli`. It accepts exactly one path argument, with `-` meaning standard input. When it succeeds, it prints one summary line to stdout in a fixed order. Errors go to stderr and produce a nonzero exit code. No partial summary is printed when input is invalid. The format is one `LEVEL message` record per line. Levels are exactly INFO, WARN, and ERROR. Messages must contain non-whitespace text. Input must be UTF-8. LF, CRLF, and an unterminated final record are supported. Each record may contain at most 4096 bytes including its newline if present. Counts use checked `u64` arithmetic. ## Build and install ```sh cargo build --release --locked -p audit-cli cargo install --locked --path crates/audit-cli printf 'INFO ready\nWARN retry\nERROR stopped\n' | audit-cli - ``` Expected stdout is `INFO=1 WARN=1 ERROR=1`, followed by a newline. For a file, replace `-` with its path. The executable name is `audit-cli`; Fieldnotes is the product name used in this course. `cargo install` places the executable in Cargo's binary directory, which must be on your PATH. ```rust use std::{ env, fs::File, io::{self, BufReader, Write}, process::ExitCode, }; fn run() -> Result<(), Box> { let mut args = env::args_os().skip(1); let path = args.next().ok_or("usage: audit-cli ")?; if args.next().is_some() { return Err("usage: audit-cli ".into()); } let summary = if path == "-" { audit_core::summarize(io::stdin().lock())? } else { audit_core::summarize(BufReader::new(File::open(path)?))? }; writeln!( io::stdout().lock(), "INFO={} WARN={} ERROR={}", summary.info, summary.warn, summary.error )?; Ok(()) } fn main() -> ExitCode { match run() { Ok(()) => ExitCode::SUCCESS, Err(e) => { let _ = writeln!(io::stderr().lock(), "audit: {e}"); ExitCode::FAILURE } } } ``` [View the complete source](https://github.com/robertdevore/rust.robertdevore.com/blob/main/crates/audit-cli/src/main.rs) ## Why main stays small Argument parsing uses `args_os`, allowing platform-native paths rather than assuming every filename is Unicode. Opening the file and choosing stdin belong in the CLI. Reading, syntax, record limits, and counts belong in the library. Printing only after the full input succeeds prevents partial counts from appearing as a complete result. Output writing can fail, including a downstream pipe closing. The program returns a failure in that case. Stderr writing during error reporting is best-effort because another failure cannot be reported indefinitely. Handle these failures even when reading and parsing succeeded. ## Acceptance suite Run `npm run verify:rust`. It checks the workspace, runs examples and compiler drills, and exercises the CLI with valid stdin, empty input, malformed input, invalid UTF-8, an oversized record, a missing path, and a real file. The library tests independently cover chunk boundaries and count overflow. Run `cargo test --release --workspace --locked` when changing arithmetic or profile settings. ## Your extension Add `--allow-errors` only after specifying its output contract. Decide how rejected rows are counted, whether oversized input can be skipped without unbounded storage, and how an I/O failure differs from a malformed row. Test each of those cases as well as the new argument.
Reference design and acceptance check Represent completeness in the result type rather than silently discarding failures. Keep strict mode's behavior unchanged. A lenient mode should report both accepted and rejected counts and still fail on unreadable input. Discarding an oversized record requires scanning to a terminator while keeping storage bounded. Document whether a rejected final unterminated record is counted.
## Check your finished tool Your tool should count correctly, limit its record buffer, report failures clearly, and build with the pinned toolchain. It should not need unsafe code. You should be able to trace each borrow to its owner and each allocation to a reason. Performance claims need measurements. A clean compile is necessary but does not establish every product requirement. Sources: [`args_os`](https://doc.rust-lang.org/std/env/fn.args_os.html), [Cargo install](https://doc.rust-lang.org/cargo/commands/cargo-install.html), and [`ExitCode`](https://doc.rust-lang.org/std/process/struct.ExitCode.html). --- # Release, maintain, and keep learning Check compatibility, release a reproducible build, and keep the course up to date. Canonical: https://rust.robertdevore.com/course/28-release/ Author: Robert DeVore Technical baseline: Rust 1.98.1, edition 2024; verified 2026-09-06. ## Make the release reproducible The course repository is independently buildable with Node 24 and Rust 1.98.1. The website and Rust application have separate build paths. The website is static HTML with local assets; the application is an ordinary Cargo workspace. Neither needs another course repository at runtime or build time. Before releasing a change, run the documented checks from a clean checkout. Keep generated build artifacts out of the source commit unless they are intentional evidence such as compiler diagnostic snapshots. Review the lockfile diff when updating dependencies. A passing test suite does not excuse an unexplained dependency change. ### Run the example ```sh cargo run --locked --example 13_cargo ``` ```rust fn main() { assert_eq!(env!("CARGO_PKG_NAME"), "rust-course-examples"); println!( "package={} version={}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION") ); } ``` [View the tested source](https://github.com/robertdevore/rust.robertdevore.com/blob/main/examples/13_cargo.rs) ## Review the public API Other programs may rely on your public fields, trait bounds, enum variants, and error types. Removing an implementation or adding a required bound can break callers even when your own tests compile. Adding variants to an exhaustive public enum can be breaking. `#[non_exhaustive]` leaves room to add variants or fields later, but limits how callers construct or match values. Decide whether you need it before publishing. Cargo's semver guidance and tools such as cargo-semver-checks help identify API changes. They do not prove behavioral compatibility, validate a file format, or establish that every performance expectation is preserved. Write release notes around observable effects, including changes to limits and failure behavior. The course packages set `publish = false` because they are for local study. Publishing a general-purpose crate would require selecting an appropriate name, reviewing metadata and licensing, testing a packaged archive, and establishing a support policy. Learners can install the application with `cargo install --path`; it does not need a registry release. ## A practical maintenance loop 1. Inspect the current stable release and edition guidance. 2. Review source changes for concepts marked evolving. 3. Update the evidence ledger with dates and affected lessons. 4. Compile every runnable example and regenerate intentional failures. 5. Run tests, Clippy, formatting, and the isolated Miri lab. 6. Build the site, check links and metadata, and test keyboard/mobile behavior. 7. Deploy and verify the actual production domain, including deep links. The ledger records sources and recommendations without claiming that named maintainers reviewed or endorsed the course. Their work informs review questions; our own examples and tests supply reproducible evidence. ## Exercise Propose a change that would allow lowercase levels. Describe whether it is a parser behavior change, an API signature change, a file-format change, or several of these. Add tests for the intended mixed-case policy and explain how the release notes should describe it.
Solution and acceptance check Accepting lowercase broadens the file grammar even if function signatures remain unchanged. Decide whether all case combinations are valid and whether messages remain untouched. Existing strict callers might rely on rejection, so consider compatibility in context. Release notes should state exactly which inputs now succeed and whether strict mode remains available.
## Where to go next Choose a real problem: a network service, an embedded peripheral, a library used by another team, or a performance-sensitive tool. Learn what that problem requires. Study new language features as you need them. Revisit ownership, errors, synchronization, and invariants at each boundary. The goal is software whose behavior another developer can understand and maintain. Sources: [semver compatibility](https://doc.rust-lang.org/cargo/reference/semver.html), [publishing](https://doc.rust-lang.org/cargo/reference/publishing.html), and [cargo-semver-checks](https://github.com/obi1kenobi/cargo-semver-checks).