Workshop · Rust · Intermediate
RS100 Rust I
Forty-eight steps across eight sessions that turn a beginner into a Rust programmer: ownership and borrowing taught properly, structs and enums that make illegal states unrepresentable, iterators instead of index loops, Result with ? and thiserror, unit and integration tests, files in CSV and JSON with serde, modules and documentation, and the Stockroom capstone, a tested inventory library and command-line tool the grader proves with hidden tests.
Open Coding Tutor at codingtutor.agilesystemsoftware.com, pick RS100 from the course button in the chat and press Tutor me. Every lesson, demo and hands-on step is also published below.
Who it is for
People who can already write a small program in Rust or in any other language — variables, loops, functions, a list and a map — and want to write Rust the way the compiler wants it written: ownership and borrowing understood rather than fought, data modelled with structs and enums, errors as values, and tests that run on every build.
Bridge. Coming from PY050, JV050, DN050, NG050: the concepts you already proved there are credited here (10 of them), and the first session re-teaches the syntax.
Where it leads. RS300 Rust, advanced: services in production shape
What you will be able to do
- Explain and apply ownership, moves, borrows and lifetimes in a small program, and read the borrow checker's messages as advice
- Use cargo offline, rustfmt, clippy and the debugger as part of every change
- Model data with structs, enums carrying data, Option, impl blocks and traits, and hold money in rust_decimal
- Transform collections with iterator chains (map, filter, fold, collect), sort by key and group with itertools
- Handle errors as values with Result, ?, thiserror in a library and anyhow at the edge, and release resources with Drop
- Write unit, integration, table-driven and doc tests and run them with cargo test
- Read and write CSV and JSON with the csv crate and serde, and handle dates with chrono
- Organise a crate into modules with a lib and a bin target, document it with cargo doc and a README
Syllabus
Every session ends in a lab the machine checks on your own code. Open a session's mini steps to read each lesson, demo and hands-on task; the badge shows how far the engine can verify it: checked · T3 a check runs over your files, proven · T4 the grader also tries inputs you did not see, tutored · T0 done on your word. Each step has its own link (for example #s1-1).
- 1. Idiomatic Rust and the tools
- 2. Types and data modelling
- 3. Collections and iterators
- 4. Errors and resources
- 5. Testing
- 6. Files, data formats and time
- 7. Crates, modules and docs
- 8. Capstone: Stockroom
Session 1 · Idiomatic Rust and the tools
Six steps that make the seat a Rust workshop. cargo offline, the project layout and Cargo.lock. rustfmt as the end of every style argument and clippy as the reviewer that never tires; both run before every check in this course. Ownership taught properly: a value has one owner, a move ends the old name, Clone copies on purpose. Borrowing: as many shared references as you like or exactly one mutable one, and why that rule is the whole memory-safety story. Slices, String versus &str again with ownership in view, and the debugger stepping through a loop. Live in the seat: a move error read as advice, a function rewritten to borrow, a clippy warning fixed, a breakpoint inside a word count.
Lab The borrow-checked word counter: a function count_words(text: &str) -> Vec<(String, usize)> that borrows its input, returns counts sorted by count descending then word, is clippy-clean and formatted, with a main that reads a file named on the command line. Session 1 checkpoint.
6 mini steps · ~52 min
1.1cargo offline, the layout, the lock file8 minchecked · T3
Predict
You add a crate to Cargo.toml that is not in the seat's vendored set and run cargo build --offline. What happens?
Lesson
A crate is Cargo.toml plus src/. Dependencies are declared there and locked in Cargo.lock, which you commit. The seat's cargo is offline and resolves only from its vendored set, so a missing crate fails fast instead of hanging. cargo build, cargo run, cargo test and cargo doc are the four verbs you will use all course.
Demo
The tutor opens rust/rs100/demo/s1, reads Cargo.toml and Cargo.lock, adds serde and runs cargo build --offline, then adds an unvendored crate to show the immediate failure, and removes it.
Hands-on
Create rust/rs100/s1 with cargo new s1. Add itertools = "0.14" to [dependencies], run cargo build --offline, and open Cargo.lock to find the itertools entry. Print "ready" from main.
Explain
Why commit Cargo.lock for a program?
Check
itertools declared, lock written, build clean — checked (T3):
seat_fileseat_fileseat_run1.2rustfmt and clippy on every change8 minchecked · T3
Predict
clippy sees let x = v.len() == 0; What does it suggest?
Lesson
cargo fmt rewrites your files to the standard style, so style is never discussed. cargo clippy reads your code for the mistakes the compiler allows: needless clones, len() == 0, a loop that should be an iterator. In this course every check runs clippy with warnings denied; fix them, do not silence them.
Demo
The tutor pastes a deliberately ugly function into the demo, runs cargo fmt and shows the diff, then cargo clippy and fixes three warnings one by one, reading each lint name and its help text.
Hands-on
Copy rust/rs100/samples/s1/messy.rs over rust/rs100/s1/src/main.rs. Run cargo fmt, then cargo clippy, and fix every warning until clippy is silent. The program must still print the same three lines.
Explain
Why fix a clippy warning instead of allowing it?
Check
messy.rs formatted and clippy-clean — checked (T3):
seat_lintseat_run1.3Ownership: one owner, moves, Clone9 minchecked · T3
Predict
let a = String::from("x"); let b = a; println!("{}", a); What does the compiler say?
Lesson
Every value has exactly one owner; when the owner goes out of scope the value is dropped. Assigning or passing a String moves it, so the old name is dead. Types like i32 are Copy and are duplicated instead. .clone() copies a String on purpose and visibly. The borrow checker's message tells you which line moved what.
Demo
The tutor writes the move error in the demo, reads the message line by line including the "value moved here" note, fixes it with clone(), then shows that an i32 does not move, and finally drops a value in an inner block to show when Drop happens.
Hands-on
In rust/rs100/s1/src/main.rs write fn shout(s: String) -> String that returns the uppercase; call it with a String, then try to print the original and read the error; fix it by passing a clone. Print both the original and the shouted text.
Explain
What did the compiler stop you from doing, and why is that good?
Check
shout takes ownership, caller clones — checked (T3):
seat_fileseat_run1.4Borrowing: & and &mut9 minchecked · T3
Predict
let mut v = vec![1]; let r = &v; v.push(2); println!("{:?}", r); What happens?
Lesson
A reference borrows a value without taking it: &T to read, &mut T to change. The rule: any number of &T, or exactly one &mut T, never both alive at once. Functions should borrow (&str, &[T]) unless they need to keep the value. The checker enforces the rule at compile time, which is why Rust has no data races.
Demo
The tutor rewrites shout to take &str and return String so no clone is needed, then writes fn add_tax(prices: &mut Vec<i64>) and shows the shared-then-mutable conflict and how ending the first borrow earlier fixes it.
Hands-on
Change shout to fn shout(s: &str) -> String and remove the clone. Add fn bump(prices: &mut Vec<i64>) that adds 10 to every element in place. Call both and print the shouted text and the bumped vector with {:?}.
Explain
Why should a function take &str rather than String when it only reads?
Check
shout borrows, bump mutates in place — checked (T3):
seat_fileseat_runseat_lint1.5Slices and the debugger8 minchecked · T3
Predict
let s = String::from("hello world"); let w = &s[0..5]; What is w and what type?
Lesson
A slice is a borrowed view of part of something: &s[a..b] of a String is a &str, &v[a..b] of a Vec is a &[T]. Functions that take slices accept arrays, Vecs and Strings alike. The seat's debugger (Run and Debug, rust-gdb) stops on a breakpoint and shows every local, which beats a hundred println! calls.
Demo
The tutor writes fn first_word(s: &str) -> &str using split_whitespace().next(), sets a breakpoint inside a loop over words in the demo, steps three iterations in the debugger reading the variables pane, then runs to the end.
Hands-on
Write fn first_word(s: &str) -> &str and fn total(prices: &[i64]) -> i64 in rust/rs100/s1/src/main.rs. Call total with both an array and a Vec. Set a breakpoint inside total, step twice, then print "First hello" and "Total 60" for [10, 20, 30].
Explain
Why can total(&[i64]) accept both an array and a Vec?
Check
first_word and total over slices — checked (T3):
seat_fileseat_run1.6Lab: the borrow-checked word counter10 minchecked · T3
Predict
count_words("b a b") sorted by count descending then word gives which first pair?
Lesson
Everything from this session: a function that borrows &str, lowercases and splits on whitespace, counts into a HashMap<String, usize>, collects into a Vec and sorts by count descending then by word, returning owned pairs. main reads the file named in argv and prints the top five. clippy-clean and formatted.
Demo
The tutor builds count_words in the demo, runs it over rust/rs100/samples/s1/speech.txt, then introduces a needless clone and a manual index loop to show what clippy says before fixing both.
Hands-on
In rust/rs100/s1/src/main.rs write pub fn count_words(text: &str) -> Vec<(String, usize)> as specified. main reads the file in argv[1] (exit 2 if unreadable) and prints the top five as "word count". Must pass cargo clippy and cargo fmt --check.
Explain
Where does your function borrow and where does it own, and why each?
Check
count_words borrows, sorts, prints top five, clippy-clean — checked (T3):
seat_fileseat_lintseat_runseat_run
Session 2 · Types and data modelling
Six steps on saying what the data is. Structs with named fields and derive for Debug, Clone and PartialEq. Enums that carry data, so a status or a payment method cannot hold an impossible value, and match that must cover every variant. Option for a value that may be absent instead of a sentinel. impl blocks: constructors, methods, associated functions. Traits as contracts: Display for printing, a Priced trait of your own, and generic functions bounded by a trait. Money as rust_decimal::Decimal with dec! literals, so 0.10 is 0.10. Live in the seat: an Item struct, a Category enum with a Custom(String) variant, an Option<Decimal> discount, impl Item with total(), Display for a receipt line, and the typed catalogue lab.
Lab The typed catalogue in rust/rs100/stock: Item, Category, Discount, impl Item::new and total(), Display for Item, a Priced trait implemented by Item and Bundle, and a generic fn grand_total<T: Priced>(items: &[T]) -> Decimal, all under test. Session 2 checkpoint.
6 mini steps · ~52 min
2.1Structs and derive8 minchecked · T3
Predict
struct Item { name: String, qty: u32 } without derives. println!("{:?}", item) does what?
Lesson
A struct names a group of fields with their types. #[derive(Debug, Clone, PartialEq)] generates printing, copying and comparison so you do not write them. Fields are private outside the module unless pub. Construct with the literal syntax and the field shorthand when a local has the same name.
Demo
The tutor creates the lib crate rust/rs100/demo/stock, writes pub struct Item { pub sku: String, pub name: String, pub qty: u32, pub unit_cents: i64 } with derives, builds two items, prints them with {:?} and {:#?}, and compares them with ==.
Hands-on
Create rust/rs100/stock with cargo new stock --lib and add src/main.rs. Define pub struct Item with sku: String, name: String, qty: u32 and unit_cents: i64, derive Debug, Clone and PartialEq, and in main print one item with {:?} and "Equal true" for a clone.
Explain
What does derive save you from writing?
Check
Item struct with derives, printed and compared — checked (T3):
seat_fileseat_run2.2Enums that carry data9 minchecked · T3
Predict
enum Category { Food, Books, Custom(String) } and a match with arms for Food and Books only. Compiles?
Lesson
An enum is one of a fixed set of variants, and a variant can carry data: Custom(String), Discount::Percent(u8), Discount::Fixed(i64). match on it must cover every variant, so adding one later turns up every place that needs a decision. Enums with data replace stringly-typed fields and magic numbers.
Demo
The tutor adds pub enum Category { Food, Books, Stationery, Custom(String) } and pub enum Discount { None, Percent(u8), Fixed(i64) } to the demo lib, writes fn label(c: &Category) -> String with match, and shows the non-exhaustive error when a variant is added.
Hands-on
Add pub enum Category { Food, Books, Stationery, Custom(String) } and pub enum Discount { None, Percent(u8), Fixed(i64) } to src/lib.rs, both with derives, plus pub fn label(c: &Category) -> String returning the name or the custom text. Add category: Category to Item.
Explain
Why is Custom(String) better than a category field that is just a String?
Check
Category and Discount enums with data — checked (T3):
seat_fileseat_build2.3Option instead of a sentinel8 minchecked · T3
Predict
let d: Option<u8> = None; let v = d.unwrap_or(0); What is v?
Lesson
When a value may be absent, say so in the type: Option<T> is Some(value) or None. No -1, no empty string with a meaning. Read it with match, if let, unwrap_or, map or ?. The compiler then forces every reader to think about the None case.
Demo
The tutor changes Discount::None into an Option<Discount> field on Item, then writes fn discount_cents(item: &Item) -> i64 with match on Some(Percent(p)), Some(Fixed(c)) and None, and shows if let for the quick path.
Hands-on
Remove the None variant from Discount and give Item a field discount: Option<Discount>. Write pub fn discount_cents(item: &Item) -> i64: Percent(p) is qty × unit_cents × p / 100, Fixed(c) is c, None is 0. Test all three with assert_eq!.
Explain
Why is Option<Discount> better than a Discount::None variant?
Check
Option<Discount> and discount_cents under test — checked (T3):
seat_fileseat_test2.4impl: constructors and methods8 minchecked · T3
Predict
impl Item { pub fn total(&self) -> i64 { ... } } How is it called?
Lesson
impl Item groups the functions that belong to the type. An associated function with no self, like Item::new(...), is a constructor that can validate. A method takes &self to read or &mut self to change. Keep invariants inside: new() refuses a negative price, so no Item is ever wrong.
Demo
The tutor writes impl Item with pub fn new(sku, name, qty, unit_cents, category) -> Item, pub fn total(&self) -> i64 (qty × unit_cents minus discount_cents) and pub fn restock(&mut self, n: u32), calling each from main.
Hands-on
Add impl Item with pub fn new(sku: &str, name: &str, qty: u32, unit_cents: i64, category: Category) -> Item (discount None), pub fn total(&self) -> i64 and pub fn restock(&mut self, n: u32). Test total with and without a discount, and restock.
Explain
Why put total() on Item instead of a free function?
Check
impl Item with new, total and restock — checked (T3):
seat_fileseat_test2.5Traits: Display, your own trait, generics9 minchecked · T3
Predict
fn grand_total<T: Priced>(items: &[T]) -> Decimal. Can it take a &[Item] and a &[Bundle]?
Lesson
A trait is a contract: a set of methods a type promises. Implement std::fmt::Display to control {} printing. Define pub trait Priced { fn price(&self) -> Decimal; } and implement it for Item and Bundle. A generic fn with T: Priced works for any implementer. Decimal from rust_decimal with the dec! macro holds money exactly.
Demo
The tutor adds rust_decimal and rust_decimal_macros to the demo Cargo.toml, implements Display for Item as a receipt line, defines Priced, implements it for Item and a Bundle struct of Vec<Item>, and writes grand_total, printing dec!(0.10) + dec!(0.20).
Hands-on
Add rust_decimal and rust_decimal_macros to Cargo.toml. Implement Display for Item ("SKU name x qty 12.99"), define pub trait Priced { fn price(&self) -> Decimal; }, implement it for Item (total as Decimal with two places) and for pub struct Bundle(pub Vec<Item>), and write pub fn grand_total<T: Priced>(items: &[T]) -> Decimal.
Explain
Why hold the price as Decimal rather than f64 at this layer?
Check
Display, Priced and a generic grand_total — checked (T3):
seat_fileseat_fileseat_build2.6Lab: the typed catalogue10 minchecked · T3
Predict
Two items: 3 × 2.50 with Percent(10), and 1 × 12.99. grand_total is?
Lesson
Everything from this session as one library under test: Item, Category, Discount, Option, impl Item, Display, Priced, Bundle and grand_total, with tests for every rule including the Custom category and the Fixed discount. main prints a small catalogue and its grand total from rust/rs100/samples/s2/catalogue.txt.
Demo
The tutor completes the demo stock crate, runs cargo test with eight tests green, cargo clippy silent, and prints the catalogue with its grand total, then changes one derive to show how a missing PartialEq breaks a test's assert_eq!.
Hands-on
Finish rust/rs100/stock: tests for label (Custom), discount_cents (all three), total, restock, Display output and grand_total over two Items and over a Bundle. main prints each item with {} and "Grand total 19.74" for the two items in the predict question.
Explain
Name one impossible state your types now rule out.
Check
Typed catalogue: eight tests green, clippy clean, Grand total 19.74 — checked (T3):
seat_testseat_lintseat_runcraft
Session 3 · Collections and iterators
Six steps on doing things to many values without an index loop. Vec, HashMap and BTreeMap, and when the ordered map is the right default for reports. iter(), into_iter() and iter_mut(), and what each one lends or gives away. The adaptor chain: map, filter, enumerate, zip, take, skip, chain, and collect into a Vec, a String or a map. Folding to a single value with sum, fold, min_by_key and max_by_key. sort_by_key, dedup and binary_search on a sorted Vec. Grouping with itertools: group_by via into_group_map and join. Live in the seat: a loop rewritten as a chain clippy approves of, a report built by collect into a BTreeMap, a fold that totals cents, and a grouped report lab the grader runs with hidden data.
Lab The iterator report in rust/rs100/s3: read rows of category,cents from a file into Vec<(String, i64)>, then with iterator chains only — no index loops — produce the per-category totals in a BTreeMap, the three dearest rows, and the share of each category as a percentage; print them aligned. Hidden input files prove it. Session 3 checkpoint.
6 mini steps · ~52 min
3.1Vec, HashMap, BTreeMap: choosing8 minchecked · T3
Predict
You insert "pear", "apple", "fig" into a BTreeMap and iterate. Which order?
Lesson
Vec for a sequence, HashMap for fast lookup by key with no order, BTreeMap for lookup by key in sorted order. For a report that prints categories, BTreeMap saves the sort. Lookups are O(1) in a HashMap and O(log n) in a BTreeMap; both beat scanning a Vec.
Demo
The tutor loads the same pairs into a HashMap and a BTreeMap in rust/rs100/demo/s3/src/main.rs, prints both twice to show the ordering difference, then times 100,000 lookups in a Vec versus a HashMap with Instant.
Hands-on
Create rust/rs100/s3. Load the pairs from rust/rs100/samples/s3/rows.csv (category,cents) into a BTreeMap<String, i64> of totals with the entry API and print "books 1299", "food 750", "travel 1500" in key order without calling sort.
Explain
When would you still choose HashMap over BTreeMap?
Check
BTreeMap totals in key order without sort — checked (T3):
seat_fileseat_run3.2iter, into_iter, iter_mut8 minchecked · T3
Predict
let v = vec![String::from("a")]; for s in v { } println!("{}", v.len()); What happens?
Lesson
Three ways to walk a collection: iter() lends &T, iter_mut() lends &mut T, into_iter() gives T away and consumes the collection. for x in &v is iter(); for x in v is into_iter(). Pick by what you need to do with each element, and the borrow checker will agree.
Demo
The tutor shows all three on a Vec<String> in the demo: iter() to print, iter_mut() to push_str onto each, into_iter() to build a new Vec of uppercase, and the move error from the predict question.
Hands-on
In rust/rs100/s3/src/main.rs take a Vec<String> of three categories: print them with iter(), uppercase them in place with iter_mut(), then move them into a Vec<(String, usize)> of (name, length) with into_iter(). Print the final Vec with {:?}.
Explain
Why does into_iter() consume the Vec?
Check
iter, iter_mut and into_iter each used once — checked (T3):
seat_fileseat_run3.3The adaptor chain and collect9 minchecked · T3
Predict
(1..=6).filter(|n| n % 2 == 0).map(|n| n * n).collect::<Vec<_>>() is?
Lesson
Iterators are lazy pipelines: map transforms, filter keeps, enumerate numbers, zip pairs, take and skip cut. Nothing runs until collect (or a consumer) pulls. collect's target type decides the container: Vec<_>, String, HashMap<_, _>, or even Result<Vec<_>, _>. clippy prefers a chain to an index loop with push.
Demo
The tutor rewrites three loops from the demo as chains: a filtered Vec, a joined String with map and collect::<Vec<_>>().join, and a HashMap built from zip, running clippy before and after to show the needless_range_loop lint vanish.
Hands-on
In rust/rs100/s3/src/main.rs, from the rows Vec<(String, i64)>: build the Vec of categories with cents over 1000 (filter + map + collect), the String of all categories joined by ", " and a HashMap<String, i64> from zip of two Vecs. Print each. No index loops.
Explain
Why does collect need a type?
Check
filter/map/collect, join, zip — no index loops — checked (T3):
seat_fileseat_lintseat_build3.4Folding to one value8 minchecked · T3
Predict
rows.iter().map(|r| r.1).sum::<i64>() over cents 500, 1200, 250 gives?
Lesson
A consumer reduces the stream to one value: sum, count, min_by_key, max_by_key, any, all, position, and the general fold with an accumulator and a closure. fold(0, |acc, x| acc + x) is sum spelled out; fold with a tuple carries two running values at once.
Demo
The tutor computes the total cents with sum, the dearest row with max_by_key, the count over 1000 with filter().count(), and (total, count) in one fold in rust/rs100/demo/s3/src/main.rs, then asks clippy about a manual fold that should be sum.
Hands-on
In rust/rs100/s3/src/main.rs compute from the rows: the total cents with sum, the dearest row with max_by_key, and (total, count) in one fold. Print "Total 3549", "Dearest books 1299" and "Fold (3549, 5)".
Explain
When is fold the right tool rather than sum or max_by_key?
Check
sum, max_by_key and a two-value fold — checked (T3):
seat_fileseat_run3.5Sorting, dedup, binary search, itertools9 minchecked · T3
Predict
v.sort_by_key(|r| std::cmp::Reverse(r.1)) does what to rows?
Lesson
sort_by_key with a key closure, Reverse for descending, sort_unstable when equal elements may swap. dedup removes adjacent duplicates after a sort; binary_search finds in O(log n) on a sorted Vec. itertools adds into_group_map for grouping and join for printing, both in the seat's offline set.
Demo
The tutor sorts rows by cents descending, takes the top three, dedups a sorted Vec of categories, binary_searches it, then groups rows by category with itertools::Itertools::into_group_map and joins the keys with ", ".
Hands-on
In rust/rs100/s3/src/main.rs: print the three dearest rows sorted by cents descending as "books 1299" etc., the deduplicated sorted category list joined with ", " via itertools, and "travel found true" from binary_search on the sorted categories.
Explain
Why must a Vec be sorted before dedup and binary_search?
Check
Top three, dedup + join, binary_search — checked (T3):
seat_fileseat_run3.6Lab: the iterator report10 minproven · T4
Predict
Totals books 1299, food 750, travel 1500 of 3549. The food share, rounded to one decimal, is?
Lesson
Everything from this session, with iterator chains only: read the rows file named in argv, collect into Vec<(String, i64)>, totals into a BTreeMap, the three dearest rows by sort_by_key with Reverse, and each category's share as a percentage with one decimal, printed aligned. The grader runs it on files you did not see.
Demo
The tutor builds the report in the demo, runs it on samples/s3/rows.csv, then on a file with one category only and on an empty file, and reads clippy to confirm no index loop survived.
Hands-on
In rust/rs100/s3/src/main.rs: cargo run -- <file> prints the BTreeMap totals as "books 12.99 36.6%", then "Top 3" and the three dearest rows, then "Total 35.49". Exit 2 if the file cannot be read; an empty file prints only Total 0.00. No index loops; clippy clean.
Explain
Where did a chain replace a loop in your report, and what did it buy you?
Check
Iterator report over hidden files — proven (T4):
seat_fileseat_lintseat_run
Session 4 · Errors and resources
Six steps on failure as a value. Result<T, E> and the ? operator that returns early and converts the error type with From. Why panic is for bugs and Result is for expected failure, and when unwrap and expect are honest. Your own error enum with thiserror: one variant per failure the domain knows, with the source error attached. anyhow at the edge, in main, with context on every ? so the message reads like a story. Drop and RAII: a file, a lock or a temporary folder that cleans itself up when the handle goes out of scope, even on the error path. Live in the seat: a function that returns three kinds of error, a thiserror enum, main with anyhow::Context, a Drop guard that prints when it runs, and the error-typed loader lab.
Lab The error-typed loader in rust/rs100/s4: a library that loads a catalogue file into Vec<Item> and returns LoadError (Io, BadLine { line, reason }, Duplicate(sku)) with thiserror, a main on anyhow with context and exit codes 2/3/4, and a Drop guard that always writes a run log. Session 4 checkpoint.
6 mini steps · ~51 min
4.1Result, ? and early return8 minchecked · T3
Predict
fn f() -> Result<i64, ParseIntError> { let n = "12x".parse::<i64>()?; Ok(n * 2) } f() returns?
Lesson
Result<T, E> makes failure part of the signature. ? unwraps Ok or returns Err to the caller right away, converting the error with From when the types differ. A function that can fail says so; a caller that cannot handle it passes it up with ?.
Demo
The tutor writes fn parse_cents(s: &str) -> Result<i64, ParseIntError> and fn parse_row(line: &str) -> Result<(String, i64), ParseIntError> using ? in rust/rs100/demo/s4/src/lib.rs, then shows the same without ? as a match to make the sugar visible.
Hands-on
Create rust/rs100/s4 as a lib with a main. Write pub fn parse_row(line: &str) -> Result<(String, i64), std::num::ParseIntError> that splits on a comma and parses the second field with ?. Test a good row and a bad number.
Explain
What does ? do on Ok and on Err?
Check
parse_row with ? under test — checked (T3):
seat_fileseat_test4.2panic versus Result; unwrap and expect honestly8 minchecked · T3
Predict
Which is a bug, not an expected failure: a missing input file, or an index into a Vec you just filled with three items?
Lesson
panic is for bugs: a broken invariant inside your own code. Result is for failures the world can cause: files, input, networks. unwrap is honest only when you have just proven the value is there; expect("why") documents that proof. A library never panics on caller input.
Demo
The tutor shows a justified expect on a regex literal, an unjustified unwrap on a file read, and a slice index that is a real bug, running each and reading the panic message versus the Result path.
Hands-on
In rust/rs100/s4/src/lib.rs write pub fn middle(v: &[i64]) -> Option<i64> returning the middle element or None, and pub fn checked_div(a: i64, b: i64) -> Result<i64, String> returning Err("divide by zero") instead of panicking. Test both.
Explain
Give one place unwrap is honest and one where it is not.
Check
middle and checked_div never panic — checked (T3):
seat_fileseat_testcraft4.3Your own error type with thiserror9 minchecked · T3
Predict
#[derive(Error)] enum LoadError { #[error("io")] Io(#[from] std::io::Error), ... } What does #[from] give you?
Lesson
A library names its failures: pub enum LoadError with one variant per cause, deriving thiserror::Error for Display and std::error::Error. #[error("...")] writes the message with fields; #[from] wires ? conversion from the underlying error. Callers match on the variants they care about.
Demo
The tutor adds thiserror to the demo Cargo.toml, writes LoadError { Io(#[from] io::Error), BadLine { line: usize, reason: String }, Duplicate(String) }, and changes parse_row's caller to return Result<Vec<_>, LoadError> with ? converting the io error.
Hands-on
Add thiserror to Cargo.toml. In src/lib.rs define pub enum LoadError with Io(#[from] std::io::Error), BadLine { line: usize, reason: String } and Duplicate(String), each with an #[error] message. Write pub fn load(path: &str) -> Result<Vec<(String, i64)>, LoadError> that reads the file with ? and maps parse failures to BadLine.
Explain
Why an enum of errors rather than a String?
Check
LoadError with thiserror; load() returns it — checked (T3):
seat_fileseat_fileseat_test4.4anyhow at the edge, with context8 minproven · T4
Predict
main returns anyhow::Result<()>, and load(path).context("loading catalogue")? fails with a missing file. What prints?
Lesson
Libraries define error types; binaries mostly report them. anyhow::Result in main accepts any error, and .context("...") on each ? adds a sentence, so the printed chain tells the story. For planned exit codes, match on the LoadError variant with downcast_ref and call std::process::exit.
Demo
The tutor adds anyhow to the demo, writes main() -> anyhow::Result<()> with context on load and on a parse, runs it against a missing file to show the chain, then maps Io to exit 2 and BadLine to exit 3 with downcast_ref.
Hands-on
Add anyhow to Cargo.toml. Write src/main.rs with fn main() -> anyhow::Result<()> that calls load(argv[1]).context("loading the catalogue")?, prints the row count, and maps LoadError::Io to exit 2, BadLine to exit 3 and Duplicate to exit 4 via downcast_ref before returning.
Explain
Why does a library use thiserror but main uses anyhow?
Check
anyhow main with context and exit codes — proven (T4):
seat_fileseat_fileseat_run4.5Drop and RAII8 minchecked · T3
Predict
A struct Guard implements Drop printing "closed". let g = Guard; then a ? returns early with Err. Does "closed" print?
Lesson
Resource Acquisition Is Initialisation: a value owns a resource and its Drop impl releases it when the value goes out of scope, on every path. Files, locks and temp folders in std already do this. Write your own Drop for a run log or a timer, and call drop(x) only when you need an earlier release.
Demo
The tutor writes struct RunLog with Drop that appends "finished" to a log file, creates it at the top of main, triggers an error path with ?, and shows the line is still written; then times a block with a Timer guard.
Hands-on
In rust/rs100/s4/src/lib.rs write pub struct RunLog { path: String, started: std::time::Instant } with pub fn start(path: &str) -> RunLog and impl Drop that appends "run finished after <ms> ms" to the file. Use it in main so the line appears even when load fails.
Explain
Why is Drop more reliable than calling close() yourself?
Check
RunLog writes on the error path too — checked (T3):
seat_fileseat_runseat_file4.6Lab: the error-typed loader10 minchecked · T3
Predict
The catalogue file lists sku A1 twice. load() returns?
Lesson
Everything from this session: load(path) -> Result<Vec<Item>, LoadError> over the stock Item type with Io, BadLine and Duplicate detection, no unwrap in the library, a main on anyhow with context that maps variants to exit 2, 3 and 4, and a RunLog guard that always records the run. Tests cover each variant.
Demo
The tutor completes the demo loader, runs it over a good file, a missing file, a bad line and a duplicate sku, reading exit codes and the run.log after each, then runs cargo test to show the variant tests green.
Hands-on
Finish rust/rs100/s4: load() detects duplicates with a HashSet, tests tests::load_ok_count, tests::load_missing_is_io, tests::load_bad_line and tests::load_duplicate pass, main exits 0/2/3/4 as specified with a context message on stderr, and run.log always gets its line.
Explain
Trace one error from the file to the exit code, naming each layer.
Check
Loader: typed errors, exit 2/3/4, run log, tests green — checked (T3):
seat_testseat_runseat_runseat_runcraft
Session 5 · Testing
Six steps on proof. Unit tests in a #[cfg(test)] module next to the code, with assert!, assert_eq! and #[should_panic]. Test first: write the red test from the specification, then the code. Table-driven tests that walk a slice of cases and name the failing one. Integration tests in tests/ that use the crate only through its public API. Doc tests: examples in /// comments that cargo test compiles and runs, so documentation cannot rot. Test doubles: a trait for the clock or the file source so a test controls time and input. Live in the seat: a red-green cycle on a pricing rule, a table of ten cases, an integration test over the lib, a doc example that fails when the signature changes, and a fake clock.
Lab The tested price engine in rust/rs100/s5: a library with price rules (quantity breaks, a percentage promo valid between two dates, a minimum charge), developed test first, with unit, table-driven, integration and doc tests, and a Clock trait with a fake for the date rule. The grader adds hidden tests. Session 5 checkpoint.
6 mini steps · ~52 min
5.1Unit tests next to the code8 minchecked · T3
Predict
#[test] #[should_panic(expected = "zero")] fn f() { checked(1, 0); } and checked panics with "divide by zero". Pass or fail?
Lesson
Tests live in #[cfg(test)] mod tests { use super::*; } so they see private items and vanish from release builds. assert_eq! prints left and right; assert! takes a message; #[should_panic(expected = "...")] proves a bug is caught. cargo test runs them in parallel and names each one.
Demo
The tutor creates rust/rs100/demo/s5 as a lib, writes pub fn unit_price(qty: u32, base: i64) -> i64 with a quantity break at 10, adds three unit tests including one should_panic for qty 0, runs cargo test, and shows a failing left/right.
Hands-on
Create rust/rs100/s5 as a lib. Write pub fn unit_price(qty: u32, base_cents: i64) -> i64 that panics on qty 0 and gives 10% off from 10 units. Tests: tests::single_unit, tests::break_at_ten, tests::zero_qty_panics with should_panic.
Explain
Why can tests in the same file call private functions?
Check
Three unit tests including should_panic — checked (T3):
seat_fileseat_test5.2Test first: red, green, tidy8 minchecked · T3
Predict
You write the test for a minimum charge of 100 cents before the function exists. cargo test says?
Lesson
Test first: write the test that states the rule, watch it fail (compile error, then assertion), write the least code that passes, then tidy without changing behaviour. The test is the specification written down; the green bar is the proof it holds.
Demo
The tutor adds the minimum-charge rule to the demo test first: red compile error, stub returning 0, red assertion, real code, green, then a refactor into a MIN_CHARGE constant with the tests still green.
Hands-on
Test first in rust/rs100/s5: pub fn line_total(qty: u32, base_cents: i64) -> i64 applies unit_price and never returns less than a MIN_CHARGE of 100 cents. Write tests::min_charge_applies and tests::min_charge_not_needed before the function, then make them pass.
Explain
What did the red step tell you that a green-first test cannot?
Check
line_total with a minimum charge, test first — checked (T3):
seat_fileseat_test5.3Table-driven tests8 minchecked · T3
Predict
A table test loops ten cases with assert_eq!(got, want). Case 7 fails. What do you see?
Lesson
A table test is one body over a slice of (input, expected) tuples. Name every case in the assert message so a failure says which row. Tables make boundaries cheap to cover: 9, 10 and 11 units, zero, the maximum. Keep each table about one rule.
Demo
The tutor writes tests::unit_price_table with eight cases around the quantity break in the demo, breaks the rule off by one, and shows the message naming the failing case.
Hands-on
Add tests::unit_price_table to rust/rs100/s5 with at least eight (qty, base, want) cases including 9, 10, 11 and 100 units and a 1-cent base, each assert carrying a message with the qty. All green.
Explain
Why one table per rule rather than one giant table?
Check
An eight-case table test — checked (T3):
seat_fileseat_test5.4Integration tests and doc tests9 minchecked · T3
Predict
A /// doc comment has a code block calling line_total(2, 300) and asserting 540. You rename the function. cargo test?
Lesson
Files in tests/ are integration tests: separate crates that use your library only through pub items, like a user would. Doc tests are code blocks in /// comments that cargo test compiles and runs, so examples stay true. Together they prove the public surface, not just the insides.
Demo
The tutor adds tests/pricing.rs to the demo using only pub functions, then writes a /// example on line_total with an assert, runs cargo test to show the Doc-tests section, and renames the function to show the doc test fail.
Hands-on
Add tests/pricing.rs to rust/rs100/s5 with tests through the public API: order_of_three_lines and min_charge_end_to_end. Add a /// doc comment with a runnable example to unit_price and to line_total. cargo test shows unit, integration and doc tests green.
Explain
What does an integration test prove that a unit test does not?
Check
Integration tests and two doc tests — checked (T3):
seat_fileseat_fileseat_test5.5Test doubles: a Clock trait9 minchecked · T3
Predict
A promo is valid from 2026-11-01 to 2026-11-30. The test uses a FakeClock set to 2026-12-01. promo_applies(&clock)?
Lesson
Code that reads the real clock or the real disk cannot be tested repeatably. Put the dependency behind a trait — pub trait Clock { fn today(&self) -> NaiveDate; } — use SystemClock in main and FakeClock in tests. The same trick works for files, random numbers and networks. chrono is in the seat.
Demo
The tutor adds chrono to the demo, defines Clock with SystemClock and FakeClock, writes pub fn promo_applies(clock: &dyn Clock) -> bool for a November window, and tests the day before, the first day, the last day and the day after.
Hands-on
Add chrono to Cargo.toml. In rust/rs100/s5 define pub trait Clock { fn today(&self) -> chrono::NaiveDate; }, pub struct SystemClock and a FakeClock in tests. Write pub fn promo_percent(clock: &dyn Clock) -> u8 returning 15 from 2026-11-01 to 2026-11-30 inclusive, else 0. Four boundary tests.
Explain
Why is a trait better than passing today's date as a parameter here?
Check
Clock trait with a fake; four boundary tests — checked (T3):
seat_fileseat_fileseat_test5.6Lab: the tested price engine10 minproven · T4
Predict
12 units at 300 cents, promo 15% active, minimum charge 100. Line total?
Lesson
Everything from this session in one library: pub fn price_line(qty, base_cents, clock) applying the quantity break, then the promo from the Clock, then the minimum charge, in that order, with unit, table, integration and doc tests, and a FakeClock. The grader runs its own hidden tests through the public API.
Demo
The tutor completes the demo engine, runs cargo test showing all four kinds of test, then changes the order of promo and minimum charge to show a table case go red.
Hands-on
Finish rust/rs100/s5: pub fn price_line(qty: u32, base_cents: i64, clock: &dyn Clock) -> i64 in the stated order; tests::price_line_table with eight cases using a FakeClock in and out of the window; tests/pricing.rs updated; doc example on price_line. All green, clippy clean.
Explain
Why does the order of the three rules matter, and how did a test prove it?
Check
Price engine: all test kinds green plus hidden tests — proven (T4):
seat_fileseat_testseat_lint
Session 6 · Files, data formats and time
Six steps on data that arrives from outside. std::fs and BufReader for large files, Path and PathBuf instead of string paths. The csv crate: a typed reader with headers, quoting handled, and a writer. serde: #[derive(Serialize, Deserialize)] on your structs, serde_json to and from text, renaming fields and skipping defaults. chrono: NaiveDate parsing and formatting, date arithmetic, and ISO 8601 as the only format you store. Streaming: converting a file row by row without loading it whole. Live in the seat: a 10,000-line file read through BufReader, a CSV with a quoted comma, an Item round-tripped through JSON, a date 30 days hence, and the CSV-to-JSON converter lab.
Lab The CSV-to-JSON converter in rust/rs100/s6: cargo run -- <in.csv> <out.json> reads typed rows with the csv crate (sku, name, qty, unit_cents, received as an ISO date), validates them, adds an expires field 365 days after received, and writes a JSON array with serde_json; exit 2 on an unreadable input, 3 on the first bad row. Session 6 checkpoint.
6 mini steps · ~52 min
6.1std::fs, BufReader, Path8 minchecked · T3
Predict
A 200 MB log is read with read_to_string, then with BufReader::lines(). Which holds less memory at once?
Lesson
read_to_string is fine for small files; for large ones wrap File::open in BufReader and iterate lines(), which yields io::Result<String> per line. Build paths with Path and PathBuf (join, extension, file_name) rather than string concatenation, and write with BufWriter for speed.
Demo
The tutor generates rust/rs100/demo/s6/big.txt with 100,000 lines, reads it both ways timing with Instant, then builds an output path with Path::new(input).with_extension("json").
Hands-on
Create rust/rs100/s6. Read rust/rs100/samples/s6/big.txt (10,000 lines) with BufReader, count the lines and the lines containing "error", and print "Lines 10000" and "Errors 37". Build the output path with Path::with_extension and print it.
Explain
When would you still use read_to_string?
Check
BufReader count and a Path-built name — checked (T3):
seat_fileseat_run6.2The csv crate: typed rows9 minchecked · T3
Predict
A CSV row is A1,"Notebook, ruled",3,250. Splitting on commas gives how many fields; the csv crate gives how many?
Lesson
csv::Reader::from_path reads with headers; with serde it deserializes each record into your struct, handling quotes, escapes and line endings. Field names match the header; #[serde(rename)] bridges differences. csv::Writer writes records and quotes when needed.
Demo
The tutor adds csv and serde to the demo, defines #[derive(Deserialize)] struct Row { sku, name, qty, unit_cents }, reads rust/rs100/samples/s6/stock.csv including the quoted comma, prints each Row, and writes a filtered copy with Writer.
Hands-on
Add csv and serde (derive) to Cargo.toml. Define #[derive(Debug, Deserialize)] pub struct Row { sku: String, name: String, qty: u32, unit_cents: i64, received: String }. Read samples/stock.csv, print "Rows 6" and the name of the row with the quoted comma: "Notebook, ruled".
Explain
What does the csv crate get right that split(',') gets wrong?
Check
Typed CSV rows with a quoted comma — checked (T3):
seat_fileseat_fileseat_run6.3serde and serde_json round trip9 minchecked · T3
Predict
struct Item { sku: String, qty: u32 } with Serialize. serde_json::to_string_pretty prints what shape?
Lesson
#[derive(Serialize, Deserialize)] gives a struct a text form for free. serde_json::to_string / to_string_pretty write, from_str reads with full type checking. #[serde(rename = "unitCents")] maps names, #[serde(default)] tolerates missing fields, and Option<T> fields may be absent. A round trip test proves the mapping.
Demo
The tutor adds serde_json, derives Serialize and Deserialize on the stock Item, writes it pretty, reads it back, asserts equality, then renames a field with #[serde(rename)] and shows the JSON key change.
Hands-on
Add serde_json to Cargo.toml. Give Row Serialize too, with #[serde(rename = "unitCents")] on unit_cents. Write a test tests::row_round_trip: to_string then from_str gives an equal Row (derive PartialEq). Print the pretty JSON of the first row.
Explain
Why write a round-trip test for a serialised type?
Check
Row round-trips through JSON with a renamed key — checked (T3):
seat_fileseat_fileseat_testseat_run6.4Dates with chrono8 minchecked · T3
Predict
NaiveDate::parse_from_str("2026-02-28", "%Y-%m-%d") + Duration::days(1) is?
Lesson
Store and exchange dates as ISO 8601 text (YYYY-MM-DD); parse with NaiveDate::parse_from_str, format with format("%d %b %Y") only for display. Add with Duration::days, compare with < and >, and let chrono handle month lengths and leap years. Never do date maths on strings or on day counts by hand.
Demo
The tutor parses the received dates from stock.csv, adds 365 days to each, formats one as "01 Nov 2027", finds the oldest row with min_by_key, and shows the parse error on 2026-13-01.
Hands-on
Add chrono to Cargo.toml. Parse each Row's received as NaiveDate, compute expires = received + 365 days, print the first row's expires in ISO form and as "%d %b %Y", and print "Oldest A3" for the row with the earliest received date.
Explain
Why store dates as ISO text and not as "11/01/26"?
Check
Dates parsed, 365 days added, oldest found — checked (T3):
seat_fileseat_fileseat_run6.5Streaming: row in, row out8 minchecked · T3
Predict
You convert a million CSV rows to JSON Lines by collecting into a Vec first, versus writing each row as you read it. Which uses bounded memory?
Lesson
csv::Reader::deserialize() is an iterator of Result<Row>; map and write each one as it arrives, through a BufWriter, and the memory stays flat however big the file. A JSON array needs brackets and commas around the stream; JSON Lines needs nothing. The writer flushes and closes on Drop.
Demo
The tutor streams stock.csv into stock.jsonl in the demo, one serde_json::to_string per row through a BufWriter, then does the same into a JSON array by writing [ and ] and a comma between rows.
Hands-on
In rust/rs100/s6 write fn convert(input: &Path, output: &Path) -> anyhow::Result<usize> that streams rows from the CSV into a JSON array file with BufWriter, returning the row count. Print "Wrote 6 rows to samples/stock.json" for the sample.
Explain
Who flushes and closes the output file?
Check
Streaming CSV to a JSON array — checked (T3):
seat_fileseat_runseat_file6.6Lab: the CSV-to-JSON converter10 minchecked · T3
Predict
Row 4 of the input has qty "three". What does the converter do?
Lesson
Everything from this session: cargo run -- <in.csv> <out.json>, typed rows via csv + serde, received parsed with chrono and expires added 365 days later, output as a JSON array via serde_json with unitCents renamed, streaming through BufWriter, exit 2 if the input cannot be opened and 3 on the first bad row (type, date or negative qty), reported on stderr.
Demo
The tutor builds the converter from the lab text, runs it on stock.csv, then on a file with a bad date and one with a missing column, reading each exit code and the output file.
Hands-on
Finish rust/rs100/s6/src/main.rs as specified. For samples/stock.csv print "Wrote 6 rows to samples/out.json"; samples/out.json is a JSON array whose first object has "sku": "A1" and "expires": "2027-11-01". samples/bad_qty.csv exits 3; a missing input exits 2.
Explain
Which validation does serde do for you and which did you add?
Check
Converter: JSON array with expires, exit 2/3 contract — checked (T3):
seat_runseat_fileseat_runseat_runseat_lint
Session 7 · Crates, modules and docs
Six steps on shape. The module tree: mod pricing; with src/pricing.rs, nested modules in folders, and the rule that the tree follows the files. Visibility: private by default, pub, pub(crate), and re-exporting with pub use so the public surface is small and deliberate. A lib target plus a bin target in one crate, and src/bin/ for more tools. cargo doc with //! crate docs and /// item docs that link and compile. A README that says what, how to run, how to test, and Cargo.toml metadata. Live in the seat: the price engine split into four modules, a leaked internal made private, rustdoc rendered in the seat's browser preview, and the documented crate lab.
Lab The documented crate in rust/rs100/s7: the session 5 price engine restructured into modules (pricing, promo, clock, money) with a small public surface via pub use, a lib and a bin target, //! and /// docs with runnable examples, cargo doc without warnings, and a README with run and test instructions. Session 7 checkpoint.
6 mini steps · ~50 min
7.1The module tree follows the files8 minchecked · T3
Predict
src/lib.rs says mod pricing; Where does Rust look for the code?
Lesson
A crate is a tree of modules rooted at lib.rs or main.rs. mod name; pulls in src/name.rs; a module with children lives in src/name.rs plus src/name/child.rs. Paths are crate::pricing::line_total. One responsibility per module, named for what it owns.
Demo
The tutor copies the session 5 engine into rust/rs100/demo/s7 and splits it into src/pricing.rs, src/promo.rs, src/clock.rs and src/money.rs, declaring each in lib.rs and fixing the use paths until cargo build is clean.
Hands-on
Create rust/rs100/s7 as a lib. Copy your session 5 lib.rs in and split it: src/pricing.rs (unit_price, line_total, price_line), src/promo.rs (promo_percent), src/clock.rs (Clock, SystemClock), src/money.rs (format cents). lib.rs declares the four mods. Builds clean.
Explain
Why does Rust make you declare every module?
Check
Four modules declared and present — checked (T3):
seat_fileseat_fileseat_fileseat_build7.2pub, pub(crate) and re-exports8 minchecked · T3
Predict
lib.rs has mod pricing; (not pub) and pub use pricing::price_line; Can a user call crate::price_line? And crate::pricing::price_line?
Lesson
Everything is private unless pub. pub(crate) shares within the crate only. pub use at the root re-exports the few items users need, so the public API is a short list you control and the module layout can change without breaking anyone. Small surface, happy users.
Demo
The tutor makes the demo modules private, adds pub use pricing::{price_line, line_total, unit_price}; pub use clock::{Clock, SystemClock}; pub use promo::promo_percent; to lib.rs, marks a helper pub(crate), and runs the integration tests to prove the surface still works.
Hands-on
In rust/rs100/s7/src/lib.rs keep the mods private and re-export exactly price_line, line_total, unit_price, Clock, SystemClock, promo_percent and money::format_cents with pub use. Mark any helper pub(crate). Add tests/api.rs that calls each re-export through s7::.
Explain
What does a small public surface buy the crate's author?
Check
Private modules, seven re-exports, API test — checked (T3):
seat_fileseat_fileseat_test7.3A lib and a bin in one crate8 minchecked · T3
Predict
A crate has src/lib.rs and src/main.rs. How does main.rs use the library?
Lesson
One package can hold a library (src/lib.rs) and one or more binaries (src/main.rs, src/bin/*.rs). The binary uses the library by its crate name. Keep logic in the lib, keep main thin: parse arguments, call, print, exit. Extra tools go in src/bin/ and run with cargo run --bin name.
Demo
The tutor adds src/main.rs to the demo that prices a line from three arguments through the public API, then src/bin/promo.rs that prints today's promo percent, running each with cargo run --bin.
Hands-on
Add src/main.rs to rust/rs100/s7: cargo run -- <qty> <base_cents> prints "Line 2754" using price_line and SystemClock. Add src/bin/promo.rs that prints "Promo <n>%" from promo_percent. Both run with cargo run and cargo run --bin promo.
Explain
Why keep main thin and the logic in the lib?
Check
Thin main and a second binary — checked (T3):
seat_fileseat_fileseat_run7.4cargo doc: //! and ///8 minchecked · T3
Predict
cargo doc is run on a crate where a /// comment links to [
price_line] but the item is private. Result?Lesson
//! at the top of lib.rs documents the crate: what it is, a quick example. /// documents each pub item: one sentence, then details, then a runnable example. square-bracketed backtick names links items. cargo doc --no-deps builds HTML you can open in the seat's preview; keep it warning-free.
Demo
The tutor writes //! docs with a quick-start example for the demo crate, /// docs with examples on the seven re-exports, runs cargo doc --no-deps, opens target/doc in the preview, then introduces a broken link to show the warning.
Hands-on
Document rust/rs100/s7: a //! crate comment with a quick-start example that compiles, and /// docs with a runnable example on every re-exported function and trait. cargo doc --no-deps shows no warnings; cargo test runs the examples green.
Explain
Why do doc examples run as tests?
Check
Crate docs and item docs with examples — checked (T3):
seat_fileseat_fileseat_test7.5README, metadata and examples/8 minchecked · T3
Predict
A file examples/quote.rs exists. How is it run?
Lesson
A README answers three questions in order: what is this, how do I run it, how do I test it. Cargo.toml carries description, license and readme fields. examples/ holds small programs that show the library in use and are compiled by cargo build --examples, so they cannot rot either.
Demo
The tutor writes the demo README with the three sections and a sample command and output, fills description and license in Cargo.toml, and adds examples/quote.rs that prices three lines and prints a mini quote.
Hands-on
Write rust/rs100/s7/README.md with headings What it is, Run, Test, each with a command. Add description and license = "MIT" to Cargo.toml. Add examples/quote.rs that prints three priced lines and a total; cargo build --examples is clean.
Explain
Why does a README belong next to the code and not in a wiki?
Check
README with three sections, metadata, an example — checked (T3):
seat_fileseat_fileseat_fileseat_build7.6Lab: the documented crate10 minchecked · T3
Predict
You move promo_percent from promo.rs into pricing.rs but keep pub use pricing::promo_percent at the root. Do users notice?
Lesson
Everything from this session as a finished crate: four private modules, seven re-exports, a thin main and a second bin, crate and item docs with runnable examples, an examples/ program, a README and metadata, all tests (unit, integration, doc) green, clippy and rustfmt clean, cargo doc warning-free.
Demo
The tutor runs the full checklist on the demo crate — cargo fmt --check, cargo clippy, cargo test, cargo doc --no-deps, cargo build --examples — and reads each result, then shows the rendered docs once more.
Hands-on
Finish rust/rs100/s7 so the whole checklist is clean: fmt, clippy, test (unit + integration + doc), doc without warnings, examples built. Run cargo run -- 12 300 and confirm "Line 2754" when the November promo is active, or the un-promoted value otherwise.
Explain
Describe your crate to a new team member in three sentences: what, where, how to trust it.
Check
Documented crate: fmt, clippy, tests, docs, examples all clean — checked (T3):
seat_lintseat_testseat_fileseat_runcraft
Session 8 · Capstone: Stockroom
Six steps that build Stockroom, a tested inventory library and command-line tool, from a specification. Reading the spec and sketching the public API before typing. A clap command line with import, export, report, adjust and value subcommands. The Inventory type with a JSON store file behind a small trait. Import from CSV with validation and typed errors. The low-stock report and quantity adjustments that refuse to go below zero. Unit and integration tests, a README, clippy and rustfmt clean, and the grader's hidden tests and hidden input files. Live in the seat: the sketch, the clap skeleton that parses every subcommand, the import that rejects a bad row, the report sorted by quantity, and the final checklist.
Lab Stockroom: cargo run -- import <csv> | export <json> | report --low <n> | adjust <sku> <delta> | value, over stock.json in the project folder, with exit codes 0 ok, 2 unreadable input, 3 bad row, 4 unknown sku, 5 quantity below zero, the published outputs for samples/items.csv matched exactly, and the hidden tests and files passing. Session 8 checkpoint and the course placement step.
6 mini steps · ~58 min
8.1Reading the spec, sketching the API9 minartefact · T2
Predict
Which layer decides that adjust A3 -5 on a quantity of 2 is an error: the clap parser, Inventory::adjust, or main?
Lesson
Read the spec once, then write the public API as signatures: pub struct Item, pub struct Inventory, pub enum StockError, pub fn Inventory::from_csv_str, low_stock, adjust, value_cents, to_json, from_json. The CLI is a thin layer over them. The sketch is what the hidden tests will call, so get the names right now.
Demo
The tutor reads rust/rs100/samples/s8/STOCKROOM-SPEC.md, creates rust/rs100/demo/stockroom as lib + bin, and writes the signatures with todo!() bodies so the crate builds before any logic exists.
Hands-on
Read rust/rs100/samples/s8/STOCKROOM-SPEC.md. Create rust/rs100/stockroom with cargo new stockroom --lib plus src/main.rs. In src/lib.rs write the spec's public signatures with todo!() bodies and derive what the spec asks. cargo build must be clean.
Explain
Why write the signatures before any body?
Check
Stockroom skeleton builds with the spec's signatures — artefact (T2):
seat_fileseat_build8.2The clap command line9 minproven · T4
Predict
#[derive(Subcommand)] enum Cmd { Report { #[arg(long)] low: u32 } } and the user runs report --low 5. What does low hold?
Lesson
clap with derive turns a struct and an enum into a full command line: #[derive(Parser)] for the top level, #[derive(Subcommand)] for import/export/report/adjust/value, #[arg(long)] for flags, typed fields parsed for you, and --help generated. Parsing errors exit 2 by clap's convention, which matches our contract for bad input.
Demo
The tutor adds clap (derive) to the demo, writes Cli and Cmd, prints the parsed command with {:?} for each subcommand, and shows the generated help and a parse error for report --low five.
Hands-on
Add clap with the derive feature to Cargo.toml. In src/main.rs define Cli and enum Cmd { Import { file: PathBuf }, Export { file: PathBuf }, Report { #[arg(long)] low: u32 }, Adjust { sku: String, delta: i64 }, Value }. For now print the parsed command with {:?}.
Explain
What does clap take off your plate?
Check
Five subcommands parse — proven (T4):
seat_fileseat_fileseat_run8.3Inventory, StockError and the JSON store10 minchecked · T3
Predict
Inventory::from_json("[]") should return?
Lesson
Item derives Serialize and Deserialize; Inventory wraps a Vec<Item> and keeps skus unique. StockError (thiserror) has Io, Csv, Json, BadRow { line, reason }, Duplicate(String), UnknownSku(String), BelowZero { sku, have, want }. to_json and from_json use serde_json; main loads stock.json before a command and saves after a change. The store is one function pair, easy to swap.
Demo
The tutor writes Item, Inventory, StockError, to_json and from_json in the demo lib with a round-trip test, then adds load_store and save_store in main over stock.json and shows a missing store treated as empty for every command except import's output.
Hands-on
Add serde, serde_json and thiserror to Cargo.toml. Implement Item, Inventory { items: Vec<Item> }, StockError as listed, to_json (pretty) and from_json. Tests tests::json_round_trip and tests::from_json_empty. main loads stock.json (missing = empty) and saves after import and adjust.
Explain
Why keep the store behind two functions instead of calling serde_json everywhere?
Check
StockError, JSON round trip, store load/save — checked (T3):
seat_fileseat_fileseat_test8.4Import from CSV with validation10 minproven · T4
Predict
items.csv lists sku A1 twice. import should?
Lesson
from_csv_str(text) -> Result<Inventory, StockError> parses with the csv crate into typed rows, rejects a negative or unparseable qty and a duplicate sku with the line number, and only on full success does main replace stock.json. Unreadable file → exit 2; bad row or duplicate → exit 3, message on stderr, store untouched.
Demo
The tutor implements from_csv_str in the demo, imports samples/items.csv to see "Imported 6 items into stock.json", then imports a file with a duplicate and shows stock.json unchanged and exit 3.
Hands-on
Implement pub fn from_csv_str(text: &str) -> Result<Inventory, StockError> with csv + serde and the duplicate check. Wire import: "Imported 6 items into stock.json" for samples/items.csv; samples/dup.csv → exit 3 and the store unchanged; a missing file → exit 2. Tests tests::import_ok and tests::import_duplicate.
Explain
Why is import all-or-nothing?
Check
Import validates and writes the store atomically — proven (T4):
seat_testseat_runseat_file8.5Report, adjust and value10 minproven · T4
Predict
After importing items.csv, report --low 5 lists which skus, in order?
Lesson
low_stock(threshold) returns items with qty < threshold sorted by qty then sku. adjust(sku, delta) finds the item, refuses an unknown sku (exit 4) and a result below zero (exit 5), and returns the new qty. value_cents sums qty × unit_cents. Printing is main's job: "C1 Coffee beans qty 0", "Low 3 of 6", "A3 qty 12", "Value 279.54".
Demo
The tutor implements the three methods with iterator chains in the demo, runs report --low 5, adjust A3 10, adjust C1 -1 (exit 5), adjust Z9 1 (exit 4) and value, reading each output and exit code.
Hands-on
Implement low_stock, adjust and value_cents with tests tests::low_stock_sorted, tests::adjust_below_zero, tests::adjust_unknown and tests::value_sum. Wire report, adjust and value in main with the exact output lines from the lesson and exit codes 4 and 5.
Explain
Why does adjust return the new quantity rather than ()?
Check
Report sorted, adjust guarded, value exact — proven (T4):
seat_testseat_run8.6Lab: Stockroom proven10 minproven · T4
Predict
export samples/out.json then import samples/out.json? The spec says import takes CSV only. What happens?
Lesson
The proof step: your tests, the grader's hidden tests through the public API (from_csv_str, low_stock, adjust, value_cents, to_json, from_json), and hidden runs: another items file, an empty inventory report, an adjust to exactly zero, an export whose JSON parses back. README, clippy and rustfmt complete the checklist.
Demo
The tutor runs the full checklist on the demo Stockroom, then runs export and reads the JSON back with from_json in a test, and finally presses Check and reads the grader's lines.
Hands-on
Finish rust/rs100/stockroom: export writes pretty JSON ("Exported 6 items to <file>"), README with Run and Test sections, clippy and rustfmt clean, all tests green. Press Check and read every line the grader returns.
Explain
What does Stockroom guarantee to its users, in two sentences?
Check
Stockroom: tests, hidden tests, hidden runs, README, lint — proven (T4):
seat_testseat_lintseat_fileseat_run
Outcome
By the end of the course the tutor's checks will have verified: a compiled, clippy-clean program for every hands-on step; eight session labs that each grow the last — the borrow-checked word counter, the typed catalogue, the iterator report, the error-typed loader, the tested price engine, the CSV-to-JSON converter, the documented crate; and the Stockroom capstone, whose import, export, low-stock report and error contract match the published expected values and pass hidden tests.
Certificate
Agile System Software Certificate — Rust I (RS100) attests that you can design, build, test and document a Rust library and command-line tool with idiomatic ownership, typed data models, iterator pipelines, error types and serialised data, verified by a machine from your own seat including hidden tests.
To earn it:
- Pass the lab check of every session (steps 1.6, 2.6, 3.6, 4.6, 5.6, 6.6 and 7.6) on your own code.
- Pass the Stockroom capstone check (8.6) including the hidden tests and hidden input files the grader supplies.
- Score 80% or better on the 25-question reading quiz.
Gates (step · tier required):
Plus a 25-question reading quiz at 80%, retake after 48 h. Certificates are issued once the seat check runner is live; until then every step records what the engine saw.
Environment
A VS Code seat in your browser with Rust 1.99, cargo, clippy, rustfmt and rust-analyzer, plus the offline crate set (serde, serde_json, csv, chrono, clap, thiserror, anyhow, itertools, rust_decimal); nothing to install. Your work lives in rust/rs100; the tutor's demo files live apart from yours and the tutor never edits your hands-on file while you are working on it.
Open Coding Tutor at codingtutor.agilesystemsoftware.com, pick RS100 from the course button in the chat and press Tutor me. Every lesson, demo and hands-on step is also published below.