Agile System SoftwareLearn

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.

  • LevelIntermediate
  • Durationself-paced ~30–35 h
  • Size8 sessions · 48 mini steps · ~419 min of lessons
  • EnvironmentA 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
  • PrerequisitesRS050, or a from-zero course in any Workshop language (PY050, JV050, DN050, NG050). Session 1 re-teaches the syntax; the ideas transfer.
    Courses: RS050 or PY050 or JV050 or DN050 or NG050
  • CertificateAgile System Software Certificate — Rust I (RS100)

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).

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. 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_file seat_file seat_run

    #s1-1

  2. 1.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_lint seat_run

    #s1-2

  3. 1.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_file seat_run

    #s1-3

  4. 1.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_file seat_run seat_lint

    #s1-4

  5. 1.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_file seat_run

    #s1-5

  6. 1.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_file seat_lint seat_run seat_run

    #s1-6

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
  1. 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_file seat_run

    #s2-1

  2. 2.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_file seat_build

    #s2-2

  3. 2.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_file seat_test

    #s2-3

  4. 2.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_file seat_test

    #s2-4

  5. 2.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_file seat_file seat_build

    #s2-5

  6. 2.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_test seat_lint seat_run craft

    #s2-6

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
  1. 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_file seat_run

    #s3-1

  2. 3.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_file seat_run

    #s3-2

  3. 3.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_file seat_lint seat_build

    #s3-3

  4. 3.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_file seat_run

    #s3-4

  5. 3.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_file seat_run

    #s3-5

  6. 3.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_file seat_lint seat_run

    #s3-6

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
  1. 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_file seat_test

    #s4-1

  2. 4.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_file seat_test craft

    #s4-2

  3. 4.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_file seat_file seat_test

    #s4-3

  4. 4.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_file seat_file seat_run

    #s4-4

  5. 4.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_file seat_run seat_file

    #s4-5

  6. 4.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_test seat_run seat_run seat_run craft

    #s4-6

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
  1. 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_file seat_test

    #s5-1

  2. 5.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_file seat_test

    #s5-2

  3. 5.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_file seat_test

    #s5-3

  4. 5.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_file seat_file seat_test

    #s5-4

  5. 5.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_file seat_file seat_test

    #s5-5

  6. 5.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_file seat_test seat_lint

    #s5-6

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
  1. 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_file seat_run

    #s6-1

  2. 6.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_file seat_file seat_run

    #s6-2

  3. 6.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_file seat_file seat_test seat_run

    #s6-3

  4. 6.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_file seat_file seat_run

    #s6-4

  5. 6.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_file seat_run seat_file

    #s6-5

  6. 6.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_run seat_file seat_run seat_run seat_lint

    #s6-6

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
  1. 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_file seat_file seat_file seat_build

    #s7-1

  2. 7.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_file seat_file seat_test

    #s7-2

  3. 7.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_file seat_file seat_run

    #s7-3

  4. 7.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_file seat_file seat_test

    #s7-4

  5. 7.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_file seat_file seat_file seat_build

    #s7-5

  6. 7.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_lint seat_test seat_file seat_run craft

    #s7-6

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
  1. 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_file seat_build

    #s8-1

  2. 8.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_file seat_file seat_run

    #s8-2

  3. 8.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_file seat_file seat_test

    #s8-3

  4. 8.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_test seat_run seat_file

    #s8-4

  5. 8.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_test seat_run

    #s8-5

  6. 8.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_test seat_lint seat_file seat_run

    #s8-6

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.