commit 6ddf41aa81776ad2ddf69a57ee7cdb69890f435e Author: Sulthan Zaki Date: Sun Sep 6 23:59:08 2026 +0700 Rust learning: lessons, notes, and exercise crates diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..94df751 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +target/ +hello_world/main +.playwright-mcp/ diff --git a/MISSION.md b/MISSION.md new file mode 100644 index 0000000..845d0e3 --- /dev/null +++ b/MISSION.md @@ -0,0 +1,18 @@ +# Mission: Rust + +## Why +Land a backend/CLI job that uses Rust. Two months on the book already happened, but the knowledge did not stick — the goal now is real fluency (can write and reason about Rust under pressure), not a second pass at "having read the book." + +## Success looks like +- Explain ownership/borrowing rules without hesitating, and predict compile errors before running `cargo build`. +- Write a small CLI or backend service (e.g. a JSON API) from scratch, handling errors with `Result`/`?`, not `panic!`. +- Use structs, enums, traits, generics, and modules idiomatically in own code, not just recognize them in a book example. +- Pass a Rust-focused technical interview or take-home without needing to relearn fundamentals first. + +## Constraints +- ~2 months already spent on [The Rust Book](https://doc.rust-lang.org/stable/book/), chapters 1–9 covered (variables through error handling), but retention is weak — treat as "seen before, not owned" until proven otherwise. +- Sessions are crash-course paced: prioritize fast, high-yield retrieval practice over slow first-pass reading. + +## Out of scope +- Embedded / `no_std` Rust. +- Deep async internals — only as much async as a backend job needs (revisit if the job requires more). diff --git a/NOTES.md b/NOTES.md new file mode 100644 index 0000000..50899a3 --- /dev/null +++ b/NOTES.md @@ -0,0 +1,71 @@ +# Notes + +- User has 2 months of prior exercises in this dir (ch1–9 of the Rust Book: variables, functions, control flow, ownership, slices, structs, enums, modules, collections, error handling) but reports forgetting all of it. Treat ch1–9 as "exposure, not mastery" until a lesson or diagnostic proves otherwise. +- Goal: backend/CLI job. Bias future lessons toward `Result`/error handling, traits/generics, and eventually a backend framework (axum/tokio) over embedded topics. +- Preference confirmed 2026-08-28: diagnostic-first — find actual gaps before building recap lessons, rather than re-teaching everything blind. +- `get-dependecies/` has an unused `trpl = "0.2.0"` dependency (the async-book helper crate) — signals prior intent to reach ch17 (async), not yet attempted. +- **Lesson format rule (2026-08-28):** typing-first. User forgot how to *write* code, not just what it means — every lesson needs hands on keyboard in a real cargo project, `cargo run`/`cargo test` as the feedback loop. See LR-0001. +- User asked to cover topics the diagnostic marked "solid" too. Do not skip them — fold them in as supporting material in typing lessons rather than dedicating lessons to them. +- Do not let the user copy-paste lesson code. State the no-paste rule explicitly in each lesson. +- Always link `reference/rust-syntax.html` from lessons; forgotten syntax was eating the working memory needed for concepts. +- Verify claimed compiler error text by actually running it before shipping a lesson (done for E0004 non-exhaustive, E0382 moved value in lesson 0002). +- Env: rustc/cargo 1.96.1 installed and working. Chrome opens lesson files via `nohup google-chrome --new-window "file://$(realpath …)" & disown` (plain `wslview` blocks the shell; `rm -rf` is blocked by policy — use `mktemp -d` for scratch projects). +- Next lesson candidates, in priority order: (1) reading compiler errors fluently, (2) Modules & Paths (0/1, and `restauran`/`learn-modules` exist as material), (3) enums + `Option`/`Result` modelling, (4) structs+impl by building something, (5) collections drill. +- **Format shift (lesson 0003):** user finished 0002, reported still "feeling like lacking", and asked for spec-driven work — a project brief with acceptance tests, NOT step-by-step syntax guidance, plus syntax examples they can consult. Self-identified weak: packages, enums, structs. Deliver specs + test suites from here; put syntax help in a *different domain* so it cannot be pasted, and collapse hints behind `
` so they choose the difficulty. +- Always verify a shipped test suite is passable by writing a private reference impl in a temp dir first (done for 0003: 17/17). Never ship an unproven spec. +- `cargo new --lib` does NOT create `tests/` — instructions must include `mkdir tests`. +- **Two-lesson rule (2026-09-02):** for any topic the user names as weak, ship a *concept* lesson (reading, real compiler output, from zero) BEFORE the *project* lesson. 0003 shipped project-only and was unusable; 0004 backfilled it. See LR-0002. +- **Specs need prose.** Signatures alone are not a specification. Every project brief needs: what the program is for, what its data is, what its commands are — in plain language, written so the type choices (struct vs enum) fall out of the description. Then signatures. +- Diagnostic scores overstate ability: recognition-style questions pass without design ability. Self-reported weakness > quiz score. (Struct 2/2, Enum 1/2 on 0001, yet both needed teaching from zero.) +- Teaching demos: use a domain *different* from the project domain (café for 0004 vs tasks for 0003) so nothing is pasteable. Capture real `cargo run`/`cargo build` output including deliberate errors — never write error text from memory. +- **0003 result (2026-09-02):** 17/17 green, idiomatic code (closures, `iter_mut`, `?`, private field + `&[Task]` accessor). Structs/enums/packages are now *produced*, not just recognised — stop teaching them as weak. See LR-0003. +- **Untested prose is undone prose.** The spec's stderr/exit-1 requirement was the one thing the 17 integration tests could not reach, and it is the one thing that was not done (`println!` for errors, exit 0, `.unwrap()` panic on `done 9`). Either test it or make it the next lesson's drill. +- Drill format that works: operate on the crate the user already wrote, one shell-level check per step (`cargo run --quiet -- fly ; echo $?`). No new test file, and their real project improves. +- Next lesson candidates after 0005: (1) **0006 project** — `String` → `TaskError` enum with `Display`/`Error`/`From` across all four files, plus persistence to a file (`fs`, `io::Error`, real `From` conversion) — the natural project for traits; (2) collections + iterators drill (`HashMap`, `filter`/`map`/`collect`), still untouched since the 1/2 diagnostic; (3) lifetimes, only once traits are solid; (4) then axum/serde, where traits pay off. +- **Dark mode is the house style (2026-09-02, user request).** `assets/style.css` is dark by default and every lesson/reference doc gets it by linking that one file — so a new lesson needs no colour work, just ``. Never inline colours or a per-lesson palette. Panels use `var(--panel)` / `var(--panel-hover)`; `@media print` re-declares the whole variable block as ink-on-paper so printouts stay readable. +- **Typography (2026-09-02, user request):** the old serif stack was unreadable — none of "Iowan Old Style"/Palatino/Georgia/Charter is installed on this machine, so it fell back to DejaVu Serif. Now a humanist sans stack (`Inter`, `Ubuntu Sans`, `system-ui`, …) at `1.2rem` / `1.68` line-height, measure narrowed to `40em`, code at `0.87em` in `DejaVu Sans Mono`. Only fonts actually installed here: Ubuntu Sans, DejaVu Sans, Liberation Sans, DejaVu Sans Mono, Ubuntu Mono — check with `fc-list : family` before naming a font. +- **Quiz authoring checklist (bug found 2026-09-02).** A recall question without `` renders dead: `initRecall` in `assets/quiz.js` bails out if reveal/answer/grade are not all present, so the Got it / Missed it buttons never appear. Four questions in 0005 shipped that way. Before shipping any lesson with a quiz, click every question in the browser — do not eyeball the HTML. +- `quiz.js` marks a graded recall with `opt-correct` / `opt-incorrect`, which the stylesheet did not define — Got it / Missed it gave no colour feedback in any lesson. Fixed in `assets/style.css`; the graded choice now turns green/red and the other button dims to 0.45 opacity. +- **0005 drill result (2026-09-03): all three checks green.** `Display for Task` correct, `unwrap` gone, `run() -> Result<(), String>`, `Command::parse(args)?` with the match on `Command` values (fixed after review), `eprintln!` + `process::exit(1)`, exit 1 on both failure paths, 17/17 still pass. The stderr/exit-1 contract missed in 0003 is now produced. Remaining nits are cosmetic only (`&args` on a `&[String]` param, `match` where `if let Err` would do). Traits/Display/`?` are produced, not just recognised — 0006 can assume them. +- Concrete material for 0006 (used): `"id not found"` is returned by both `command.rs` (no id *argument* given) and `store.rs` (id *not in the store*) — two different failures, identical prose. Their own defect, not an invented example. Enums scored 0/1 on the 0005 recall for exactly this reason. +- **0006 shipped (2026-09-03):** `TaskError` enum + `Display` + `Error::source()` + `From` across `error.rs`/`command.rs`/`store.rs`/`main.rs`, driven by a shipped `tests/errors.rs` (7 tests). Reference impl verified in a temp dir first: 17 + 7 = 24 green. Deliberately NOT in 0006: file persistence (that is 0007, and `From` is easier once `TaskError` exists). +- New in 0006 beyond 0005's recipe: `Error::source()`, plus the std rule that a wrapped cause goes in *either* `source()` or `Display`, never both. Cited from the std page, and the tests enforce it (`BadId` Display says "task id must be a number"; the `ParseIntError` sentence is only reachable via `source()`). +- Real compiler output captured for 0006 (never from memory): `E0004` non-exhaustive after adding a variant, `E0432` unresolved import when `pub mod error;` is missing, `E0308` leftover `String` error, `E0271` `?` with no `From` impl (the annotated-target variant of `E0277`, exactly as 0005 predicted), `E0369` missing `PartialEq` seen from the test file. +- **`reference/book-coverage.html` (new, 2026-09-03):** ch1–21 mapped to Read / Produced / Gap, chapter list taken verbatim from the book's `SUMMARY.md`. Answers "am I missing something important?" without guessing. Keep it updated after every lesson — it is now the thing that picks the next lesson. Named gaps: ch8 collections/`HashMap`, ch11 writing own tests, ch13 iterator chains, ch10.3 lifetimes, ch16/17 concurrency+async. +- Lesson order after 0006: (1) **0007** persistence — `fs`, `io::Error`, `From`, `FromStr` to read a task back from a line; (2) **0008** collections + iterators (`HashMap`, `filter`/`map`/`collect`, first hand-written generic fn); (3) **0009** writing your own tests (ch11) — the user has consumed 24 of my tests and written zero; (4) lifetimes as reading practice; then serde → axum → async. +- Quiz checklist ran for 0006 (2026-09-03): served the workspace over `python3 -m http.server` (Chrome/Playwright blocks `file://`), clicked all 6 questions via Playwright — 4 recalls have reveal+grade, 2 MCQs mark correct/incorrect, summary and Copy report work, only console error is a missing favicon. Do this for every lesson; it is three tool calls. +- Style additions for 0006: `blockquote` (verbatim primary-source quotes) and `.gap` (red cell in the coverage table) now live in `assets/style.css`. Both print correctly because the print block re-declares the variables. +- **0006 drill result (2026-09-03): 24/24 green** (17 spec + 7 errors), `tests/errors.rs` byte-identical to the shipped spec. `TaskError` has all 7 variants, `Display` correct, `source()` returns the `ParseIntError` only for `BadId`, `From` written. **But `From` is never exercised**: `command.rs` hand-matches `id.parse()` with `Err(e) => return Err(TaskError::BadId(e))` in both `done` and `remove`, duplicated, so the `?` conversion the lesson taught is dead code. Also unchanged from 0005: `match`/`Ok(_) => ()` in `main` instead of `if let Err`. Traits are produced; the gap is *reaching for* `?`+`From` instead of manual matching. +- **0007 shipped (2026-09-03):** files + `FromStr`. `fs::read_to_string`/`fs::write`, `io::Error` + `ErrorKind::NotFound` match guard, `From` (the second `From`, which answers the user's own question), `FromStr for Task` with an associated `type Err`, `env::var` with a default, and a hand-written `PartialEq` because `io::Error` is not `PartialEq`. Reference impl verified in `/tmp/ref7` first: 17 + 7 + 8 = 32 green, spec shipped as `lessons/0007-persist-spec.rs`. +- Real compiler output captured for 0007 by running it: `E0369` (derive `PartialEq` over an `io::Error` field), `E0277` (`?` with no `From`), `E0277` (`Task: FromStr` not satisfied), `E0046` (missing `type Err`), `E0119` (conflicting `From` impls). Never from memory. +- **Drill steps need a mechanical check, not prose (third repeat — see LR-0005).** 0006 asked in prose for the two `match id.parse()` blocks to collapse to `?`; it did not happen. 0007 makes it step 0 with `grep -c "match id.parse" src/command.rs` → `0`. +- The user asks mechanism questions after each lesson (this session: can two `From` impls exist, where does the wrapped message go, must a dev walk `source()` by hand). Answer with runnable evidence — a scratch binary in `/tmp` and its real output beats prose, and takes one tool call. +- Deliberately left un-idiomatic in 0007 so 0008 has the user's own code to rewrite: the `for` loop in `Store::load` that pushes into a `Vec` is a `collect::, _>>()` waiting to happen. +- **0008 shipped (2026-09-04):** iterators + `HashMap` + first generic function. Reference impl verified in `/tmp/ref8` first: 17 + 7 + 8 + 14 = **46 green**, `cargo clippy --all-targets` clean apart from one pre-existing `Default for Store` suggestion. Spec shipped as `lessons/0008-collections-spec.rs` (14 tests, 162 lines). +- What 0008 makes the user produce: `Store::load` rewritten as `collect::, TaskError>>()?` (the loop 0007 left behind), `count_by_priority` on a `HashMap`, `remove_completed` via `Vec::retain`, `titles_with` as a `filter`+`map`+`collect` chain, `find` vs `position` distinguished, and `fn tally(items: &[T], key: F) -> HashMap where K: Eq + Hash, F: Fn(&T) -> K` written from the signature up in a new `stats.rs`. +- Real compiler output captured for 0008 by running it (never from memory): `unused Map that must be used` + "iterators are lazy and do nothing unless consumed" (no consumer), `E0283` type annotations needed on a bare `collect()`, `E0277` `Priority: Eq`/`Priority: Hash` not satisfied at the `tally` call site, `E0502` an `iter_mut()` borrow held across a `self.books.len()` read, `E0507` cannot move `task.priority` out from behind `&Task`, `E0004` non-exhaustive `match` after adding `Stats`/`Clear`. +- **The `Copy` derive is the interesting one.** Keying a `HashMap` by an enum field read through `&T` forces the user to choose between `.clone()`, `#[derive(Clone, Copy)]`, and keying by `&str` — a real ownership decision with three defensible answers, not a syntax lookup. It is quiz question 4 (Ownership) for that reason. +- 0008 ends with a runnable payoff, not just green tests: two new CLI commands (`stats`, `clear`) wired through `Command`/`main`, so the `HashMap` and the `retain` are visible from the terminal. Real session captured in the lesson verbatim (`stats` → `high 1 / medium 1 / low 1`, `clear` → `cleared 1 completed`, exit 0). +- Reference doc grew three sections, all anchored and linked from 0008: `#iterators` (the one-method trait, the three ways in, adapters vs consumers, `collect` targets, `lines()` vs `split('\n')`, the four errors), `#hashmap-keys` (`Eq + Hash`, the `entry` idiom, arbitrary order, `BTreeMap` as the sorted alternative), and a generic-function block in `#traits` (monomorphisation, the `where` clause as a two-way contract, `Fn`/`FnMut`/`FnOnce`). +- Quiz checklist ran for 0008 (2026-09-04): served over `python3 -m http.server 8899`, clicked all 6 questions through Playwright — 2 MCQs mark correct/incorrect, 4 recalls reveal + grade, summary reports `6 of 6 answered, 6 correct` broken down by topic, every local link and `#anchor` resolves, only console error is the missing favicon. +- Interleaving in the 0008 quiz is deliberate: only 2 of 6 questions are about iterators. The rest revisit `HashMap` keys, the `Copy`/`clone` ownership choice, and trait bounds — spaced retrieval of 0005/0006/0007 material inside a new lesson. +- **Coverage map after 0008:** ch8 and ch13 flip Gap/Partial → Produced, ch10.1 Read → Produced. One core gap remains: **ch11, writing your own tests** — the user has now run 46 of my tests and written zero `#[test]`. That is 0009, and the map's "order that follows" section says so. Then lifetimes (ch10.3) as reading practice — 0008 had the user write one unknowingly, since `titles_with` returns `Vec<&str>` borrowed from `&self` and elision hid the annotation. +- Nothing deliberately left un-idiomatic in 0008 — 0009 supplies its own material, because the target is the tests the user writes rather than the code they refactor. +- **Lesson format rule (2026-09-04, user-reported):** *define the demo domain's data model before the first example.* 0008 shipped with `Book`, `Shelf`, `book()`, and `shelf_of()` used across ten snippets and defined nowhere — the user could not tell what fields a book had, so every example needed guessing. Fixed by adding a "The demo domain, in full" section before Part 1: both type definitions, both helpers, the literal four-book `Vec`, and an explicit note that `shelf` (lowercase) is the `Vec` while `Shelf` (capitalised) is the enum. Every future lesson with a demo domain does this first. +- Related trap the same complaint exposed: **one variable name must have one element type.** 0008 used `shelf` for both a `Vec` (closure `|b|`) and a `Vec<&str>` (closure `|t|`), because the two error examples came from a different scratch file. Fixed by re-running `/tmp/demo8/examples/{e1,e2}.rs` with a `titles` binding and re-capturing real rustc output — never hand-edit a variable name inside quoted compiler output, re-run it. +- **Layout rule (2026-09-04, user-decided): narrow prose column, small code font, and let the few verbatim blocks scroll.** 13 of 28 code blocks in 0008 were scrolling horizontally, because prose was capped at `max-width: 40em` while verbatim rustc output runs to ~105 characters. Two fixes were tried and rejected: (1) letting `pre` break out of the prose column with negative margins — the user said code hanging past the paragraph above it looks broken; (2) widening the body to `65rem` so prose and code share one edge — the user said the long measure strained their eyes to read. Final: `body { max-width: 40em }` is back, `pre` drops to `font-size: 0.73em` (0.62em in print), and the handful of blocks still too wide scroll on the wheel. +- What that buys: the column fits ~81 characters of code. **Every authored line in every lesson and in `rust-syntax.html` is now trimmed to fit it** — the pass that did it lives in the session log, but the rule going forward is simply: keep authored code and its trailing `//` comment inside 81 characters. +- What still scrolls, and why it must: verbatim rustc output only — 83 to 103 characters, `E0502`/`E0277`/`E0507`/`E0432`, the `^^^^` and `-----` spans. Those cannot be wrapped or hand-shortened, because the underlines have to stay column-aligned or they point at the wrong token. Measured at 1385px with layout settled: `rust-syntax` 4/51, 0005 3/34, 0007 2/22, 0008 4/28, and 0/N on 0001-0004 and 0006. All quizzes still click through. +- Measure `pre` overflow **after** a settle delay. A bare `iframe.onload` read reported 22/51 on `rust-syntax` where the settled read gives 4/51 — the scrollbar geometry is not final at load. +- **Keep terminal sessions short with a shell helper** (`run(){ ... }`), as 0008 does. 0007 repeated the full `TASKS_FILE=t.txt cargo run -q --manifest-path ...` on every line and was the one block still too wide; re-recorded with a `run()` helper — re-run the session, never retype it by hand. +- **Lesson format rule (2026-09-04, user-reported): a lesson must read top to bottom, and no name may appear before it is defined.** 0008 put `tally(self.tasks(), |task| task.priority)` inside the Part 5 error output, three sections before Part 6 defines `tally` — the user could not read the error, because the call site it pointed at was meaningless. I did not notice, because I wrote the lesson as a whole and read it as a whole; the user reads it in one direction, once. Captured compiler output is verbatim and must never be hand-edited (see the `shelf`/`titles` trap above), so the fix is always to introduce the name *earlier* — a signature plus a one-line gloss before the first sighting — never to alter the quote or reorder the parts. **Ship check: read the lesson in order and list every identifier at its first appearance; if it is not defined above that point, it is a bug.** This is the demo-domain rule one level up: that one covers the demo's data model, this one covers every other name, including ones the drill will define later. +- **The "specs need prose" rule (line 18) covers drill steps too — enforcement failure found 2026-09-05.** 0008 Step 3 shipped as two bare signatures (`titles_with`, `remove_completed`) plus one note about `&str`, and the user was blocked: a signature says what goes in and out, never what the function is *for* or what its edge cases are. I had the behaviour, it was in the six spec tests — order preserved, empty `Vec` not an error, ids not renumbered, counter untouched, `0` is a legal answer — and I left the user to reverse-engineer prose out of `assert_eq!`. Fixed by writing those out. **Rule going forward: every function a drill asks for gets a plain-language sentence on what it is for, plus the edge cases its tests pin down, written from the test file at authoring time. A signature is never the specification.** Step 5 of the same drill is the pattern that works — the captured terminal session specifies both new commands by example, so no prose was needed there. +- **0008 drill result (2026-09-05): 46/46 green, library produced, CLI half missed three requirements.** `tally` written from the signature, `load` as one `collect::, TaskError>>()?`, `retain`, the `filter`/`map`/`collect` chain — all correct. But `stats` printed high/low/medium (`main.rs` looped `[Priority::High, Low, Medium]`), `clear` printed nothing and dropped the `usize`, and nothing tested either, because `run` lives in `src/main.rs` where no `tests/` file can import it. Third repeat of "untested prose is undone prose" (LR-0003, LR-0005), first time the cause is structural. See LR-0006. +- **0009 shipped (2026-09-05):** writing your own tests (ch11). Demo domain is `/tmp/heating` — a thermostat with a private field, a private helper, one panicking constructor and one `Result` setter, so the page can show `#[should_panic]` and a `Result`-returning test against the same type. All 28 code/output blocks captured by running them; no new spec file, because the skill is producing assertions rather than satisfying mine. +- **New grading device: `lessons/0009-mutants.sh`.** Copies the crate to a temp dir, applies one `sed` mutation, runs the user's tests, restores nothing (the copy is thrown away) — six mutations covering stats order, the zero-count line, the clear count, the `Display` line, `Status::parse`'s `in-progress` arm, and `Command::parse`'s case folding. Verified to discriminate: `0 killed, 6 survived` against only the 46 shipped tests, `6 killed, 0 survived` against the reference impl in `/tmp/ref9`. Pre-drill run on the user's crate: `0 killed, 3 survived, 3 skipped` (three mutations target `src/cli.rs`, which the drill creates). Drill's finishing condition is the report, not a test count — reuse this device for any lesson where the deliverable is tests. +- The 0009 refactor is the point, not scaffolding: `run` moves from `src/main.rs` to `src/cli.rs` and takes `out: &mut impl Write`, so `main` passes `io::stdout().lock()` and a test passes `Vec` and asserts on the bytes. That single parameter is what makes the three 0008 CLI defects testable, and it is the same move that makes an axum handler testable later. Reference impl `/tmp/ref9`: 4 unit + 6 cli + 14 + 7 + 3 + 8 + 17 = 59 green, no warnings, CLI session verified end to end (`stats` in high/medium/low order, `cleared 1 completed`, `done 9` → exit 1). +- Deliberately NOT in 0009: doc tests (`///` examples, ch14), `#[bench]`, `assert_cmd`/`predicates` for subprocess testing, `proptest`, and `cargo-mutants` (the real version of the shipped script). Named in a "then stop" section so the user knows they exist and why they are not next. +- **Test names drive the width of captured output.** Five of 28 blocks in 0009 still scroll, all of them on cargo's own 93–97 character `test result:` line, which cannot be shortened. Four more were scrolling only because my demo test names were 34–40 characters long (`a_room_below_the_target_is_heating`, `every_legal_target_survives_a_round_trip`); renaming them to ~17–30 characters and **re-running the captures** fixed those blocks. Never hand-edit the quoted output to fit — rename in the source and re-run (same rule as the `shelf`/`titles` trap, NOTES line 55). +- Quiz checklist ran for 0009 (2026-09-05): served over `python3 -m http.server 8899`, clicked all 6 questions through Playwright — 3 MCQs mark correct/incorrect, 3 recalls reveal + grade, summary reports `6 of 6 answered, 6 correct` split Tests 3/3, Traits 1/1, Collections 1/1, Modules 1/1; every local link and `#anchor` resolves (including the new `rust-syntax.html#tests`); console clean, zero errors this time (the favicon 404 is gone because the page is served, not opened from `file://`). +- Reference doc gained `#tests` (the attribute pair, the three macros and what each failure prints, `should_panic` vs a `Result` test, the unit-vs-integration access table, the `tests/common/mod.rs` spelling, the writer-injection shape, the runner flags, `E0433`/`E0603`/`E0616`) and its title/subtitle now say ch. 1–13. Coverage map: ch8, ch10.1 and ch13 flip to Produced after the 0008 drill; ch11 is "Taught — drill pending"; the "order that follows" section now names lifetimes (10.3) and patterns (19) as the remainder, then serde → axum → async. +- After the 0009 drill the book's core has no Gap left except ch10.3 lifetimes (reading practice) and ch19 patterns (one page). Next real decision is serde vs a first axum service — ask the user which, since both are now reachable. diff --git a/RESOURCES.md b/RESOURCES.md new file mode 100644 index 0000000..7cfb2b4 --- /dev/null +++ b/RESOURCES.md @@ -0,0 +1,32 @@ +# Rust Resources + +## Knowledge + +- [Book: _The Rust Programming Language_ (official)](https://doc.rust-lang.org/stable/book/) + Primary source for all fundamentals lessons (ch1–9 and beyond). Use for: syntax, ownership rules, canonical examples. +- [Rust by Example](https://doc.rust-lang.org/rust-by-example/) + Terse, runnable code samples per concept, no prose. Use for: quick syntax lookup without re-reading the book. +- [std library docs](https://doc.rust-lang.org/std/) + Use for: exact method signatures (`Vec`, `HashMap`, `Result`, `Option`, …) when writing real code. +- [Rust API Guidelines](https://rust-lang.github.io/api-guidelines/) + Use for: idiomatic backend/library code once past fundamentals (naming, error types, trait design). Specifically [C-GOOD-ERR](https://rust-lang.github.io/api-guidelines/interoperability.html#error-types-are-meaningful-and-well-behaved-c-good-err) — the checklist a reviewer applies to an error type: implement `Error`, be `Send + Sync`, never use `()`, and keep `Display` messages lowercase without trailing punctuation. Quoted verbatim in lesson 0006. +- [std: `std::error::Error`](https://doc.rust-lang.org/std/error/trait.Error.html) + Two screens, and it is the definition of what an error *is*: the `Debug + Display` supertraits, plus `source()` for a wrapped cause. States the rule that a cause belongs in *either* `source()` or `Display`, never both. +- [Book table of contents (`SUMMARY.md`)](https://github.com/rust-lang/book/blob/main/src/SUMMARY.md) + The authoritative chapter list. Use for: checking coverage claims against the real book instead of memory — `reference/book-coverage.html` is built from it. +- [std: `std::str::FromStr`](https://doc.rust-lang.org/std/str/trait.FromStr.html) + One screen, and it is what `.parse()` calls. Use for: the associated-type pattern (`type Err`) and the `Point` example, which is the same shape as `Task` in 0007. +- [std: `std::fs`](https://doc.rust-lang.org/std/fs/) and [`std::io::ErrorKind`](https://doc.rust-lang.org/std/io/enum.ErrorKind.html) + Use for: exact signatures of `read_to_string`/`write`, and the list of io failure kinds you can match on. `ErrorKind::NotFound` is the one that means "first run", not "broken". +- [Book ch12 — An I/O Project](https://doc.rust-lang.org/stable/book/ch12-00-an-io-project.html) + Primary source for lesson 0007: files, stderr, and `env::var`. 12.2 and 12.5 are the two sections that matter. + +### Gaps +- No curated async/backend-framework resource yet (axum/tokio). Add once lessons reach networking — a `trpl` dependency already sits unused in `get-dependecies/`, signaling this is coming. +- Deliberately deferred, not missing: [`thiserror`](https://docs.rs/thiserror) (derives the `Display`/`From` code written by hand in 0006) and [`anyhow`](https://docs.rs/anyhow) (application-level `Box` with context). Both are what real crates use; neither teaches what the trait does. Reach for them on the second real project. + +## Wisdom (Communities) + +- [r/rust](https://reddit.com/r/rust) — active, well-moderated. Use for: code review requests, "is this idiomatic?" checks. +- [users.rust-lang.org](https://users.rust-lang.org) — official user forum. Use for: "why won't this compile" borrow-checker questions, and the **Code Review** category. First concrete ask, set after 0006: post `tasks/src/error.rs` and ask whether one enum for both parse and store failures is right, or whether those should be two types with a wrapping variant. +- [This Week in Rust](https://this-week-in-rust.org) — weekly newsletter. Use for: staying current once past fundamentals. diff --git a/assets/quiz.js b/assets/quiz.js new file mode 100644 index 0000000..b754f27 --- /dev/null +++ b/assets/quiz.js @@ -0,0 +1,158 @@ +// Shared quiz engine for lessons. Two question types, both graded client-side, no server. +// +// Recall (free-recall self-check — best for storage strength): +//
+//

Ownership

+//

...question...

+// +// +// +//
+// +// Multiple choice (instant feedback): +//
+//

Enums

+//

...question...

+//
+// +// +//
+// +//
+// +// Bottom of page needs: +//

+//
+ +(function () { + function all(sel, ctx) { + return Array.from((ctx || document).querySelectorAll(sel)); + } + + function initRecall(q) { + var revealBtn = q.querySelector(".reveal-btn"); + var answer = q.querySelector(".answer"); + var grade = q.querySelector(".grade"); + if (!revealBtn || !answer || !grade) return; + revealBtn.addEventListener("click", function () { + answer.classList.remove("hidden"); + grade.classList.remove("hidden"); + revealBtn.classList.add("hidden"); + }); + all("button", grade).forEach(function (btn) { + btn.addEventListener("click", function () { + q.dataset.result = btn.dataset.grade === "hit" ? "hit" : "miss"; + q.classList.add("graded"); + all("button", grade).forEach(function (b) { + b.disabled = true; + }); + btn.classList.add(btn.dataset.grade === "hit" ? "opt-correct" : "opt-incorrect"); + updateSummary(); + }); + }); + } + + function initMcq(q) { + var opts = all(".opt", q); + var explain = q.querySelector(".explain"); + opts.forEach(function (opt) { + opt.addEventListener("click", function () { + if (q.classList.contains("graded")) return; + var correct = opt.dataset.correct === "true"; + q.classList.add("graded"); + q.dataset.result = correct ? "hit" : "miss"; + opts.forEach(function (o) { + o.disabled = true; + if (o.dataset.correct === "true") o.classList.add("correct"); + }); + if (!correct) opt.classList.add("incorrect"); + if (explain) explain.classList.remove("hidden"); + updateSummary(); + }); + }); + } + + function byTopic() { + var map = {}; + all(".q").forEach(function (q) { + var topic = q.dataset.topic || "General"; + map[topic] = map[topic] || { hit: 0, graded: 0, total: 0 }; + map[topic].total++; + if (q.dataset.result) { + map[topic].graded++; + if (q.dataset.result === "hit") map[topic].hit++; + } + }); + return map; + } + + function updateSummary() { + var body = document.getElementById("summary-body"); + var total = document.getElementById("summary-total"); + var reportBtn = document.getElementById("report-btn"); + if (!body) return; + var map = byTopic(); + var lines = []; + var sumHit = 0, + sumGraded = 0, + sumAll = 0; + Object.keys(map).forEach(function (topic) { + var t = map[topic]; + sumHit += t.hit; + sumGraded += t.graded; + sumAll += t.total; + var pct = t.graded ? Math.round((100 * t.hit) / t.graded) : null; + lines.push( + topic + + ": " + + t.hit + + "/" + + t.graded + + (t.graded < t.total ? " (of " + t.total + ")" : "") + + (pct === null ? "" : " — " + pct + "%") + ); + }); + body.textContent = lines.join("\n"); + if (total) total.textContent = sumGraded + " of " + sumAll + " answered, " + sumHit + " correct."; + if (reportBtn) reportBtn.disabled = sumGraded === 0; + } + + function buildReport() { + var map = byTopic(); + var weak = []; + var solid = []; + Object.keys(map).forEach(function (topic) { + var t = map[topic]; + var entry = topic + " (" + t.hit + "/" + t.total + ")"; + if (t.hit < t.total) weak.push(entry); + else solid.push(entry); + }); + var lines = ["Rust diagnostic result:"]; + lines.push(weak.length ? "Weak: " + weak.join(", ") : "Weak: none"); + lines.push(solid.length ? "Solid: " + solid.join(", ") : "Solid: none"); + return lines.join("\n"); + } + + document.addEventListener("DOMContentLoaded", function () { + all(".q").forEach(function (q) { + if (q.dataset.type === "recall") initRecall(q); + if (q.dataset.type === "mcq") initMcq(q); + }); + updateSummary(); + var reportBtn = document.getElementById("report-btn"); + if (reportBtn) { + reportBtn.addEventListener("click", function () { + var report = buildReport(); + var out = document.getElementById("report-output"); + if (out) { + out.textContent = report; + out.classList.remove("hidden"); + } + if (navigator.clipboard) navigator.clipboard.writeText(report).catch(function () {}); + }); + } + }); +})(); diff --git a/assets/style.css b/assets/style.css new file mode 100644 index 0000000..0717dca --- /dev/null +++ b/assets/style.css @@ -0,0 +1,224 @@ +/* Shared stylesheet for all lessons and reference docs. Quiet, humanist sans, generous size. + Dark by default; the print block below swaps back to ink-on-paper. */ + +:root { + --ink: #e4e1d9; + --paper: #15171b; + --muted: #9b978d; + --rule: #2f333a; + --panel: #1d2025; + --panel-hover: #24282e; + --accent: #e3a172; + --good: #74c48a; + --good-bg: #172a1d; + --bad: #e8796e; + --bad-bg: #2b1715; + color-scheme: dark; +} + +* { box-sizing: border-box; } + +body { + max-width: 40em; + margin: 3rem auto 6rem; + padding: 0 1.5rem; + background: var(--paper); + color: var(--ink); + font-family: "Inter", "Ubuntu Sans", Ubuntu, system-ui, "Segoe UI", -apple-system, + "Noto Sans", "DejaVu Sans", sans-serif; + font-size: 1.2rem; + line-height: 1.68; + -webkit-font-smoothing: antialiased; +} + +h1, h2, h3 { + font-weight: 600; + line-height: 1.25; +} + +h1 { font-size: 2.1rem; margin-bottom: 0.2rem; letter-spacing: -0.01em; } +h1 + .subtitle { color: var(--muted); font-size: 1.02rem; margin-top: 0; margin-bottom: 2rem; } + +h2 { font-size: 1.45rem; margin-top: 2.5rem; border-top: 1px solid var(--rule); padding-top: 1.5rem; } + +a { color: var(--accent); text-decoration: underline dotted; text-underline-offset: 2px; } +a:hover { text-decoration-style: solid; } + +code, pre { + font-family: "JetBrains Mono", "Cascadia Mono", "DejaVu Sans Mono", + "Ubuntu Sans Mono", Consolas, Menlo, monospace; + font-size: 0.87em; + font-variant-ligatures: none; +} + +pre { + background: var(--panel); + /* Smaller than inline code, so long rustc output overflows by as little as possible. + Wrapping is not an option: the `^^^^` underlines must stay column-aligned or they + point at the wrong token. Blocks that still exceed the column scroll sideways. */ + font-size: 0.73em; + line-height: 1.5; + border: 1px solid var(--rule); + border-radius: 4px; + padding: 0.9em 1em; + overflow-x: auto; +} + +code { background: var(--panel); padding: 0.1em 0.3em; border-radius: 3px; } +pre code { background: none; padding: 0; font-size: 1em; } /* no compounding */ + +.cite { + font-size: 0.85em; + color: var(--muted); + border-left: 2px solid var(--rule); + padding-left: 0.7em; + margin: 0.5em 0 0; +} + +.callout { + border: 1px solid var(--rule); + background: var(--panel); + border-radius: 6px; + padding: 1em 1.2em; + margin: 1.5rem 0; + font-size: 0.95em; +} + +/* Quoted primary sources — verbatim text from the book, std docs, or guidelines */ +blockquote { + margin: 1.5rem 0 0.4rem; + padding: 0.2em 0 0.2em 1.1em; + border-left: 3px solid var(--accent); + color: var(--ink); + font-size: 0.97em; +} +blockquote p { margin: 0.4em 0; } +blockquote + .cite { margin-top: 0; } + +/* Coverage tables: a cell that names a real gap */ +.gap { color: var(--bad); font-weight: 600; } + +/* Quiz widget */ +.q { + border-top: 1px solid var(--rule); + padding: 1.4rem 0; +} + +.q .topic { + display: inline-block; + font-size: 0.75rem; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted); + margin-bottom: 0.4em; +} + +.q .prompt { margin: 0 0 0.8em; } + +.q button { + font-family: inherit; + color: inherit; + font-size: 0.92rem; + background: transparent; + border: 1px solid var(--rule); + border-radius: 4px; + padding: 0.35em 0.8em; + cursor: pointer; + margin: 0.2em 0.4em 0.2em 0; +} +.q button:hover:not(:disabled) { background: var(--panel-hover); } +.q button:disabled { cursor: default; } + +.options { display: flex; flex-direction: column; align-items: flex-start; } +.opt { text-align: left; width: 100%; } +.opt.correct, .grade button.opt-correct { border-color: var(--good); background: var(--good-bg); color: var(--good); } +.opt.incorrect, .grade button.opt-incorrect { border-color: var(--bad); background: var(--bad-bg); color: var(--bad); } +.q button:disabled { opacity: 1; } +.grade button:disabled:not(.opt-correct):not(.opt-incorrect) { opacity: 0.45; } + +.answer, .explain { + background: var(--panel); + border-left: 3px solid var(--accent); + padding: 0.7em 1em; + margin-top: 0.6em; +} + +.grade button[data-grade="hit"]:hover { border-color: var(--good); } +.grade button[data-grade="miss"]:hover { border-color: var(--bad); } + +.hidden { display: none !important; } + +#summary { + margin-top: 2.5rem; + border-top: 2px solid var(--ink); + padding-top: 1rem; +} +#summary-body { white-space: pre-line; } +#report-output { + white-space: pre-line; + background: var(--panel); + padding: 0.8em 1em; + border-radius: 4px; + margin-top: 0.8em; +} + +/* Collapsible hints */ +details { + border: 1px solid var(--rule); + border-radius: 6px; + padding: 0.6em 1em; + margin: 0.7rem 0; + background: var(--panel); +} +details[open] { background: var(--panel-hover); } +summary { + cursor: pointer; + font-weight: 600; + color: var(--accent); +} +details > *:last-child { margin-bottom: 0; } + +/* Spec tables */ +table { + border-collapse: collapse; + width: 100%; + margin: 1rem 0; + font-size: 0.95em; +} +th, td { + border-bottom: 1px solid var(--rule); + padding: 0.4em 0.6em; + text-align: left; + vertical-align: top; +} +th { border-bottom: 2px solid var(--ink); } + +footer { + margin-top: 3rem; + padding-top: 1.5rem; + border-top: 1px solid var(--rule); + color: var(--muted); + font-size: 0.9rem; +} + +@media print { + :root { + --ink: #1a1a1a; + --paper: #fff; + --muted: #555; + --rule: #ccc; + --panel: #f4f2ec; + --panel-hover: #eeece5; + --accent: #8a3324; + --good: #2a7a3b; + --good-bg: #eaf5ec; + --bad: #a3312a; + --bad-bg: #fbeceb; + color-scheme: light; + } + body { margin: 0.5rem auto; } + .hidden { display: block !important; } + .q button, #report-btn { display: none !important; } + pre { font-size: 0.62em; } /* paper is narrower than a screen, and cannot scroll */ + pre, blockquote, table, .q { break-inside: avoid; } +} diff --git a/control_flow/.gitignore b/control_flow/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/control_flow/.gitignore @@ -0,0 +1 @@ +/target diff --git a/control_flow/Cargo.lock b/control_flow/Cargo.lock new file mode 100644 index 0000000..b3e3f72 --- /dev/null +++ b/control_flow/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "control_flow" +version = "0.1.0" diff --git a/control_flow/Cargo.toml b/control_flow/Cargo.toml new file mode 100644 index 0000000..537ea1a --- /dev/null +++ b/control_flow/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "control_flow" +version = "0.1.0" +edition = "2024" + +[dependencies] diff --git a/control_flow/src/main.rs b/control_flow/src/main.rs new file mode 100644 index 0000000..aef9cb3 --- /dev/null +++ b/control_flow/src/main.rs @@ -0,0 +1,62 @@ +fn main() { + let number = 3; + + if number < 5 { + println!("condition was true"); + } else { + println!("condition was false"); + } + + let number = 12; + + if number % 4 == 0 { + println!("number is divisible by 4"); + } else if number % 3 == 0 { + println!("number is divisible by 3"); + } else if number % 2 == 0 { + println!("number is divisible by 2"); + } else { + println!("number is not divisible by 4, 3, or 2"); + } + + let condition = true; + let number = if condition { 5 } else { 6 }; + + println!("The value of number is: {number}"); + + { + let x = 5; + println!("this is five {x}") + } + + let mut count = 0; + 'counting_up: loop { + println!("count = {count}"); + let mut remaining = 10; + + loop { + println!("remaining = {remaining}"); + if remaining == 9 { + break; + } + if count == 2 { + break 'counting_up; + } + remaining -= 1; + } + + count += 1; + } + println!("End count = {count}"); + + let a = [10, 20, 30, 40, 50]; + + for element in a { + println!("the value is: {element}"); + } + + for number in (1..4).rev() { + println!("{number}!"); + } + println!("LIFTOFF!!!"); +} diff --git a/function/.gitignore b/function/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/function/.gitignore @@ -0,0 +1 @@ +/target diff --git a/function/Cargo.lock b/function/Cargo.lock new file mode 100644 index 0000000..0602f88 --- /dev/null +++ b/function/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "function" +version = "0.1.0" diff --git a/function/Cargo.toml b/function/Cargo.toml new file mode 100644 index 0000000..affe7f9 --- /dev/null +++ b/function/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "function" +version = "0.1.0" +edition = "2024" + +[dependencies] diff --git a/function/src/main.rs b/function/src/main.rs new file mode 100644 index 0000000..f3bce67 --- /dev/null +++ b/function/src/main.rs @@ -0,0 +1,29 @@ +fn main() { + println!("Hello, world!"); + + let y = { + let x = 3; + x + 1 + }; + + println!("The value of y is: {y}"); + + another_function(32, 'a'); + let z = five(); + println!("the value of z is {z}"); + + let x = plus_one(5); + println!("the value of x is {x}") +} + +fn another_function(a: usize, b: char) { + println!("Another function. {a}, {b}"); +} + +fn five() -> i32 { + 5 +} + +fn plus_one(x: i32) -> i32 { + x + 1 +} diff --git a/get-dependecies/.gitignore b/get-dependecies/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/get-dependecies/.gitignore @@ -0,0 +1 @@ +/target diff --git a/get-dependecies/Cargo.lock b/get-dependecies/Cargo.lock new file mode 100644 index 0000000..bceee50 --- /dev/null +++ b/get-dependecies/Cargo.lock @@ -0,0 +1,1981 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.2.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cssparser" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3df4f93e5fbbe73ec01ec8d3f68bba73107993a5b1e7519273c32db9b0d5be" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.11.3", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "ego-tree" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12a0bb14ac04a9fcf170d0bbbef949b44cc492f4452bd20c095636956f653642" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "get-dependecies" +version = "0.1.0" +dependencies = [ + "rand", + "trpl", +] + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "html5ever" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13771afe0e6e846f1e67d038d4cb29998a6779f93c809212e4e9c32efd244d4" +dependencies = [ + "log", + "mac", + "markup5ever", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "markup5ever" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45" +dependencies = [ + "log", + "phf 0.11.3", + "phf_codegen 0.11.3", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_shared 0.10.0", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_codegen" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb1c3a8bc4dd4e5cfce29b44ffc14bedd2ee294559a294e2a4d4c9e9a6a13cd" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher 1.0.3", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scraper" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b90460b31bfe1fc07be8262e42c665ad97118d4585869de9345a84d501a9eaf0" +dependencies = [ + "ahash", + "cssparser", + "ego-tree", + "getopts", + "html5ever", + "once_cell", + "selectors", + "tendril", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4eb30575f3638fc8f6815f448d50cb1a2e255b0897985c8c59f4d37b72a07b06" +dependencies = [ + "bitflags", + "cssparser", + "derive_more", + "fxhash", + "log", + "new_debug_unreachable", + "phf 0.10.1", + "phf_codegen 0.10.0", + "precomputed-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "servo_arc" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d036d71a959e00c77a63538b90a6c2390969f9772b096ea837205c6bd0491a44" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "trpl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d428ee808100b0c1c99d0ef515fda45f256f0655d23d769d0462dc04f5ff725" +dependencies = [ + "futures", + "reqwest", + "scraper", + "tokio", + "tokio-stream", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/get-dependecies/Cargo.toml b/get-dependecies/Cargo.toml new file mode 100644 index 0000000..0f6fc68 --- /dev/null +++ b/get-dependecies/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "get-dependecies" +version = "0.1.0" +edition = "2024" + +[dependencies] +rand = "0.8.5" +trpl = "0.2.0" diff --git a/get-dependecies/src/main.rs b/get-dependecies/src/main.rs new file mode 100644 index 0000000..e7a11a9 --- /dev/null +++ b/get-dependecies/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("Hello, world!"); +} diff --git a/grader/.gitignore b/grader/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/grader/.gitignore @@ -0,0 +1 @@ +/target diff --git a/grader/Cargo.lock b/grader/Cargo.lock new file mode 100644 index 0000000..55c0f6e --- /dev/null +++ b/grader/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "grader" +version = "0.1.0" diff --git a/grader/Cargo.toml b/grader/Cargo.toml new file mode 100644 index 0000000..3c2753b --- /dev/null +++ b/grader/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "grader" +version = "0.1.0" +edition = "2024" + +[dependencies] diff --git a/grader/src/main.rs b/grader/src/main.rs new file mode 100644 index 0000000..64d46f5 --- /dev/null +++ b/grader/src/main.rs @@ -0,0 +1,63 @@ +use std::env; + +fn main() { + let args: Vec = env::args().collect(); + if args.len() < 2 { + println!("usage : cargo run -- [score]"); + return; + } + + let mut scores: Vec = Vec::new(); + for arg in &args[1..] { + match arg.parse::() { + Ok(score) => { + println!("{score} -> {}", grade_score(score)); + scores.push(score); + } + Err(_) => println!("{arg} not a number"), + } + } + + if scores.is_empty() { + println!("not a valid score"); + } else { + println!("average scores: {}", average(&scores)); + } + + println!("{args:?}"); +} + +fn grade_score(score: u32) -> String { + match score { + 90..=100 => "A".to_string(), + 80..90 => "B".to_string(), + 70..80 => "C".to_string(), + _ => "F".to_string(), + } +} + +fn average(scores: &Vec) -> u32 { + let mut total = 0; + for s in scores { + total = total + s; + } + + total / scores.len() as u32 +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn grade_map_to_letters() { + assert_eq!(grade_score(95), "A"); + assert_eq!(grade_score(70), "B"); + assert_eq!(grade_score(43), "F"); + } + + #[test] + fn average_of_three() { + assert_eq!(average(&vec![80, 90, 70]), 80); + } +} diff --git a/guessing_game/.gitignore b/guessing_game/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/guessing_game/.gitignore @@ -0,0 +1 @@ +/target diff --git a/guessing_game/Cargo.lock b/guessing_game/Cargo.lock new file mode 100644 index 0000000..9acff32 --- /dev/null +++ b/guessing_game/Cargo.lock @@ -0,0 +1,133 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "guessing_game" +version = "0.1.0" +dependencies = [ + "rand", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/guessing_game/Cargo.toml b/guessing_game/Cargo.toml new file mode 100644 index 0000000..9f9c4ac --- /dev/null +++ b/guessing_game/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "guessing_game" +version = "0.1.0" +edition = "2024" + +[dependencies] +rand = "0.8.5" diff --git a/guessing_game/src/main.rs b/guessing_game/src/main.rs new file mode 100644 index 0000000..88daae9 --- /dev/null +++ b/guessing_game/src/main.rs @@ -0,0 +1,36 @@ +use rand::Rng; +use std::cmp::Ordering; +use std::io; + +fn main() { + println!("guess the number!!"); + + let secret_number = rand::thread_rng().gen_range(1..=100); + + println!("The secret number is {secret_number}"); + + loop { + println!("please input your guess"); + + let mut guess = String::new(); + + io::stdin() + .read_line(&mut guess) + .expect("failed to read line"); + + let guess: u32 = match guess.trim().parse() { + Ok(num) => num, + Err(_) => continue, + }; + println!("You guessed: {guess}"); + + match guess.cmp(&secret_number) { + Ordering::Less => println!("too small"), + Ordering::Greater => println!("Too big"), + Ordering::Equal => { + println!("You win!"); + break; + } + } + } +} diff --git a/hello_cargo/.gitignore b/hello_cargo/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/hello_cargo/.gitignore @@ -0,0 +1 @@ +/target diff --git a/hello_cargo/Cargo.lock b/hello_cargo/Cargo.lock new file mode 100644 index 0000000..feebdd1 --- /dev/null +++ b/hello_cargo/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "hello_cargo" +version = "0.1.0" diff --git a/hello_cargo/Cargo.toml b/hello_cargo/Cargo.toml new file mode 100644 index 0000000..f43e5f8 --- /dev/null +++ b/hello_cargo/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "hello_cargo" +version = "0.1.0" +edition = "2024" + +[dependencies] diff --git a/hello_cargo/src/main.rs b/hello_cargo/src/main.rs new file mode 100644 index 0000000..99b51c3 --- /dev/null +++ b/hello_cargo/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("Hello from cargo, world!"); +} diff --git a/hello_world/main.rs b/hello_world/main.rs new file mode 100644 index 0000000..d50843d --- /dev/null +++ b/hello_world/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("Hello, World"); +} diff --git a/learn-challenges/.gitignore b/learn-challenges/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/learn-challenges/.gitignore @@ -0,0 +1 @@ +/target diff --git a/learn-challenges/Cargo.lock b/learn-challenges/Cargo.lock new file mode 100644 index 0000000..b76d03b --- /dev/null +++ b/learn-challenges/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "learn-challenges" +version = "0.1.0" diff --git a/learn-challenges/Cargo.toml b/learn-challenges/Cargo.toml new file mode 100644 index 0000000..1a226d7 --- /dev/null +++ b/learn-challenges/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "learn-challenges" +version = "0.1.0" +edition = "2024" + +[dependencies] diff --git a/learn-challenges/src/main.rs b/learn-challenges/src/main.rs new file mode 100644 index 0000000..c236e75 --- /dev/null +++ b/learn-challenges/src/main.rs @@ -0,0 +1,66 @@ +use std::{collections::HashMap, vec}; + +fn main() { + let list = vec![3, 1, 4, 1, 5]; + + let med: usize = list.len() / 2; + + println!("this is the median {}", list.get(med).unwrap_or(&0)); + + let mut count = HashMap::new(); + + for &v in &list { + let x = count.entry(v).or_insert(0); + *x += 1; + } + + let mut mode = 0; + let mut max_count = 0; + + for (k, v) in &count { + if *v > max_count { + max_count = *v; + mode = *k; + } + } + + println!("this is the mode {}", mode); + + let latin = "apple banana".to_string(); + + println!("{latin} = this is the pig latin: {}", pig_latin(&latin)); + + let latin = "kdlkjlkdsgjh gkjldgjd".to_string(); + + println!("{latin} = this is the pig latin: {}", pig_latin(&latin)); +} + +fn pig_latin(s: &String) -> String { + let mut results_words: Vec = Vec::new(); + + for word in s.split_whitespace() { + let mut vowel_idx = None; + for (i, v) in word.char_indices() { + match v { + 'a' | 'i' | 'e' | 'u' | 'o' => { + vowel_idx = Some(i); + break; + } + _ => {} + } + } + + let transformed = match vowel_idx { + Some(0) => format!("{}-hay", word), + Some(idx) => { + let x = &word[..idx]; + let rmdr = &word[idx..]; + format!("{rmdr}-{x}ay") + } + None => word.to_string(), + }; + results_words.push(transformed); + } + + results_words.join(" ") +} diff --git a/learn-collections/.gitignore b/learn-collections/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/learn-collections/.gitignore @@ -0,0 +1 @@ +/target diff --git a/learn-collections/Cargo.lock b/learn-collections/Cargo.lock new file mode 100644 index 0000000..6bee7f5 --- /dev/null +++ b/learn-collections/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "learn-collections" +version = "0.1.0" diff --git a/learn-collections/Cargo.toml b/learn-collections/Cargo.toml new file mode 100644 index 0000000..9233549 --- /dev/null +++ b/learn-collections/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "learn-collections" +version = "0.1.0" +edition = "2024" + +[dependencies] diff --git a/learn-collections/src/main.rs b/learn-collections/src/main.rs new file mode 100644 index 0000000..fe046b4 --- /dev/null +++ b/learn-collections/src/main.rs @@ -0,0 +1,76 @@ +enum SpreadsheetCell { + Int(i32), + Float(f32), + Text(String), +} + +fn main() { + println!("Hello, world!"); + let mut v = vec![1, 2, 3, 4]; + + v.push(5); + v.push(6); + v.push(7); + + let Some(&x) = v.get(3) else { + println!("index not found"); + return; + }; + + for i in &v { + print!("{i}"); + } + println!(""); + + for i in &mut v { + *i = *i * 10; + } + + for i in &v { + print!("{i}"); + } + println!(""); + + println!("this is the value of the 100th index {x}"); + + let _row = vec![ + SpreadsheetCell::Int(3), + SpreadsheetCell::Float(3.14), + SpreadsheetCell::Text("bodo".into()), + ]; + + let _s1 = String::from("Hello"); + let _s2 = "World".to_string(); + let _s1: String = _s1 + &_s2; + println!("THis is: {_s1}"); + let _s3 = "FooBar".to_string(); + let _s = format!("{_s1} {_s3}"); + println!("{}", _s); + + use std::collections::HashMap; + + let mut scores = HashMap::new(); + + scores.insert("blue".to_string(), 1); + scores.insert("red".to_string(), 3); + + let score_blue = scores.get(&"blue".to_string()).copied().unwrap_or(0); + let score_red = scores.get(&"red".to_string()).copied().unwrap_or(0); + + println!("this is blue: {}, this is red {}", score_blue, score_red); + for (k, v) in &scores { + println!("team {k}: {v} scores"); + } + + scores.insert("green".to_string(), 10); + + scores.insert(String::from("Blue"), 10); + scores.insert(String::from("Blue"), 25); + + println!("{scores:?}"); + + scores.entry("blue".to_string()).or_insert(100); + scores.entry("yellow".to_string()).or_insert(100); + + println!("{scores:?}"); +} diff --git a/learn-modules/.gitignore b/learn-modules/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/learn-modules/.gitignore @@ -0,0 +1 @@ +/target diff --git a/learn-modules/Cargo.lock b/learn-modules/Cargo.lock new file mode 100644 index 0000000..dd4f001 --- /dev/null +++ b/learn-modules/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "learn-modules" +version = "0.1.0" diff --git a/learn-modules/Cargo.toml b/learn-modules/Cargo.toml new file mode 100644 index 0000000..9bdd443 --- /dev/null +++ b/learn-modules/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "learn-modules" +version = "0.1.0" +edition = "2024" + +[dependencies] diff --git a/learn-modules/src/garden.rs b/learn-modules/src/garden.rs new file mode 100644 index 0000000..6c7f9b1 --- /dev/null +++ b/learn-modules/src/garden.rs @@ -0,0 +1 @@ +pub mod vegetables; diff --git a/learn-modules/src/garden/vegetables.rs b/learn-modules/src/garden/vegetables.rs new file mode 100644 index 0000000..5dfb691 --- /dev/null +++ b/learn-modules/src/garden/vegetables.rs @@ -0,0 +1 @@ +pub struct Asparagus {} diff --git a/learn-modules/src/main.rs b/learn-modules/src/main.rs new file mode 100644 index 0000000..d779829 --- /dev/null +++ b/learn-modules/src/main.rs @@ -0,0 +1,8 @@ +use crate::garden::vegetables::Asparagus; + +pub mod garden; + +fn main() { + let _plant = Asparagus {}; + println!("Hello, world!"); +} diff --git a/learn-panic/.gitignore b/learn-panic/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/learn-panic/.gitignore @@ -0,0 +1 @@ +/target diff --git a/learn-panic/Cargo.lock b/learn-panic/Cargo.lock new file mode 100644 index 0000000..a94d20b --- /dev/null +++ b/learn-panic/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "learn-panic" +version = "0.1.0" diff --git a/learn-panic/Cargo.toml b/learn-panic/Cargo.toml new file mode 100644 index 0000000..f7b74c8 --- /dev/null +++ b/learn-panic/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "learn-panic" +version = "0.1.0" +edition = "2024" + +[dependencies] diff --git a/learn-panic/hello.txt b/learn-panic/hello.txt new file mode 100644 index 0000000..6b48b89 --- /dev/null +++ b/learn-panic/hello.txt @@ -0,0 +1 @@ +alkdsfjlkafdsj diff --git a/learn-panic/src/main.rs b/learn-panic/src/main.rs new file mode 100644 index 0000000..1af030a --- /dev/null +++ b/learn-panic/src/main.rs @@ -0,0 +1,30 @@ +use std::{fs::File, io::ErrorKind, panic}; +fn main() { + let _v = vec![1, 2, 3]; + + let greeting_file_result = File::open("hello.txt"); + + let _greeting_file = match greeting_file_result { + Ok(file) => file, + Err(error) => match error.kind() { + ErrorKind::NotFound => match File::create("hello.txt") { + Ok(fc) => fc, + Err(e) => panic!("Problem creating the file: {e}"), + }, + _ => { + panic!("Problem opening the file: {error:?}"); + } + }, + }; + + // v[99]; +} + +use std::io::{self, Read}; + +fn read_username_from_file() -> Result { + let mut username_file = File::open("hello.txt")?; + let mut username = String::new(); + username_file.read_to_string(&mut username)?; + Ok(username) +} diff --git a/learn-struct/.gitignore b/learn-struct/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/learn-struct/.gitignore @@ -0,0 +1 @@ +/target diff --git a/learn-struct/Cargo.lock b/learn-struct/Cargo.lock new file mode 100644 index 0000000..061cad7 --- /dev/null +++ b/learn-struct/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "learn-struct" +version = "0.1.0" diff --git a/learn-struct/Cargo.toml b/learn-struct/Cargo.toml new file mode 100644 index 0000000..ad24c02 --- /dev/null +++ b/learn-struct/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "learn-struct" +version = "0.1.0" +edition = "2024" + +[dependencies] diff --git a/learn-struct/src/main.rs b/learn-struct/src/main.rs new file mode 100644 index 0000000..baf5386 --- /dev/null +++ b/learn-struct/src/main.rs @@ -0,0 +1,81 @@ +struct User { + active: bool, + username: String, + email: String, + sign_in_count: usize, +} + +#[derive(Debug)] +struct Rectangle { + width: u32, + height: u32, +} + +impl Rectangle { + fn area(&self) -> u32 { + self.width * self.height + } + + fn width(&self) -> bool { + self.width > 0 + } + + fn square(size: u32) -> Self { + Self { + width: size, + height: size, + } + } +} + +fn main() { + println!("Hello, world!"); + let mut user1 = User { + active: true, + username: String::from("username123"), + email: String::from("user@ecample.com"), + sign_in_count: 2, + }; + + user1.username = String::from("kiki"); + + let user2 = build_user("email@asl.com", "kiki"); + + let _user3 = User { + active: false, + ..user2 + }; + + // println!("{}", user2.username); //Error because the username is a String on heap and its already moved to the user3 + + let rect1 = Rectangle { + width: 30, + height: 20, + }; + println!( + "this is the area of the rect {} square pixels", + rect1.area() + ); + + println!("rect is {rect1:?}"); + + if rect1.width() { + println!("The rectange have width") + } + + let rect2 = Rectangle::square(42); + + println!( + "this is the area of the rect2 {} square pixels", + rect2.area() + ); +} + +fn build_user(email: &str, username: &str) -> User { + User { + active: true, + username: String::from(username), + email: String::from(email), + sign_in_count: 2, + } +} diff --git a/learn_enum/.gitignore b/learn_enum/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/learn_enum/.gitignore @@ -0,0 +1 @@ +/target diff --git a/learn_enum/Cargo.lock b/learn_enum/Cargo.lock new file mode 100644 index 0000000..2db6580 --- /dev/null +++ b/learn_enum/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "learn_enum" +version = "0.1.0" diff --git a/learn_enum/Cargo.toml b/learn_enum/Cargo.toml new file mode 100644 index 0000000..38955ef --- /dev/null +++ b/learn_enum/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "learn_enum" +version = "0.1.0" +edition = "2024" + +[dependencies] diff --git a/learn_enum/src/main.rs b/learn_enum/src/main.rs new file mode 100644 index 0000000..191f71e --- /dev/null +++ b/learn_enum/src/main.rs @@ -0,0 +1,46 @@ +enum IpVer { + V4(String), + V6(String), +} + +enum Coin { + Penny, + Nickel, + Dime, + Quarter(String), +} + +fn main() { + println!("Hello, world!"); + + let home = IpVer::V4("192.14.1.1".into()); + let loopback = IpVer::V6("::1".into()); + value_in_cents(Coin::Quarter("alaska".to_string())); + + let config_max = Some(3u8); + match config_max { + Some(max) => println!("the maximum is configure to be {max}"), + _ => (), + } + + if let Some(max) = config_max + && max < 4 + { + println!("Maximum is {max}"); + } +} + +fn value_in_cents(coin: Coin) -> u8 { + match coin { + Coin::Penny => { + println!("Lucky Penny"); + 1 + } + Coin::Nickel => 5, + Coin::Dime => 10, + Coin::Quarter(s) => { + println!("from state {s}"); + 25 + } + } +} diff --git a/learning-records/0001-recognition-without-production.md b/learning-records/0001-recognition-without-production.md new file mode 100644 index 0000000..25659b5 --- /dev/null +++ b/learning-records/0001-recognition-without-production.md @@ -0,0 +1,14 @@ +# Recognition without production: ch1–9 is recall-only, writing ability is gone + +Diagnostic (lesson 0001, 18 questions across ch1–9) scored 8/18, but the decisive signal was the user's own report: *"I even forgot how to write the code."* Recognition of concepts is partially intact; **production from a blank file is not**. Future lessons must be typing-first — quizzes measure this gap but do not close it. + +**Evidence** — self-graded diagnostic, 2026-08-28: +- Weak: Ownership 1/2, Error Handling 1/2, Data Types 0/1, References & Borrowing 1/2, Enums & Pattern Matching 1/2, Common Collections 1/2, Modules & Paths 0/1, Control Flow 0/1 +- Solid: Variables 1/1, Structs 2/2, Functions 1/1, Slices 1/1 + +**Implications** +- Every lesson from 0002 on must have the user typing real code in a real `cargo` project, with `cargo run` / `cargo test` as the feedback loop. Pure quiz lessons are diagnostic instruments only. +- The user explicitly asked to cover "solid" topics too — treat the diagnostic as weighting, not as a filter. Solid topics get folded into lessons as supporting material rather than skipped. +- "Solid" results here are low-confidence: 1/1 and 2/2 samples, self-graded, on recognition-style questions. Do not treat Structs/Functions/Slices as owned until seen in produced code. +- A syntax reference was the missing prerequisite — forgotten syntax was consuming the working memory needed for concepts. `reference/rust-syntax.html` now exists and should be linked from every lesson. +- Modules & Paths (0/1) is untouched by lesson 0002 beyond `mod tests`. It needs its own lesson, and the user's existing `restauran`/`learn-modules` projects are the natural material. diff --git a/learning-records/0002-spec-needs-prose-and-concepts-first.md b/learning-records/0002-spec-needs-prose-and-concepts-first.md new file mode 100644 index 0000000..b540c0d --- /dev/null +++ b/learning-records/0002-spec-needs-prose-and-concepts-first.md @@ -0,0 +1,5 @@ +# A spec of signatures is not a spec; concepts must precede the project + +Lesson 0003 shipped as type signatures plus a test suite and the user could not start: "you don't even have description about the specification, so i don't know what to make." Signatures answer *what the compiler will accept*, not *what the program is for*. A spec-driven lesson needs a plain-language description of the program's purpose, its data, and its commands **before** any signature appears — and the description should be written so the type choices fall out of it ("a task is an id AND a title AND a status" → struct; "a status is one of three" → enum), letting the learner derive the model instead of reading it off a contract. + +The user also asked to be taught structs, enums, and packages as if from zero, having previously said they were weak on them. This corrects an earlier assumption: the diagnostic's per-topic scores (Structs 2/2, Enums 1/2) overstated real understanding, because recognition-style questions can be passed without being able to *design* with the concept. Self-reported weakness outranks diagnostic scores. Lesson 0004 was written to fill this, and the ordering rule going forward is: concept lesson (knowledge, reading, real compiler output) → project lesson (skill, typing, tests) — never a project alone for a topic the user has named as weak. diff --git a/learning-records/0003-library-solid-main-untested.md b/learning-records/0003-library-solid-main-untested.md new file mode 100644 index 0000000..47c6aaa --- /dev/null +++ b/learning-records/0003-library-solid-main-untested.md @@ -0,0 +1,15 @@ +# 0003 landed: structs/enums/packages are owned; the untested file is where the gap moved + +Lesson 0003 came back with 17/17 tests green and code that is genuinely idiomatic: `?` with `ok_or`, `iter().find(|task| task.id == id)` with a closure, `iter_mut()` to mutate in place, a private `Vec` behind `pub fn tasks(&self) -> &[Task]`. Structs, enums with data, `impl`, the two-crate package and cross-module `crate::` paths can be treated as **produced, not just recognised** — the first topics in this workspace to earn that. Self-reported weakness on those three is resolved. + +**The gap moved to the file no test could reach.** `src/main.rs` violates the spec's error contract in three ways, all confirmed by running the crate: + +- `cargo run -- fly` → message on **stdout**, exit **0** (spec: stderr, exit 1) +- `cargo run -- done 9` → `.unwrap()` panic, exit **101** +- errors formatted by hand in `main` instead of by the type + +This is exactly the mission's "errors with `Result`, not `panic!`", and it went unnoticed because the 17 tests are integration tests against the library crate — by design they cannot see `main.rs`. **Implication for future specs: any behaviour stated in prose but unreachable by the test suite will not get done.** Either test it (a spec test shelling out to the binary) or make it the explicit drill of the following lesson. Lesson 0005 takes the second route, deliberately, because the fix needs traits. + +**Sequencing decision.** Traits were chosen over collections/iterators or async as the next topic, because: the user's own code now has two visible trait-shaped holes (hand-built display strings, `String` errors), every backend crate they will meet (serde, axum, tokio) is trait-driven, and ch10 is the next unread chapter. Per LR-0002's ordering rule, 0005 is the concept lesson; the `String` → `TaskError` enum conversion is the project lesson (0006) and is explicitly deferred in 0005's text so the drill stays inside working memory. + +**Format note that worked and should continue:** the drill in 0005 targets the crate the user already wrote, with a shell-level check per step (`echo $?`) rather than a new test file. Feedback is immediate, and the reward is their own project getting better rather than a throwaway exercise. Reference impl proved in a temp copy first: 17/17 still pass after the drill's three steps. diff --git a/learning-records/0004-traits-produced-not-recognised.md b/learning-records/0004-traits-produced-not-recognised.md new file mode 100644 index 0000000..eb4fd8c --- /dev/null +++ b/learning-records/0004-traits-produced-not-recognised.md @@ -0,0 +1,23 @@ +# Traits are produced, not just recognised — and the CLI contract finally landed + +The 0005 drill was completed on the user's own `tasks` crate: `impl fmt::Display for Task` written from the signature +up, `unwrap` removed from `main.rs`, `run(args, store) -> Result<(), String>` extracted, `Command::parse(args)?` +matching on `Command` values, and `eprintln!` + `process::exit(1)` at the edge. All three shell checks pass +(`fly` → exit 1, `done 9` → exit 1, `add "buy milk" 2>/dev/null` → exit 0) and the 17 spec tests still pass. +Traits, `Display`, and `?` can be treated as owned from here — lesson 0006 assumes them instead of teaching them. + +## Evidence +Verified by running the drill's own checks against `~/learn-rust/tasks`, not by reading the diff. +The `?`-on-`parse` shape was wrong on first submission (`match Command::parse(&args) { … Err(e) => Err(e) }`) and +was corrected after review, so `?` is now produced but was not the first instinct — worth one more forced repetition +in 0006, where the error type changes under five call sites at once. + +## Implications +- The stderr/exit-1 contract has now been missed twice before landing (0003 spec, 0005 step 3). The pattern is + clear: a step whose check the user does not actually paste into a shell does not get done. Every future drill step + needs a one-line runnable check, and the lesson should say "run it, do not eyeball it". +- The recall quiz scored Enums 0/1 while the same session produced three working enums. Recall of *why* a construct + exists lags the ability to use it. Fix by making the compiler state the reason (0006 shows real `E0004` output for + a newly added variant) rather than asserting it in prose. +- Their own duplicated `"id not found"` string across `command.rs` and `store.rs` is the concrete motivation for + `TaskError`; using the user's own defect beats an invented example. diff --git a/learning-records/0005-from-written-but-not-reached-for.md b/learning-records/0005-from-written-but-not-reached-for.md new file mode 100644 index 0000000..1038524 --- /dev/null +++ b/learning-records/0005-from-written-but-not-reached-for.md @@ -0,0 +1,38 @@ +# A trait impl can be written correctly and still not be reached for + +The 0006 drill landed 24/24 with the shipped spec unedited: seven `TaskError` variants, correct `Display` +sentences, `source()` returning the wrapped `ParseIntError` for exactly one variant, and +`impl From for TaskError`. Every trait obligation was produced from the signature up. + +And the `From` impl was never called. `command.rs` still converted by hand, twice: + +```rust +let id: u32 = match id.parse() { Ok(n) => n, Err(e) => return Err(TaskError::BadId(e)) }; +``` + +That is `From::from` typed out longhand. The tests pass either way, so nothing in the feedback loop objected. + +## Evidence + +Read against the real crate, plus `cargo clippy`: 24 green, `tests/errors.rs` byte-identical to +`lessons/0006-errors-spec.rs`, two duplicated `match id.parse()` blocks in `command.rs`, and the same +`match … Ok(_) => ()` in `main.rs` that LR-0004 flagged after 0005 (clippy's `single_match`). + +The follow-up questions were the more useful signal. All three were about the *mechanism*, not the syntax: +can two `From` impls exist for one type, where does the wrapped message go, does a developer walk `source()` +by hand every time. The syntax was owned; the model of what the syntax buys was not. + +## Implications + +- **A drill step needs a check that fails when the point is missed.** "Both `match` blocks collapse to + `id.parse()?`" was written in the 0006 prose and had no check beside it, so it did not happen — the same + failure mode as the stderr/exit-1 contract in LR-0004, now seen three times. 0007 gives it a grep check + (`grep -c "match id.parse" src/command.rs` → `0`) as step 0. +- **Force the impl, do not suggest it.** In 0007 the second conversion (`From`) cannot be hand-rolled + around without the compiler complaining, because `?` on `fs::write` is the only reasonable shape. `E0277` + does the teaching that prose could not. +- **Recall of "why" still lags production.** Same pattern as the 0005 quiz (Enums 0/1 while writing three enums). + The fix that works is showing real compiler output for the failure mode, not asserting the rule — so 0007 + ships `E0119`, `E0277`, `E0046`, and `E0369`, each captured by running it. +- The user asks precise mechanism questions when given room to. Budget for them: leaving the ladder of + `?` → `From` → `source()` → `anyhow` explicit in the lesson costs less than answering it four times after. diff --git a/learning-records/0006-green-suite-blind-to-the-cli-half.md b/learning-records/0006-green-suite-blind-to-the-cli-half.md new file mode 100644 index 0000000..7766148 --- /dev/null +++ b/learning-records/0006-green-suite-blind-to-the-cli-half.md @@ -0,0 +1,47 @@ +# A green suite says nothing about the surface it cannot reach + +The 0008 drill landed 46/46 with the shipped spec unedited. The library half is genuinely produced: +`tally` written from its signature and used at three `T`/`K` pairs, `Store::load` as one +`collect::, TaskError>>()?`, `remove_completed` on `Vec::retain`, `titles_with` as a +`filter`/`map`/`collect` chain, `count_by_priority` a one-line delegate. Nothing in `src/store.rs` or +`src/stats.rs` needed correcting beyond a commented-out loop left behind. + +The CLI half of the same drill missed three of its requirements, and every one of them was invisible to +those 46 tests: + +- `stats` printed `high / low / medium`, because `main.rs` looped over `[Priority::High, Low, Medium]`. + The spec asked for high, medium, low. +- `clear` printed nothing, discarding the `usize` that `remove_completed` returns. Expected + `cleared 1 completed`. +- Untested-because-unreachable, so also unfixed: the `in-progress` arm of `Status::parse`, the `Display` + line format, and `Command::parse`'s case folding. + +## Evidence + +`cargo test` in `tasks/`: 17 + 7 + 8 + 14 = 46 passed, 0 failed. Every one of those tests lives in +`tests/` and therefore imports the *library*; `run` lives in `src/main.rs`, which a binary crate does not +export, so no test in the workspace can call it. The three defects sit entirely inside `run`. + +Measured rather than argued: six one-line mutations planted in a copy of the crate +(`lessons/0009-mutants.sh`) and the user's suite run against each. Result before 0009: `0 killed, +3 survived, 3 skipped`. The same six against a reference implementation with 13 more tests: `6 killed, +0 survived`. The suite's blindness is not a matter of degree — it is a whole surface. + +## Implications + +- **This is the third repeat of LR-0003's finding**, and the first time the cause is structural rather + than a missing check. 0003 lost the stderr/exit-1 contract, 0006 lost the `?`/`From` collapse, 0008 lost + the CLI output shape. A drill step whose result no test can observe does not land, however clearly the + prose states it. +- **The fix is architectural, so it is the drill.** 0009 moves `run` into `src/cli.rs` and gives it + `out: &mut impl Write`. That is not a lesson about tests bolted onto a refactor; the refactor is the only + way the tests can exist, which is exactly the book's argument for a thin `main.rs`. +- **Grade a test suite by planted bugs, not by test count.** The user has now run 46 tests and would + reasonably infer the crate is well covered. Six mutations refute that in four seconds and give a + finishing condition (`6 killed, 0 survived`) that counting cannot. +- **Ship no new spec file for 0009.** Every earlier lesson handed over `assert_eq!`s to satisfy; the skill + being built here is writing them, so the only deliverable is the mutation script. The drill names the + behaviour to pin in prose — per the rule in NOTES line 62 — and leaves the assertions to the user. +- Watch for on the next read: whether the assertions are exact (`assert_eq!` on the whole printed string) + or hedged (`assert!(out.contains("cleared"))`). The hedged form passes the mutants that matter least and + is the likeliest way this drill goes green while staying blind. diff --git a/lessons/0001-diagnostic-ch1-9.html b/lessons/0001-diagnostic-ch1-9.html new file mode 100644 index 0000000..2aa8f56 --- /dev/null +++ b/lessons/0001-diagnostic-ch1-9.html @@ -0,0 +1,239 @@ + + + + +Diagnostic: Rust Book ch. 1–9 + + + + +

Diagnostic: what's still there from ch. 1–9

+

Lesson 0001 · Rust Book chapters 1–9 · no new material, pure retrieval

+ +

Your learn-* folders prove you worked through variables, functions, control flow, ownership, borrowing, slices, structs, enums, modules, collections, and error handling. This lesson does not re-teach any of it. It tests what is still retrievable — that is the only kind of "knowing" that helps in an interview or on the job.

+ +
+How to take this. For each question, think of your answer before revealing it. Grading yourself honestly matters more than "winning" — this diagnostic decides which lessons get built next. Multiple-choice ones grade themselves; short-answer ones ask you to self-grade after reading the answer. +
+ +
+

Variables — ch03.1

+

This code:

+
let x = 5;
+x = 6;
+println!("{x}");
+

Does it compile? If not, what's the fix?

+ + + +

Book: 3.1 Variables and Mutability

+
+ +
+

Ownership — ch04.1

+

This code:

+
let s1 = String::from("hello");
+let s2 = s1;
+println!("{s1}");
+

Does it compile? Why or why not?

+ + + +

Book: 4.1 What Is Ownership?

+
+ +
+

Structs — ch05.3

+

Given struct Rectangle { width: u32, height: u32 }, where do you define a method like fn area(&self) -> u32?

+
+ + + + +
+ +

Book: 5.3 Method Syntax

+
+ +
+

Error Handling — ch09

+

What's the real difference between calling panic! and returning Err(...) from a function whose signature returns Result?

+ + + +

Book: 9. Error Handling

+
+ +
+

Data Types — ch03.2

+

With no suffix and no other constraint, what type does Rust infer for let x = 5;?

+
+ + + + +
+ +

Book: 3.2 Data Types

+
+ +
+

References & Borrowing — ch04.2

+

Inside fn calculate_length(s: &String) -> usize, can you modify the String through s?

+
+ + + + +
+ +

Book: 4.2 References and Borrowing

+
+ +
+

Enums & Pattern Matching — ch06.2

+

Why must a match expression cover every variant of an enum?

+ + + +

Book: 6.2 The match Control Flow Construct

+
+ +
+

Common Collections — ch08.1

+

Which type owns and can grow a heap-allocated list of values of the same type?

+
+ + + + +
+ +

Book: 8.1 Storing Lists of Values with Vectors

+
+ +
+

Functions — ch03.3

+

Does the last expression in a function body need a return keyword to become the return value? What one character must you drop from that line for it to count as an expression, not a statement?

+ + + +

Book: 3.3 Functions

+
+ +
+

Slices — ch04.3

+
let s = String::from("hello world");
+let hello = &s[0..5];
+

What Rust type is hello? A slice stores two things internally — what are they?

+ + + +

Book: 4.3 The Slice Type

+
+ +
+

Modules & Paths — ch07

+

In your restauran project you called crate::front_of_house::hosting::add_to_waitlist(). What does the pub keyword do to hosting and to add_to_waitlist? What breaks if you remove it?

+ + + +

Book: 7. Packages, Crates, and Modules

+
+ +
+

Control Flow — ch03.5

+

Can if sit on the right side of a let to assign a value, like a ternary?

+
+ + + + +
+ +

Book: 3.5 Control Flow

+
+ +
+

Ownership — ch04.1

+

Name the trait that lets simple stack-only types like i32 get duplicated instead of moved on assignment.

+ + + +

Book: 4.1 What Is Ownership? — Stack-Only Data: Copy

+
+ +
+

Structs — ch05.3

+

Why do most struct methods take &self (borrowed) instead of self (owned)?

+ + + +

Book: 5.3 Method Syntax

+
+ +
+

Common Collections — ch08.3

+

From your own learn-challenges code:

+
let x = count.entry(v).or_insert(0);
+*x += 1;
+

What does .entry(v) return, and what does .or_insert(0) do whether v is a new key or an existing one?

+ + + +

Book: 8.3 Storing Keys with Associated Values in Hash Maps

+
+ +
+

Enums & Pattern Matching — ch06.3

+

What's the shorthand for a match that only cares about one pattern and ignores every other case?

+
+ + + + +
+ +

Book: 6.3 Concise Control Flow with if let

+
+ +
+

Error Handling — ch09.2

+

From your own learn-panic code:

+
let mut username_file = File::open("hello.txt")?;
+

What does the ? do when File::open returns Err?

+
+ + + + +
+ +

Book: 9.2 Recoverable Errors with Result

+
+ +
+

References & Borrowing — ch04.2

+

True or false: at any single point, Rust allows either one mutable reference OR any number of immutable references to the same data, never a mix. Why does this rule exist?

+ + + +

Book: 4.2 References and Borrowing

+
+ +
+

Results

+
No questions answered yet.
+

+ + +

Copying the report and pasting it into the chat is the fastest way to tell the agent what to build next.

+
+ + + + diff --git a/lessons/0002-write-a-cli-from-blank.html b/lessons/0002-write-a-cli-from-blank.html new file mode 100644 index 0000000..234beba --- /dev/null +++ b/lessons/0002-write-a-cli-from-blank.html @@ -0,0 +1,203 @@ + + + + +Write a CLI from a blank file + + + + +

Write a CLI from a blank file

+

Lesson 0002 · production, not recognition · ~25 minutes

+ +

Your diagnostic said something more useful than the score: you recognise Rust but can't produce it. That's a different skill, and re-reading the book does not fix it. Only typing does.

+ +

So this lesson has no reading section. You will type a working command-line tool from an empty file, running it after every stage. By the end you'll have touched — in your own fingers — types, functions, control flow, match, Result, Vec, and borrowing. Six of your eight weak topics, in one 45-line program.

+ +
+Keep the syntax reference open in another tab. Looking up syntax is not cheating — a working memory clogged with "how do I write a for loop again" has nothing left for the actual concept. Look it up, type it, move on. +

+Rule for this lesson: type every line by hand. Do not copy-paste. The muscle memory is the lesson. +
+ +

What you're building

+

A grade tool. You pass it scores; it prints a letter for each, skips garbage input, and prints the average:

+
$ cargo run -- 95 83 71 abc 40
+95 -> A
+83 -> B
+71 -> C
+abc -> not a number, skipped
+40 -> F
+average: 72
+ +

Stage 0 — new project

+
cd ~/learn-rust
+cargo new grader
+cd grader
+

Open src/main.rs. Cargo wrote a hello-world in it. Delete all of it — blank file.

+ +

Stage 1 — read the arguments

+

Type this:

+
use std::env;
+
+fn main() {
+    let args: Vec<String> = env::args().collect();
+    println!("{args:?}");
+}
+

Run it: cargo run -- 95 83

+
+

Predict before you run

+

How many items will be in args, and what is the first one?

+ + + +
+

Two things to notice in what you just typed: the type annotation Vec<String> is required here, because .collect() can build many different collections and needs to be told which. And {args:?} uses Debug formatting — {} alone would not compile, because a Vec has no Display impl.

+

Book: 12.1 Accepting Command Line Arguments

+ +

Stage 2 — guard against no input

+

Replace the println! with:

+
    if args.len() < 2 {
+        println!("usage: cargo run -- <score> [more scores...]");
+        return;
+    }
+

Run cargo run with no arguments — you should get the usage line. This is your first trust boundary: never assume input exists. Backend code lives or dies on this habit.

+ +
+

Checkpoint

+

What type does args.len() return?

+
+ + + + +
+ +

Book: 3.2 Data Types

+
+ +

Stage 3 — parse each argument

+

Below the guard, add:

+
    for arg in &args[1..] {
+        match arg.parse::<u32>() {
+            Ok(score) => println!("{score} -> ok"),
+            Err(_) => println!("{arg} -> not a number, skipped"),
+        }
+    }
+

Run: cargo run -- 95 abc 40. You should see two ok lines and one skip.

+

Three weak topics just collided in five lines, so slow down here:

+
    +
  • &args[1..] is a slice — a borrowed view of the vector from index 1 onward. You did not copy the arguments and you did not take ownership of them.
  • +
  • .parse() returns a Result, because parsing can fail. ::<u32> is the turbofish telling it which type to aim for.
  • +
  • match forces you to handle both arms. This is the whole point of Result: the compiler will not let you forget the failure case. Compare this to your learn-panic project, where you reached for panic! — here, bad input just gets skipped and the program carries on. That is the difference between a script and a tool.
  • +
+

Book: 9.2 Recoverable Errors with Result

+ +

Stage 4 — the grading function

+

Below main's closing brace, add a new function:

+
fn grade(score: u32) -> String {
+    match score {
+        90..=100 => "A".to_string(),
+        80..=89 => "B".to_string(),
+        70..=79 => "C".to_string(),
+        _ => "F".to_string(),
+    }
+}
+

Now use it — change the Ok arm inside main to:

+
            Ok(score) => println!("{score} -> {}", grade(score)),
+

Run: cargo run -- 95 83 71 40 → A B C F.

+ +
+

Checkpoint

+

Delete the _ => "F".to_string(), arm and run cargo build. What does the compiler say, and why?

+ + + +

Book: 6.2 The match Control Flow Construct

+
+ +

Note the return type: String, not &str. The function builds a value and hands ownership to its caller. Returning a borrowed &str here would force you to answer "borrowed from what, and does that thing outlive the caller?" — which is lifetimes, chapter 10, and deliberately not today's problem.

+ +

Stage 5 — collect and average

+

Above the for loop, add a vector to accumulate into:

+
    let mut scores: Vec<u32> = Vec::new();
+

Change the Ok arm to a block, so it can do two things:

+
            Ok(score) => {
+                println!("{score} -> {}", grade(score));
+                scores.push(score);
+            }
+

After the loop, add:

+
    if scores.is_empty() {
+        println!("no valid scores");
+    } else {
+        println!("average: {}", average(&scores));
+    }
+

And a second function at the bottom of the file:

+
fn average(scores: &[u32]) -> u32 {
+    let mut total = 0;
+    for score in scores {
+        total += score;
+    }
+    total / scores.len() as u32
+}
+

Run: cargo run -- 95 83 71 abc 40 → you should get exactly the output from the top of this page, ending in average: 72.

+ +
+

Checkpoint — the important one

+

Change the call to average(scores) and the parameter to scores: Vec<u32>, then try to print scores.len() on the line after that call. What happens, and why does the & version not have this problem?

+ + + +

Book: 4.2 References and Borrowing

+
+ +

Two details worth burning in:

+
    +
  • The parameter is &[u32], not &Vec<u32>. A slice accepts a Vec, an array, or part of either — strictly more useful, same speed. Idiomatic Rust prefers &[T] in every read-only signature.
  • +
  • scores.len() as u32 needs the cast because len() is usize and total is u32. Rust does no implicit numeric conversion, ever.
  • +
+ +

Stage 6 — a feedback loop that outlives you

+

At the very bottom of the file:

+
#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn grades_map_to_letters() {
+        assert_eq!(grade(95), "A");
+        assert_eq!(grade(80), "B");
+        assert_eq!(grade(42), "F");
+    }
+
+    #[test]
+    fn average_of_three() {
+        assert_eq!(average(&[90, 80, 70]), 80);
+    }
+}
+

Run cargo test. Expect 2 passed.

+

Now break something on purpose — change 80..=89 to 81..=89 and run cargo test again. One test fails and tells you exactly what it expected. That loop, not the compiler, is what you'll lean on when programs get big enough that you can't hold them in your head. Change it back.

+

You just met three things at once: #[cfg(test)] (compile this module only during tests), mod tests (a module — the same feature as your restauran project), and use super::* (pull in everything from the parent module, which is how the test sees grade). Testing proper is chapter 11; today it's just the loop.

+

Book: 11.1 How to Write Tests

+ +

Your win

+

Forty-five lines, typed by hand, that read real input, reject bad input without crashing, and prove themselves with tests. That is a smaller program than your learn-challenges exercise — but you wrote this one from a blank file, which is the thing you said you'd lost.

+ +
+

Checkpoint results

+
No checkpoints answered yet.
+

+ + +
+ +

Stretch task (optional, do it before the next lesson)

+

Without looking at this page: add a highest function that returns the top score, and print it. You'll need Option, because an empty slice has no maximum. If you get stuck on the signature, that's a real question — ask the agent.

+ + + + diff --git a/lessons/0003-build-a-task-cli.html b/lessons/0003-build-a-task-cli.html new file mode 100644 index 0000000..5563950 --- /dev/null +++ b/lessons/0003-build-a-task-cli.html @@ -0,0 +1,418 @@ + + + + +Project: task CLI (spec-driven) + + + +

Project: a task CLI

+

Lesson 0003 · spec-driven · no walkthrough · 1–3 hours

+ +

You asked for a specification instead of a guided build. This is that. There are no numbered stages here and no code to copy. You get four things: a plain-language description of the program, a contract the code must satisfy, a test suite that checks it, and a syntax crib written in a different domain so it shows you the shape without handing you the answer.

+ +

Target: packages, structs, and enums — the three you named. Modules & Paths was 0/1 on your diagnostic, so this project is deliberately split across four files that must see each other.

+ +
+Read lesson 0004 first. It teaches structs, enums, and packages from zero, with real compiler output. This page assumes you have read it and does not re-explain the concepts. +

+The feedback loop is cargo test. 17 tests define done. They will all fail at first — that is correct. Make them go green one at a time. +

+Rules. Look up syntax as often as you like — your reference sheet and the crib below exist for that. Do not read a hint until you have been stuck on that specific thing for ten minutes. Being stuck is the lesson; a hint spent too early is a lesson wasted. +
+ +

What you are building, in words

+ +

A command-line to-do list. You run it with a command and some arguments, it does one thing, prints one line, and exits. It holds tasks in memory for the duration of a single run — there is no file, no database, no interactive prompt, and no loop. One run, one command, done.

+ +

A task is four pieces of information:

+
    +
  • an id — a number the program assigns, so you can refer to the task later
  • +
  • a title — the text you typed, e.g. "buy milk"
  • +
  • a status — where it is up to: not started, being worked on, or finished
  • +
  • a priority — how important: low, medium, or high
  • +
+ +

Read that list again with 0004 in mind, because it is telling you the types. A task is an id and a title and a status and a priority — four things at once, so it is a struct. A status is not-started or in-progress or done — one of three, so it is an enum. Priority likewise. That is the whole modelling decision, and it is why this project targets the topics it does.

+ +

The program supports four commands:

+ + + + + + +
CommandWhat it does
add <title> [priority]Creates a task. Priority is optional and defaults to medium. Prints the new id.
listPrints every task, one per line, in the order they were added.
done <id>Marks that task finished.
remove <id>Deletes that task.
+ +

Anything else — a command that does not exist, a missing argument, an id that is not a number, an id with no matching task — prints an error to stderr and exits with status 1.

+ +

Four commands, each needing different information: add needs a title and a priority, done and remove need an id, list needs nothing at all. That is an enum whose variants carry different data — the Part 2 idea from 0004, applied. Once the user's input is a Command value, every impossible combination is gone: there is no way to hold a done with no id, or an add with an id and no title.

+ +

The work splits into three jobs, which is where the four files come from:

+
    +
  1. Describing a task — the types and their small helpers. This is task.rs. It knows nothing about command lines.
  2. +
  3. Understanding what the user typed — turning a list of strings into one of four commands, or into an error explaining why not. This is command.rs. It knows nothing about storage.
  4. +
  5. Holding the tasks and changing them — the list, the id counter, add/complete/remove/find. This is store.rs. It knows nothing about command lines either.
  6. +
+

Then main.rs is the thin layer that connects them: read the arguments, ask command.rs what they mean, tell store.rs to do it, print the result or the error. It contains no logic of its own worth testing — which is exactly why the tests can live entirely against the other three.

+ +

That separation is the real subject of this project. Each of the three has one job, does not know about the others' jobs, and can be tested on its own. It is the same shape as a backend service: request parsing, domain types, storage, and a thin handler wiring them together.

+ +

What it looks like when it runs

+
$ cargo run -- add "buy milk"
+added task 1
+
+$ cargo run -- add "ship the feature" high
+added task 2
+
+$ cargo run -- list
+1 [todo] buy milk (medium)
+2 [todo] ship the feature (high)
+
+$ cargo run -- done 1
+completed 1
+
+$ cargo run -- remove 9
+error: no task with id 9        # and exits with status 1
+
+$ cargo run -- fly
+error: unknown command: fly     # and exits with status 1
+

Tasks live in memory only. Each run starts empty — persistence is a stretch goal, not part of the spec.

+ +

Setup

+
cd ~/learn-rust
+cargo new tasks --lib
+cd tasks
+mkdir tests
+cp ../lessons/0003-tasks-spec.rs tests/spec.rs
+cargo test          # 3 unresolved-import errors. Good. Start here.
+

That first failure is the right one to see:

+
error[E0432]: unresolved import `tasks::command`
+error[E0432]: unresolved import `tasks::store`
+error[E0432]: unresolved import `tasks::task`
+

The tests are asking for three modules that do not exist yet. Your first job is to make those three names resolve — empty files and one line each in lib.rs is enough to change the error.

+

The package must be named tasks — the tests import it by that name.

+ +

Required file layout

+
tasks/
+├── Cargo.toml
+├── src/
+│   ├── lib.rs        ← library crate root: declares the modules
+│   ├── task.rs       ← Task struct, Status enum, Priority enum
+│   ├── command.rs    ← Command enum + parsing
+│   ├── store.rs      ← Store struct, owns the task list
+│   └── main.rs       ← binary crate: args in, text out, exit codes
+└── tests/
+    └── spec.rs       ← the tests (do not edit)
+ +

This layout is the point of the exercise, so here is why rather than how:

+ +

cargo new tasks --lib gives you src/lib.rs and no src/main.rs. You create main.rs yourself. You do not add anything to Cargo.toml — no [lib], no [[bin]]. Cargo finds both by filename convention.

+ +

You now have one package containing two crates:

+
    +
  • src/lib.rs → a library crate named tasks. All the logic lives here.
  • +
  • src/main.rs → a binary crate. It is a consumer of the library, exactly like an outside user.
  • +
+

So main.rs reaches your code the same way the tests do — use tasks::store::Store; — not with crate::. Inside the library, modules refer to each other with crate::. Getting this wrong is the most common stumble in this project; when a path will not resolve, first ask which crate am I in right now?

+

Integration tests in tests/ can only reach pub items through the library crate. That is what makes the visibility rules bite for real, instead of in theory.

+

Book: 7.1 Packages and Crates · 7.5 Separating Modules into Different Files · 11.3 Test Organization

+ +

The contract

+

These signatures are fixed — the tests call exactly these. Everything else is yours: field order, private helpers, how you search a Vec, how you word error messages.

+ +

task.rs

+
pub enum Status   { Todo, InProgress, Done }
+pub enum Priority { Low, Medium, High }
+
+pub struct Task {
+    pub id: u32,
+    pub title: String,
+    pub status: Status,
+    pub priority: Priority,
+}
+
+impl Status   { pub fn label(&self) -> &str }
+impl Priority { pub fn label(&self) -> &str }
+impl Priority { pub fn parse(text: &str) -> Option<Priority> }
+impl Task     { pub fn new(id: u32, title: &str, priority: Priority) -> Task }
+
    +
  • label returns "todo", "in-progress", "done", "low", "medium", "high".
  • +
  • Priority::parse accepts exactly "low", "medium", "high". Anything else is None. Note it returns Option, not Result — there is no reason to report beyond "that is not a priority".
  • +
  • Task::new always starts a task at Status::Todo.
  • +
  • InProgress is never produced by any command. Define it anyway — it is there so your match arms have a third case to handle.
  • +
+ +

command.rs

+
pub enum Command {
+    Add { title: String, priority: Priority },
+    List,
+    Done { id: u32 },
+    Remove { id: u32 },
+}
+
+impl Command {
+    pub fn parse(args: &[String]) -> Result<Command, String>
+}
+

parse receives arguments with the program name already removed. The error type is String — a real project would define an error enum, but that needs traits, so not today.

+ + + + + + + + + + + + + + + +
InputResult
["add", "buy milk"]Add { title: "buy milk", priority: Medium }
["add", "ship it", "high"]Add { title: "ship it", priority: High }
["list"]List
["done", "7"]Done { id: 7 }
["remove", "12"]Remove { id: 12 }
[]Err — no command given
["fly"]Err — unknown command
["add"]Err — add needs a title
["add", "x", "urgent"]Err — not a priority word
["done"]Err — needs an id
["done", "abc"]Err — id must be a number
["remove", "-1"]Err — negative is not a u32
+

The last row needs no special handling. Think about why before you write anything for it.

+

Titles are a single argument. add buy milk without quotes is not your problem — the shell splits it, and "milk" is simply not a priority word, so it is an error. That is acceptable behaviour.

+ +

store.rs

+
pub struct Store { /* private fields — your choice */ }
+
+impl Store {
+    pub fn new() -> Store
+    pub fn add(&mut self, title: &str, priority: Priority) -> u32   // -> new id
+    pub fn complete(&mut self, id: u32) -> Result<(), String>
+    pub fn remove(&mut self, id: u32) -> Result<(), String>
+    pub fn tasks(&self) -> &[Task]
+    pub fn find(&self, id: u32) -> Option<&Task>
+}
+

Rules the tests enforce:

+
    +
  • Ids start at 1 and increase by one on every add.
  • +
  • Ids are never reused, even after a removal. Add, remove, add again → the second id must differ. This decides how you store the counter.
  • +
  • tasks() returns them in insertion order.
  • +
  • complete and remove on an unknown id return Err. The message is yours; the tests only check is_err().
  • +
+

Store's fields must be private. Note that pub struct does not make fields public — each field needs its own pub, and here you want none of them. Everything outside goes through the methods. This is why tasks() exists and why it hands back &[Task] rather than the Vec itself: callers may read the list, and cannot touch your id counter or reorder anything. Encapsulation, enforced by the compiler.

+ +

main.rs

+

Not covered by the tests — this part is yours to judge. It must:

+
    +
  • Collect arguments and hand everything after the program name to Command::parse.
  • +
  • match the resulting Command and call the right Store method.
  • +
  • Print the success text to stdout, matching the session at the top of this page.
  • +
  • Print error: ... to stderr and exit with status 1 on any failure.
  • +
+

Since state is in memory, list after a fresh add shows only that run. That is expected. Seed a task or two in main if you want list to show something.

+ +

Derives you will need

+

The tests use assert_eq! on your types and print them on failure. That requires two abilities you met in our Debug vs Display discussion:

+
#[derive(Debug, PartialEq)]
+

PartialEq gives ==. Debug gives {:?} for the failure output. Add Clone and Copy where it makes life easier — think about which of these types are small enough to copy, and which own heap data and therefore cannot be Copy.

+

If you forget these, the compiler tells you exactly which trait is missing on which type. That error is the lesson; read it rather than pattern-matching against this paragraph.

+ +

Syntax crib

+

A vending machine, not a task list. Same shapes, different domain — you cannot paste any of this.

+ +

Modules across files

+
// src/lib.rs — the library crate root
+pub mod coin;
+pub mod machine;
+
+// src/coin.rs
+pub enum Coin { Nickel, Dime }
+
+// src/machine.rs — one module reaching another, inside the same crate
+use crate::coin::Coin;
+
+// src/main.rs — a DIFFERENT crate, so use the package name
+use vending::machine::Machine;
+ +

Enum with unit variants, and a method that matches on itself

+
#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum Coin { Nickel, Dime, Quarter }
+
+impl Coin {
+    pub fn value(&self) -> u32 {
+        match self {
+            Coin::Nickel => 5,
+            Coin::Dime => 10,
+            Coin::Quarter => 25,
+        }
+    }
+
+    pub fn parse(text: &str) -> Option<Coin> {
+        match text {
+            "nickel" => Some(Coin::Nickel),
+            "dime" => Some(Coin::Dime),
+            _ => None,
+        }
+    }
+}
+ +

Enum whose variants carry data, and matching it apart

+
#[derive(Debug, PartialEq)]
+pub enum Event {
+    Insert { coin: Coin, count: u32 },   // named fields
+    Select { slot: u32 },
+    Refund,                              // no data
+}
+
+// building one
+let event = Event::Insert { coin: Coin::Dime, count: 2 };
+
+// taking one apart — the names bind as variables
+match event {
+    Event::Insert { coin, count } => println!("{count} x {}", coin.value()),
+    Event::Select { slot } => println!("slot {slot}"),
+    Event::Refund => println!("refunding"),
+}
+ +

Struct with private fields, and its impl block

+
pub struct Machine {
+    credit: u32,          // private: no `pub`
+    coins: Vec<Coin>,
+}
+
+impl Machine {
+    pub fn new() -> Machine {    // associated fn: no self, called Machine::new()
+        Machine { credit: 0, coins: Vec::new() }
+    }
+
+    pub fn insert(&mut self, coin: Coin) -> u32 {   // &mut self: may change it
+        self.credit += coin.value();
+        self.coins.push(coin);
+        self.credit
+    }
+
+    pub fn credit(&self) -> u32 { self.credit }     // &self: read only
+
+    pub fn coins(&self) -> &[Coin] { &self.coins } // borrowed view, not the Vec
+}
+ +

Reading optional arguments

+
let words: Vec<String> = std::env::args().collect();
+
+words.first()          // Option<&String> — the first, if any
+words.get(2)           // Option<&String> — index 2, if any
+&words[1..]            // slice of everything after the first
+
+// Option -> Result, so ? can carry it
+let name = words.first().ok_or("nothing to do")?;
+
+// match on a &String needs &str
+match name.as_str() {
+    "insert" => { }
+    other => return Err(format!("unknown: {other}")),
+}
+ +

Turning a parse failure into your own error type

+
// parse gives Result<u32, ParseIntError>; map_err rewrites the error side
+let slot = text.parse::<u32>()
+    .map_err(|_| format!("slot must be a number, got: {text}"))?;
+
+// Option -> Result with a message
+let coin = Coin::parse(text).ok_or(format!("unknown coin: {text}"))?;
+ +

Searching and changing a Vec

+
// read-only search, returning a borrow of the item
+for coin in &self.coins {
+    if coin.value() == 10 { return Some(coin); }
+}
+
+// mutable pass — change an item in place
+for coin in &mut self.coins {
+    // *coin = Coin::Dime;
+}
+
+// keep only what matches
+self.coins.retain(|coin| coin.value() > 5);
+
+// the short forms — |coin| ... is a closure (ch 13); both are worth knowing now
+self.coins.iter().find(|coin| coin.value() == 10)    // -> Option<&Coin>
+self.coins.iter().position(|coin| coin.value() == 10) // -> Option<usize>
+

A plain for loop does everything here. Use it if the closure forms feel unfamiliar — clarity beats brevity while you rebuild.

+ +

Exiting with a status code

+
eprintln!("error: {message}");    // stderr, not stdout
+std::process::exit(1);
+ +

Suggested order

+

Not stages — just the order that keeps the compiler useful. Run cargo test after each.

+
    +
  1. lib.rs + task.rs. Four tests should go green. This proves your module wiring works before any logic exists.
  2. +
  3. command.rs. Four more. The rejects_bad_input test is the interesting one.
  4. +
  5. store.rs. The remaining nine.
  6. +
  7. main.rs. Untested — verify by running the session from the top of this page yourself.
  8. +
+ +

Hints

+

Ten minutes stuck on the same thing first. Earlier than that and you are buying a smaller lesson.

+ +
+My module paths will not resolve +

Three separate rules, and mixing them up causes most of these errors:

+
    +
  • Inside the library (any file under src/ except main.rs), reach a sibling module with use crate::task::Priority;.
  • +
  • In main.rs and in tests/, you are in a different crate. Use the package name: use tasks::task::Priority;.
  • +
  • A module does not exist until lib.rs declares it. pub mod task; in lib.rs is what makes the file src/task.rs part of the crate. No declaration, no module — regardless of the file existing.
  • +
+

Also: pub is needed at every level of a path. A pub fn inside a private mod is still unreachable from outside.

+
+ +
+Ids get reused after a removal and I cannot see why +

If the next id is derived from the list — its length, or the largest id present — then deleting changes it. The counter has to be its own field that only ever increases, independent of what the list currently holds. Two fields, not one.

+
+ +
+The borrow checker rejects my complete() or remove() +

You are likely holding a read borrow and then asking for a write borrow while the first is still live — the E0502 pattern we walked through. Options, in order of simplicity:

+
    +
  • Iterate mutably in one pass: for task in &mut self.tasks, and mutate when the id matches.
  • +
  • Find the index first (a usize, which is Copy and holds no borrow), let that borrow end, then index to mutate or remove.
  • +
  • For remove, retain does it in one call with no explicit borrow at all.
  • +
+

The question to ask is always: which two borrows overlap, and can I end the first sooner?

+
+ +
+Something about a missing trait on my types +

Read which trait and which type the error names, then add it to that type's derive list. assert_eq! needs PartialEq to compare and Debug to print the failure. If a type contains a String, it cannot be Copy — String owns heap data, and that is exactly the move-versus-copy distinction from chapter 4.

+
+ +
+How do I return &str from label() without lifetime annotations? +

pub fn label(&self) -> &str compiles as written. The returned lifetime is inferred from &self, and a string literal outlives everything, so it fits. You do not need to write 'static or any annotation. If you would rather sidestep it entirely, return String and adjust nothing else — but try the borrowed version first, since it is the idiomatic one.

+
+ +
+I cannot get the "-1 is an error" test to pass +

Try it in isolation: what does "-1".parse::<u32>() return? Write four lines in a scratch project and look. The answer means this row needs no code of its own — your existing numeric parsing already covers it.

+
+ +

Done means

+
    +
  • cargo test → 17 passed; 0 failed.
  • +
  • Every session line at the top of this page reproduces.
  • +
  • Failures print to stderr and exit non-zero: cargo run -q -- fly; echo $? → 1.
  • +
  • cargo build is warning-free. Warnings are findings, not noise — read each one.
  • +
  • Store's fields are private, and nothing outside store.rs needs them.
  • +
+ +

Stretch goals

+

Only after 17/17. Each one drags in the next chapter you will need for backend work:

+
    +
  1. Display instead of label(). Implement std::fmt::Display for Status and Priority, then print with {}. This is your first hand-written trait impl (ch10). Keep label() so the tests still pass.
  2. +
  3. An error enum. Replace String errors with enum TaskError { UnknownCommand(String), NotFound(u32), ... }. You will need Display on it, and the tests will keep passing since they only check is_err(). This is how real Rust reports errors.
  4. +
  5. A start command that sets Status::InProgress — and notice how the compiler lists every match you now have to update. That is exhaustiveness paying you back.
  6. +
  7. Sort list by priority, high first. Needs #[derive(PartialOrd, Ord)] and awareness that variant declaration order defines the ordering.
  8. +
  9. Persist to a file between runs. Plain text is enough; no dependencies needed. This is where ?, io::Error, and real error mixing stop being an exercise.
  10. +
+ + + + diff --git a/lessons/0003-tasks-spec.rs b/lessons/0003-tasks-spec.rs new file mode 100644 index 0000000..9da7dfb --- /dev/null +++ b/lessons/0003-tasks-spec.rs @@ -0,0 +1,184 @@ +// The specification, as executable tests. Do not edit this file — make it pass. +// Copy to: tasks/tests/spec.rs +// Run with: cargo test + +use tasks::command::Command; +use tasks::store::Store; +use tasks::task::{Priority, Status, Task}; + +/// Helper: build an argument list the way main() would pass it in +/// (program name already stripped). +fn args(list: &[&str]) -> Vec { + list.iter().map(|s| s.to_string()).collect() +} + +// ---------- task.rs ---------- + +#[test] +fn new_task_starts_as_todo() { + let task = Task::new(1, "write the spec", Priority::High); + assert_eq!(task.id, 1); + assert_eq!(task.title, "write the spec"); + assert_eq!(task.status, Status::Todo); + assert_eq!(task.priority, Priority::High); +} + +#[test] +fn status_labels() { + assert_eq!(Status::Todo.label(), "todo"); + assert_eq!(Status::InProgress.label(), "in-progress"); + assert_eq!(Status::Done.label(), "done"); +} + +#[test] +fn priority_labels() { + assert_eq!(Priority::Low.label(), "low"); + assert_eq!(Priority::Medium.label(), "medium"); + assert_eq!(Priority::High.label(), "high"); +} + +#[test] +fn priority_parses_known_words_only() { + assert_eq!(Priority::parse("low"), Some(Priority::Low)); + assert_eq!(Priority::parse("medium"), Some(Priority::Medium)); + assert_eq!(Priority::parse("high"), Some(Priority::High)); + assert_eq!(Priority::parse("urgent"), None); + assert_eq!(Priority::parse(""), None); +} + +// ---------- command.rs ---------- + +#[test] +fn parses_add_with_default_priority() { + let command = Command::parse(&args(&["add", "buy milk"])).unwrap(); + assert_eq!( + command, + Command::Add { + title: "buy milk".to_string(), + priority: Priority::Medium, + } + ); +} + +#[test] +fn parses_add_with_explicit_priority() { + let command = Command::parse(&args(&["add", "ship it", "high"])).unwrap(); + assert_eq!( + command, + Command::Add { + title: "ship it".to_string(), + priority: Priority::High, + } + ); +} + +#[test] +fn parses_list_done_and_remove() { + assert_eq!(Command::parse(&args(&["list"])).unwrap(), Command::List); + assert_eq!( + Command::parse(&args(&["done", "7"])).unwrap(), + Command::Done { id: 7 } + ); + assert_eq!( + Command::parse(&args(&["remove", "12"])).unwrap(), + Command::Remove { id: 12 } + ); +} + +#[test] +fn rejects_bad_input() { + assert!(Command::parse(&args(&[])).is_err(), "no command at all"); + assert!(Command::parse(&args(&["fly"])).is_err(), "unknown command"); + assert!(Command::parse(&args(&["add"])).is_err(), "add with no title"); + assert!( + Command::parse(&args(&["add", "x", "urgent"])).is_err(), + "unknown priority word" + ); + assert!(Command::parse(&args(&["done"])).is_err(), "done with no id"); + assert!( + Command::parse(&args(&["done", "abc"])).is_err(), + "id is not a number" + ); + assert!( + Command::parse(&args(&["remove", "-1"])).is_err(), + "negative id is not a u32" + ); +} + +// ---------- store.rs ---------- + +#[test] +fn add_returns_ids_starting_at_one() { + let mut store = Store::new(); + assert_eq!(store.add("first", Priority::Low), 1); + assert_eq!(store.add("second", Priority::Low), 2); + assert_eq!(store.add("third", Priority::Low), 3); +} + +#[test] +fn tasks_come_back_in_insertion_order() { + let mut store = Store::new(); + store.add("first", Priority::Low); + store.add("second", Priority::High); + + let listed = store.tasks(); + assert_eq!(listed.len(), 2); + assert_eq!(listed[0].title, "first"); + assert_eq!(listed[1].title, "second"); +} + +#[test] +fn new_store_is_empty() { + let store = Store::new(); + assert!(store.tasks().is_empty()); +} + +#[test] +fn complete_sets_status_to_done() { + let mut store = Store::new(); + let id = store.add("do the thing", Priority::Medium); + + assert_eq!(store.find(id).unwrap().status, Status::Todo); + assert!(store.complete(id).is_ok()); + assert_eq!(store.find(id).unwrap().status, Status::Done); +} + +#[test] +fn complete_unknown_id_is_an_error() { + let mut store = Store::new(); + store.add("only task", Priority::Low); + assert!(store.complete(99).is_err()); +} + +#[test] +fn remove_deletes_only_that_task() { + let mut store = Store::new(); + let first = store.add("first", Priority::Low); + let second = store.add("second", Priority::Low); + + assert!(store.remove(first).is_ok()); + assert_eq!(store.tasks().len(), 1); + assert!(store.find(first).is_none()); + assert!(store.find(second).is_some()); +} + +#[test] +fn remove_unknown_id_is_an_error() { + let mut store = Store::new(); + assert!(store.remove(1).is_err()); +} + +#[test] +fn find_returns_none_for_missing_id() { + let store = Store::new(); + assert!(store.find(1).is_none()); +} + +#[test] +fn ids_are_not_reused_after_remove() { + let mut store = Store::new(); + let first = store.add("first", Priority::Low); + store.remove(first).unwrap(); + let second = store.add("second", Priority::Low); + assert_ne!(first, second, "a removed id must not be handed out again"); +} diff --git a/lessons/0004-structs-enums-packages.html b/lessons/0004-structs-enums-packages.html new file mode 100644 index 0000000..0eeedae --- /dev/null +++ b/lessons/0004-structs-enums-packages.html @@ -0,0 +1,398 @@ + + + + +Structs, enums, and packages — from zero + + + +

Structs, enums, and packages

+

Lesson 0004 · read this before 0003 · reading, not typing · ~25 minutes

+ +

Three ideas, taught from nothing. Every code block below was run in a real project and every output and error message on this page is copied from that run — none of it is written from memory.

+ +

The domain is a café, deliberately. Lesson 0003 is a task CLI, so nothing here can be pasted into it. You will have to translate, and translating is where the understanding happens.

+ +
+Read this one. Do not type it. This is the knowledge half. 0003 is the skill half — that is where your hands go on the keyboard. Reading is cheap and this page is short; the project is where it sticks. +
+ +

Part 1 — Structs

+ +

A struct is a named bundle of fields. That is genuinely all it is. If you have used an object, a record, or a dictionary with fixed keys, you already have the idea.

+ +
struct Drink {
+    name: String,
+    shots: u32,
+    iced: bool,
+}
+ +

This defines a type. It creates nothing and allocates nothing — it tells the compiler that a thing called Drink has exactly these three fields with exactly these types.

+ +

Building one

+ +
let latte = Drink {
+    name: String::from("latte"),
+    shots: 1,
+    iced: false,
+};
+
+println!("{} / {} shots / iced={}", latte.name, latte.shots, latte.iced);
+
1. latte / 1 shots / iced=false
+ +

Two rules that catch people coming from other languages:

+
    +
  • Every field must be given a value. There are no defaults and no null. If you want "no value", the type has to say so — that is what Option is for, and we get to it in Part 2.
  • +
  • Field order in the literal does not matter. Names do.
  • +
+ +

Methods: the impl block

+ +

Functions that belong to a type live in a separate block. The struct says what it is; the impl block says what it can do.

+ +
impl Drink {
+    fn new(name: &str, shots: u32) -> Drink {          // no self
+        Drink { name: name.to_string(), shots, iced: false }
+    }
+
+    fn price(&self) -> u32 {                            // &self
+        250 + self.shots * 50
+    }
+
+    fn add_shot(&mut self) {                            // &mut self
+        self.shots += 1;
+    }
+
+    fn into_name(self) -> String {                      // self
+        self.name
+    }
+}
+ +

The first parameter is the whole lesson here. There are four possibilities and they mean four different things:

+ + + + + + + +
First parameterNameCalled asMeans
noneassociated functionDrink::new(..)Related to the type, but there is no instance yet. This is how you make one.
&selfmethoddrink.price()Borrows to read. Cannot change anything.
&mut selfmethoddrink.add_shot()Borrows to change. Requires the variable be mut.
selfconsuming methoddrink.into_name()Takes ownership. The caller cannot use the value afterwards.
+ +

Rust has no constructor keyword. new is an ordinary associated function that people agreed to call new. Nothing enforces the name.

+ +

Real output from all four:

+
2. price = 300
+3. after add_shot: 2 shots, price = 350
+4. Drink::new gave: espresso / 2 shots / iced=false
+5. into_name took ownership, returned: espresso
+ +

What the compiler enforces

+ +

Call add_shot on a binding that is not mut:

+
let d = Drink::new("mocha", 1);
+d.add_shot();
+
error[E0596]: cannot borrow `d` as mutable, as it is not declared as mutable
+help: consider changing this to be mutable
+ +

Use a value after a consuming method took it:

+
let e = Drink::new("espresso", 2);
+let name = e.into_name();
+println!("{} {}", name, e.shots);
+
error[E0382]: borrow of moved value: `e`
+   |                  ----------- `e` moved due to this method call
+note: `Drink::into_name` takes ownership of the receiver `self`, which moves `e`
+ +

That second message is ownership from chapter 4, showing up in the design of your own API. The choice between &self and self is a promise to your callers about whether they keep their value. It is a design decision, not a syntax detail.

+ +

Private fields — the point of structs in a library

+ +

pub struct makes the type visible. It does not make the fields visible. Each field needs its own pub, and often you want none of them:

+ +
pub struct Menu {
+    items: Vec<String>,     // private: no `pub`
+}
+
+impl Menu {
+    pub fn new() -> Menu { Menu { items: Vec::new() } }
+    pub fn add(&mut self, name: &str) { self.items.push(name.to_string()); }
+    pub fn items(&self) -> &[String] { &self.items }
+}
+ +

From outside the module, reaching for the field directly fails:

+
error[E0616]: field `items` of struct `Menu` is private
+   |                        ^^^^^ private field
+help: a method `items` also exists, call it with parentheses
+ +

Note what items() hands back: &[String], a borrowed view, not the Vec itself. Callers may read every item and cannot push, clear, or reorder. You decided what outsiders can do, and the compiler enforces it with no runtime check.

+ +

Book: 5.1 Defining Structs · 5.3 Method Syntax · 7.3 Paths and privacy

+ +

Part 2 — Enums

+ +

An enum lists every value this type is allowed to be. A value is exactly one of them at a time.

+ +
enum Size {
+    Small,
+    Medium,
+    Large,
+}
+ +

A Size is small, medium, or large. Not "smal", not "venti", not empty, not null. Where a String has billions of possible values and three that you meant, Size has three. The illegal states no longer exist, so you never write code to check for them.

+ +

Matching on yourself

+ +

The most common thing an enum does is answer a question about which variant it is:

+ +
impl Size {
+    fn ml(&self) -> u32 {
+        match self {
+            Size::Small => 240,
+            Size::Medium => 350,
+            Size::Large => 470,
+        }
+    }
+}
+ +

match compares a value against patterns top to bottom and runs the first arm that fits. It is an expression — it produces a value, which is why there is no return above.

+ +
1. Large is 470 ml
+2. is it large? true
+ +

Exhaustiveness — the reason enums are worth it

+ +

Delete one arm:

+
error[E0004]: non-exhaustive patterns: `&Size::Large` not covered
+   |               ^^^^ pattern `&Size::Large` not covered
+note: `Size` defined here
+   = note: the matched value is of type `&Size`
+help: ensure that all possible cases are being handled by adding a match arm
+      with a wildcard pattern or an explicit pattern as shown
+ +

Not a warning. The program does not build. Now the version that actually pays you back — add a fourth variant and change nothing else:

+ +
enum Size { Small, Medium, Large, ExtraLarge }
+
error[E0004]: non-exhaustive patterns: `&Size::ExtraLarge` not covered
+ +

The compiler now walks you to every single place in the codebase that has to think about the new case. In a language with string constants or integer flags, adding a case is silent, and you find the places you forgot in production.

+ +

This is why _ => {} as a catch-all arm should make you pause. It silences that help forever. Use it when you genuinely mean "everything else", not to shut the compiler up.

+ +

Variants that carry data

+ +

This is the part with no equivalent in most languages, and the part worth slowing down for. Each variant can carry different data of a different shape.

+ +
enum Payment {
+    Cash { received: u32 },                  // named fields, like a struct
+    Card(String),                            // one unnamed field, like a tuple
+    Voucher { code: String, off: u32 },      // several named fields
+    OnTheHouse,                              // nothing at all
+}
+ +

A Payment is one of four things, and the data it carries depends on which. A cash payment has an amount received. A voucher has a code and a discount. A free drink has nothing. There is no Payment that has a voucher code but is cash — that state cannot be constructed.

+ +

Building them:

+
Payment::Cash { received: 500 }
+Payment::Card(String::from("4242"))
+Payment::Voucher { code: String::from("FREE10"), off: 100 }
+Payment::OnTheHouse
+ +

And matching pulls the data back out, binding it to names you can use in that arm:

+ +
match payment {
+    Payment::Cash { received } => format!("cash, {received} received"),
+    Payment::Card(last4) => format!("card ending {last4}"),
+    Payment::Voucher { code, off } => format!("voucher {code}, {off} off"),
+    Payment::OnTheHouse => String::from("free"),
+}
+ +
5. Cash { received: 500 } -> cash, 500 received
+5. Card("4242") -> card ending 4242
+5. Voucher { code: "FREE10", off: 100 } -> voucher FREE10, 100 off
+5. OnTheHouse -> free
+ +

Inside Payment::Cash { received }, the name received becomes a variable holding that variant's value. You cannot reach it any other way — the data is sealed inside the variant, and match is the key. That sealing is exactly why the compiler can promise you never read a voucher code off a cash payment.

+ +

You have been using enums the whole time

+ +
enum Option<T> { Some(T), None }
+enum Result<T, E> { Ok(T), Err(E) }
+ +

That is their real definition — ordinary enums with data-carrying variants, no special compiler magic. Everything you learned about Result in the Result pattern is just this:

+ +
match found {
+    Some(s) => println!("Option::Some carried a {:?}", s),
+    None => println!("Option::None carried nothing"),
+}
+
6. Option::Some carried a Small
+7. if let pulled out Large
+ +

And if let is the shortcut for when you care about one variant and want to ignore the rest:

+
if let Some(s) = Size::parse("large") {
+    println!("if let pulled out {:?}", s);
+}
+ +

Option vs Result, when you write your own function

+ +
fn parse(text: &str) -> Option<Size> {
+    match text {
+        "small" => Some(Size::Small),
+        "medium" => Some(Size::Medium),
+        "large" => Some(Size::Large),
+        _ => None,
+    }
+}
+
3. parse("medium") = Some(Medium)
+4. parse("venti")  = None
+ +

Why Option and not Result here? Because there is nothing useful to say about the failure. "That is not a size" is the whole story, and the absence itself carries it. Reach for Result when the caller needs to know why — a file that was missing versus one you lacked permission to read.

+ +

Struct or enum?

+ + + + + +
QuestionUse
Is it this AND this AND this?struct
Is it this OR this OR this?enum
+ +

A drink has a name and a shot count and a size → struct. A size is small or medium or large → enum. They nest freely: the struct holds a field whose type is the enum, which is precisely what Drink { size: Size } means and what you will build in 0003.

+ +

Book: 6.1 Defining an Enum · 6.2 The match Control Flow Construct · 6.3 Concise Control Flow with if let

+ +

Part 3 — Packages, crates, modules

+ +

Four words that get used interchangeably and should not be. From outside in:

+ + + + + + + +
WordWhat it isWhere you see it
PackageWhat Cargo manages. One Cargo.toml. Can hold up to one library crate and any number of binary crates.cargo new cafe
CrateWhat the compiler compiles, in one go. A tree of modules with a single root file.src/lib.rs, src/main.rs
ModuleA namespace inside a crate. Controls what is visible to whom.pub mod menu;
PathHow you name an item: cafe::menu::Menu.use lines
+ +

The one that matters for your project: a package can contain two crates, and they are as separate as if a stranger wrote one of them.

+ +

The layout

+ +
cafe/
+├── Cargo.toml
+├── src/
+│   ├── lib.rs      ← root of the LIBRARY crate, named `cafe`
+│   ├── menu.rs     ← a module in that crate
+│   ├── order.rs    ← another module in that crate
+│   └── main.rs     ← root of the BINARY crate
+└── tests/
+    └── spec.rs     ← integration tests: a separate crate again
+ +

Cargo finds all of this by filename. Here is the entire Cargo.toml for the working demo, unchanged from what cargo new produced:

+ +
[package]
+name = "cafe"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+ +

No [lib]. No [[bin]]. Convention over configuration: src/lib.rs means "library crate", src/main.rs means "binary crate", and both are picked up automatically.

+ +

Wiring it, one error at a time

+ +

This is the sequence I actually ran, and it is the sequence you will hit.

+ +

Step 1. src/menu.rs exists and contains pub struct Menu. src/lib.rs is empty.

+
error[E0433]: cannot find `menu` in `cafe`
+

The file existing is not enough. A module does not exist until its parent declares it. src/menu.rs is an unread file on disk until something says mod menu;.

+ +

Step 2. Put mod menu; in lib.rs.

+
error[E0603]: module `menu` is private
+  |                       private module
+note: the module `menu` is defined here
+

Now it exists, and it is invisible from outside. Everything in Rust is private by default, including modules. mod menu; means "this module is part of my crate". pub mod menu; means "and outsiders may use it".

+ +

Step 3. pub mod menu;

+
["latte"]
+

Working. Three states, two error messages, and each message named exactly what was wrong.

+ +

crate:: versus the package name

+ +

The single most common stumble, and the one to memorise:

+ +
// src/order.rs — INSIDE the library crate, reaching a sibling module
+use crate::menu::Menu;
+
+// src/main.rs — a DIFFERENT crate, so use the library's name
+use cafe::menu::Menu;
+
+// tests/spec.rs — also a different crate, same as main.rs
+use cafe::menu::Menu;
+ +

crate means "the root of the crate I am compiling right now". In main.rs, that root is main.rs — which has no menu module, so:

+
error[E0432]: unresolved import `crate::menu`
+ +

The question to ask whenever a path will not resolve: which crate am I in right now? If the file is main.rs or anything under tests/, you are outside the library and must use its name.

+ +

The working version, run for real:

+
all items: ["latte", "mocha"]
+first item: Some("latte")
+ +

Why bother with a library crate at all?

+ +

You could put everything in main.rs. Two concrete reasons not to:

+ +
    +
  1. Integration tests can only reach a library. Files in tests/ compile as separate crates and can only use public items from the library. They cannot see inside main.rs at all. If your logic lives in main.rs, it is untestable from tests/.
  2. +
  3. It forces you to design a real boundary. Your main.rs becomes just another consumer, so anything awkward about your API you feel immediately — the same as an outside user would.
  4. +
+ +
test cannot_touch_private_field ... ok
+test menu_starts_empty ... ok
+test result: ok. 2 passed; 0 failed
+ +

The privacy rules, complete

+ +
    +
  • Everything is private by default: modules, structs, fields, functions, enum variants' containing type.
  • +
  • pub is needed at every level of the path. A pub fn inside a private mod is unreachable from outside.
  • +
  • pub struct does not make fields public. Each field needs its own pub.
  • +
  • pub enum does make all its variants public. Enums are the exception — a variant you cannot name is useless.
  • +
  • Child modules can always see their ancestors' private items. Privacy points outward, not inward.
  • +
+ +

Book: 7.1 Packages and Crates · 7.2 Defining Modules · 7.5 Separating Modules into Files · 11.3 Test Organization

+ +

Derives, briefly

+ +

You will need these in 0003 and they look like magic, so: #[derive(..)] asks the compiler to write an obvious implementation for you.

+ + + + + + + +
DeriveGives youNeeded when
Debug{:?} printingAny test that prints your type on failure
PartialEq== and !=assert_eq! on your type
Clone.clone()You need a second copy explicitly
CopyAssignment copies instead of movingSmall types with no heap data
+ +
#[derive(Debug, Clone, Copy, PartialEq)]
+enum Size { Small, Medium, Large }
+ +

Copy has a hard limit: a type containing a String or Vec cannot be Copy, because those own heap memory and copying the pointer twice would mean freeing it twice. That is the move-versus-copy split from chapter 4, now constraining your own types. A three-variant enum with no data is a single byte and copies happily; a struct with a String title does not.

+ +

If you forget one, the compiler names the exact trait and the exact type. Read that message rather than guessing from this table.

+ +

The five sentences worth keeping

+ +
    +
  1. A struct is this AND this AND this. An enum is this OR this OR this.
  2. +
  3. impl holds the behaviour; the first parameter (&self, &mut self, self, or nothing) decides what the caller keeps.
  4. +
  5. match on an enum must cover every variant — which is why adding a variant produces a to-do list instead of a bug.
  6. +
  7. A package holds crates; src/lib.rs and src/main.rs are two separate crates, so main.rs says use tasks::.., never use crate::...
  8. +
  9. Everything is private until you write pub, and a file is not a module until a parent declares mod.
  10. +
+ + + + diff --git a/lessons/0005-traits-display-and-errors.html b/lessons/0005-traits-display-and-errors.html new file mode 100644 index 0000000..6d5dcb9 --- /dev/null +++ b/lessons/0005-traits-display-and-errors.html @@ -0,0 +1,542 @@ + + + + +Traits — the promise, and the two your CLI needs + + + +

Traits, Display, and a real error type

+

Lesson 0005 · after 0003 · reading, then a 20-minute drill on your own code · ~30 minutes

+ +

Every code block, every output line, and every error message on this page was produced by running it. Nothing here is written from memory.

+ +

The demo domain is a temperature sensor, deliberately — your project is a task CLI, so nothing below can be pasted into it. You have to translate, and translating is where the learning happens.

+ +

First: what 0003 actually showed

+ +

You passed all 17 tests. The three modules — task, command, store — do what the spec asked, you used ? with ok_or, and find(|task| task.id == id) is the idiomatic answer, not a loop. Structs, enums and the two-crate package are no longer the weak spot.

+ +

But the tests only reach the library. main.rs is the one file no test could see, and it is the one file that misses the contract. Run your own crate:

+ +
$ cargo run -- fly
+no valid commands
+$ echo $?
+0
+
+$ cargo run -- done 9
+thread 'main' (146760) panicked at src/main.rs:25:33:
+called `Result::unwrap()` on an `Err` value: "id not found"
+$ echo $?
+101
+ +

The spec said: errors print to stderr and exit with status 1. What happens instead is three separate faults:

+ +
    +
  1. println! sends errors to stdout, so a pipe or a redirect mixes them into real output.
  2. +
  3. Exit status 0 means "success", so any script calling your CLI believes the failure worked.
  4. +
  5. .unwrap() on complete() and remove() panics — status 101 and a backtrace hint — on the ordinary, expected case of a wrong id.
  6. +
+ +

That is not a syntax gap. It is the exact thing your mission names: errors with Result, not panic!. And the tidy fix needs one thing you have not met yet: traits. So — traits first, then you fix those three faults yourself in the drill at the bottom.

+ +

Part 1 — A trait is a promise

+ +

A trait is a list of method signatures that a type can promise to provide. Nothing more. If you have used an interface, you have the shape already; the differences come later.

+ +
trait Reading {
+    fn celsius(&self) -> f64;
+
+    fn label(&self) -> String {
+        format!("{:.1}C", self.celsius())
+    }
+}
+ +

Two kinds of method are in there, and the difference is the whole of Part 1:

+ +
    +
  • celsius ends in a semicolon — a required method. Every implementor must write it.
  • +
  • label has a body — a default method. Implementors get it for free and may override it. Note that the default calls self.celsius(), a method the trait does not yet have an implementation for. That is allowed: the trait can build on its own promises.
  • +
+ +

Now two unrelated types keep that promise. impl Trait for Type:

+ +
struct Thermometer { room: String, celsius: f64 }
+struct Kettle { fahrenheit: f64 }
+
+impl Reading for Thermometer {
+    fn celsius(&self) -> f64 { self.celsius }
+
+    fn label(&self) -> String {                 // overrides the default
+        format!("{} is {:.1}C", self.room, self.celsius)
+    }
+}
+
+impl Reading for Kettle {
+    fn celsius(&self) -> f64 {                  // takes the default `label`
+        (self.fahrenheit - 32.0) * 5.0 / 9.0
+    }
+}
+ +
1. kitchen is 21.5C
+2. 100.0C
+ +

Line 1 is the override, line 2 is the default method doing the work for a type whose numbers were never even in Celsius. Two types, one vocabulary.

+ +
+The mental model. An impl Type block is what a type can do for itself. An impl Trait for Type block is a type keeping a promise someone else defined. Same keyword, two different jobs — that is why 0004's impl Task { .. } and this page's impl Reading for Kettle { .. } look so similar. +
+ +

Break the promise — drop celsius from the Kettle impl — and the compiler names exactly what is missing:

+ +
error[E0046]: not all trait items implemented, missing: `celsius`
+  --> src/main.rs:31:1
+   |
+ 5 |     fn celsius(&self) -> f64;
+   |     ------------------------- `celsius` from trait
+...
+31 | impl Reading for Kettle {
+   | ^^^^^^^^^^^^^^^^^^^^^^^ missing `celsius` in implementation
+ +

Book: 10.2 Defining a Trait · Default Implementations

+ +

Part 2 — Display: the trait behind {}

+ +

Here is the part that pays off immediately. println!("{}", x) is not magic and it is not built into the language for "printable things". It calls one trait method, Display::fmt. A type prints with {} if — and only if — someone implemented that trait for it.

+ +
use std::fmt;
+
+impl fmt::Display for Thermometer {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        write!(f, "{} [{:.1}C]", self.room, self.celsius)
+    }
+}
+ +

Three things in that signature to notice, then never think about again:

+ +
    +
  • You do not return a string. You write into the formatter f that the caller supplied — no allocation happens for a println!.
  • +
  • write! is format! aimed at a destination. It has the same syntax and returns the fmt::Result you need, which is why the body is one line with no semicolon.
  • +
  • fmt::Result is just Result<(), fmt::Error> under a different name.
  • +
+ +
3. kitchen [21.5C]
+4. to_string gave: kitchen [21.5C]
+ +

Line 4 is the bonus and it is worth understanding: .to_string() was never written for Thermometer. The standard library says every type implementing Display gets ToString automatically. One trait implemented, a second one granted. (std: ToString — "implemented automatically for any type which implements Display")

+ +

And the type without the impl — Kettle — cannot use {} at all:

+ +
error[E0277]: `Kettle` doesn't implement `std::fmt::Display`
+  --> src/main.rs:63:23
+   |
+63 |     println!("5. {}", k);
+   |                  --   ^ `Kettle` cannot be formatted with the default formatter
+   |
+help: the trait `std::fmt::Display` is not implemented for `Kettle`
+   = note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
+ +

You have met this error already, in 0003 — that is what #[derive(Debug)] and {:?} were for. Now the split is clear:

+ + + + + + +
 Debug — {:?}Display — {}
AudienceYou, debuggingThe user of the program
How you get it#[derive(Debug)]Hand-written; no derive exists
ShapeStructure: Task { id: 1, .. }Whatever you decide it reads like
+ +

There is no #[derive(Display)] on purpose: the compiler can print your fields mechanically, but only you know how the sentence should read.

+ +

Book: 10.2 Implementing a Trait on a Type · std: fmt::Display

+ +

The one rule that will bite you

+ +

You may write impl SomeTrait for SomeType only if the trait or the type is yours. Display for your Task: fine, the type is yours. Your own trait for Vec<T>: fine, the trait is yours. Display for Vec<String>: rejected — both belong to the standard library. This is the orphan rule, and it exists so no other crate can change what your code already does. (Book 10.2, "coherence")

+ +

Part 3 — Trait bounds: generics that are allowed to do something

+ +

A generic <T> on its own means "any type at all" — and a function that accepts any type may do almost nothing with it, because the compiler has no idea what it can do. Watch it fail:

+ +
fn hottest<T>(items: &[T]) -> Option<&T> {
+    items.iter().max_by(|a, b| a.celsius().total_cmp(&b.celsius()))
+}
+ +
error[E0599]: no method named `celsius` found for reference `&&T` in the current scope
+  --> src/main.rs:46:34
+   |
+46 |     items.iter().max_by(|a, b| a.celsius().total_cmp(&b.celsius()))
+   |                                  ^^^^^^^ method not found in `&&T`
+   |
+   = help: items from traits can only be used if the trait is implemented and in scope
+note: `Reading` defines an item `celsius`, perhaps you need to implement it
+ +

The fix is a bound — a promise demanded of the caller's type:

+ +
fn hottest<T: Reading>(items: &[T]) -> Option<&T> {
+    items.iter().max_by(|a, b| a.celsius().total_cmp(&b.celsius()))
+}
+ +
5. hottest: attic [28.0C]
+ +

Read <T: Reading> as: T can be any type, as long as it implements Reading. Inside the function you may now use every method the trait promises, and nothing else. Both sides get a guarantee, both checked at compile time, and no lookup happens at runtime.

+ +

Three spellings of the same idea, so you recognise all of them in other people's code:

+ +
fn show<T: Reading>(r: &T)                      // bound in the angle brackets
+fn show(r: &impl Reading)                       // same thing, shorter
+fn show<T>(r: &T) where T: Reading            // same thing, for long bound lists
+ +

Book: 10.2 Traits as Parameters · 10.1 Generic Data Types

+ +

Part 4 — An error type is a type with two traits on it

+ +

Your tasks crate reports failures as String. That works and 0003 asked for it deliberately, because the real answer needs Part 1 to Part 3. Here it is.

+ +

Start with what you already know how to write — an enum, one variant per way of failing, carrying whatever the caller needs:

+ +
#[derive(Debug)]
+enum SensorError {
+    Empty,
+    NotANumber(ParseFloatError),
+    OutOfRange(f64),
+}
+ +

Compare that with String. A caller can match on this and react differently per case; it cannot match on prose. The bad reading is still in the value, so the message can be built later, at the edge of the program. And you cannot typo a variant — "out of rnage" compiles, OutOfRnage does not.

+ +

Then two traits turn it from "an enum" into "an error":

+ +
impl fmt::Display for SensorError {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        match self {
+            SensorError::Empty => write!(f, "no reading given"),
+            SensorError::NotANumber(e) => write!(f, "not a number: {}", e),
+            SensorError::OutOfRange(v) => write!(f, "{} is outside -90..60", v),
+        }
+    }
+}
+
+impl Error for SensorError {}                   // std::error::Error
+ +

Display is the human sentence — Part 2, applied. Error is an empty impl: it adds no code, it only marks the type as an error so it fits everywhere the ecosystem expects one. But it does demand something. Delete the Display impl and keep impl Error:

+ +
error[E0277]: `SensorError` doesn't implement `std::fmt::Display`
+  --> src/main.rs:13:16
+   |
+13 | impl Error for SensorError {}
+   |                ^^^^^^^^^^^ unsatisfied trait bound
+   |
+help: the trait `std::fmt::Display` is not implemented for `SensorError`
+ +

Error requires Display and Debug — a trait can demand other traits, the same way a function demands bounds. That is why #[derive(Debug)] sits on the enum. (std: Error — "Errors must describe themselves through the Display and Debug traits")

+ +

From: a trait you have been using since chapter 1

+ +

Yes — From is another trait, and it lives in the standard library. Its whole definition is one required method:

+ +
trait From<T> {
+    fn from(value: T) -> Self;      // build a Self out of a T
+}
+ +

You have called it in every lesson so far without knowing it had a name:

+ +
let s = String::from("hi");   // this IS From: impl From<&str> for String, in std
+ +

So read the impl below as an English sentence — "here is how to build a SensorError out of a ParseFloatError":

+ +
impl From<ParseFloatError> for SensorError {
+    fn from(e: ParseFloatError) -> SensorError {
+        SensorError::NotANumber(e)
+    }
+}
+ +

It is an ordinary function with a wrapper around it. You can call it by hand, and nothing magic happens:

+ +
let e: ParseFloatError = "nope".parse::<f64>().unwrap_err();
+let wrapped: SensorError = SensorError::from(e);    // just a function call
+ +

So why bother writing it?

+ +

Because ? calls it for you. This is the whole point. ? does not simply hand the error to your caller — it converts it first:

+ +
let value = thing()?;
+
+// what the compiler writes for you:
+let value = match thing() {
+    Ok(v) => v,
+    Err(e) => return Err(From::from(e)),        // <- YOUR impl runs here
+};
+ +

These three functions are therefore the same function. Same output, three spellings:

+ +
fn by_hand(text: &str) -> Result<f64, SensorError> {
+    match text.parse::<f64>() {
+        Ok(v) => Ok(v),
+        Err(e) => Err(SensorError::from(e)),    // call it yourself
+    }
+}
+
+fn with_into(text: &str) -> Result<f64, SensorError> {
+    match text.parse::<f64>() {
+        Ok(v) => Ok(v),
+        Err(e) => Err(e.into()),         // `.into()` is From from the other side
+    }
+}
+
+fn with_question(text: &str) -> Result<f64, SensorError> {
+    Ok(text.parse::<f64>()?)                    // `?` calls it for you
+}
+ +
1. Err(NotANumber(ParseFloatError { kind: Invalid }))
+2. Err(NotANumber(ParseFloatError { kind: Invalid }))
+3. Err(NotANumber(ParseFloatError { kind: Invalid }))
+ +

e.into() and SensorError::from(e) are the same trait read in opposite directions: from starts from the destination type, into starts from the value you hold. Implement From and you get into for free — you never write an Into impl.

+ +

What it looks like when the impl is missing

+ +

Delete the impl From and the compiler names precisely what is absent:

+ +
error[E0277]: `?` couldn't convert the error to `SensorError`
+  --> src/main.rs:28:27
+   |
+27 | fn with_question(text: &str) -> Result<f64, SensorError> {
+   |                                 ------------------------ expected `SensorError` because of this
+28 |     Ok(text.parse::<f64>()?)
+   |             --------------^ the trait `From<ParseFloatError>` is not implemented for `SensorError`
+   |             |
+   |             this can't be annotated with `?` because it has type `Result<_, ParseFloatError>`
+ +

"the trait From<X> is not implemented for YourError" always means the same thing: write the recipe from X to YourError. (Write the same code with an annotated let value: f64 = text.parse()?; and the report arrives as E0271 instead, pointing at the same missing impl.)

+ +

You already depend on this — in command.rs, line 13

+ +
fn parse(args: &[String]) -> Result<Command, String> {
+    let first = args.first().ok_or("No Arguments Found")?;
+  //                              ^^^^^^^^^^^^^^^^^^ this is a &str, not a String
+}
+ +

ok_or("No Arguments Found") produces Result<_, &str>, and your function promises Result<_, String>. Two different types — the same mismatch as above. It compiled because the standard library already ships impl From<&str> for String, and ? found it. Verified:

+ +
$ cargo run
+Err("No Arguments Found")           // a String, converted on the way out
+ +

That is why the mechanism was invisible in 0003: std had written the impl you needed. The moment your own error type appears, you write it.

+ +

And that is what keeps a deep call stack readable — every layer writes a bare ?, and each error type carries its own recipe for becoming the layer above.

+ +

The finished function, with all three failure paths and one bare ? doing the conversion:

+ +
fn parse_reading(text: &str) -> Result<f64, SensorError> {
+    if text.is_empty() {
+        return Err(SensorError::Empty);
+    }
+    let value: f64 = text.parse()?;   // ParseFloatError becomes SensorError here
+    if value < -90.0 || value > 60.0 {
+        return Err(SensorError::OutOfRange(value));
+    }
+    Ok(value)
+}
+ +
1. Ok(21.5)
+2. not a number: invalid float literal
+3. 900 is outside -90..60
+4. no reading given
+ +

Book: 9.2 The ? operator · std: From

+ +

Where the error meets the user

+ +

One trait object is worth knowing before you touch your CLI. Box<dyn Error> means "some value on the heap that implements Error, decided at runtime" — the escape hatch when a function can fail in unrelated ways and you do not want an enum listing them all. main may return it:

+ +
fn main() -> Result<(), Box<dyn Error>> {
+    let ok = parse_reading("18.25")?;
+    println!("5. ? gave us {}", ok);
+    let boom = parse_reading("nope")?;          // fails here
+    println!("never printed {}", boom);
+    Ok(())
+}
+ +
5. ? gave us 18.25
+Error: NotANumber(ParseFloatError { kind: Invalid })
+$ echo $?
+1
+ +

The exit status is right, and ? in main is genuinely useful in a script or a test binary. But look at the message: NotANumber(ParseFloatError { kind: Invalid }). That is Debug, not your carefully written Display — main's reporting uses {:?}. For a CLI a human runs, you want your own sentence, so you handle it yourself at the top:

+ +
match parse_reading(text) {
+    Ok(v) => println!("reading {:.1}C", v),
+    Err(e) => {
+        eprintln!("error: {}", e);              // stderr, and Display
+        process::exit(1);
+    }
+}
+ +
$ cargo run -- 21.5
+reading 21.5C
+$ echo $?
+0
+$ cargo run -- warm
+error: not a number: warm
+$ echo $?
+1
+ +

eprintln! is println! aimed at stderr; process::exit(1) sets the status a caller reads. Those two lines and the missing ? are the entirety of what your main.rs is short of.

+ +

Retrieval — before the drill

+ +

Answer from memory. Scrolling up to check first is the one way to waste these. Some questions are about older topics on purpose — mixing them is what makes any of it stick.

+ +
+

Traits

+

In a trait definition, what is the difference between a method that ends with a semicolon and one that ends with a block?

+ + + +
+ +
+

Display

+

You wrote impl fmt::Display for Task. Which of these does that also give you, with no extra code?

+
+ + + +
+ +
+ +
+

Error handling

+

Your function returns Result<T, MyError> and calls something that fails with io::Error. You want a bare ? to work. What must you write?

+ + + +
+ +
+

Generics

+

Why does fn longest<T>(a: &T, b: &T) refuse to compare a and b with >, and what is the smallest fix?

+ + + +
+ +
+

Ownership

+

In fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result, why is the first parameter &self rather than self?

+
+ + + +
+ +
+ +
+

Enums

+

Name two concrete advantages an error enum has over a String error, as your tasks crate uses today.

+ + + +
+ +
+

Modules & paths

+

You add impl fmt::Display for Task in src/task.rs. What does main.rs have to import to print a task with {}?

+ + + +
+ +

+
+ +

The drill — 20 minutes, your own crate

+ +
+Type it, do not paste it. The code above is a sensor in a different project; none of it fits tasks unchanged. Keep the syntax reference open — looking syntax up is free, copying answers is not. +
+ +

Work in ~/learn-rust/tasks. Three steps, each with its own check. Run cargo test at the end: all 17 must still pass, because you are not changing the library's contract.

+ +

Step 1 — Display for Task

+ +

Your main.rs builds the list line by hand inside a closure. Move that decision to the type: implement fmt::Display for Task in src/task.rs, producing exactly the format the spec prints — 1 [todo] buy milk (medium) — then reduce the list arm to printing each task with {}.

+ +

Check: cargo run -- add x still works, and list's output format has not changed.

+ +
+Stuck for ten minutes on the signature? +

The file needs use std::fmt; at the top. The impl block goes anywhere in task.rs, and the one method is fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result. The body is a single write!(f, ..) with no semicolon; inside it you can call self.status.label() just as main.rs does now.

+
+ +

Step 2 — no unwrap, no panic

+ +

Move the body of main into a second function that returns Result<(), String>, so Command::parse, complete and remove can all be reached with ? instead of unwrap and nested matches. main keeps only: collect the args, call it, and deal with the error. While you are there, remove prints nothing today — make it say removed <id>.

+ +

Check: grep unwrap src/main.rs finds nothing.

+ +
+Stuck for ten minutes on the shape? +

fn run(args: &[String], store: &mut Store) -> Result<(), String>. Its first line can be match Command::parse(args)? { .. } — the ? lands on parse, so the match arms deal with Command values, not Results. Every arm ends in (), and the function's last line is Ok(()). This works with no From impl because every error in play is already String.

+
+ +

Step 3 — the contract from 0003

+ +

In main, report the failure on stderr with your own message and exit with status 1. Nothing else changes.

+ +

Check — all three must hold:

+ +
$ cargo run --quiet -- fly ; echo $?
+error: no valid commands
+1
+$ cargo run --quiet -- done 9 ; echo $?
+error: id not found
+1
+$ cargo run --quiet -- add "buy milk" 2>/dev/null ; echo $?
+added task 1
+0
+ +
+Stuck for ten minutes on the last two lines? +

use std::process; at the top. Then if let Err(e) = run(&args, &mut store) { .. } is enough — inside it, eprintln!("error: {}", e); followed by process::exit(1);. The third check passes automatically once errors leave stdout: redirecting stderr to /dev/null must not swallow real output.

+
+ +

Then stop

+ +

Converting String errors into a proper TaskError enum with Display, Error and From is the obvious next move, and it is deliberately not in this drill — it touches all four files and it is the next lesson. Get these three green first.

+ +

The five sentences worth keeping

+ +
    +
  1. A trait is a list of promised methods; impl Trait for Type is a type keeping that promise.
  2. +
  3. {} is Display and nothing else — you write it by hand, and to_string() comes free with it. {:?} is Debug, which you derive.
  4. +
  5. A bare <T> can do nothing; <T: Trait> can do exactly what the trait promises.
  6. +
  7. An error type is an enum plus Display plus the empty impl Error; ? converts between error types by calling From.
  8. +
  9. Failures a program can expect belong in Result, on stderr, with exit 1. unwrap is for the cases you have proved impossible.
  10. +
+ + + + + + diff --git a/lessons/0006-errors-spec.rs b/lessons/0006-errors-spec.rs new file mode 100644 index 0000000..7c9ec33 --- /dev/null +++ b/lessons/0006-errors-spec.rs @@ -0,0 +1,104 @@ +// The 0006 specification, as executable tests. Do not edit this file — make it pass. +// Copy to: tasks/tests/errors.rs +// Run with: cargo test + +use std::error::Error; +use tasks::command::Command; +use tasks::error::TaskError; +use tasks::store::Store; +use tasks::task::Priority; + +fn args(list: &[&str]) -> Vec { + list.iter().map(|s| s.to_string()).collect() +} + +// ---------- one variant per way of failing ---------- + +#[test] +fn parse_errors_name_the_exact_failure() { + assert_eq!(Command::parse(&args(&[])).unwrap_err(), TaskError::NoCommand); + assert_eq!( + Command::parse(&args(&["fly"])).unwrap_err(), + TaskError::UnknownCommand("fly".to_string()) + ); + assert_eq!( + Command::parse(&args(&["add"])).unwrap_err(), + TaskError::MissingTitle + ); + assert_eq!( + Command::parse(&args(&["add", "x", "urgent"])).unwrap_err(), + TaskError::BadPriority("urgent".to_string()) + ); + assert_eq!( + Command::parse(&args(&["done"])).unwrap_err(), + TaskError::MissingId + ); +} + +#[test] +fn store_reports_which_id_was_missing() { + let mut store = Store::new(); + store.add("only task", Priority::Low); + assert_eq!(store.complete(99).unwrap_err(), TaskError::NotFound(99)); + assert_eq!(store.remove(7).unwrap_err(), TaskError::NotFound(7)); +} + +// ---------- Display: the sentence the user reads ---------- + +#[test] +fn messages_are_lowercase_and_unpunctuated() { + assert_eq!(TaskError::NoCommand.to_string(), "no command given"); + assert_eq!( + TaskError::UnknownCommand("fly".to_string()).to_string(), + "unknown command: fly" + ); + assert_eq!(TaskError::MissingTitle.to_string(), "add needs a title"); + assert_eq!( + TaskError::MissingId.to_string(), + "this command needs a task id" + ); + assert_eq!( + TaskError::BadPriority("urgent".to_string()).to_string(), + "unknown priority: urgent" + ); + assert_eq!(TaskError::NotFound(9).to_string(), "no task with id 9"); +} + +// ---------- From + ? : the parse error is wrapped, not thrown away ---------- + +#[test] +fn a_bad_id_wraps_the_parse_error() { + let err = Command::parse(&args(&["done", "abc"])).unwrap_err(); + assert!(matches!(err, TaskError::BadId(_)), "expected BadId"); + assert!( + Command::parse(&args(&["remove", "-1"])).is_err(), + "a negative id is not a u32" + ); +} + +#[test] +fn a_wrapped_error_is_reported_once_and_reachable() { + let err = Command::parse(&args(&["done", "abc"])).unwrap_err(); + // Display says your sentence and does not repeat std's. + assert_eq!(err.to_string(), "task id must be a number"); + // std's sentence is still reachable, through Error::source(). + let inner = err.source().expect("BadId must expose its source"); + assert_eq!(inner.to_string(), "invalid digit found in string"); +} + +#[test] +fn errors_that_carry_nothing_have_no_source() { + assert!(TaskError::NoCommand.source().is_none()); + assert!(TaskError::NotFound(1).source().is_none()); +} + +// ---------- the Error trait, and the bounds the ecosystem expects ---------- + +#[test] +fn task_error_is_a_real_error() { + fn assert_usable_as_error() {} + assert_usable_as_error::(); + + let boxed: Box = Box::new(TaskError::NotFound(1)); + assert_eq!(boxed.to_string(), "no task with id 1"); +} diff --git a/lessons/0006-your-own-error-type.html b/lessons/0006-your-own-error-type.html new file mode 100644 index 0000000..b735812 --- /dev/null +++ b/lessons/0006-your-own-error-type.html @@ -0,0 +1,512 @@ + + + + +0006 — Your own error type + + + + +

Your own error type

+

Lesson 0006 · after 0005 · reading, then a 25-minute drill against a shipped test file · ~40 minutes

+ +
+Every code block and every compiler message on this page was produced by running it, in a scratch project, today. +Nothing is written from memory. The demo domain is a config loader — your project is a task CLI, so nothing here +pastes in. Translating is the work. +
+ +

Where 0005 left you

+ +

The drill landed: Display for Task, no unwrap, Command::parse(args)?, +eprintln! + process::exit(1), all 17 tests still green. The contract 0003 asked for and never +got is now real code.

+ +

So look at what is left. Two lines in two different files:

+ +
// src/command.rs — the user typed `done` with no id after it
+let id = args.get(1).ok_or("id not found")?;
+
+// src/store.rs — the user typed `done 9`, and task 9 does not exist
+Err("id not found".to_string())
+ +

Two unrelated failures, one identical sentence. A caller cannot tell them apart, and neither can you at 3am. +There is a third: Err(String::from("no valid commands")) throws away the word the user actually typed, +so your CLI can never say which command it did not recognise.

+ +

That is the last thing standing between tasks and a crate you would show an interviewer. Today it +becomes one enum and three traits.

+ +

Part 1 — What a String error costs

+ +

Three costs, and they are not stylistic.

+ + + + + + + + + + + + + + + +
With StringWith an enum
The caller gets prose. To react differently per failure it must match on text — if msg == "id not found" — and that breaks the day you fix a typo.The caller matches on a variant. The compiler checks the arms.
The data is gone. format!("no task with id {id}") flattens the id into text; nothing downstream can use it.The value rides along — NotFound(9) — and the sentence is built at the edge, where the human is.
A misspelled message compiles.A misspelled variant does not.
+ +

This is not just taste; it is the published guideline for the language:

+ +
+

"Error types should always implement the std::error::Error trait… Never use () as an error +type, even where there is no useful additional information for the error to carry… The error message given by the +Display representation of an error type should be lowercase without trailing punctuation, and typically +concise."

+
+

Rust API Guidelines: C-GOOD-ERR

+ +

Note the lowercase rule — that is why "invalid digit found in string", straight from +std, has no capital and no full stop. Your messages will match that style, and the shipped tests check +it.

+ +

Part 2 — The one new trait method: source()

+ +

0005 gave you the recipe: #[derive(Debug)], then Display, then the empty +impl Error, plus From so a bare ? converts. That is 90% of today's drill and +you already have it.

+ +

Here is the 10% that is new, and it is the part that makes wrapped errors behave. When one of your variants +carries another error — BadPort(ParseIntError) — you now have two sentences for one failure: yours and +std's. The Error trait has a slot for the inner one:

+ +
pub trait Error: Debug + Display {
+    fn source(&self) -> Option<&(dyn Error + 'static)> { ... }
+    // …plus deprecated description() / cause(), which you never implement
+}
+

std: std::error::Error — note the +supertraits: Debug + Display is a requirement of the trait itself, the same bound idea as +0005's <T: Reading>, applied to a trait instead of a function.

+ +

The rule that goes with it is one sentence, and it is easy to get wrong:

+ +
+

"In error types that wrap an underlying error, the underlying error should be either returned by the outer error's +Error::source(), or rendered by the outer error's Display implementation, but not both."

+
+

std: Error source

+ +

So: say your sentence in Display, hand the cause to source(), and never print both. +Here is the whole pattern in a config loader — Option in, u16 out, two ways to fail:

+ +
#[derive(Debug)]
+enum ConfigError {
+    Missing(String),
+    BadPort(ParseIntError),
+}
+
+impl fmt::Display for ConfigError {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            ConfigError::Missing(key) => write!(f, "missing setting: {}", key),
+            // no `{e}` on the next arm - the port text is not worth echoing
+            ConfigError::BadPort(_) => write!(f, "port must be a number"),
+        }
+    }
+}
+
+impl Error for ConfigError {
+    fn source(&self) -> Option<&(dyn Error + 'static)> {
+        match self {
+            ConfigError::BadPort(e) => Some(e),   // the cause lives here instead
+            ConfigError::Missing(_) => None,       // nothing underneath this one
+        }
+    }
+}
+
+impl From<ParseIntError> for ConfigError {
+    fn from(e: ParseIntError) -> ConfigError { ConfigError::BadPort(e) }
+}
+
+fn port(text: Option<&str>) -> Result<u16, ConfigError> {
+    let text = text.ok_or(ConfigError::Missing("port".to_string()))?;
+    Ok(text.parse()?)                   // ParseIntError -> ConfigError, via From
+}
+ +
1. Ok(8080)
+2. port must be a number
+3. Some("invalid digit found in string")
+4. missing setting: port
+5. None
+ +

Line 2 is your sentence. Line 3 is std's, reached through source() — still available for +a log or a --verbose flag, not shoved in the user's face. Lines 4–5: a variant with nothing underneath +it returns None, and that is not a gap, it is the answer.

+ +

Read Option<&(dyn Error + 'static)> as "maybe a reference to some error, whatever type it +is" — the dyn from 0005's Box<dyn Error>, borrowed instead of boxed. Copy the +signature; it is not worth memorising.

+ +

What the enum buys the caller

+ +

Now the payoff that a String can never give you — recovering from one failure and staying +fatal on the rest:

+ +
fn port_or_default(text: Option<&str>) -> Result<u16, ConfigError> {
+    match port(text) {
+        Err(ConfigError::Missing(_)) => Ok(8080),   // recover from ONE variant
+        other => other,                        // every other failure stays fatal
+    }
+}
+ +
6. Ok(8080)
+7. Err("port must be a number")
+ +

A missing setting falls back to a default; a typo'd one still fails. Try writing that against +Result<u16, String> — you would be comparing prose. This is the shape of every real config loader, +retry policy, and HTTP status decision you will write in a backend job.

+ +

Part 3 — What the compiler starts doing for you

+ +

An enum is a closed set, and the compiler knows all of it. Add a variant to a shipped error type:

+ +
enum TaskError {
+    …
+    NotFound(u32),
+    StoreFull,          // new today
+}
+ +
error[E0004]: non-exhaustive patterns: `&TaskError::StoreFull` not covered
+  --> src/error.rs:19:15
+   |
+19 |         match self {
+   |               ^^^^ pattern `&TaskError::StoreFull` not covered
+   |
+note: `TaskError` defined here
+  --> src/error.rs:6:10
+   |
+ 6 | pub enum TaskError {
+   |          ^^^^^^^^^
+...
+14 |     StoreFull,
+   |     --------- not covered
+   = note: the matched value is of type `&TaskError`
+help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern
+      or an explicit pattern as shown
+ +

That is the answer to the question you missed in 0005's quiz, delivered by the compiler: adding a +failure mode makes the build fail everywhere the new case is unhandled. With a String, adding +a failure mode is silent — you find out in production. This is also the argument for not reaching for +_ => … in a match on your own error type: the wildcard throws the guarantee away.

+ +

Book: 6.2 match +(exhaustiveness) · 9.2 ? and From

+ +

The three errors you will meet during the migration

+ +

Not hypotheticals — I ran your crate with each mistake in place. Recognise them and each costs you ten seconds +instead of ten minutes.

+ +

1. You wrote the new module but never declared it.

+
error[E0432]: unresolved import `crate::error`
+ --> src/command.rs:1:12
+  |
+1 | use crate::error::TaskError;
+  |            ^^^^^ unresolved import
+

A file in src/ is not a module until a mod declaration names it. Add +pub mod error; to src/lib.rs. (Chapter 7, still true.)

+ +

2. You changed the signature but left an old String behind.

+
error[E0308]: mismatched types
+   --> src/store.rs:37:13
+    |
+ 37 |         Err("id not found".to_string())
+    |         --- ^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `TaskError`, found `String`
+    |         |
+    |         arguments to this enum variant are incorrect
+

This is the migration working as intended: the compiler is listing your remaining String errors one +at a time. Follow it until it stops.

+ +

3. You used ? on parse() with no From impl.

+
error[E0271]: type mismatch resolving `<u32 as FromStr>::Err == TaskError`
+  --> src/command.rs:31:43
+   |
+31 |                 Ok(Command::Done { id: id.parse()? })
+   |                                           ^^^^^ expected `TaskError`, found `ParseIntError`
+

0005 predicted exactly this: when the target type comes from context rather than a turbofish, the missing +From is reported as E0271 instead of E0277. Same meaning, same fix — write +impl From<ParseIntError> for TaskError.

+ +

Retrieval — before the drill

+ +

From memory. Scrolling up first is the one way to waste these. Two questions are about older material on +purpose — interleaving is what makes any of it stick.

+ +
+

Enums

+

You add a variant to an error enum that is matched in four places. What does the compiler do, and what does the equivalent change to a String error do?

+ + + +
+ +
+

Error handling

+

Your variant BadId(ParseIntError) wraps another error. Where should std's message appear?

+
+ + + +
+ +
+ +
+

Traits

+

The trait is declared pub trait Error: Debug + Display. What are those two names doing there, and what happens if your type has neither?

+ + + +
+ +
+

Generics

+

What does this test actually assert, given that its body is empty? fn assert_usable_as_error<E: Error + Send + Sync + 'static>() {} then assert_usable_as_error::<TaskError>();

+ + + +
+ +
+

Ownership

+

Why does UnknownCommand(String) hold an owned String rather than a borrowed &str taken from the argument list?

+
+ + + +
+ +
+ +
+

Modules & paths

+

You create src/error.rs and store.rs says use crate::error::TaskError;. It fails with E0432: unresolved import. Why, and what is the fix?

+ + + +
+ +

+
+ +

The drill — 25 minutes, your own crate

+ +

Type it, do not paste it. The config loader above is a different program; none of it fits +tasks unchanged. Keep the syntax reference open — +looking syntax up is free, copying answers is not.

+ +

This time the feedback loop is a test file, like 0003. Install it first:

+ +
cd ~/learn-rust/tasks
+cp ../lessons/0006-errors-spec.rs tests/errors.rs
+cargo test            # 7 new tests fail to compile — that is the starting line
+ +

Do not edit tests/errors.rs. Do not edit tests/spec.rs either: all 17 must still pass, +because the library's behaviour is not changing today — only the type it reports failures with. Target at +the end: 24 passing.

+ +

Step 1 — src/error.rs

+ +

New file, new module. The tests name the variants, so this shape is fixed:

+ +
pub enum TaskError {
+    NoCommand,                 // no arguments at all
+    UnknownCommand(String),    // the word the user actually typed
+    MissingTitle,              // `add` with nothing after it
+    MissingId,                 // `done` / `remove` with nothing after it
+    BadPriority(String),       // the priority word that was not low/medium/high
+    BadId(ParseIntError),      // `done abc` — wraps std's parse failure
+    NotFound(u32),             // the id that was not in the store
+}
+ +

Write the four things it needs: the derive, Display, impl Error with +source(), and From<ParseIntError>. Two derives are required — +Debug because Error demands it, and PartialEq because the tests compare +variants with assert_eq!. Miss the second and you get +error[E0369]: binary operation == cannot be applied to type TaskError.

+ +

The messages are part of the contract — the tests compare them exactly. Lowercase, no trailing punctuation, +per C-GOOD-ERR:

+ + + + + + + + + + +
Variantto_string() must be
NoCommandno command given
UnknownCommand("fly")unknown command: fly
MissingTitleadd needs a title
MissingIdthis command needs a task id
BadPriority("urgent")unknown priority: urgent
BadId(..)task id must be a number
NotFound(9)no task with id 9
+ +

Check: cargo build compiles the library, with pub mod error; added to +src/lib.rs.

+ +
+Stuck for ten minutes on source()? +

Imports: use std::error::Error;, use std::fmt;, +use std::num::ParseIntError;. The method signature is +fn source(&self) -> Option<&(dyn Error + 'static)> — copy it from Part 2, it is not worth +deriving. Exactly one variant returns +Some(e); a _ => None arm is acceptable here because you are matching to find one case, +not to handle every case.

+
+ +

Step 2 — command.rs: Result<Command, TaskError>

+ +

Change the signature, then let the compiler walk you through the five ok_or / Err sites. +Two of them get better: the _ => arm can now name the word it rejected, and both +match id.parse() { Ok(n) => n, Err(_) => return Err(..) } blocks collapse to +id.parse()? — that is what the From impl was for.

+ +

Check: cargo test --test errors parse_errors_name_the_exact_failure passes, and +grep -c "match id.parse" src/command.rs prints 0.

+ +
+Stuck on the unknown-command arm? +

The match arm _ => … discards the value it matched. Bind it instead: +other => Err(TaskError::UnknownCommand(other.to_string())). other is a +&str (you matched on .as_str()), and the variant holds a String — see the +Ownership question above for why.

+
+ +

Step 3 — store.rs: report which id

+ +

Both error returns become TaskError::NotFound(id). Nothing else in the file changes.

+ +

Check: cargo test --test errors store_reports_which_id_was_missing passes.

+ +

Step 4 — main.rs: the edge

+ +

run now returns Result<(), TaskError>. Everything else you already wrote in 0005 +stays exactly as it is — eprintln!("error: {}", e) keeps working because Display is +implemented, and that is the whole point of the trait.

+ +

Check — the observable behaviour of your CLI:

+ +
$ cargo run --quiet -- fly ; echo $?
+error: unknown command: fly
+1
+$ cargo run --quiet -- done abc ; echo $?
+error: task id must be a number
+1
+$ cargo run --quiet -- done 9 ; echo $?
+error: no task with id 9
+1
+$ cargo run --quiet ; echo $?
+error: no command given
+1
+$ cargo run --quiet -- add "buy milk" 2>/dev/null ; echo $?
+added task 1
+0
+$ cargo test
+… 17 passed … 7 passed …
+ +

Compare the first three lines with what your CLI said an hour ago — no valid commands, +id not found, id not found. Same code paths, same exit codes; the errors now name the +thing that went wrong. That is the whole return on one enum.

+ +

Then stop

+ +

Persistence is the obvious next move and it is deliberately not here: reading and writing a file brings +fs, io::Error, a second From impl, and turning a line of text back into a +Task. That is lesson 0007, and it is much easier once TaskError exists to convert +into.

+ +

What you are missing, measured

+ +

You asked not to miss anything important, so I mapped this workspace against the book's real table of contents +rather than my memory of it: the coverage map. Read it +once — it is the shortest honest answer to "where am I?".

+ +

The summary: chapters 1–7, 9, and 10.2 are produced, not just read. Three genuine gaps stand between you +and a job-ready floor, in the order I intend to teach them:

+ +
    +
  1. ch 12 — files and io::Error: your CLI forgets everything on exit (lesson 0007).
  2. +
  3. ch 8 + 13 — HashMap, map/filter/collect: your +weakest measured area, and the most common shape in real Rust code.
  4. +
  5. ch 11 — writing tests: you have consumed 24 of my tests and written none. A take-home will ask +you to produce them.
  6. +
+ +

Lifetimes (ch 10.3) are untouched, and that is fine for now — you have dodged them by owning your data, which is +the right call in a CLI. They become unavoidable when you read other people's code.

+ +

Take it outside

+ +

Once the 24 tests are green, tasks is a small, complete, idiomatic crate — the first thing in this +workspace worth showing to strangers. The highest-value thing you can do with it is ask people who write Rust daily +whether your error type is idiomatic:

+ +
    +
  • users.rust-lang.org — the official forum, "Code Review" category. +Paste error.rs and ask specifically: is one enum for both parse and store failures right, or should +those be two types with a wrapping variant? That is a genuine design question with a real answer, and it is the kind +of thing a reviewer will teach you in one reply.
  • +
  • r/rust — faster, noisier; good for "is this idiomatic?" checks.
  • +
+ +

Two things you will likely hear back, and both are worth knowing in advance: real crates often reach for +thiserror to derive exactly the Display/From +code you just wrote by hand, and applications often use +anyhow instead of an enum. Both are correct advice, and both are +the wrong place to start — you cannot judge a macro that writes an Error impl until you have written +one yourself. Today's version is the one that teaches; reach for the crates on your second real project.

+ +

The five sentences worth keeping

+ +
    +
  1. An error type is an enum with one variant per way of failing, carrying the data that failed.
  2. +
  3. Display is the sentence a human reads: lowercase, no trailing punctuation, no repeat of the cause.
  4. +
  5. source() is where a wrapped error goes — either source() or Display, never both.
  6. +
  7. From<Cause> for MyError is what makes a bare ? convert; missing, it reports as +E0277 or E0271 depending on how the target type was named.
  8. +
  9. The compiler's exhaustiveness check is the real reason for an enum: adding a failure mode breaks the build, +not production.
  10. +
+ + + + + + diff --git a/lessons/0007-files-and-fromstr.html b/lessons/0007-files-and-fromstr.html new file mode 100644 index 0000000..0e387e1 --- /dev/null +++ b/lessons/0007-files-and-fromstr.html @@ -0,0 +1,588 @@ + + + + +0007 — Files, io::Error, and FromStr + + + + +

Files, io::Error, and FromStr

+

Lesson 0007 · after 0006 · reading, then a 30-minute drill against a shipped test file · ~45 minutes

+ +
+Every code block and every compiler message on this page was produced by running it today, in a scratch project. +Nothing is written from memory. The demo domain is a weather log — your project is a task CLI, so nothing here +pastes in. Translating is the work. +
+ +

Where 0006 left you, and the one loose end

+ +

Twenty-four tests are green, and the error type behind them is real work: TaskError has seven +variants, each with its own Display sentence, a source() that hands back the one wrapped +cause, and an impl From<ParseIntError>. That is the full set of obligations for a std-compatible +error type, and you wrote all of it from the signatures up.

+ +

There is one loose end, and it is worth looking at closely before adding anything new. The From +impl you wrote is never actually called. Over in command.rs, the conversion is still done by hand — +once in the done arm, once in remove:

+ +
let id: u32 = match id.parse() {
+    Ok(n) => n,
+    Err(e) => return Err(TaskError::BadId(e)),   // this IS From::from, typed out
+};
+ +

Compare that with what From exists to do. Your impl says "given a ParseIntError, +build a TaskError::BadId" — which is exactly what the Err arm above says, in five lines +instead of zero. The impl is correct; it simply never gets reached, because match handles the error +before ? would have had a chance to convert it. So the mechanism was learned and the reflex was not, +which is the most common way a Rust concept half-lands.

+ +

Step 0 of today's drill deletes both blocks. Then today adds a second From impl, and this +one you will not be able to route around by hand: the shape the code needs makes ? the only +reasonable option, and the compiler stops the build until the impl exists.

+ +

Part 1 — A file is a String that can fail

+ +

A save file is, from Rust's point of view, nothing more exotic than a String that might not arrive. +Two functions in std::fs cover everything a CLI of this size needs. +Each one opens the file, does the work, and closes it again, all inside the single call — there is no handle to keep +track of and nothing to remember to close:

+ +
fn read_to_string<P: AsRef<Path>>(path: P) -> io::Result<String>
+fn write<P: AsRef<Path>, C: AsRef<[u8]>>(path: P, contents: C) -> io::Result<()>
+

std: fs::read_to_string · +fs::write

+ +

The return types look unfamiliar, so take them apart before going further. +io::Result<T> is not a new kind of Result; it is +a type alias, declared in std as +type Result<T> = std::result::Result<T, io::Error>. In other words the error half has already been +filled in for you, because every function in that module fails the same way. Whenever you meet +io::Result<String> in a signature, read it silently as Result<String, io::Error> and +carry on — it is the same Result you have been matching on since chapter 9, and ? works +on it exactly as you would expect.

+ +

(The P: AsRef<Path> in the signature is a convenience bound, and you can read past it for now. +All it means is that you may pass a &str, a String, a &Path, or a +PathBuf, and std will accept any of them. It is the same idea as the trait bounds from 0005, used to +widen what a function will take.)

+ +

The interesting half is io::Error. Unlike ParseIntError, which really only means "that +was not a number", an io::Error stands for dozens of distinct situations: the file does not exist, the +path is a directory rather than a file, the process lacks permission to read it, the disk is full, the name is too +long. All of those arrive as the same type, so the type alone cannot tell you what went wrong. You separate them by +asking the value, using e.kind(), +which returns a variant of the ErrorKind enum:

+ +
let missing = fs::read_to_string("/tmp/definitely-not-here.txt");
+println!("{:?}", missing.map_err(|e| e.kind()));
+
missing -> Err(NotFound)
+ +

Keep that NotFound in mind. It looks like a small detail, but it turns into the most important +design decision of the whole lesson, and Part 4 comes back to it: on the very first run of your CLI, the save file +legitimately does not exist yet, and how you treat that one kind decides whether a fresh install works or looks +broken.

+ +

Part 2 — The second From, and why it is legal

+ +

You asked, after the last lesson, whether a type can have two From impls. The answer decides how +today's code is shaped, so here is the rule stated precisely: for any given source type T, +there may be exactly one impl From<T> for YourType in the whole program. Write a second +one for the same T and the compiler stops you with +error[E0119]: conflicting implementations.

+ +

The reason is worth holding on to, because it explains a lot of Rust's trait rules. At the moment you write +value?, the only information the compiler has is the pair of types involved: it is converting a +ParseIntError into a TaskError. Nothing at that call site records what you meant +by the failure. If two impls existed for that pair, there would be two possible answers and no way to choose +between them, so the language forbids the ambiguity up front rather than picking one for you.

+ +

Nothing stops you from adding an impl for a different source type, though, and that is what today +needs — one conversion for parse failures, and a new one for file failures:

+ +
impl From<ParseIntError> for TaskError { .. }   // T = ParseIntError   (0006)
+impl From<io::Error>     for TaskError { .. }   // T = io::Error   (0007)
+ +

Write fs::write(path, out)? before the impl exists and the compiler tells you exactly what is +missing. Real output:

+ +
error[E0277]: `?` couldn't convert the error to `TaskError`
+  --> src/store.rs:82:29
+   |
+82 |         fs::write(path, out)?;
+   |         --------------------^ the trait `From<std::io::Error>` is not implemented for `TaskError`
+   |         |
+   |         this can't be annotated with `?` because it has type `Result<_, std::io::Error>`
+   |
+note: `TaskError` needs to implement `From<std::io::Error>`
+   = note: the question mark operation (`?`) implicitly performs a conversion
+           on the error value using the `From` trait
+ +

The final note in that message is the part worth reading twice. ? has no special knowledge of +io, and it is not a built-in shortcut for file handling: it simply calls From::from on +whatever error it is given. It is the identical mechanism that has been quietly converting your +ParseIntError into a BadId since 0006. Once you see ? as "return early, and +run the error through From on the way out", every one of these messages becomes predictable rather +than mysterious.

+ +

Part 3 — FromStr: the trait behind .parse()

+ +

You have been calling .parse() since the guessing game, and it has probably felt like a built-in +piece of string handling. It is not. It is a trait method, and once you see the two declarations behind it, the +whole thing stops being magic:

+ +
impl str {
+    pub fn parse<F: FromStr>(&self) -> Result<F, F::Err> { .. }
+}
+
+pub trait FromStr: Sized {
+    type Err;                                          // an ASSOCIATED TYPE
+    fn from_str(s: &str) -> Result<Self, Self::Err>;
+}
+

std: FromStr · +Book: 20.2 — associated types

+ +

Read the two together. "42".parse::<u32>() works for exactly one reason: somewhere in std there +is an impl FromStr for u32, and it declares type Err = ParseIntError. There is no +special case for integers in the language. That means the door is open to you — implement the same trait for your +own type and .parse() begins working on it immediately. This is a genuinely different move from +writing a Task::from_line helper of your own. A helper is a function only your code knows about; +implementing the trait means your type joins an interface that std and every other crate already speak, so any +generic function taking F: FromStr will now accept a Task as well.

+ +

The unfamiliar line is type Err;, and it deserves its own paragraph because it is your first +associated type. Think of it as a slot in the trait that the implementor fills in, once, +and permanently — as opposed to a generic parameter, which the caller chooses at each call site. That +distinction is exactly why ParseIntError appears nowhere in parse()'s signature: the +signature says F::Err, meaning "whatever error type F declared when it implemented the +trait". When you write type Err = TaskError in your impl, you are filling that slot for +Task, and from then on line.parse::<Task>() is known to return +Result<Task, TaskError> without anyone having to say so again.

+ +

Leave the slot out and the compiler is explicit about the missing piece:

+ +
error[E0046]: not all trait items implemented, missing: `Err`
+   --> src/task.rs:103:1
+    |
+103 | impl FromStr for Task {
+    | ^^^^^^^^^^^^^^^^^^^^^ missing `Err` in implementation
+    |
+    = help: implement the missing item: `type Err = /* Type */;`
+ +

And call .parse::<Task>() before implementing it at all:

+ +
error[E0277]: the trait bound `Task: FromStr` is not satisfied
+  --> src/store.rs:98:29
+   |
+98 |             tasks.push(line.parse::<Task>()?);
+   |                             ^^^^^ unsatisfied trait bound
+   |
+help: the trait `FromStr` is not implemented for `Task`
+ +

The whole thing, in the weather-log demo — run today, output below:

+ +
#[derive(Debug, PartialEq)]
+struct Reading { station: String, celsius: f64 }
+
+impl FromStr for Reading {
+    type Err = String;                        // your error type goes in the slot
+
+    fn from_str(line: &str) -> Result<Reading, String> {
+        let (station, temp) =
+            line.split_once('=').ok_or_else(|| line.to_string())?;
+        Ok(Reading {
+            station: station.to_string(),
+            celsius: temp.parse().map_err(|_| line.to_string())?,
+        })
+    }
+}
+
Ok(Reading { station: "oslo", celsius: -3.5 })
+Ok(Reading { station: "lagos", celsius: 31.0 })
+Err("broken")
+ +

One line in there is doing something you have not seen before, so look at the inner +temp.parse().map_err(|_| ..)? closely. It parses a f64, and the failure it can produce is +a ParseFloatError — but notice what that failure means in this context. It does not mean "the +user typed a bad number at the keyboard"; it means "the line stored in this file is corrupt". Those are two +different problems, they deserve two different variants, and only one of them can be the one that From +produces automatically.

+ +

So the shape to remember is this: ? on its own handles the single canonical conversion, +and map_err is how you name a different variant for any other meaning of the same error type. +In the drill you will write both, a few lines apart, on the very same ParseIntError — the CLI path +keeps ? and produces BadId, while the file path uses map_err and produces +BadLine.

+ +

Part 4 — Three decisions the tests will hold you to

+ +

A missing file is not an error

+ +

Picture the very first time anyone runs your CLI. There is no tasks.txt yet, because nothing has +ever created one. If load simply propagates whatever fs::read_to_string returns, that +first run prints an error to stderr and exits 1 — the program looks broken before the user has done anything wrong. +A missing file here is not a failure at all; it is the normal starting state, and it means "you have no tasks yet".

+ +

So this is one of the rare places where you deliberately catch a single kind of error and turn it into a +successful result, while letting every other kind through untouched:

+ +
let text = match fs::read_to_string(path) {
+    Ok(text) => text,
+    Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Store::new()),
+    Err(e) => return Err(TaskError::Io(e)),      // permissions etc. still fail
+};
+ +

The new syntax is Err(e) if .., which is called a match guard: an extra condition +attached to an arm, so the arm only matches when the pattern fits and the condition holds. Here the first +Err arm catches only NotFound, and anything else falls through to the arm below it.

+ +

It is worth being clear about what this is not, because the lazy version is tempting. This is not +unwrap_or_default() and it is not .ok(). Both of those would treat every io +failure as "no tasks" — so a permissions problem, or a disk that has gone read-only, would silently present the +user with an empty list, and the next save would overwrite their real file with nothing. One specific +kind of failure is expected; the rest genuinely are failures and must still be reported.

+ +

The saved format is not the Display format

+ +

You already have a Display for Task from lesson 0005, and it prints +1 [done] buy milk (high). That is a good sentence for a person reading a terminal, and it is a poor +format to read back in: to reconstruct the task you would have to find the brackets and parentheses, while allowing +for a title that might itself contain either. The format fights you because it was never designed to be parsed.

+ +

Storage has different requirements from presentation, so give it its own format — one with a separator that +splits cleanly and a fixed field order:

+ +
1|done|high|buy milk
+ +

Two audiences, two formats: Display stays exactly as it is for the list command, and +to_line is added beside it for the file. Do not be tempted to make one serve both.

+ +

Notice also that the title is placed last. That is deliberate, and it lets the title contain anything at +all, including the separator itself. The reason is how splitn works: +"a|b|c|d|e".splitn(4, '|') stops splitting after it has produced four pieces, so the fourth piece is +the entire remainder, "d|e", with its | intact. Reach for split instead and a +title containing a pipe silently loses everything after it. One of the shipped tests covers exactly this case.

+ +

#[derive(PartialEq)] will break, and that is informative

+ +

Add Io(io::Error) to the enum and the derive on line 3 fails:

+ +
error[E0369]: binary operation `==` cannot be applied to type `&std::io::Error`
+  --> src/error.rs:12:8
+   |
+ 3 | #[derive(Debug, PartialEq)]
+   |                 --------- in this derive macro expansion
+...
+12 |     Io(std::io::Error),
+   |        ^^^^^^^^^^^^^^
+   |
+note: `std::io::Error` does not implement `PartialEq`
+ +

The note at the bottom is the interesting part: io::Error deliberately does not implement +PartialEq. That is a considered decision by the std authors, not an oversight — two io failures can +carry the same message and still come from entirely different OS state, so "are these two errors equal?" has no +honest answer. Your enum now contains one, and equality for the whole enum is therefore no longer derivable.

+ +

This is also a useful moment to see what derive actually is. It is not a language feature attached +to the type; it is a code generator that writes an ordinary impl for you, comparing every field with +==. When one field cannot be compared, the generated line does not compile, and you get the error +above pointing at the derive itself.

+ +

Dropping PartialEq is not an option, because the 0006 tests compare TaskError values +with assert_eq! and you may not edit them. So write the impl by hand instead. This part is mechanical +rather than conceptual — type it, understand the three notes underneath, and move on:

+ +
impl PartialEq for TaskError {
+    fn eq(&self, other: &Self) -> bool {
+        use TaskError::*;
+        match (self, other) {
+            (UnknownCommand(a), UnknownCommand(b))
+            | (BadPriority(a), BadPriority(b))
+            | (BadLine(a), BadLine(b)) => a == b,
+            (BadId(a), BadId(b)) => a == b,
+            (NotFound(a), NotFound(b)) => a == b,
+            (Io(a), Io(b)) => a.kind() == b.kind(),  // the kind, not the error
+            _ => std::mem::discriminant(self) == std::mem::discriminant(other),
+        }
+    }
+}
+ +

Three pieces of that impl are new, and each is useful well beyond this one function.

+ +

First, match (self, other) matches on a tuple of two values at once. You build a +temporary pair and pattern-match both halves together, which is how you ask "are these the same variant, and if so, +are their payloads equal?" in a single expression.

+ +

Second, (A(a), A(b)) | (B(a), B(b)) => is an or-pattern: several patterns +sharing one arm. Rust allows it here because every alternative binds the same names, a and +b, at the same types, so the arm's body is valid whichever alternative matched. That is what lets three +String-carrying variants share a single line instead of taking three.

+ +

Third, mem::discriminant +returns an opaque value identifying which variant a value is, ignoring any payload. Comparing two of them +answers "same variant?" without your having to name the variants at all, which handles the four payload-free cases +in one line.

+ +

That last convenience has a real cost, and it is the kind of thing to notice now rather than discover later. The +_ arm means the compiler will never again force you to update this impl when you add a variant — and a +new variant carrying data would then be compared by variant alone, treating two different payloads as equal. It is +an acceptable trade for a small error type, but it is a trade, not a free win.

+ +

Check yourself before the drill

+ +

Six questions before you touch the keyboard. Try to answer each one out loud, in full sentences, before you +reveal or click — an answer you can say is an answer you have understood, and one you can only recognise on a page +usually is not. Getting one wrong here costs you nothing; getting the same thing wrong twenty minutes into the +drill costs you the drill.

+ +
+

Traits

+

You have impl From<ParseIntError> for TaskError. Which second impl is rejected by the compiler?

+
+ + + + +
+ +
+ +
+

Traits

+

What is type Err in impl FromStr, and why is it not written impl FromStr<Err>?

+ + + +
+ +
+

Error handling

+

Your load calls fs::read_to_string(path)? and the file does not exist yet. What does the user see on their first ever run?

+
+ + + + +
+ +
+ +
+

Error handling

+

Both done abc (a CLI argument) and a corrupt saved line produce a ParseIntError. You want two different variants. How, given only one From impl is allowed?

+ + + +
+ +
+

Ownership

+

fn load(path: &Path) -> Result<Store, TaskError> — no &self, and it returns a Store by value. Why is that not a copy, and where does the returned value live?

+ + + +
+ +
+

Enums

+

After loading two tasks from a file, why must Store recompute its next id instead of starting at 1?

+ + + +
+ +

+
+ +

The drill — 30 minutes, your own crate

+ +

Type it, do not paste it. The weather log above is a different program. Keep the +files & FromStr reference open — looking syntax up is free.

+ +
cd ~/learn-rust/tasks
+cp ../lessons/0007-persist-spec.rs tests/persist.rs
+cargo test            # 8 new tests fail to compile — that is the starting line
+ +

Do not edit anything in tests/. All 24 existing tests must still pass. Target at the end: +32 passing.

+ +

Step 0 — pay off 0006 (2 minutes)

+ +

Delete both match id.parse() blocks in command.rs. Each becomes one line, and the +From impl you wrote last lesson finally does its job:

+ +
let id: u32 = args.get(1).ok_or(TaskError::MissingId)?.parse()?;
+ +

Check: grep -c "match id.parse" src/command.rs prints 0, and +cargo test --test errors still passes 7.

+ +

Step 1 — two new variants, and a hand-written PartialEq

+ +

Add to TaskError:

+ +
BadLine(String),    // a saved line that cannot be read back — carries the line
+Io(io::Error),      // the file could not be read or written — wraps std's error
+ +

Then: remove PartialEq from the derive and write the impl from Part 4; add both +Display arms; add Io(e) => Some(e) to source(); add +From<io::Error>. Exact messages, checked by the tests:

+ + + + + +
Variantto_string() must be
BadLine("rubbish")cannot read saved line: rubbish
Io(..)cannot read or write the task file
+ +

Check: cargo build passes, and cargo test --test errors is still 7/7 +— the old variants must behave exactly as before.

+ +

Step 2 — task.rs: one line out, one line in

+ +

Three additions:

+
    +
  • Status::parse(&str) -> Option<Status> — the mirror of label(), same shape as +Priority::parse, which you already have.
  • +
  • Task::to_line(&self) -> String — id|status|priority|title.
  • +
  • impl FromStr for Task with type Err = TaskError, using +splitn(4, '|'). Every failure is BadLine(line.to_string()) — including the id, which is +where map_err earns its keep.
  • +
+ +

Check: cargo test --test persist a_task_becomes and +cargo test --test persist a_title_may both pass.

+ +
+Stuck on repeating BadLine(line.to_string()) five times? +

Bind it once as a closure and hand it to ok_or_else: let bad = || TaskError::BadLine(line.to_string()); +then parts.next().ok_or_else(bad)?. Note ok_or_else, not ok_or — the first +takes a closure and only builds the error when there is one, the second builds it every time. With a +String allocation inside, that difference is real.

+
+ +

Step 3 — store.rs: save and load

+ +
pub fn save(&self, path: &Path) -> Result<(), TaskError>
+pub fn load(path: &Path) -> Result<Store, TaskError>   // associated fn
+ +

save builds one String — one line per task, each ending in \n — and calls +fs::write once. load reads, skips empty lines, parses each into a Task, and +recomputes the next id. Both the missing-file guard and the id recomputation are in Part 4.

+ +

Check: cargo test --test persist passes all 8.

+ +

Step 4 — main.rs: load, act, save

+ +

Where the file lives should not be hard-coded into the logic. One line of ch12 gets you an override for free:

+ +
let path = PathBuf::from(
+    env::var("TASKS_FILE").unwrap_or_else(|_| "tasks.txt".to_string()));
+ +

Then run takes the path, loads the store itself, and saves at the end. Note the ordering that falls +out of ?: an error anywhere means save is never reached, so a failed command cannot +corrupt the file.

+ +

Check — your CLI now remembers things:

+ +
$ cd $(mktemp -d)      # empty dir: proves a first run works with no file
+$ run(){ TASKS_FILE=t.txt cargo run -q --manifest-path ~/learn-rust/tasks/Cargo.toml -- "$@"; }
+$ run add "buy milk" high
+added task 1
+$ run add "call bank"
+added task 2
+$ run done 1
+completed 1
+$ run list
+1 [done] buy milk (high)
+2 [todo] call bank (medium)
+$ cat t.txt
+1|done|high|buy milk
+2|todo|medium|call bank
+$ echo "rubbish" >> t.txt ; run list ; echo $?
+error: cannot read saved line: rubbish
+1
+ +

That last one is the payoff for BadLine(String) carrying the line: the message names the exact +text to go and fix. A String error, or a bare Io, could not.

+ +

Final check: cargo test → 17 + 7 + 8 = 32 passed. And add +tasks.txt to .gitignore — it is user data, not source.

+ +

Then stop

+ +

Not today: serde and JSON (the real answer for storage, but it teaches a crate rather than a +concept), file locking, and BufReader for files too big to hold in memory. Your task file is a few +kilobytes; read_to_string is the correct tool, and reaching for a buffered reader here would be +copying a pattern you do not need.

+ +

What this closed

+ +

Chapter 12 moves to produced on the coverage map: +env::args, env::var, fs, stderr, and exit codes are now all in your own +code. Two gaps left before the job-ready floor, and they are next:

+ +
    +
  1. ch 8 + 13 — HashMap, map/filter/collect, +plus your first hand-written generic function (lesson 0008). Your load loop is a +collect::<Result<Vec<_>, _>>() waiting to happen — I left it as a for loop +deliberately so 0008 has something of yours to rewrite.
  2. +
  3. ch 11 — writing your own tests: you have now consumed 32 of mine and written zero +(lesson 0009).
  4. +
+ +

Take it outside

+ +

The forum ask from 0006 still stands and is now stronger: error.rs holds both +user mistakes (UnknownCommand, BadPriority) and system failures +(Io, BadLine) in one enum. Many Rust developers would split those into two types. Post it +on users.rust-lang.org (Code Review category) and ask which they would +do and why. That is a genuine design question with real disagreement behind it — the answer you get back is wisdom +you cannot derive from the book.

+ +

The five sentences worth keeping

+ +
    +
  1. io::Result<T> is just Result<T, io::Error>; e.kind() is how you +tell one io failure from another.
  2. +
  3. One impl From<T> for YourError per T — a different T is a new impl, +a repeat T is E0119.
  4. +
  5. ? for the canonical conversion, map_err for every other meaning of the same error.
  6. +
  7. FromStr is what .parse() calls; type Err is an associated type — a slot +the implementor fills, not a parameter the caller passes.
  8. +
  9. A missing file on first run is expected, not exceptional: match ErrorKind::NotFound, and let every +other kind fail loudly.
  10. +
+ + + + + + diff --git a/lessons/0007-persist-spec.rs b/lessons/0007-persist-spec.rs new file mode 100644 index 0000000..11ddc8c --- /dev/null +++ b/lessons/0007-persist-spec.rs @@ -0,0 +1,106 @@ +// The 0007 specification, as executable tests. Do not edit this file — make it pass. +// Copy to: tasks/tests/persist.rs +// Run with: cargo test + +use std::error::Error; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU32, Ordering}; +use tasks::error::TaskError; +use tasks::store::Store; +use tasks::task::{Priority, Status, Task}; + +// a fresh path per test, inside the OS temp dir — no file is ever left in your crate +fn temp_path() -> PathBuf { + static N: AtomicU32 = AtomicU32::new(0); + let n = N.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!("tasks-test-{}-{}.txt", std::process::id(), n)) +} + +// ---------- a Task survives the trip to text and back ---------- + +#[test] +fn a_task_becomes_a_line_and_a_line_becomes_a_task() { + let task = Task { + id: 3, + title: String::from("buy milk"), + priority: Priority::High, + status: Status::Done, + }; + assert_eq!(task.to_line(), "3|done|high|buy milk"); + assert_eq!("3|done|high|buy milk".parse::().unwrap(), task); +} + +#[test] +fn a_title_may_contain_the_separator() { + let line = "1|todo|low|read a|b testing"; + let task: Task = line.parse().unwrap(); + assert_eq!(task.title, "read a|b testing"); + assert_eq!(task.to_line(), line); +} + +#[test] +fn a_corrupt_line_names_itself() { + for bad in ["", "nonsense", "1|todo|low", "x|todo|low|t", "1|sleeping|low|t", "1|todo|urgent|t"] { + assert_eq!( + bad.parse::().unwrap_err(), + TaskError::BadLine(bad.to_string()), + "line {:?} should be reported as a bad line", + bad + ); + } +} + +// ---------- the store round trip ---------- + +#[test] +fn every_task_survives_save_then_load() { + let path = temp_path(); + let mut store = Store::new(); + store.add("buy milk", Priority::High); + let second = store.add("call bank", Priority::Medium); + store.complete(second).unwrap(); + store.save(&path).unwrap(); + + let loaded = Store::load(&path).unwrap(); + assert_eq!(loaded.tasks(), store.tasks()); +} + +#[test] +fn ids_do_not_restart_after_a_reload() { + let path = temp_path(); + let mut store = Store::new(); + store.add("first", Priority::Low); + store.add("second", Priority::Low); + store.save(&path).unwrap(); + + let mut loaded = Store::load(&path).unwrap(); + assert_eq!(loaded.add("third", Priority::Low), 3); +} + +#[test] +fn a_missing_file_is_an_empty_store_not_an_error() { + let path = temp_path(); // never created + let store = Store::load(&path).expect("first run must not fail"); + assert!(store.tasks().is_empty()); +} + +#[test] +fn a_corrupt_file_fails_loudly() { + let path = temp_path(); + std::fs::write(&path, "1|todo|low|fine\nrubbish\n").unwrap(); + assert_eq!( + Store::load(&path).unwrap_err(), + TaskError::BadLine(String::from("rubbish")) + ); +} + +// ---------- io::Error becomes TaskError, and keeps its cause ---------- + +#[test] +fn a_write_that_cannot_happen_is_an_io_error() { + let path = temp_path().join("no-such-dir").join("t.txt"); + let err = Store::new().save(&path).unwrap_err(); + assert!(matches!(err, TaskError::Io(_)), "got {:?}", err); + assert_eq!(err.to_string(), "cannot read or write the task file"); + assert!(err.source().is_some(), "the io::Error must stay reachable"); +} diff --git a/lessons/0008-collections-spec.rs b/lessons/0008-collections-spec.rs new file mode 100644 index 0000000..958a61a --- /dev/null +++ b/lessons/0008-collections-spec.rs @@ -0,0 +1,162 @@ +// The 0008 specification, as executable tests. Do not edit this file — make it pass. +// Copy to: tasks/tests/collections.rs +// Run with: cargo test + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU32, Ordering}; +use tasks::stats::tally; +use tasks::store::Store; +use tasks::task::Priority; + +// a fresh path per test, inside the OS temp dir — no file is ever left in your crate +fn temp_path() -> PathBuf { + static N: AtomicU32 = AtomicU32::new(0); + let n = N.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!("tasks-coll-{}-{}.txt", std::process::id(), n)) +} + +// ---------- tally: one generic function, any element type, any key type ---------- + +#[test] +fn tally_counts_how_often_each_key_appears() { + let words = ["red", "blue", "red", "green", "red"]; + let counts = tally(&words, |word| *word); + assert_eq!(counts[&"red"], 3); + assert_eq!(counts[&"blue"], 1); + assert_eq!(counts.len(), 3); +} + +#[test] +fn tally_accepts_a_key_type_that_is_not_the_element_type() { + let words = ["a", "bb", "cc", "ddd"]; + let by_length = tally(&words, |word| word.len()); + assert_eq!(by_length, HashMap::from([(1, 1), (2, 2), (3, 1)])); +} + +#[test] +fn tally_of_nothing_is_an_empty_map() { + let nothing: [u32; 0] = []; + assert!(tally(¬hing, |n| *n).is_empty()); +} + +// ---------- counting the store, built on that same function ---------- + +#[test] +fn count_by_priority_counts_every_priority_present() { + let mut store = Store::new(); + store.add("buy milk", Priority::High); + store.add("call bank", Priority::High); + store.add("water plants", Priority::Low); + + assert_eq!( + store.count_by_priority(), + HashMap::from([(Priority::High, 2), (Priority::Low, 1)]) + ); +} + +#[test] +fn count_by_priority_has_no_entry_for_an_absent_priority() { + let mut store = Store::new(); + store.add("buy milk", Priority::High); + + let counts = store.count_by_priority(); + assert_eq!(counts.len(), 1, "absent priorities must not appear as zero"); + assert_eq!(counts.get(&Priority::Low), None); +} + +#[test] +fn count_by_priority_of_an_empty_store_is_empty() { + assert!(Store::new().count_by_priority().is_empty()); +} + +// ---------- selecting titles: filter, map, collect ---------- + +#[test] +fn titles_with_returns_matching_titles_in_order() { + let mut store = Store::new(); + store.add("buy milk", Priority::High); + store.add("water plants", Priority::Low); + store.add("call bank", Priority::High); + + assert_eq!(store.titles_with(Priority::High), ["buy milk", "call bank"]); +} + +#[test] +fn titles_with_returns_nothing_when_no_task_matches() { + let mut store = Store::new(); + store.add("buy milk", Priority::High); + + assert!(store.titles_with(Priority::Medium).is_empty()); +} + +// ---------- clearing out finished work: retain ---------- + +#[test] +fn remove_completed_drops_done_tasks_and_reports_how_many() { + let mut store = Store::new(); + let first = store.add("buy milk", Priority::High); + store.add("water plants", Priority::Low); + let third = store.add("call bank", Priority::Medium); + store.complete(first).unwrap(); + store.complete(third).unwrap(); + + assert_eq!(store.remove_completed(), 2); + assert_eq!(store.titles_with(Priority::Low), ["water plants"]); + assert_eq!(store.tasks().len(), 1); +} + +#[test] +fn remove_completed_removes_nothing_when_nothing_is_done() { + let mut store = Store::new(); + store.add("buy milk", Priority::High); + store.add("call bank", Priority::Low); + + assert_eq!(store.remove_completed(), 0); + assert_eq!(store.tasks().len(), 2); +} + +#[test] +fn remove_completed_keeps_the_remaining_ids_unchanged() { + let mut store = Store::new(); + let first = store.add("buy milk", Priority::High); + store.add("call bank", Priority::Low); + store.complete(first).unwrap(); + store.remove_completed(); + + assert_eq!(store.tasks()[0].id, 2, "surviving tasks keep their own id"); + assert_eq!(store.add("new one", Priority::Low), 3, "and the counter is untouched"); +} + +// ---------- load: one collect, and the line rules that come with it ---------- + +#[test] +fn a_file_without_a_trailing_newline_still_loads() { + let path = temp_path(); + std::fs::write(&path, "1|todo|low|fine").unwrap(); + + let store = Store::load(&path).unwrap(); + assert_eq!(store.tasks().len(), 1); +} + +#[test] +fn a_blank_line_inside_the_file_is_a_bad_line() { + let path = temp_path(); + std::fs::write(&path, "1|todo|low|fine\n\n2|todo|low|also fine\n").unwrap(); + + let err = Store::load(&path).unwrap_err(); + assert_eq!(err.to_string(), "cannot read saved line: "); +} + +#[test] +fn one_bad_line_loses_the_whole_load_not_part_of_it() { + let path = temp_path(); + std::fs::write(&path, "1|todo|low|fine\nrubbish\n3|todo|low|also fine\n").unwrap(); + + assert!(Store::load(&path).is_err()); + // the file itself is untouched by a failed load — nothing was half-written + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + "1|todo|low|fine\nrubbish\n3|todo|low|also fine\n" + ); +} diff --git a/lessons/0008-iterators-and-hashmap.html b/lessons/0008-iterators-and-hashmap.html new file mode 100644 index 0000000..5f87da0 --- /dev/null +++ b/lessons/0008-iterators-and-hashmap.html @@ -0,0 +1,784 @@ + + + + +0008 — Iterators, HashMap, and your first generic function + + + + +

Iterators, HashMap, and your first generic function

+

Lesson 0008 · after 0007 · reading, then a 35-minute drill against a shipped test file · ~50 minutes

+ +
+Every code block, every compiler message, and every terminal session on this page was produced by running it +today. Nothing is written from memory. The demo domain is a library shelf, defined in full in the next section — +your project is a task CLI, so nothing here pastes in. Translating is the work. +
+ +

The demo domain, in full

+ +

Every example on this page runs against the same four books, so it is worth reading the data model once +before the examples start. Then any snippet below can be read without guessing what a field is called or what +type it holds. This is the whole thing — two type definitions, two helpers, and one Vec:

+ +
use Shelf::*;   // so the examples can say Fiction instead of Shelf::Fiction
+
+#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
+enum Shelf { Fiction, History, Poetry }        // fieldless, so Copy is free
+
+#[derive(Debug)]
+struct Book {
+    title: String,        // owned text
+    shelf: Shelf,         // which shelf it belongs on
+    borrowed: bool,       // is it out on loan right now
+}
+
+fn book(title: &str, shelf: Shelf, borrowed: bool) -> Book {
+    Book { title: title.to_string(), shelf, borrowed }
+}
+
+// "fiction" -> Some(Fiction), anything unknown -> None
+fn shelf_of(word: &str) -> Option<Shelf> {
+    match word {
+        "fiction" => Some(Fiction),
+        "history" => Some(History),
+        "poetry"  => Some(Poetry),
+        _ => None,
+    }
+}
+
+// The data. Four books, three shelves, one of them out on loan.
+let mut shelf: Vec<Book> = vec![
+    book("Dubliners", Fiction, true),
+    book("SPQR",      History, false),
+    book("Ariel",     Poetry,  false),
+    book("Beloved",   Fiction, false),
+];
+
+// A second value, used only where an element must be a plain string:
+let titles: Vec<&str> = vec!["Dubliners", "SPQR", "Ariel"];
+ +

Two naming conventions to hold on to, because they are the only way to know an element's type at a glance. +shelf (lowercase) is the Vec<Book>, so shelf.iter() hands you +&Book and the closure parameter is written |b|. Shelf (capitalised) is +the enum, so b.shelf is a field holding one of its three variants. And titles is a +Vec<&str>, so titles.iter() hands you &&str and the closure +parameter is written |t|.

+ +

The mapping onto your own crate is exact, which is what makes the translation mechanical rather than +creative: Book is Task, title is title, +Shelf is Priority, and borrowed is Status. So when a +snippet below counts books per shelf, you are reading the count_by_priority you are about to +write.

+ +

Where 0007 left you, and one prediction I got wrong

+ +

Thirty-two tests are green and the persistence layer behind them is real: fs::read_to_string and +fs::write, a NotFound match guard so a first run does not look broken, an +impl FromStr for Task with its own associated type Err, and a hand-written +PartialEq because io::Error refuses to have one. The best signal is in +command.rs: both hand-rolled match id.parse() blocks are gone, replaced by +.parse()?. That was the whole point of step 0, and it landed — ? plus From +is now a reflex rather than a fact.

+ +

But I predicted something in 0007 that turned out to be false, and it is worth a paragraph because the lesson +generalises. I claimed the compiler would force you to write impl From<io::Error> for +TaskError, because ? on fs::write would be the only reasonable shape. You wrote +this instead:

+ +
fs::write(path, contents).map_err(TaskError::Io)
+ +

That is perfectly good Rust. TaskError::Io is a tuple-variant constructor, which means it is also +a function of type fn(io::Error) -> TaskError, so handing it straight to map_err is +idiomatic and allocation-free. No From impl needed, no error, and the test still passes. My claim +was simply wrong: a compiler error can only force a design when no legal alternative exists, and +here a legal alternative existed.

+ +

So which one should you write? Both are correct, and the difference is leverage rather than style. +map_err converts at one call site; From converts at every call site, +including ones you have not written yet, and it is what makes bare ? work on any function in +std::fs, std::io, or a future crate that returns an io::Error. Today's +save gets rewritten anyway, and the rewrite is shorter when ? just works — so step 0 of +the drill writes the impl and deletes the map_err. One line each way.

+ +

Part 1 — An iterator is a lazy machine with one button

+ +

You have written iterator code already, in bursts: env::args().skip(1).collect() in +main, self.tasks.iter().find(|t| t.id == id) in find, and +.map(|t| t.id).max().unwrap_or(0) in load. What you have not had yet is the model +underneath them, so each one was memorised separately. The model is unusually small — one trait, one method:

+ +
pub trait Iterator {
+    type Item;
+    fn next(&mut self) -> Option<Self::Item>;
+    // ~75 more methods, all with default bodies built on next()
+}
+

Book: 13.2 — Processing a +series of items with iterators · std: +Iterator

+ +

That is the entire interface. next hands back Some(item) until the sequence runs +out, then None forever. Notice type Item: it is an associated type, the same +mechanism you filled in as type Err when you implemented FromStr last lesson. Every +other method — map, filter, find, collect, sum +— is a default method written in terms of next. Which is why learning the vocabulary is cheap: +there is no new machinery behind any of them, only different ways of pressing the same button.

+ +

The one property that trips everybody up is laziness. The book states it flatly:

+ +
+

In Rust, iterators are lazy, meaning they have no effect until you call methods that consume the +iterator to use it up.

+
+

Book: 13.2

+ +

Building a chain of adapters does no work and touches no elements. It only describes work. Write a chain and +forget to finish it, and the closure never runs even once — the compiler warns, because the warning is the only +thing standing between you and a silently dead line of code:

+ +
titles.iter().map(|t| t.to_uppercase());
+
warning: unused `Map` that must be used
+ --> examples/e1.rs:3:5
+  |
+3 |     titles.iter().map(|t| t.to_uppercase());
+  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+  |
+  = note: iterators are lazy and do nothing unless consumed
+  = note: `#[warn(unused_must_use)]` (part of `#[warn(unused)]`) on by default
+ +

So every chain has exactly two parts, and it is worth naming them because the names tell you where a chain +must end. Adapters take an iterator and return another iterator: map, +filter, enumerate, skip, take, rev. They are +lazy, and they compose. Consumers take an iterator and return something that is not an +iterator: collect, find, position, count, sum, +max, any, for_each. They do the work, and a chain that does not end in +one has not run.

+ +

The performance question answers itself once you see the structure, and it matters for the job you are aiming +at. A chain of adapters is not a chain of temporary vectors — each adapter is a small struct wrapping the previous +one, and the whole tower compiles down to a single pass. That is why rewriting a for loop as an +iterator chain costs nothing at runtime, and why nobody in Rust treats the choice as a speed trade-off.

+ +

Part 2 — Three ways to iterate, and the ownership behind each

+ +

Before any adapter runs you have to say how you want the elements, and this is the one place where +iterators meet the borrow rules. There are three methods, they differ only in the ownership they hand out, and +picking the wrong one is the most common way an iterator chain fails to compile:

+ + + + + + +
CallItem typeUse it when
v.iter()&TYou are reading. The collection survives.
v.iter_mut()&mut TYou are editing in place. It survives.
v.into_iter()TYou want the elements out. It is consumed.
+

Book: 13.2 — “if we want +to create an iterator that takes ownership … we can call into_iter”

+ +

This is the same three-way choice you already make with &self, &mut self, +and self in a method signature, applied one element at a time — so nothing new is being introduced, +only a new place for a rule you already know. It also explains a piece of your own code you may have written +without reading: your complete uses iter_mut because it assigns to +task.status, while find uses iter because it only looks. Swap them and +neither compiles.

+ +

One trap deserves seeing before you hit it in the drill, because the error message is about a borrow and the +cause is an iterator. When you keep the result of an iter_mut chain in a variable, the mutable +borrow of the whole collection stays alive for as long as that variable does:

+ +
struct Library { books: Vec<String> }   // titles only, to keep the error bare
+
+impl Library {
+    fn rename(&mut self, from: &str, to: &str) {
+        let book = self.books.iter_mut().find(|b| *b == from).unwrap();
+        println!("{} books", self.books.len());   // asks for a second borrow
+        *book = to.to_string();
+    }
+}
+
error[E0502]: cannot borrow `self.books` as immutable because it is also borrowed as mutable
+ --> examples/e4.rs:5:44
+  |
+4 |         let book = self.books.iter_mut().find(|b| *b == from).unwrap();
+  |                    ---------- mutable borrow occurs here
+5 |         println!("renaming 1 of {} books", self.books.len());
+  |                                            ^^^^^^^^^^ immutable borrow occurs here
+6 |         *book = to.to_string();
+  |         ----- mutable borrow later used here
+ +

Read the three annotations as a timeline and the rule falls out: the borrow begins at +iter_mut(), and it ends after the last use of book, not at the end of the +statement that created it. Anything else touching self.books in between is a second borrow, and +that is exactly the rule from chapter 4. The fix is to reorder — read the length first, or finish with +book before asking. Nothing about iterators is special here; they just make the overlap easy to +write by accident.

+ +

Part 3 — The verbs you will use every day

+ +

Here is the whole working vocabulary, run against a shelf of books. Read the calls beside their real output +rather than trying to memorise signatures — the shapes are what you want in your fingers:

+ +
// the same four books from the top of the page
+let mut shelf: Vec<Book> = vec![
+    book("Dubliners", Fiction, true), book("SPQR", History, false),
+    book("Ariel", Poetry, false),     book("Beloved", Fiction, false),
+];
+
+let all_titles: Vec<&str> = shelf.iter().map(|b| b.title.as_str()).collect();
+shelf.iter_mut().for_each(|b| b.borrowed = false);
+
+let fiction: Vec<&str> = shelf.iter()
+    .filter(|b| b.shelf == Shelf::Fiction)
+    .map(|b| b.title.as_str())
+    .collect();
+
+shelf.iter().find(|b| b.title == "Ariel").map(|b| b.shelf);
+shelf.iter().position(|b| b.title == "Ariel");
+shelf.iter().any(|b| b.borrowed);               // bool
+shelf.iter().filter(|b| !b.borrowed).count();   // usize
+shelf.retain(|b| b.shelf != Shelf::Poetry);     // delete in place
+
all_titles  -> ["Dubliners", "SPQR", "Ariel", "Beloved"]
+iter_mut    -> every book returned, borrowed set to false on each
+fiction     -> ["Dubliners", "Beloved"]
+find        -> Some(Poetry)        // the ITEM,  mapped: Option<Shelf>
+position    -> Some(2)             // the INDEX: Option<usize>, Ariel is 3rd
+any / count -> false / 4           // nothing is borrowed now, so all 4 are in
+retain      -> removed 1, 3 left   // Ariel was the only poetry book
+ +

Two of those are worth a second look, because they are the ones your own store.rs currently +writes out longhand as loops.

+ +

find versus position is a question about what you need next. +find gives you the element, which is what complete wants — it has to assign to +status. position gives you the index, which is what remove wants — +Vec::remove takes an index, not an element. Both return an Option, and both compose +straight into the error handling you already have: .ok_or(TaskError::NotFound(id))? turns a +None into your own error and unwraps the rest, collapsing a nine-line loop into one expression.

+ +

retain is the one that saves you from a genuine bug. Deleting several elements +from a Vec by index in a loop is a classic error: each removal shifts everything after it down one, +so the loop skips elements. retain takes a predicate meaning “keep this one” and does a single +compacting pass. It is a method on Vec rather than on Iterator, because it mutates +the collection in place — one of several useful methods that live on the collection rather than on the trait.

+

std: Vec::retain +· Iterator::position

+ +

Part 4 — collect is the interesting one

+ +

collect looks like “make a Vec”, and that undersells it enough to hide the single +most useful trick in this lesson. Its real signature says something much stronger:

+ +
fn collect<B: FromIterator<Self::Item>>(self) -> B
+

std: Iterator::collect +· FromIterator

+ +

Read that as: collect will build any type that knows how to be built from this kind of item. The +target is chosen by B, and B is decided by you, at the call site — which is why +collect is the one method where you routinely have to state a type. Leave it out and the compiler +has nothing to go on:

+ +
let shouted = titles.iter().map(|t| t.to_uppercase()).collect();
+
error[E0283]: type annotations needed
+    --> examples/e2.rs:3:9
+     |
+   3 |     let shouted = titles.iter().map(|t| t.to_uppercase()).collect();
+     |         ^^^^^^^                                           ------- type must be known at this point
+     |
+     = note: multiple `impl`s satisfying `_: FromIterator<String>` found in the `alloc` crate:
+             - impl FromIterator<String> for Box<str>;
+             - impl FromIterator<String> for String;
+ +

The note is the teaching. This is not the compiler being fussy about vectors — it is telling you that several +types can be built from a stream of Strings and it will not guess which one you meant. You answer +either on the left, let shouted: Vec<String> = ..., or on the right with a turbofish, +.collect::<Vec<String>>(). Both are common; pick whichever reads better in the line.

+ +

Now the trick. Result and Option both implement FromIterator, so +an iterator of Results can collect into a single Result holding a +Vec. The same chain, with only the target type changed, gives two entirely different +answers:

+ +
let words = ["fiction", "history", "rubbish", "poetry"];
+
+let each: Vec<Option<Shelf>> = words.iter().map(|w| shelf_of(w)).collect();
+let all:  Option<Vec<Shelf>> = words.iter().map(|w| shelf_of(w)).collect();
+
+let numbers: Result<Vec<u32>, _> =
+    "1 2 x 4".split(' ').map(str::parse::<u32>).collect();
+
Vec<Option> -> [Some(Fiction), Some(History), None, Some(Poetry)]
+Option<Vec> -> None
+Result<Vec> -> Err("invalid digit found in string")
+

std: impl +FromIterator<Result<A, E>> for Result<V, E>

+ +

Vec<Option<Shelf>> keeps every outcome, hole included. Option<Vec<Shelf>> +means all-or-nothing: the first None ends the iteration and the whole result is None. +That short-circuit is not a detail — it is the reason this is the right tool for reading a file. Your +load currently loops, parses each line, and pushes into a Vec, with +? inside the loop. One collect replaces all of it:

+ +
let tasks: Vec<Task> = contents.lines()
+    .map(str::parse)
+    .collect::<Result<Vec<Task>, TaskError>>()?;
+ +

The behaviour is exactly what a save file wants. Every line parses and you get the tasks; one line is corrupt +and you get that line's error and nothing else — no half-loaded store to accidentally save back over the good +file. And notice map(str::parse): you can pass a function path where a closure is expected, +because |line| line.parse() and str::parse are the same function. Which +parse, of the many possible, is settled by the collect target — the Vec<Task> tells +the compiler to look for Task's FromStr impl, the one you wrote last lesson.

+ +

One line-splitting detail comes with this rewrite, and it is a real trap rather than trivia:

+ +
let file = "1|fiction|Dubliners\n2|poetry|Ariel\n";
+file.split('\n')   // -> ["1|fiction|Dubliners", "2|poetry|Ariel", ""]
+file.lines()       // -> ["1|fiction|Dubliners", "2|poetry|Ariel"]
+

std: str::lines

+ +

split('\n') yields an empty final piece for a file that ends in a newline, because the text +after the last separator is the empty string. That is why your current load needs an +if !items.is_empty() guard — the guard exists to paper over the wrong splitter. +lines() is built for +this job: it treats the trailing newline as a terminator rather than a separator, and it strips a +\r\n too, which is free Windows compatibility. Switch splitters and the guard disappears — after +which a blank line in the middle of a file is no longer silently skipped but reported as a bad line, +which is the honest answer for a corrupt file. A shipped test pins that behaviour.

+ +

Part 5 — HashMap, and the two traits a key must have

+ +

HashMap<K, V> is the last of the three common collections, and it is the one you have not +used at all. It is a lookup by key rather than by position, it lives on the heap like Vec, and it +is not in the prelude, so it needs an import:

+ +
use std::collections::HashMap;
+
+let mut counts: HashMap<Shelf, usize> = HashMap::new();
+counts.insert(Shelf::Fiction, 2);
+counts.get(&Shelf::Fiction);              // Option<&usize> — may be absent
+counts.get(&Shelf::Poetry).copied().unwrap_or(0);   // absent counts as 0
+for (shelf, n) in &counts { }             // ARBITRARY order — never trust it
+

Book: 8.3 — Storing keys +with associated values in hash maps

+ +

Two things there are easy to skim past and expensive to learn later. get returns an +Option<&V>, so “missing key” is a value you handle rather than a crash — the same shape as +Vec::get. And iteration order is arbitrary and not stable between runs. If a user is going to read +your output, you must impose an order yourself; the stats command in today's drill prints high, +medium, low in a fixed sequence for exactly that reason.

+ +

The idiom that makes hash maps worth their weight is entry. Counting things is the standard +example, and the book's version is four lines:

+ +
for b in &shelf {
+    *counts.entry(b.shelf).or_insert(0) += 1;
+}
+
entry() -> {Fiction: 2, History: 1}
+

Book: 8.3 — Listing +8-25, counting occurrences of words

+ +

Take that line apart slowly, because it is dense and it is everywhere in real Rust. +entry(key) returns an Entry, an enum standing for a slot that may or may not be +filled. or_insert(0) fills it with 0 if it was empty, and either way hands back a +&mut usize pointing into the map. * follows that reference so ++= 1 lands on the number itself. The whole thing is one hash lookup — the version you would write +by hand, if !map.contains_key(k) { map.insert(k, 0) } followed by a get_mut, costs +two or three and reads worse.

+ +

Now the part that is specific to Rust. A key type must implement Eq and Hash, and +you will meet that rule at a call site from today's drill — so here is the line the next two errors point at, +before they point at it. Store::count_by_priority is one line long, and it hands the work to a +small generic function called tally. You write both in the drill; Part 6 builds +tally from this signature:

+ +
// src/stats.rs — Part 6 explains it and the drill writes the body
+pub fn tally<T, K, F>(items: &[T], key: F) -> HashMap<K, usize>
+
+// src/store.rs:68 — count_by_priority, in full
+tally(self.tasks(), |task| task.priority)
+ +

Read that as “count the items, grouped by whatever the closure pulls out of each one”. It is all you need +for the errors below; the three type parameters and the body are Part 6's job. Your Priority +implements neither Eq nor Hash, so the first attempt at using it as a key fails twice +over:

+ +
error[E0277]: the trait bound `Priority: Eq` is not satisfied
+  --> src/store.rs:68:9
+   |
+68 |         tally(self.tasks(), |task| task.priority)
+   |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Eq` is not implemented for `Priority`
+   |
+help: consider annotating `Priority` with `#[derive(Eq)]`
+
+error[E0277]: the trait bound `Priority: Hash` is not satisfied
+help: consider annotating `Priority` with `#[derive(Hash)]`
+ +

The requirement is not bureaucracy; it is the data structure stating its contract. To find a key the map +hashes it to pick a bucket, then compares for equality inside that bucket — so a key it cannot hash or cannot +compare is a key it cannot store. Hash gives it the first, Eq the second.

+ +

Eq is worth understanding rather than just deriving, since you already have +PartialEq and this looks like a duplicate. It is not: Eq is a marker with no methods +of its own, and it promises one extra property that PartialEq does not — that every value equals +itself. The famous exception is f64, where NAN != NAN, which is precisely why +f64 implements PartialEq but not Eq, and why a f64 cannot +be a HashMap key. A three-variant enum has no such problem, so the derive is honest.

+

std: Eq · +Hash

+ +

Fix those two and a third error appears, which is the most instructive of the set:

+ +
error[E0507]: cannot move out of `task.priority` which is behind a shared reference
+  --> src/store.rs:68:36
+   |
+68 |         tally(self.tasks(), |task| task.priority)
+   |                                    ^^^^^^^^^^^^^ move occurs because `task.priority` has type `Priority`,
+   |                                                  which does not implement the `Copy` trait
+   |
+note: if `Priority` implemented `Clone`, you could clone the value
+ +

The closure receives &Task — a borrow — and a map key has to be owned, since the map keeps +it. Reading task.priority out of a borrow is a move out of something you do not own, which is +E0507, one of the most common errors in real Rust. Three fixes exist and they are not equivalent: +.clone() works but is noise for three variants; #[derive(Clone, Copy)] makes +Priority behave like u32, copied implicitly wherever it is read; keying by +task.priority.label() sidesteps it by using a &str instead. Derive +Copy. A fieldless enum is a single small integer at runtime, copying it is free, and it is what std +does for its own small enums such as ErrorKind.

+ +

Part 6 — Your first generic function

+ +

Counting tasks by priority is one specific job, and today you will write it once and never again — because the +function you write is generic over what it counts. This is your first hand-written generic, so here it is whole, +and then taken apart:

+ +
use std::collections::HashMap;
+use std::hash::Hash;
+
+pub fn tally<T, K, F>(items: &[T], key: F) -> HashMap<K, usize>
+where
+    K: Eq + Hash,
+    F: Fn(&T) -> K,
+{
+    let mut counts = HashMap::new();
+    for item in items {
+        *counts.entry(key(item)).or_insert(0) += 1;
+    }
+    counts
+}
+ +

Three type parameters, and each one is there for a reason. T is the element type, and the +function never looks inside a T, which is exactly why it works on tasks and on strings alike. +K is the key type, and it carries the bound Eq + Hash — not because tally +cares, but because the HashMap it returns does. F is the closure type. Every closure in +Rust has its own anonymous type, so the only way to accept one is a type parameter bounded by +Fn(&T) -> K, which reads as “anything callable that takes a &T and returns a +K”.

+ +

The where clause is worth seeing as the point of the exercise rather than syntax to tolerate. It +is a contract in both directions: callers must supply types that satisfy it, and inside the body you may use +exactly the operations it guarantees and nothing else. That is why generics in Rust do not blow up at the call +site the way C++ templates can — the bounds are checked once, against the definition. Try to call +item.to_string() in there and it will not compile, because nothing in the clause promised +T: Display.

+ +

The payoff is that one definition serves cases that have nothing to do with each other:

+ +
tally(&shelf, |b| b.shelf)          // -> {History: 1, Fiction: 2}
+tally(&["a", "bb", "cc"], |w| w.len())   // -> {1: 1, 2: 2}
+ +

Two calls, two different T, two different K, and — this is the part that matters +for the interviews you are aiming at — no runtime cost for the generality. Rust monomorphises: it compiles one +specialised copy of tally per combination of types actually used, so each call site gets code as +tight as if you had written that version by hand.

+

Book: 10.1 — Generic data +types and 13.1 — Closures

+ +

Last note before the drill, and it is a taste question rather than a rule. tally's body keeps a +for loop, on purpose. Iterators replace loops that search, transform, or +collect — those have a named adapter and the chain reads better than the loop. A loop that folds many +items into one accumulator is the case where a loop is still the clearest thing to write; the iterator version +exists, fold, and here it would be harder to read for no gain. The drill's grep check is scoped to +store.rs for exactly this reason.

+ +

Check yourself before the drill

+ +

Six questions before you touch the keyboard. Answer each one out loud, in full sentences, before you reveal or +click. An answer you can say is an answer you have understood; one you can only recognise on the page usually is +not. Getting one wrong here costs nothing — getting it wrong twenty minutes into the drill costs you the drill.

+ +
+

Iterators

+

How many times does the closure run in v.iter().map(|x| f(x)); — with no collect?

+
+ + + + +
+ +
+ +
+

Iterators

+

complete needs the task itself; remove needs its index. Which adapter does each want, and which iterator does each start from?

+ + + +
+ +
+

Collections

+

Four lines are parsed, the third is corrupt, and you collect::<Result<Vec<Task>, TaskError>>(). What comes back?

+
+ + + + +
+ +
+ +
+

Collections

+

Why must a HashMap key implement both Eq and Hash, and why is PartialEq not enough?

+ + + +
+ +
+

Ownership

+

|task| task.priority in a closure over &Task gives error[E0507]: cannot move out of ... behind a shared reference. What is the cause, and which of the three fixes wins?

+ + + +
+ +
+

Traits

+

In fn tally<T, K, F>(items: &[T], key: F) with K: Eq + Hash, F: Fn(&T) -> K — why does F have to be a type parameter at all, and what does the where clause buy you?

+ + + +
+ +

+
+ +

The drill — 35 minutes, your own crate

+ +

Type it, do not paste it. The bookshelf above is a different program. Keep the +iterators reference open — looking syntax up is free.

+ +
cd ~/learn-rust/tasks
+cp ../lessons/0008-collections-spec.rs tests/collections.rs
+cargo test            # 14 new tests fail to compile — that is the starting line
+ +

Do not edit anything in tests/. All 32 existing tests must still pass. Target at the end: +46 passing.

+ +

Step 0 — the second From, two minutes

+ +

Write the impl that 0007 asked for and then delete the workaround, so ? handles io errors +everywhere from here on:

+ +
impl From<io::Error> for TaskError { .. }   // in error.rs, beside the other
+fs::write(path, contents)?;                 // in save — map_err goes away
+ +

Check: grep -c "impl From<io::Error>" src/error.rs prints 1, +grep -c "map_err(TaskError::Io)" src/store.rs prints 0, and +cargo test --test persist still passes 8.

+ +

Step 1 — a new module and one generic function

+ +

Create src/stats.rs, declare it in lib.rs, and write tally from +Part 6 — from the signature, not by copying the body. It is nine lines.

+ +

Check: cargo test --test collections tally → 3 passed.

+ +
+Forgotten how a module is declared? +

pub mod stats; in src/lib.rs, alphabetically beside the others. Without that line +the file is not compiled at all and you get error[E0432]: unresolved import from the test file — +the same error 0006 showed you.

+
+ +

Step 2 — Priority as a key

+ +

Add count_by_priority(&self) -> HashMap<Priority, usize> to Store, as one +line delegating to tally. Let it fail first, read all three errors, and fix them with the derives +they ask for. Seeing E0277 twice and E0507 once, in that order, is the point of the step.

+ +

Check: cargo test --test collections count_by → 3 passed.

+ +

Step 3 — two more methods on Store

+ +
pub fn titles_with(&self, priority: Priority) -> Vec<&str>
+pub fn remove_completed(&mut self) -> usize
+ +

titles_with answers “what am I meant to be doing at this priority?”. Hand it a priority and it +gives back the title of every task that carries that priority, in the order the tasks were added. Nothing +matches, and you get an empty Vec rather than an error — an empty answer is a legitimate answer +here. Note the return type: Vec<&str>, not Vec<String>. It hands back +borrows of titles the store still owns, so nothing is cloned, and task.title.as_str() is the +conversion you need.

+ +

remove_completed is the tidy-up: it deletes every task whose status is Done and +returns how many it deleted. Three details the tests hold you to. The tasks that survive keep their own ids — +you are removing rows, not renumbering them. The id counter is untouched, so the next add carries +on from where it had got to rather than reusing a freed number. And removing nothing is a normal outcome that +returns 0, not an error. One Vec method from Part 3 does the removal; the count is +the length before minus the length after.

+ +

Check: cargo test --test collections → 11 of 14 passed.

+ +

Step 4 — rewrite store.rs with what you learned

+ +

Four functions, all currently loops, all one expression each: complete with +iter_mut().find(), remove with iter().position(), save +with map(..).collect::<String>(), and load with +lines().map(str::parse).collect::<Result<Vec<Task>, TaskError>>()?. In load +the if !items.is_empty() guard goes away with the splitter, and the trailing +mut contents = String::new() dance collapses into the match from 0007 returning +a value.

+ +

Check: grep -c "for " src/store.rs prints 0, +grep -c "lines()" src/store.rs prints 1, and cargo test → 17 + 7 + 8 + +14 = 46 passed.

+ +
+Stuck on save building a String from an iterator? +

String implements FromIterator<String>, so a chain of owned lines collects +straight into one: self.tasks.iter().map(|t| format!("{}\n", t.to_line())).collect(). Annotate the +target — let contents: String = .. — or E0283 will ask you which of several possible types you +meant.

+
+ +

Step 5 — two new commands, so the CLI shows it

+ +

Add Stats and Clear to the Command enum and to +Command::parse (the words are stats and clear). The +match in main will refuse to compile until both are handled — that is +E0004, the same non-exhaustive-match error from 0006, doing its job again.

+ +

stats must print the three priorities in a fixed order, because hash map iteration order is +arbitrary. Loop over [Priority::High, Priority::Medium, Priority::Low] and ask the map for each, +with counts.get(&p).copied().unwrap_or(0) so an absent priority prints 0 rather +than vanishing.

+ +

Check — a real session, run today against the reference implementation:

+ +
$ cd $(mktemp -d)
+$ run add "buy milk" high
+added task 1
+$ run add "call bank"
+added task 2
+$ run add "water plants" low
+added task 3
+$ run done 1
+completed 1
+$ run stats
+high   1
+medium 1
+low    1
+$ run clear
+cleared 1 completed
+$ run list
+2 [todo] call bank (medium)
+3 [todo] water plants (low)
+$ cat t.txt
+2|todo|medium|call bank
+3|todo|low|water plants
+$ run stats ; echo $?
+high   0
+medium 1
+low    1
+0
+ +

(run above is +TASKS_FILE=t.txt cargo run -q --manifest-path ~/learn-rust/tasks/Cargo.toml --.) That last +high 0 is .copied().unwrap_or(0) earning its place: the completed high-priority task +is gone, so the map has no High entry at all, and the absence prints as a zero instead of a +missing line.

+ +

Then stop

+ +

Not today: fold and zip, BTreeMap (sorted keys — the right answer if +you ever want stats ordered without hard-coding), impl Iterator for your own type, and +itertools. Each is a small step from here, and none of them is on the path to the next gap.

+ +

What this closed

+ +

Chapters 8 and 13 move to produced on the coverage +map, and 10.1 opens with a real generic function of your own rather than a book example. What is left before +the job-ready floor is short:

+ +
    +
  1. ch 11 — writing your own tests (lesson 0009). You have now consumed 46 of my tests and +written zero. Test-writing is a first-round interview question, and it is the last big gap in the book's core.
  2. +
  3. ch 10.3 — lifetimes, as reading practice. You wrote one today without noticing: +titles_with returns Vec<&str> borrowed from &self, and +elision filled in the annotation for you.
  4. +
  5. Then serde → axum, where the trait work from 0005–0008 starts paying rent.
  6. +
+ +

Take it outside

+ +

Here is a question with genuine disagreement behind it, which makes it a good one to ask people rather than +docs. Your tally takes &[T]. Most experienced Rust developers would write it to +take impl IntoIterator<Item = T> instead, so it accepts a Vec, an array, a +HashSet, or any chain of adapters — not only a slice. Post tally on +users.rust-lang.org (Code Review category) and ask whether the +IntoIterator version is worth the extra signature complexity for a small crate, and where they +personally draw that line. The answers will teach you more about idiomatic API design than any chapter, because +it is a taste question and the book cannot have taste for you.

+ +

The five sentences worth keeping

+ +
    +
  1. Adapters are lazy and return iterators; consumers do the work. A chain that does not end in a consumer never +ran.
  2. +
  3. iter borrows, iter_mut borrows mutably, into_iter takes ownership — +the &self/&mut self/self choice, one element at a time.
  4. +
  5. collect builds any FromIterator type, so an iterator of Results +collects into one Result<Vec<_>, E> that short-circuits on the first error.
  6. +
  7. A HashMap key needs Eq + Hash; *map.entry(k).or_insert(0) += 1 is the +counting idiom; iteration order is arbitrary, so impose your own before printing.
  8. +
  9. A generic function's where clause is a contract checked once against the definition, and +monomorphisation means the generality is free at runtime.
  10. +
+ + + + + + diff --git a/lessons/0009-mutants.sh b/lessons/0009-mutants.sh new file mode 100755 index 0000000..8d5fa82 --- /dev/null +++ b/lessons/0009-mutants.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# 0009 — the mutation check. +# +# Copies your crate to a temp dir, plants ONE deliberate bug in the copy, and +# runs YOUR tests against it. Your own files are never touched. +# +# killed = your tests noticed the bug. Good. +# SURVIVED = the bug is invisible to your suite. Write the missing test. +# SKIP = the pattern is not in your source, so nothing was planted. +# +# Usage: bash 0009-mutants.sh ~/learn-rust/tasks + +set -u +crate=$(cd "${1:-.}" && pwd) +work=$(mktemp -d) +export CARGO_TARGET_DIR=$work/target +killed=0 survived=0 skipped=0 + +# name file sed expression +mutants=$(cat <<'EOF' +stats-order src/cli.rs s/Priority::High, Priority::Medium, Priority::Low/Priority::Low, Priority::Medium, Priority::High/ +stats-zero src/cli.rs s/unwrap_or(0)/unwrap_or(1)/ +clear-count src/cli.rs s/remove_completed()/remove_completed() + 1/ +list-format src/task.rs s/({})"/{}"/ +status-parse src/task.rs s/"in-progress" =>/"inprogress" =>/ +command-case src/command.rs s/to_lowercase()/to_string()/ +EOF +) + +echo "crate: $crate" +while IFS=$'\t' read -r name file expr; do + d=$(mktemp -d) + cp -r "$crate/src" "$crate/tests" "$crate/Cargo.toml" "$crate/Cargo.lock" "$d/" + + if [ ! -f "$d/$file" ]; then + printf ' SKIP %-13s %s does not exist yet\n' "$name" "$file" + skipped=$((skipped + 1)) + continue + fi + + before=$(md5sum <"$d/$file") + sed -i "$expr" "$d/$file" + if [ "$before" = "$(md5sum <"$d/$file")" ]; then + printf ' SKIP %-13s pattern not found in %s\n' "$name" "$file" + skipped=$((skipped + 1)) + continue + fi + + if ! (cd "$d" && cargo build --tests -q) >/dev/null 2>&1; then + printf ' SKIP %-13s mutant does not compile\n' "$name" + skipped=$((skipped + 1)) + continue + fi + + if (cd "$d" && cargo test -q) >/dev/null 2>&1; then + printf ' SURVIVED %-13s %s\n' "$name" "$file" + survived=$((survived + 1)) + else + printf ' killed %-13s %s\n' "$name" "$file" + killed=$((killed + 1)) + fi +done <<<"$mutants" + +printf '\n%d killed, %d survived, %d skipped\n' "$killed" "$survived" "$skipped" +[ "$survived" -eq 0 ] diff --git a/lessons/0009-writing-your-own-tests.html b/lessons/0009-writing-your-own-tests.html new file mode 100644 index 0000000..ebbad3e --- /dev/null +++ b/lessons/0009-writing-your-own-tests.html @@ -0,0 +1,934 @@ + + + + +0009 — Writing your own tests + + + + +

Writing your own tests

+

Lesson 0009 · after 0008 · reading, then a 45-minute drill graded by planted bugs · ~55 minutes

+ +
+Every code block, every compiler message, and every terminal session on this page was produced by running it +today. Nothing is written from memory. Where a cargo test block is quoted, the +Compiling / Finished lines and the empty Doc-tests section are cut and +nothing else. The demo domain is a thermostat, defined in full two sections down — your project is a task CLI, +so nothing here pastes in. Translating is the work. +
+ +

Where 0008 left you, and the bug that 46 tests could not see

+ +

The library half of 0008 landed cleanly. All 46 tests pass, tally is a real generic function +written from its signature, load is one collect::<Result<Vec<Task>, +TaskError>>()?, remove_completed uses Vec::retain, and +count_by_priority is a one-line delegate. Iterators and HashMap are produced, not +recognised.

+ +

Three of the drill's checks still failed, and the interesting thing is where they failed. Here is your +stats and clear, run today against your own crate:

+ +
$ run stats
+high 	 1
+low 	 1
+medium 	 1
+$ run clear
+$ run list
+2 [todo] call bank (medium)
+3 [todo] water plants (low)
+ +

The priorities print in the wrong order — high, low, medium, because +main.rs loops over [Priority::High, Low, Medium] — and clear prints +nothing at all, throwing away the usize that remove_completed went to the trouble of +returning. Both are real defects a user would notice in the first minute. Both sat behind 46 green tests.

+ +

That is not bad luck, and it is not because you were careless. It is structural: every one of those +46 tests lives in tests/, every one of them talks to the library, and run lives in +src/main.rs, where no test in tests/ can reach it. The untested thing is the +undone thing — this is the third lesson in a row where the one requirement no test could see is the one +requirement that was not met. Today you close that loop from both ends: you learn to write tests, and you move +the code that was unreachable into a place where a test can reach it.

+ +

The demo domain, in full

+ +

Every example on this page runs against one small crate, so it is worth reading the whole thing once before +the examples start. It is a thermostat that refuses illegal targets. Three things to notice as you read: the +field target is private, the helper capped has no pub, and +new panics while set_from returns a Result — the page needs both to show +you both ways of testing failure:

+ +
// /tmp/heating/src/lib.rs — created with `cargo new --lib heating`
+pub const MIN: i32 = 5;
+pub const MAX: i32 = 30;
+
+#[derive(Debug, PartialEq)]
+pub struct Thermostat {
+    target: i32, // degrees celsius, always inside MIN..=MAX
+}
+
+impl Thermostat {
+    // panics if the target is outside the legal range
+    pub fn new(target: i32) -> Thermostat {
+        if target < MIN {
+            panic!("target must be at least {MIN}, got {target}");
+        } else if target > MAX {
+            panic!("target must be at most {MAX}, got {target}");
+        }
+        Thermostat { target }
+    }
+
+    pub fn target(&self) -> i32 {
+        self.target
+    }
+
+    // never leaves the legal range, however big `by` is
+    pub fn warmer(&mut self, by: i32) {
+        self.target = capped(self.target + by);
+    }
+
+    pub fn is_heating(&self, room: i32) -> bool {
+        room < self.target
+    }
+
+    // "21" -> Ok, "hot" or "99" -> Err
+    pub fn set_from(&mut self, text: &str) -> Result<(), String> {
+        let degrees: i32 = text
+            .trim()
+            .parse()
+            .map_err(|_| format!("not a number: {text}"))?;
+        if degrees < MIN || degrees > MAX {
+            return Err(format!("out of range: {degrees}"));
+        }
+        self.target = degrees;
+        Ok(())
+    }
+}
+
+// private helper: no `pub`, so only this file can call it
+fn capped(degrees: i32) -> i32 {
+    degrees.clamp(MIN, MAX)
+}
+ +

One naming convention, because it is the only way to read the examples without guessing: +Thermostat (capitalised) is the type, t is always a value of it, and +room is always the current room temperature rather than the target. The mapping onto your crate is +loose on purpose — this is a different program, not a template. What transfers is the shape of a test, not its +subject.

+ +

Part 1 — A test is a function that fails by panicking

+ +

The whole mechanism is one attribute. Put #[test] on a function that takes no arguments and +returns nothing, and cargo test builds a second binary out of your crate, runs every such +function, and reports on each one. The book's definition is worth reading slowly, because the second half of it +is the part people never internalise:

+ +
+

Tests fail when something in the test function panics. Each test is run in a new thread, and when the main +thread sees that a test thread has died, the test is marked as failed.

+
+

Book: 11.1 — How to +write tests

+ +

So there is no assertion framework here and no special test runtime. Panicking is the failure +protocol. Every assertion macro you are about to meet is a thin wrapper that panics when its condition +does not hold, which is why .unwrap() in a test body is not a code smell the way it is in +main — an unwrap that fires is a test that fails, with the message you wanted +anyway.

+ +

Here are two tests against the thermostat, and the output they produce. Read the output as carefully as the +code, because the failure format is the thing you will actually spend your time reading:

+ +
#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn a_new_thermostat_keeps_its_target() {
+        let t = Thermostat::new(20);
+        assert_eq!(t.target(), 20);
+    }
+
+    #[test]
+    fn warmer_never_passes_the_maximum() {
+        let mut t = Thermostat::new(28);
+        t.warmer(10);
+        assert_eq!(t.target(), MAX);
+    }
+}
+
     Running unittests src/lib.rs (target/debug/deps/heating-795830f7b1de3879)
+
+running 2 tests
+test tests::a_new_thermostat_keeps_its_target ... ok
+test tests::warmer_never_passes_the_maximum ... ok
+
+test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
+ +

You have read that summary line 46 times without needing it. Now it is yours, so take the five fields +apart once: passed and failed are self-explanatory; ignored counts tests +marked #[ignore], which Part 5 covers; measured is for nightly-only benchmarks and +will always be 0 for you; and filtered out counts tests that exist but did not run +because you passed a name filter. Note also that the test name is tests::a_new_thermostat… — +the module path is part of the test's name, which is what makes filtering by module possible +later.

+ +

Now the same run with a bug planted in capped, which drops the upper bound +(degrees.clamp(MIN, MAX) becomes degrees.max(MIN)):

+ +
running 2 tests
+test tests::a_new_thermostat_keeps_its_target ... ok
+test tests::warmer_never_passes_the_maximum ... FAILED
+
+failures:
+
+---- tests::warmer_never_passes_the_maximum stdout ----
+
+thread 'tests::warmer_never_passes_the_maximum' (192610) panicked at src/lib.rs:66:9:
+assertion `left == right` failed
+  left: 38
+ right: 30
+note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
+
+
+failures:
+    tests::warmer_never_passes_the_maximum
+
+test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
+
+error: test failed, to rerun pass `--lib`
+ +

Three sections, and each answers a different question. The per-test lines say which tests ran. The +failures: block with the stdout capture says why each failure happened, and it is the +only place the panic message appears. The short failures: list at the end is just names, so that +with forty tests and six failures you can copy one name and re-run it alone. The final +error: test failed, to rerun pass --lib is cargo telling you which target to narrow +to — --lib for unit tests, --test <name> for one integration file.

+ +

Part 2 — Three macros, and what each failure tells you

+ +

You only need three, and the choice between them is entirely about what you want printed when the test +fails. That is the whole design question: a passing test prints nothing interesting, so a macro earns its keep +only by how much it tells you on the day it goes red.

+ + + + + + +
MacroFails whenPrints
assert!(cond)cond is falsethe source text of cond
assert_eq!(a, b)a != bboth values, as left and right
assert_ne!(a, b)a == bboth values, the same way
+

Book: 11.1 — Testing +equality with assert_eq! and assert_ne!

+ +

Prefer assert_eq! whenever you have an expected value to name, because a bare +assert! throws away the numbers. Compare these two failures of the same bug — the room comparison +in is_heating flipped to room > self.target. First, assert! on its +own:

+ +
#[test]
+fn a_cold_room_heats() {
+    let t = Thermostat::new(20);
+    assert!(t.is_heating(18));
+}
+
thread 'tests::a_cold_room_heats' (196474) panicked at src/lib.rs:59:9:
+assertion failed: t.is_heating(18)
+ +

That tells you the expression was false, which you could have guessed from the test's name. When the +condition is a bool and there is nothing to compare, add the message yourself — every argument +after the condition is handed to format!, so you can print whatever would have helped:

+ +
#[test]
+fn a_cold_room_heats() {
+    let t = Thermostat::new(20);
+    assert!(
+        t.is_heating(18),
+        "a room at 18 must heat towards {}",
+        t.target()
+    );
+}
+
thread 'tests::a_cold_room_heats' (196562) panicked at src/lib.rs:59:9:
+a room at 18 must heat towards 20
+ +

Your shipped tests use this in a place worth copying. In tests/persist.rs the loop over corrupt +lines ends with "line {:?} should be reported as a bad line", bad, because the assertion runs six +times and the failure would otherwise not say which line broke it. That is the rule: if an +assertion runs inside a loop, it needs a message naming the case, or a red test sends you back to +guessing.

+ +

One requirement comes attached to assert_eq!, and you have already satisfied it without +knowing. To print the two values, the macro needs Debug; to compare them, it needs +PartialEq:

+ +
+

When the assertions fail, these macros print their arguments using debug formatting, which means the values +being compared must implement the PartialEq and Debug traits.

+
+

Book: 11.1

+ +

This is why #[derive(Debug, PartialEq)] sits on Task, Status, +Priority, Command, and Store — not decoration, a testing requirement. +And it is why TaskError has that hand-written impl PartialEq from 0007: +io::Error does not implement it, so the derive was impossible and you compared +kind() instead. Every one of those impls exists so that assert_eq! can print +something useful. Today you are finally the one calling it.

+ +

Part 3 — Two ways to test a failure

+ +

Code that works is the easy half. The interesting tests are the ones that pin down what happens when the +input is wrong, and Rust gives you two tools because your code has two ways to fail: it panics, or it returns +an Err.

+ +

For a panic, annotate the test with #[should_panic], and the test passes if and only if the +body panics. Always give it expected, a substring of the panic message, or the test will happily +pass on a panic that came from somewhere else entirely:

+ +
#[test]
+#[should_panic(expected = "at most 30")]
+fn refuses_a_high_target() {
+    Thermostat::new(99);
+}
+
running 2 tests
+test tests::a_text_target_is_read_or_reported ... ok
+test tests::refuses_a_high_target - should panic ... ok
+ +

Notice the - should panic marker in the result line: the runner tells you the test's polarity +is inverted, which matters when you are reading someone else's suite. And here is the same test against a +new whose upper-bound branch was given the lower bound's message by mistake — the code still +panics on 99, but says the wrong thing:

+ +
thread 'tests::refuses_a_high_target' (197030) panicked at src/lib.rs:15:13:
+target must be at least 5, got 99
+note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
+note: panic did not contain expected string
+      panic message: "target must be at least 5, got 99"
+ expected substring: "at most 30"
+ +

Without expected that run would have been green, and the test would have been worthless: it +would have proved only that something went wrong. The expected substring is what turns +"it panicked" into "it panicked for the reason I meant".

+ +

For an Err, the tool is different and better suited to your crate: a test may return +Result, which lets you use ? in its body. The test passes on Ok and +fails on Err:

+ +
#[test]
+fn a_text_target_is_read_or_reported() -> Result<(), String> {
+    let mut t = Thermostat::new(20);
+    t.set_from(" 21 ")?;              // an Err here fails the test
+    assert_eq!(t.target(), 21);
+    assert!(t.set_from("hot").is_err());
+    Ok(())
+}
+ +

Two rules come with that shape, and the second one is the trap. First, the return type has to be a +Result whose error implements Debug — Result<(), TaskError> +qualifies, since TaskError derives Debug. Second, from the book:

+ +
+

You can't use the #[should_panic] annotation on tests that use Result<T, E>. +To assert that an operation returns an Err variant, don't use the question mark operator +on the Result<T, E> value. Instead, use assert!(value.is_err()).

+
+

Book: 11.1 — Using +Result<T, E> in tests

+ +

Read those two together and the division of labour is clear. Use ? for the steps that are +merely setup — the save, the load, the completion that has to work before the interesting assertion +can run — and use an explicit assert_eq!(…unwrap_err(), …) or +assert!(…is_err()) for the failure you are actually testing. Your shipped +tests/errors.rs does the second half already; the drill has you write the first.

+ +

Which means, honestly, that #[should_panic] has almost no place in your crate — and that is a +result, not a gap. Since 0006 your code returns TaskError instead of panicking, so there is no +panic left to pin. Learn the attribute because interview questions and other people's crates use it; reach for +the Result form in your own.

+ +

Part 4 — Where tests live, and what each kind can see

+ +

Rust has exactly two homes for tests, and the choice is not stylistic — it decides what your test is allowed +to touch:

+ +
+

Unit tests are small and more focused, testing one module in isolation at a time, and can test +private interfaces. Integration tests are entirely external to your library and use your code in the +same way any other external code would, using only the public interface and potentially exercising multiple +modules per test.

+
+

Book: 11.3 — Test +organization

+ +

A unit test lives in the same file as the code it tests, at the bottom, in a module with two attributes' +worth of ceremony:

+ +
#[cfg(test)]          // compile this only for `cargo test`
+mod tests {
+    use super::*;     // pull the whole parent module into scope
+
+    #[test]
+    fn the_private_cap_holds_both_ends() {
+        assert_eq!(capped(99), MAX);      // private fn, reachable
+        assert_eq!(capped(-40), MIN);
+        assert_eq!(capped(21), 21);
+    }
+}
+ +

Both lines earn their place. #[cfg(test)] means the module is not compiled into +cargo build output at all, so tests cost nothing in the shipped binary. use super::* +is what gives the test its reach: the tests module is an ordinary child module, and a child may +see its parent's private items — which is the entire reason unit tests can test private functions. No +annotation grants that privilege; the module tree does, exactly as chapter 7 described it.

+ +

An integration test lives in tests/, and gets a very different deal. Each file there is +compiled as its own separate crate that uses yours from outside, so it sees precisely what a +stranger on crates.io would see. Ask for anything private and the compiler says so — this is a real +cargo test run of a tests/outside.rs that tries both:

+ +
error[E0603]: function `capped` is private
+  --> tests/outside.rs:7:25
+   |
+ 7 |     assert_eq!(heating::capped(99), 30);  // so is the helper
+   |                         ^^^^^^ private function
+   |
+note: the function `capped` is defined here
+  --> src/lib.rs:48:1
+   |
+48 | fn capped(degrees: i32) -> i32 {
+   | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+error[E0616]: field `target` of struct `Thermostat` is private
+ --> tests/outside.rs:6:18
+  |
+6 |     assert_eq!(t.target, 20);       // the field is private
+  |                  ^^^^^^ private field
+  |
+help: a method `target` also exists, call it with parentheses
+  |
+6 |     assert_eq!(t.target(), 20);       // the field is private
+  |                        ++
+ +

You have met E0616 before from the other side. In 0003 you made Store.tasks +private and added the tasks() accessor, and every one of my 46 tests goes through that accessor +because it has no choice. So the trade is now concrete: put a test in src/ and it can reach +inside; put it in tests/ and it is forced to use the API you actually ship, which means it also +notices when you break that API. Write both kinds, for different reasons — the file-local ones to pin down +awkward internals, the external ones to pin down the contract.

+ +

Sharing a helper between two integration files has one gotcha, and it is worth spending a paragraph on +because you will hit it in the drill. Since every file in tests/ is its own crate, a +tests/common.rs full of helpers is compiled as a test crate of its own and shows up in +the output as a pointless running 0 tests section. The fix is the older module-file spelling:

+ +
tests/
+├── common/
+│   └── mod.rs     ← helpers live here; not treated as a test crate
+├── cli.rs         ← `mod common;` then `common::three_tasks()`
+└── mine.rs        ← same, its own crate, its own copy
+

Book: 11.3 — +Submodules in integration tests: “Files in subdirectories of the tests directory don't get compiled +as separate crates or have sections in the test output.”

+ +

And now the rule this whole lesson turns on. It is one paragraph in the book, and it explains your +stats bug exactly:

+ +
+

If our project is a binary crate that only contains a src/main.rs file and doesn't have a +src/lib.rs file, we can't create integration tests in the tests directory and bring functions +defined in the src/main.rs file into scope with a use statement. … This is one of the +reasons Rust projects that provide a binary have a straightforward src/main.rs file that calls logic +that lives in the src/lib.rs file.

+
+

Book: 11.3 — +Integration tests for binary crates

+ +

Your crate has both files, which is why the tests can see Store at all. But your +run function — the one that decides the order of the stats lines and whether +clear says anything — is defined in main.rs, on the wrong side of that wall. No test +can import it. The book's advice is the fix: main.rs should be small enough that it needs no +test, and everything else belongs in the library. Step 3 of the drill moves run across.

+ +

Moving it is not enough on its own, though, and the second half is the more useful trick. A run +that calls println! writes to the process's stdout, which a test cannot read. So instead of +printing, take the destination as a parameter:

+ +
pub fn run(
+    args: &[String],
+    store: &mut Store,
+    out: &mut impl Write,        // std::io::Write
+) -> Result<(), TaskError>
+ +

main hands it io::stdout().lock() and behaves exactly as before. A test hands it +a Vec<u8>, which implements Write, and then asserts on the bytes. That is the +whole technique: a function that returns or writes its output can be tested; a function that prints its +output cannot. It costs one parameter, and it is the single most reusable idea in this lesson — +the same move makes an HTTP handler testable without a server, and it is the answer to the interview question +“how would you test that?”

+ +

Part 5 — Running them: the flags worth knowing

+ +

Everything so far assumed a bare cargo test. Four flags cover the rest of daily use, and the +first thing to know is where the separator goes: arguments before -- are read by cargo, +arguments after it are read by the test binary cargo just built.

+ + + + + + + + + +
CommandWhat it does
cargo test warmerruns tests whose full name contains warmer
cargo test --libonly the unit tests inside src/
cargo test --test clionly tests/cli.rs
cargo test -- --show-outputalso print stdout from tests that passed
cargo test -- --ignoredonly the tests marked #[ignore]
cargo test -- --test-threads=1no parallelism
+

Book: 11.2 — +Controlling how tests are run

+ +

Filtering matches on the whole test name, module path included, which is the payoff of that +tests:: prefix from Part 1. A real run, with three of four tests filtered out:

+ +
$ cargo test warmer
+     Running unittests src/lib.rs (target/debug/deps/heating-795830f7b1de3879)
+
+running 1 test
+test tests::warmer_never_passes_the_maximum ... ok
+
+test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 3 filtered out; finished in 0.00s
+ +

Output capture is the behaviour that surprises people: a println! in a passing test is +swallowed, and only reappears if the test fails. When you want to see it anyway, ask:

+ +
$ cargo test -- --show-output
+running 1 test
+test tests::warmer_never_passes_the_maximum ... ok
+
+successes:
+
+---- tests::warmer_never_passes_the_maximum stdout ----
+target ended at 30
+
+
+successes:
+    tests::warmer_never_passes_the_maximum
+
+test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
+ +

#[ignore] is for the test you want to keep but not run every time — the slow one, the one that +needs a network. It takes a reason string, which the runner prints, and the ignored tests are still one command +away:

+ +
#[test]
+#[ignore = "slow: walks the whole range"]
+fn every_legal_target_round_trips() {
+    for degrees in MIN..=MAX {
+        let mut t = Thermostat::new(MIN);
+        t.set_from(&degrees.to_string()).expect("legal target");
+        assert_eq!(t.target(), degrees);
+    }
+}
+
$ cargo test
+running 4 tests
+test tests::every_legal_target_round_trips ... ignored, slow: walks the whole range
+test tests::refuses_a_high_target - should panic ... ok
+test tests::the_private_cap_holds_both_ends ... ok
+test tests::warmer_never_passes_the_maximum ... ok
+
+test result: ok. 3 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.00s
+
+$ cargo test -- --ignored
+running 1 test
+test tests::every_legal_target_round_trips ... ok
+
+test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 3 filtered out; finished in 0.00s
+ +

The last flag comes with the one hard constraint of the whole chapter. Tests run in parallel, on +threads, by default:

+ +
+

Because the tests are running at the same time, you must make sure your tests don't depend on each other or +on any shared state, including a shared environment, such as the current working directory or environment +variables.

+
+

Book: 11.2 — Running +tests in parallel or consecutively

+ +

Your suite obeys this already, and now you can see why it was written that way. Every file-touching test +calls temp_path(), which mixes the process id with an atomic counter to produce a path no other +test will ever use. That is the first solution the book offers — one file per test. --test-threads=1 +is the second, and it is a worse one: it is slower, and it hides the coupling instead of removing it. Reach for +it to diagnose a flaky suite, not to fix one. The same reasoning is why no test of yours may set +TASKS_FILE: environment variables are per-process, so a test that sets one is reaching into every +other test running at that moment.

+ +

Part 6 — A test that cannot fail is not a test

+ +

Green tests are not evidence. Forty-six of them were green while stats printed its lines in the +wrong order, and no amount of staring at the count would have told you. The only honest question about a test +suite is: which bugs would it catch?

+ +

There is a mechanical way to ask it, called mutation testing. Plant a deliberate bug in a copy of the code, +run the suite, and see whether it goes red. A bug the suite notices is killed. A bug it sleeps +through survives, and every survivor is a precise, undeniable description of a missing test. This +lesson ships six of them in 0009-mutants.sh: it copies your crate to a temp +directory, applies one sed substitution, and runs your tests. Your own files are never +touched.

+ +

Here is that script run against your crate exactly as it stands right now, before the drill:

+ +
$ bash ~/learn-rust/lessons/0009-mutants.sh ~/learn-rust/tasks
+crate: /home/tan/learn-rust/tasks
+  SKIP     stats-order   src/cli.rs does not exist yet
+  SKIP     stats-zero    src/cli.rs does not exist yet
+  SKIP     clear-count   src/cli.rs does not exist yet
+  SURVIVED list-format   src/task.rs
+  SURVIVED status-parse  src/task.rs
+  SURVIVED command-case  src/command.rs
+
+0 killed, 3 survived, 3 skipped
+ +

Read the six lines as a to-do list, because that is what they are. The three SKIPs are the +mutations that live in src/cli.rs — the file you have not written yet, which is where +run is going. The three SURVIVEDs are real bugs your 46 tests cannot see today: +Display for Task could stop printing the priority, Status::parse could stop +understanding in-progress, and Command::parse could stop accepting +ADD in capitals, and every test would still pass. The drill's finishing condition is +6 killed, 0 survived.

+ +

One caveat, so you calibrate the tool correctly rather than worshipping it: a suite that kills every mutant +is not a proven-correct suite, because my six mutants are not every possible bug. Mutation testing gives you a +floor, not a ceiling. It is still the sharpest feedback available on a suite you just wrote, and it is far +better than counting tests.

+ +

Check yourself before the drill

+ +

Six questions before you touch the keyboard. Answer each one out loud, in full sentences, before you reveal +or click. Two of them revisit 0005–0008 rather than today's material, which is deliberate — retrieval of old +work is what keeps it.

+ +
+

Tests

+

What actually makes a #[test] function fail?

+
+ + + + +
+ +
+ +
+

Tests

+

A unit test and an integration test: where does each file live, and what can each one reach?

+ + + +
+ +
+

Tests

+

You write a test that returns Result<(), TaskError> and want to assert a call fails. What is the correct move?

+
+ + + + +
+ +
+ +
+

Traits

+

Why can assert_eq! compare and print two Task values, and why did TaskError need a hand-written PartialEq?

+ + + +
+ +
+

Collections

+

Why can a test not assert on count_by_priority() by iterating the map and printing as it goes?

+
+ + + + +
+ +
+ +
+

Modules

+

Your run lives in src/main.rs. Why can no file in tests/ import it, and what are the two changes that make its output testable?

+ + + +
+ +

+
+ +

The drill — 45 minutes, your own crate

+ +

Type it, do not paste it. The thermostat above is a different program. Keep the +tests reference open — looking syntax up is free.

+ +
cd ~/learn-rust/tasks
+cargo test                                   # 46 pass, as they did yesterday
+bash ../lessons/0009-mutants.sh .            # 0 killed, 3 survived, 3 skipped
+ +

Those two lines are the starting position. Every test you write today is yours — I am shipping no new spec +file, because the skill being built is writing the assertions rather than satisfying them. The finishing line +is the mutant report reading 6 killed, 0 survived, 0 skipped, and all 46 existing tests still +green.

+ +

Step 0 — the last 0008 leftover, one minute

+ +

Delete the commented-out for loop still sitting inside Store::find. The iterator +version is one line above it and git remembers the old one.

+ +

Check: grep -c "for " src/store.rs prints 0.

+ +

Step 1 — your first #[test], in src/task.rs

+ +

Add a #[cfg(test)] mod tests at the bottom of src/task.rs with three tests, and +one more at the bottom of src/stats.rs. All four target behaviour that none of my 46 tests +touches — that is why they are worth your keystrokes rather than being duplicates.

+ +

The first pins the line format on disk: build a Task with a known id, title, priority +and status, assert that to_line() produces exactly the string you expect, and assert that parsing +that string back gives the task you started with. Both directions in one test, because a round trip that only +goes one way proves nothing about the other.

+ +

The second pins every Status label, not just the two the CLI uses. Loop over the three +variants, and for each one assert that Status::parse(status.label()) gives that variant back. Your +in-progress arm is currently unreachable from the CLI and therefore completely untested — the loop +covers it without you writing three near-identical tests. Give the assertion a failure message naming the +label, per Part 2, or a red run will not say which variant broke.

+ +

The third pins what the user reads: the Display impl you wrote in 0005. Assert the +exact line for a fresh task, then set its status to Done and assert the line again. Nothing in the +suite has ever checked this string.

+ +

The fourth, in src/stats.rs, calls tally with a key that is owned rather +than Copy — a closure returning String — and asserts both a count and the map's +length. Your count_by_priority only ever hands tally a Copy key, so the +generic function has never been exercised with anything else.

+ +

Check: cargo test --lib → 4 passed. Note the names in the output: +task::tests::… and stats::tests::….

+ +
+Forgotten what the test module looks like? +

#[cfg(test)] then mod tests { then use super::*; — Part 4 has the +whole shape. Without use super::* you get E0433: failed to resolve on the first type +name, because the child module starts with an empty scope.

+
+ +

Step 2 — shared helpers, and tests that return Result

+ +

Create tests/common/mod.rs — the directory spelling from Part 4, not +tests/common.rs — holding three helpers you will use from two files: temp_path() +(copy the one from the top of tests/collections.rs; a helper worth sharing is a helper worth +moving), args(&[&str]) -> Vec<String>, and three_tasks() -> Store +which returns a store holding one task per priority with task 1 already completed. Put +#![allow(dead_code)] at the top of the file: each test crate uses only some of the helpers, and +without it the unused ones warn.

+ +

Then write tests/mine.rs with three tests, each returning +Result<(), TaskError> so the setup steps can use ?:

+ +

Completing a task that is already done is not an error. Complete task 1 a second time and +assert it is still Done. Your complete takes this path today; the test decides that +the behaviour is deliberate rather than accidental, which is what a test is for.

+ +

A reload sees exactly what was saved. Take three_tasks(), clear the completed +one, save to a temp_path(), load it back, and assert the loaded tasks equal the ones in memory. +The shipped suite tests save-then-load, but never after a removal.

+ +

Saving a smaller store shortens the file. Save three tasks, remove the completed one, save +again to the same path, then read the file with fs::read_to_string and assert it has two lines. If +save ever stops truncating, this is the only test that will notice — and a save that appends +instead of replacing is a data-loss bug, not a cosmetic one.

+ +

Check: cargo test --test mine → 3 passed, and a full cargo test +shows no Running tests/common section. If you see one, you named the file +tests/common.rs.

+ +

Step 3 — move run into the library

+ +

This is the structural step, and the point of it is Part 4's rule: nothing in main.rs can be +tested, so almost nothing should live there.

+ +

Create src/cli.rs, declare it in lib.rs, and move run into it with +this signature:

+ +
pub fn run(
+    args: &[String],
+    store: &mut Store,
+    out: &mut impl Write,
+) -> Result<(), TaskError>
+ +

Replace every println!(..) in the body with writeln!(out, ..)?. The +? is doing real work there: writeln! returns io::Result, and your +From<io::Error> for TaskError from 0008's step 0 converts it — the second time that impl has +paid for itself. Then main becomes: build the path, load the store, collect the args, take +io::stdout().lock(), call run, save, and fail on either error.

+ +

While the code is open, fix the two defects from the top of this page. stats loops over +[Priority::High, Priority::Medium, Priority::Low] — fully qualified, in that order — and prints +each with writeln!(out, "{:<6} {}", p.label(), n)?, so the counts line up in a column. +clear keeps the usize that remove_completed returns and prints +cleared N completed. Those exact formats are what the tests in step 4 assert, and they are the +output the 0008 session captured.

+ +

Check: grep -c "println!" src/cli.rs prints 0, and the CLI still +behaves — from a scratch directory, with +run(){ TASKS_FILE=t.txt cargo run -q --manifest-path ~/learn-rust/tasks/Cargo.toml -- "$@"; }:

+ +
$ run add "buy milk" high ; run add "call bank" ; run add "water plants" low
+$ run done 1
+$ run stats
+high   1
+medium 1
+low    1
+$ run clear
+cleared 1 completed
+$ run done 9 ; echo $?
+error: no task with id 9
+1
+ +

Step 4 — the tests that catch what 46 could not

+ +

Write tests/cli.rs. Start with a helper that runs one command against a store and gives back +exactly what it printed — a Vec<u8> for out, then +String::from_utf8:

+ +
fn output(command: &[&str], store: &mut Store) -> String {
+    let mut out: Vec<u8> = Vec::new();
+    run(&args(command), store, &mut out).expect("command must succeed");
+    String::from_utf8(out).expect("output must be utf-8")
+}
+ +

Then six tests, each asserting on the whole printed string with assert_eq! rather than +searching it for a substring — an exact assertion is what kills the mutants, and the escaped +\ns are part of the contract:

+ +
    +
  • stats on a store with one task per priority prints high, then medium, then low.
  • +
  • stats on a store with only a medium task prints 0 for the other two, on their own +lines, rather than omitting them.
  • +
  • clear reports how many it deleted, and reports 0 the second time.
  • +
  • list prints one line per task in insertion order.
  • +
  • A command that fails prints nothing at all — assert the Err is +TaskError::NotFound(9) and that out is still empty. Errors are main's +job, on stderr.
  • +
  • The command word is case-insensitive: ADD and List work, because +Command::parse lowercases it. Untested until now, and one of the surviving mutants.
  • +
+ +

Check: cargo test --test cli → 6 passed. Full cargo test → 4 + 6 + +14 + 7 + 3 + 8 + 17 = 59 passed, of which 13 are yours.

+ +

Step 5 — hunt the mutants

+ +

Run the script against your crate. Every one of the six should now be reported killed:

+ +
$ bash ../lessons/0009-mutants.sh .
+  killed   stats-order   src/cli.rs
+  killed   stats-zero    src/cli.rs
+  killed   clear-count   src/cli.rs
+  killed   list-format   src/task.rs
+  killed   status-parse  src/task.rs
+  killed   command-case  src/command.rs
+
+6 killed, 0 survived, 0 skipped
+ +

If one survives, do not adjust the script — read the mutation it names in the source of the script, work out +which of your tests should have caught it, and fix that test. A survivor is never wrong: it is a bug +that your suite genuinely cannot see. If one says SKIP, the pattern is not in your source, which +usually means you spelled that line differently; the script prints the file so you can compare.

+ +

Then stop

+ +

Not today: doc tests (/// examples that run — chapter 14), #[bench], +assert_cmd and predicates for testing the binary as a subprocess, +proptest for generated inputs, and cargo-mutants, which is the real version of this +lesson's script. Each is a small step from here, and none is on the path to the next gap.

+ +

What this closed

+ +

Chapter 11 moves to produced on the coverage map, and +with it the last core gap in chapters 1–11. You have now written unit tests, integration tests, shared helpers, +Result-returning tests, and an assertion on a command's exact output — plus the refactor that made +the last one possible, which is the part an interviewer will actually probe.

+ +

What is left before the job-ready floor is short, and it is no longer about the book's core:

+ +
    +
  1. ch 10.3 — lifetimes, as reading practice. You have now written two without noticing: +titles_with returns Vec<&str> borrowed from &self, and +Status::label returns &str borrowed from &self. Elision filled in +both annotations for you, and reading the explicit form is a two-lesson job at most.
  2. +
  3. serde, which replaces your to_line/FromStr pair +with two derives — worth doing after writing them by hand, which you now have.
  4. +
  5. Then axum, where the traits from 0005–0008 and the testing from today start paying rent +together: a handler is just a function you can call from a test.
  6. +
+ +

Take it outside

+ +

Here is a question with genuine disagreement behind it, which makes it a good one to ask people rather than +docs. Your output helper asserts on the exact bytes a command prints, which means a wording change +to cleared N completed breaks a test even though nothing is broken for the user. Some engineers +call that a feature — the output is the contract, and changing it should be deliberate. Others call it +a brittle test that will be deleted the first time it is inconvenient, and would assert only that the count +appears somewhere in the line. Ask on users.rust-lang.org where they +draw that line for CLI output, and what they do differently for output a machine parses versus output a human +reads. The answers will teach you more about test design than any chapter, because it is a taste question and +the book cannot have taste for you.

+ +

The five sentences worth keeping

+ +
    +
  1. A test fails when its thread panics; every assertion macro is a wrapper that panics, so +unwrap in a test is a legitimate assertion.
  2. +
  3. Unit tests live beside the code in #[cfg(test)] mod tests and can see private items; +integration tests live in tests/, are separate crates, and see only the public API.
  4. +
  5. #[should_panic(expected = "…")] tests a panic, a -> Result<(), E> test +lets you ? the setup, and the two cannot be combined.
  6. +
  7. Nothing in src/main.rs is testable, so main stays thin and everything else moves +to the library — and a function that writes to &mut impl Write is testable where one that +calls println! is not.
  8. +
  9. Tests run in parallel and share nothing safely, so give every file-touching test its own path; and judge a +suite by the bugs it kills, never by the number of tests it contains.
  10. +
+ + + + + + diff --git a/mangabookmark-index.png b/mangabookmark-index.png new file mode 100644 index 0000000..9ab2894 Binary files /dev/null and b/mangabookmark-index.png differ diff --git a/ownership/.gitignore b/ownership/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/ownership/.gitignore @@ -0,0 +1 @@ +/target diff --git a/ownership/Cargo.lock b/ownership/Cargo.lock new file mode 100644 index 0000000..8c5b86e --- /dev/null +++ b/ownership/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ownership" +version = "0.1.0" diff --git a/ownership/Cargo.toml b/ownership/Cargo.toml new file mode 100644 index 0000000..a4b0049 --- /dev/null +++ b/ownership/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "ownership" +version = "0.1.0" +edition = "2024" + +[dependencies] diff --git a/ownership/src/main.rs b/ownership/src/main.rs new file mode 100644 index 0000000..8c80b42 --- /dev/null +++ b/ownership/src/main.rs @@ -0,0 +1,71 @@ +fn main() { + let mut s = String::from("hello"); + s.push_str(", world"); + + let x = calculate_length(&s); + + println!("{s} with len {x}"); + + let y = "lksadjflksajdfc"; + println!("{y}"); + + // { + // let x = 12; + // } + // println!("{x}"); + + { + let s1 = String::from("hello"); + let s2 = s1; + + println!("{s2}, world!"); + } + + let s = String::from("hello"); // s comes into scope + + takes_ownership(s); // s's value moves into the function... + // ... and so is no longer valid here + + let x = 5; // x comes into scope + + makes_copy(x); // Because i32 implements the Copy trait, + // x does NOT move into the function, + // so it's okay to use x afterward. + + let mut s = String::from("hello"); + + { + let r1 = &mut s; + println!("{r1}"); + let x = r1.len(); + println!("{x}"); + } // r1 goes out of scope here, so we can make a new reference with no problems. + + let r2 = &mut s; +} + +fn takes_ownership(some_string: String) { + // some_string comes into scope + println!("{some_string}"); +} // Here, some_string goes out of scope and `drop` is called. The backing +// memory is freed. + +fn makes_copy(some_integer: i32) { + // some_integer comes into scope + println!("{some_integer}"); +} // Here, some_integer goes out of scope. Nothing special happens. + +fn calculate_length(s: &String) -> usize { + s.len() +} + +fn first_word(s: &String) -> usize { + let bytes = s.as_bytes(); + + for (i, &item) in bytes.iter().enumerate() { + if item == b' ' { + return i; + } + } + s.len() +} diff --git a/reference/book-coverage.html b/reference/book-coverage.html new file mode 100644 index 0000000..bb0d1a7 --- /dev/null +++ b/reference/book-coverage.html @@ -0,0 +1,136 @@ + + + + +Rust Book coverage map — what is owned, what is missing + + + +

Coverage map

+

Every chapter of The Rust Programming Language against this workspace · updated after lesson 0009

+ +
+The chapter list is taken verbatim from the book's +table of contents, not from memory. +Read means you covered it in your two months. Produced means code you wrote in this +workspace uses it correctly — that is the only column that counts for the +mission. +
+ +

The map

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ChTopicStateEvidence / gap
1–2Getting started, guessing gameProducedhello_cargo/, guessing_game/
3Variables, types, functions, control flowProducedvariables/, function/, control_flow/; 0001 diagnostic solid
4Ownership, borrowing, slicesProduced&self methods and &[Task] accessor in tasks/. Watch for: you have never hit +a borrow-checker fight in your own multi-owner code, because nothing has needed two owners yet.
5Structs and methodsProduced0003 / 0004, tasks/src/task.rs
6Enums and matchProducedStatus, Priority, Command, and now TaskError (0006)
7Packages, crates, modulesProducedtasks/ is a lib + bin package with four modules
8Collections: Vec, String, HashMapProduced0008 drill, in tasks/: HashMap<Priority, usize> built with the entry/or_insert +counting idiom, get().copied().unwrap_or(0), Eq + Hash + Copy derives on a key type, and a +String built by collect. Not done: BTreeMap, HashSet, string slicing by byte index.
9Error handling: panic!, Result, ?Produced0005 + 0006: ?, ok_or, own error enum, stderr + exit 1
10.1Generic data typesProduced0008 drill: fn tally<T, K, F>(items: &[T], key: F) -> HashMap<K, usize> in tasks/src/stats.rs, +with a where clause bounding K: Eq + Hash and F: Fn(&T) -> K — written from the +signature up, called at three different T/K pairs, one of them a non-Copy key. +Not done: generic structs and generic impl blocks.
10.2TraitsProducedDisplay for Task (0005), Display + Error + From for TaskError (0006), +FromStr for Task with an associated type Err and a hand-written PartialEq (0007)
10.3LifetimesGap — untouchedZero exposure in this workspace. You have dodged it by owning everything (String, not &'a str). +Needed to read other people's code and to review a PR; not needed to ship your CLI.
11Writing automated testsTaught — drill pending0009: #[test], #[cfg(test)] mod tests with use super::*, the three assertion macros +and their failure output, #[should_panic(expected)] vs a -> Result<(), E> test, unit vs +integration access (E0603/E0616), tests/common/mod.rs, the runner flags, and why +src/main.rs is untestable — run moves to the library and writes to &mut impl Write. +Graded by six planted mutants, not by a test count.
12I/O project: args, files, stderr, envProduced0007: fs::read_to_string/fs::write, io::Error + ErrorKind::NotFound match guard, +env::var with a default, on top of env::args and stderr + exit 1 from 0005. Not done: BufReader, +file locking, serde — none needed at this size.
13Closures and iteratorsProduced0008 drill: filter/map/collect chains, find vs position, +Vec::retain, lines(), collect::<Result<Vec<_>, E>>() short-circuiting, and a +function that takes a closure (F: Fn). Not done: fold, zip, +impl Iterator for your own type.
14Cargo, crates.io, workspacesSkip for nowLearn cargo add and features when a dependency is actually needed (serde, axum).
15Smart pointers: Box, Rc, RefCell, Deref, DropPartialBox<dyn Error> met in 0005/0006. Rc/RefCell untouched — reach for them only when a real +shared-ownership problem appears, not before.
16Concurrency: threads, channels, Send/SyncGapUntouched apart from the Send + Sync bound your 0006 test asserts. Prerequisite for understanding +why an axum handler must be Send.
17Async: futures, async/await, streamsGap — by designRequired for axum/tokio, deliberately last. The unused trpl dependency in get-dependecies/ is +the abandoned first attempt. Do it after 8, 11, 13.
18Trait objects, OOP patternsPartialdyn Error is the same mechanism as 18.2. The state-machine pattern (18.3) is optional reading.
19Patterns and matchingPartialmatch ✓, if let ✓ (0005 drill). Guards (Some(x) if x > 5), @ bindings, +let ... else, and matches! — one page of reading, high value per minute.
20Unsafe, advanced traits, macrosSkipAssociated types and generic-parameter defaults matter eventually (serde uses them). Not now.
21Final project: multithreaded web serverNot startedThe natural capstone before axum — it is a backend service with no framework.
+ +

The order that follows from this

+

After the 0009 drill, one gap in the book's core is left, and it is the mildest one:

+
    +
  1. Lifetimes (ch 10.3) — as reading practice, not as a build. You have written two without +noticing: titles_with returns Vec<&str> borrowed from &self, and +Status::label returns &str the same way. Elision filled both annotations in. Reading +other people's signatures needs the explicit form.
  2. +
  3. Patterns (ch 19) — one page: match guards, @ bindings, matches!, +let … else. Your suite already uses matches!; the rest is high value per minute.
  4. +
+

Then serde → axum → async, where the traits from 0005–0008 and the testing from 0009 stop being an exercise +and start being the whole API surface: an axum handler is a function a test can call. Chapter 21's web server is +the natural capstone before a framework.

+ + + + diff --git a/reference/rust-syntax.html b/reference/rust-syntax.html new file mode 100644 index 0000000..13ad84b --- /dev/null +++ b/reference/rust-syntax.html @@ -0,0 +1,720 @@ + + + + +Rust Syntax Reference — ch. 1–13 + + + +

Rust syntax reference

+

The compressed essence of Rust Book ch. 1–13 · built for lookup while typing, not for reading

+ +
+Keep this open in a tab while you write code. Every line here is something you already met in ch1–13 — the point is to stop it blocking you mid-sentence. +
+ +

Cargo commands

+
cargo new my_proj        # create project (binary)
+cargo new my_lib --lib   # create library
+cargo run                # build + run
+cargo run -- 95 83       # pass args to your program (after --)
+cargo build              # build only (debug)
+cargo build --release    # optimized build
+cargo check              # typecheck fast, no binary
+cargo test               # run #[test] functions
+cargo add rand           # add a dependency
+

Book: 1.3 Hello, Cargo!

+ +

Variables

+
let x = 5;               // immutable
+let mut y = 5;           // mutable
+y = 6;                   // ok, y is mut
+const MAX: u32 = 100;    // const: always typed, UPPER_CASE
+let x = 5;
+let x = x + 1;           // shadowing: new variable, same name
+

Book: 3.1

+ +

Types

+
i8 i16 i32 i64 i128 isize    // signed ints   (i32 = default)
+u8 u16 u32 u64 u128 usize    // unsigned ints (usize = index/len type)
+f32 f64                      // floats        (f64 = default)
+bool                         // true / false
+char                         // 'a' — 4 bytes, Unicode scalar
+
+let t: (i32, f64) = (500, 6.4);   // tuple
+let (a, b) = t;                   // destructure
+let first = t.0;                  // index
+
+let arr: [u16; 10] = [0; 10];     // array: fixed length, 10 zeros
+let arr = [1, 2, 3];              // inferred [i32; 3]
+
+let s = "hi";                     // &str  — borrowed, fixed
+let s = String::from("hi");       // String — owned, growable
+let n: u32 = "42".parse().unwrap();      // str -> number
+let n = "42".parse::<u32>().unwrap();    // same, turbofish form
+let s = 42.to_string();                  // number -> String
+let len = arr.len() as u32;              // numeric cast
+

Book: 3.2

+ +

Functions

+
fn add(a: i32, b: i32) -> i32 {
+    a + b            // no semicolon = this is the return value
+}
+
+fn shout(msg: &str) -> String {
+    msg.to_uppercase()
+}
+
+fn log(msg: &str) {  // no -> means returns ()
+    println!("{msg}");
+}
+

Statement vs expression: a line ending in ; is a statement (produces no value). Drop the ; and it is an expression whose value is returned. return x; works too, but is only idiomatic for early returns.

+

Book: 3.3

+ +

Control flow

+
if n < 5 { ... } else if n < 10 { ... } else { ... }
+let label = if n > 0 { "pos" } else { "neg" };   // arms must be same type
+
+loop { break; }                     // infinite until break
+let got = loop { break 7; };        // loop can return a value
+while n < 10 { n += 1; }
+for i in 0..5 { }                   // 0,1,2,3,4
+for i in 0..=5 { }                  // 0..5 inclusive
+for item in &vec { }                // borrow each item
+for (i, c) in word.char_indices() { }
+

Book: 3.5

+ +

Ownership — the three rules

+
    +
  1. Each value has exactly one owner.
  2. +
  3. There can be only one owner at a time.
  4. +
  5. When the owner goes out of scope, the value is dropped.
  6. +
+
let s1 = String::from("hi");
+let s2 = s1;             // MOVE — s1 is now invalid
+// println!("{s1}");     // compile error: borrow of moved value
+
+let s2 = s1.clone();     // deep copy — both usable, costs allocation
+
+let n1 = 5;
+let n2 = n1;             // COPY — i32 is Copy, n1 still valid
+

Copy types (stack-only, fixed size): all integers, f32/f64, bool, char, and tuples containing only Copy types. Everything heap-owning (String, Vec) moves instead.

+

Book: 4.1

+ +

References & borrowing

+
fn length(s: &String) -> usize { s.len() }        // borrow, read-only
+fn push_bang(s: &mut String) { s.push('!'); }     // borrow, mutable
+
+let mut s = String::from("hi");
+length(&s);        // pass an immutable reference
+push_bang(&mut s); // pass a mutable reference
+

The borrowing rule: at any one time you may have either one mutable reference or any number of immutable references — never both. Enforced at compile time; this is what rules out data races without a GC.

+

Book: 4.2

+ +

Slices

+
let s = String::from("hello world");
+let hello = &s[0..5];    // &str — pointer + length, owns nothing
+let world = &s[6..11];
+let whole = &s[..];
+
+let arr = [1, 2, 3, 4, 5];
+let part: &[i32] = &arr[1..3];   // [2, 3]
+
+fn total(nums: &[u32]) -> u32 { ... }   // takes Vec or array — prefer &[T]
+

Book: 4.3

+ +

Structs

+
struct Rectangle { width: u32, height: u32 }        // named fields
+struct Point(i32, i32);                             // tuple struct
+struct Marker;                                      // unit struct
+
+#[derive(Debug)]                    // enables {:?} printing
+struct User { name: String, active: bool }
+
+let r = Rectangle { width: 30, height: 50 };
+println!("{r:?}");                  // needs #[derive(Debug)]
+
+impl Rectangle {
+    fn new(width: u32, height: u32) -> Self {   // associated fn (no self)
+        Self { width, height }                  // field init shorthand
+    }
+    fn area(&self) -> u32 {                     // method: borrows
+        self.width * self.height
+    }
+    fn scale(&mut self, by: u32) {              // method: borrows mutably
+        self.width *= by;
+    }
+    fn consume(self) -> u32 { self.width }      // method: takes ownership
+}
+
+Rectangle::new(3, 4).area();        // :: for associated fn, . for method
+

Book: 5.1–5.3

+ +

Enums & pattern matching

+
enum Shape {
+    Circle(f64),                    // variant with data
+    Rect { w: f64, h: f64 },        // variant with named fields
+    Empty,                          // variant with no data
+}
+
+enum Option<T> { Some(T), None }             // in std — "maybe a value"
+enum Result<T, E> { Ok(T), Err(E) }          // in std — "value or error"
+
+match shape {
+    Shape::Circle(r) => 3.14 * r * r,
+    Shape::Rect { w, h } => w * h,
+    Shape::Empty => 0.0,
+}                                   // must be exhaustive
+
+match score {
+    90..=100 => "A",                // range pattern
+    n if n > 50 => "pass",          // match guard
+    _ => "F",                       // catch-all
+}
+
+if let Some(x) = maybe { ... }               // one pattern, ignore rest
+if let Some(x) = maybe { ... } else { ... }
+while let Some(top) = stack.pop() { ... }    // loop while pattern matches
+

Book: 6.1–6.3

+ +

Modules & paths

+
// src/lib.rs or src/main.rs — the crate root
+mod front_of_house;              // loads src/front_of_house.rs (or .../mod.rs)
+
+mod hosting {                     // inline module
+    pub fn add_to_waitlist() {}   // pub = visible outside this module
+    fn seat() {}                  // private (default)
+}
+
+crate::front_of_house::hosting::add_to_waitlist();  // absolute path
+front_of_house::hosting::add_to_waitlist();         // relative path
+super::hosting::add_to_waitlist();                  // parent module
+
+use crate::front_of_house::hosting;      // bring into scope
+use std::collections::HashMap;
+use std::io::{self, Read};               // multiple items
+use std::fmt::Result as FmtResult;       // rename
+pub use crate::hosting;                  // re-export
+

Everything is private by default. A child module can see its parent's items; a parent needs pub to see into a child. pub on a struct does not make its fields public — each field needs its own pub.

+

Book: 7.1–7.5

+

Packages vs crates — the two-crate package

+
my_proj/
+├── Cargo.toml       # ONE package
+├── src/
+│   ├── lib.rs       # crate 1: the LIBRARY, named after the package
+│   ├── thing.rs     # a module inside crate 1
+│   └── main.rs      # crate 2: the BINARY — a separate crate
+└── tests/
+    └── spec.rs      # crate 3: integration tests — separate again
+

Cargo finds all three by filename. No [lib] or [[bin]] in Cargo.toml is needed.

+
// src/thing.rs — INSIDE the library crate
+use crate::other::Thing;         // crate:: = root of the crate I am in
+
+// src/main.rs — a DIFFERENT crate
+use my_proj::thing::Thing;       // must use the package name
+
+// tests/spec.rs — also a different crate
+use my_proj::thing::Thing;       // same as main.rs
+

use crate::.. in main.rs gives error[E0432]: unresolved import. When a path will not resolve, ask: which crate am I in right now?

+

Three wiring states, in the order you hit them:

+ + + + + +
In lib.rsResult
nothingE0433: cannot find `thing` — a file is not a module until declared
mod thing;E0603: module `thing` is private
pub mod thing;works
+

Integration tests in tests/ can only reach pub items via the library crate — they cannot see inside main.rs at all. That is the reason to put logic in lib.rs and keep main.rs thin.

+

Book: 7.1 Packages and Crates · 11.3 Test Organization

+ +

Common derives

+
#[derive(Debug, Clone, Copy, PartialEq)]
+enum Size { Small, Large }
+ + + + + + + +
DeriveGivesNeeded for
Debug{:?}test failure output, logs
PartialEq==assert_eq! on your type
Clone.clone()explicit second copy
Copyassignment copies, not movessmall types, no heap data
PartialOrd, Ord<, .sort()ordering; enum variant order = the ordering
+

A type containing a String or Vec cannot be Copy. Forget a derive and the compiler names the exact trait and type.

+

Book: Appendix C — Derivable Traits

+ + +

Collections

+
// Vec — owned, growable list
+let mut v: Vec<u32> = Vec::new();
+let v = vec![1, 2, 3];
+v.push(4);
+let third = &v[2];               // panics if out of range
+let third = v.get(2);            // returns Option<&u32> — safe
+v.get(9).unwrap_or(&0);          // default if missing
+for n in &v { }                  // iterate borrowed
+for n in &mut v { *n += 1; }     // iterate mutably
+v.len(); v.is_empty(); v.pop(); v.contains(&3);
+
+// String — owned, growable, UTF-8
+let mut s = String::new();
+s.push_str("hi");  s.push('!');
+let s = format!("{a}-{b}");
+let joined = words.join(" ");
+for w in text.split_whitespace() { }
+for (i, c) in text.char_indices() { }
+text.to_uppercase(); text.trim(); text.len();   // len = BYTES, not chars
+
+// HashMap — key/value
+use std::collections::HashMap;
+let mut m = HashMap::new();
+m.insert("a", 10);
+m.get("a");                      // Option<&i32>
+let count = m.entry(key).or_insert(0);   // insert-if-absent, returns &mut
+*count += 1;                             // the counting idiom
+for (k, v) in &m { }
+

Book: 8.1–8.3

+ +

Iterators

+
// ONE trait, one method — everything else is a default method built on next()
+pub trait Iterator {
+    type Item;
+    fn next(&mut self) -> Option<Self::Item>;
+}
+
+// THREE ways in — the &self / &mut self / self choice, one element at a time
+v.iter()        // Item = &T        reading; collection survives
+v.iter_mut()    // Item = &mut T    editing in place; collection survives
+v.into_iter()   // Item = T         taking the elements; collection consumed
+
+// ADAPTERS — lazy, return an iterator, compose freely
+.map(|x| ..) .filter(|x| ..) .enumerate() .skip(n) .take(n) .rev() .zip(other)
+
+// CONSUMERS — do the work, return something that is not an iterator
+.collect() .find(|x| ..) .position(|x| ..) .any(|x| ..) .all(|x| ..)
+.count() .sum() .max() .min() .fold(init, |acc, x| ..) .for_each(|x| ..)
+
+// find gives the ITEM, position gives the INDEX — both Option, both take ok_or
+list.iter().find(|t| t.id == id).ok_or(NotFound(id))?       // -> &T
+list.iter().position(|t| t.id == id).ok_or(NotFound(id))?   // -> usize
+
+list.retain(|t| t.keep);   // Vec method, not Iterator: delete in place, one pass
+
+// COLLECT builds any FromIterator type — you must say which
+let v: Vec<String> = it.collect();
+let s: String      = it.collect();          // String: FromIterator<String>
+let m: HashMap<K, V> = pairs.collect();     // from an iterator of (K, V)
+let r: Result<Vec<T>, E> = it.collect();    // SHORT-CIRCUITS on the first Err
+let o: Option<Vec<T>>    = it.collect();    // .. and on the first None
+
+text.lines()     // trailing "\n" is a TERMINATOR — no empty last item, strips \r
+text.split('\n')      // trailing "\n" is a SEPARATOR — yields a final ""
+

Errors you will see:

+
warning: unused `Map` that must be used
+  = note: iterators are lazy and do nothing unless consumed   // no consumer
+
+error[E0283]: type annotations needed
+  | let x = it.collect();                    ------- type must be known at this point
+  = note: multiple `impl`s satisfying `_: FromIterator<String>` found
+
+error[E0502]: cannot borrow `self.v` as immutable because it is also borrowed as mutable
+  // an iter_mut() result in a variable holds the borrow until its LAST use
+
+error[E0507]: cannot move out of `x.field` which is behind a shared reference
+  // a closure over &T cannot give away an owned field — Copy, clone, or &str
+

Cost: an adapter chain is a tower of small structs compiled to one pass — no temporary +vectors, no runtime penalty against the equivalent for loop.

+

When a loop still wins: folding many items into one accumulator you mutate. Iterators +replace loops that search, transform, or collect.

+

Book: 13.2 Iterators · std: Iterator · FromIterator

+ +

HashMap keys & counting

+
use std::collections::HashMap;
+
+// A key must be Eq + Hash: hash to pick the bucket, compare inside it.
+#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]   // Copy for fieldless enums
+enum Priority { Low, Medium, High }
+
+let mut m: HashMap<Priority, usize> = HashMap::new();
+*m.entry(key).or_insert(0) += 1;   // THE counting idiom: one lookup, &mut V back
+m.entry(key).or_default();               // same, using Default::default()
+m.get(&key).copied().unwrap_or(0);       // Option<&usize> -> usize, absent = 0
+for (k, v) in &m { }         // ARBITRARY order — impose your own before printing
+HashMap::from([(Priority::High, 2)]);    // literal, for tests
+

Eq is a marker: no methods, one extra promise over PartialEq — every value equals +itself. f64 breaks it (NAN != NAN), so f64 cannot be a key.

+

Want sorted keys instead of arbitrary order? BTreeMap, same API, K: Ord.

+

Book: 8.3 Hash maps · std: Eq · Hash

+ +

Error handling

+
panic!("boom");                  // unrecoverable — stops the program
+
+// Recoverable: return Result and let the caller decide
+fn read_name() -> Result<String, io::Error> {
+    let mut f = File::open("hello.txt")?;    // ? = return Err early
+    let mut s = String::new();
+    f.read_to_string(&mut s)?;
+    Ok(s)                                     // must wrap success in Ok
+}
+
+match File::open("x.txt") {
+    Ok(f) => f,
+    Err(e) => match e.kind() {
+        ErrorKind::NotFound => File::create("x.txt").unwrap(),
+        _ => panic!("{e:?}"),
+    },
+};
+
+r.unwrap();               // value, or panic
+r.expect("message");      // value, or panic with your message
+r.unwrap_or(0);           // value, or fallback
+r.unwrap_or_else(|e| 0);  // value, or compute fallback
+r.is_ok(); r.is_err();
+
+fn main() -> Result<(), Box<dyn Error>> { ... }   // main can return Result
+

Rule of thumb: panic! / unwrap in tests, prototypes, and truly impossible states. Result everywhere else — especially in library code and anything a backend service depends on.

+

Book: 9.1–9.3

+ +

The Result pattern

+
enum Result<T, E> { Ok(T), Err(E) }    // an enum: errors are VALUES
+enum Option<T>    { Some(T), None }    // absence, with no reason attached
+

Pick the shortest rung that fits:

+
match r { Ok(v) => ..., Err(e) => ... }   // full control, both arms required
+if let Err(e) = r { ... }                 // only care about failure
+r.unwrap_or(default)                      // fallback value
+r.unwrap_or_else(|e| compute())           // fallback, computed
+r.ok()                                    // Result -> Option (drops the reason)
+r.map(|v| v + 1)                          // change Ok, leave Err untouched
+r.is_ok() / r.is_err()                    // just ask
+r.unwrap() / r.expect("why")              // value, or PANIC
+r?                                        // hand the error to my caller
+

What ? really does:

+
let v = thing()?;
+// expands to roughly:
+let v = match thing() {
+    Ok(value) => value,
+    Err(e) => return Err(From::from(e)),   // early exit + TYPE CONVERSION
+};
+

The From::from step is why ? can mix error types in one function:

+
fn load_port(path: &str) -> Result<u16, Box<dyn Error>> {
+    let text = fs::read_to_string(path)?;    // io::Error      -\
+    let port = text.trim().parse::<u16>()?;  // ParseIntError -> Box<dyn Error>
+    Ok(port)
+}
+

? requires the enclosing function to return Result or Option:

+
error[E0277]: the `?` operator can only be used in a function that returns `Result`
+  | cannot use the `?` operator in a function that returns `()`
+help: consider adding return type
+

Fix the signature, not the ?. Even main can return Result — end it with Ok(()).

+

Dropping a Result is a warning, not silence:

+
warning: unused `Result` that must be used
+  = note: this `Result` may be an `Err` variant, which should be handled
+help: use `let _ = ...` to ignore the resulting value
+

panic vs propagate: unwrap/expect in tests, prototypes, and states that truly cannot happen. Result for anything touching files, args, network, or users. In a service: propagate with ? through the inner layers, and decide what the user sees at one boundary (the request handler).

+ +

Custom error types — the recipe

+
#[derive(Debug)]                          // Error requires Debug
+enum SensorError {
+    Empty,                                // no data
+    NotANumber(ParseFloatError),          // wrap the cause
+    OutOfRange(f64),                      // keep the bad value
+}
+
+impl fmt::Display for SensorError {       // the human sentence
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        match self {
+            SensorError::Empty => write!(f, "no reading given"),
+            SensorError::NotANumber(_) => write!(f, "reading is not a number"),
+            SensorError::OutOfRange(v) => write!(f, "{v} is outside -90..60"),
+        }
+    }
+}
+
+impl Error for SensorError {}       // std::error::Error — marker, no body needed
+
+// …or, when a variant WRAPS another error, hand the cause to source() instead:
+impl Error for SensorError {
+    fn source(&self) -> Option<&(dyn Error + 'static)> {
+        match self {
+            SensorError::NotANumber(e) => Some(e),   // the wrapped cause
+            _ => None,                               // nothing underneath
+        }
+    }
+}
+
+impl From<ParseFloatError> for SensorError {   // makes bare `?` convert for you
+    fn from(e: ParseFloatError) -> SensorError { SensorError::NotANumber(e) }
+}
+

Three obligations, in order: #[derive(Debug)], then Display, then the empty impl Error. Skip Display and the marker impl fails:

+
error[E0277]: `SensorError` doesn't implement `std::fmt::Display`
+13 | impl Error for SensorError {}
+   |                ^^^^^^^^^^^ unsatisfied trait bound
+

From is a trait with one method — fn from(value: T) -> Self, i.e. "how to build me out of a T". String::from("hi") is the same trait. Three equivalent spellings once the impl exists:

+
Err(SensorError::from(e))     // call it yourself
+Err(e.into())              // same trait, from the value's side (Into comes free)
+text.parse::<f64>()?          // `?` calls From::from(e) for you
+
+// what `?` expands to:
+match thing() { Ok(v) => v, Err(e) => return Err(From::from(e)) }
+

Missing From impl, seen through ?:

+
error[E0277]: `?` couldn't convert the error to `SensorError`
+28 |     Ok(text.parse::<f64>()?)
+   |             --------------^ the trait `From<ParseFloatError>` is not implemented for `SensorError`
+
+// with an annotated `let`, the same missing impl is reported as:
+error[E0271]: type mismatch resolving `<f64 as FromStr>::Err == SensorError`
+29 |     let value: f64 = text.parse()?;
+   |                           ^^^^^ expected `SensorError`, found `ParseFloatError`
+

ok_or("text")? in a function returning Result<_, String> already relies on this: std ships impl From<&str> for String.

+

Reporting it in a CLI — main -> Result prints with {:?} (Debug), so handle it yourself when a human reads the output:

+
if let Err(e) = run(&args, &mut store) {
+    eprintln!("error: {e}");    // stderr, Display
+    process::exit(1);           // status a script can test
+}
+

Wrapped cause: source() or Display, never both. std's own rule — "the underlying error should be either returned by the outer error's Error::source(), or rendered by the outer error's Display implementation, but not both." So write your one sentence in Display, drop the {e}, and expose the cause through source() for logs and --verbose.

+
let e = parse_reading("nope").unwrap_err();
+e.to_string()                      // "reading is not a number"  <- yours
+e.source().map(|s| s.to_string())  // Some("invalid float literal")  <- std's
+
+

Reviewer's checklist for an error type (API Guidelines C-GOOD-ERR): implements Error; is Send + Sync (needed to cross threads, so needed by every web framework); never (); Display message lowercase, no trailing punctuation, concise. Assert the bounds in a test with an empty generic fn:

+
fn assert_usable_as_error<E: Error + Send + Sync + 'static>() {}
+assert_usable_as_error::<TaskError>();   // compile-time check, no body needed
+

Matching without naming every variant — matches! returns a bool:

+
assert!(matches!(err, TaskError::BadId(_)));      // true if the shape matches
+if matches!(status, Status::Todo | Status::InProgress) { .. }
+

Adding a variant is a compiler-enforced review:

+
error[E0004]: non-exhaustive patterns: `&TaskError::StoreFull` not covered
+19 |         match self {
+   |               ^^^^ pattern `&TaskError::StoreFull` not covered
+note: `TaskError` defined here … 14 |     StoreFull,  --------- not covered
+

That failure is the reason to prefer an enum over a String — and the reason not to write _ => .. when matching your own error type at the reporting edge.

+

Two more errors from the same migration:

+
error[E0432]: unresolved import `crate::error`   // `pub mod error;` missing
+error[E0369]: binary operation `==` cannot be applied to type `TaskError`
+note: `TaskError` does not implement `PartialEq`  // tests use assert_eq!
+

Book: 9.2 · std: Error · From

+ +

Files, io::Error & FromStr

+
use std::fs;
+use std::io::{self, ErrorKind};
+use std::path::{Path, PathBuf};
+
+fs::read_to_string(path)?   // io::Result<String>  = Result<String, io::Error>
+fs::write(path, text)?   // io::Result<()>       — creates or truncates, one call
+text.lines()                // iterator of &str, no trailing newlines
+
+// a path from the environment, with a default
+let path = PathBuf::from(
+    env::var("TASKS_FILE").unwrap_or_else(|_| "tasks.txt".to_string()));
+

One io failure is not another — e.kind() returns an ErrorKind. A missing file on first run is expected; everything else is not. Use a match guard:

+
let text = match fs::read_to_string(path) {
+    Ok(text) => text,
+    Err(e) if e.kind() == ErrorKind::NotFound => return Ok(Store::new()),
+    Err(e) => return Err(TaskError::Io(e)),               // permissions, disk, …
+};
+

FromStr is the trait behind .parse(). Implement it and .parse::<YourType>() starts working — type Err is an associated type: a slot the implementor fills once, not a parameter the caller passes.

+
pub trait FromStr: Sized {
+    type Err;
+    fn from_str(s: &str) -> Result<Self, Self::Err>;
+}
+
+impl FromStr for Task {
+    type Err = TaskError;
+    fn from_str(line: &str) -> Result<Task, TaskError> {
+        let bad = || TaskError::BadLine(line.to_string());  // built on failure
+        let mut parts = line.splitn(4, '|');  // splitn: last piece keeps its '|'
+        let id: u32 = parts.next().ok_or_else(bad)?.parse().map_err(|_| bad())?;
+        ..
+    }
+}
+
error[E0046]: not all trait items implemented, missing: `Err`   // no `type Err`
+error[E0277]: the trait bound `Task: FromStr` is not satisfied  // no impl
+

? vs map_err: only one impl From<T> for YourError may exist per T (a second is error[E0119]: conflicting implementations). So ? handles the canonical meaning, and map_err names the variant for every other meaning of the same error type.

+
let id: u32 = text.parse()?;                                // -> BadId, via From
+let id: u32 = text.parse().map_err(|_| bad())?;        // -> BadLine, chosen here
+

Not everything derives. io::Error is not PartialEq, so a variant holding one breaks #[derive(PartialEq)] (E0369). Write it by hand; mem::discriminant covers the payload-free variants:

+
impl PartialEq for TaskError {
+    fn eq(&self, other: &Self) -> bool {
+        match (self, other) {                         // match on a TUPLE of both
+            (Io(a), Io(b)) => a.kind() == b.kind(),
+            (NotFound(a), NotFound(b)) => a == b,
+            _ => std::mem::discriminant(self) == std::mem::discriminant(other),
+        }
+    }
+}
+

Book: 12.2, 12.5 · std: fs · ErrorKind · FromStr

+ +

Traits & generics

+
trait Reading {
+    fn celsius(&self) -> f64;             // REQUIRED — semicolon
+    fn label(&self) -> String {        // DEFAULT — has a body, may be overridden
+        format!("{:.1}C", self.celsius())
+    }
+}
+
+impl Reading for Kettle {                  // impl TRAIT for TYPE
+    fn celsius(&self) -> f64 { (self.fahrenheit - 32.0) * 5.0 / 9.0 }
+}
+
+impl fmt::Display for Thermometer {        // the trait behind `{}`
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        write!(f, "{} [{:.1}C]", self.room, self.celsius)   // INTO f, no `;`
+    }
+}
+

Bounds — four spellings, one meaning ("any T that implements Reading"):

+
fn show<T: Reading>(r: &T)                       // angle brackets
+fn show(r: &impl Reading)                        // shorthand
+fn show<T>(r: &T) where T: Reading               // where clause, for long lists
+fn show(r: &dyn Reading)                  // trait OBJECT: type chosen at runtime
+fn show<T: Reading + Clone>(r: &T)               // two promises at once
+

Box<dyn Error> is the same dyn idea: "some heap value that implements Error". Use it when the failures are unrelated and an enum is not worth writing.

+

A generic function of your own — one definition, one specialised copy compiled per set of +types actually used (monomorphisation), so the generality is free at runtime:

+
pub fn tally<T, K, F>(items: &[T], key: F) -> HashMap<K, usize>
+where
+    K: Eq + Hash,      // required by the HashMap being returned, not by the body
+    F: Fn(&T) -> K,        // each closure has its OWN anonymous type
+{
+    let mut counts = HashMap::new();
+    for item in items { *counts.entry(key(item)).or_insert(0) += 1; }
+    counts
+}
+
+tally(&tasks, |t| t.priority)            // T = Task,  K = Priority
+tally(&["a", "bb"], |w| w.len())         // T = &str,  K = usize
+

The clause is a contract both ways: callers must satisfy it, and the body may use only what it +promises. Fn = borrows its captures · FnMut = mutates them · FnOnce = +consumes them. Take Fn unless you need more.

+

Errors you will see:

+
error[E0046]: not all trait items implemented, missing: `celsius`
+   | ^^^^^^^^^^^^^^^^^^^^^^^ missing `celsius` in implementation
+
+error[E0277]: `Kettle` doesn't implement `std::fmt::Display`
+   = note: in format strings you may be able to use `{:?}` instead
+
+error[E0599]: no method named `celsius` found for reference `&&T` in the current scope
+   = help: items from traits can only be used if the trait is implemented and in scope
+note: `Reading` defines an item `celsius`, perhaps you need to implement it
+

That last one is the missing-bound error: a bare <T> promises nothing, so no method exists on it. Add the bound.

+

Free consequences of one impl: Display grants ToString (so .to_string() works); From<A> for B grants A.into(): B and makes ? convert.

+

Orphan rule: impl Trait for Type is allowed only if the trait or the type is yours. Display for MyTask ✓ · MyTrait for Vec<T> ✓ · Display for Vec<String> ✗.

+

Trait in scope: to call a trait method you must have the trait imported (use std::io::Write; before .write_all()). println!("{}") is exempt — the macro names Display by full path.

+

Book: 10.2 Traits · 10.1 Generics · std: Display

+ +

Printing

+
println!("plain text");
+println!("{}", value);           // Display
+println!("{value}");             // inline variable (preferred)
+println!("{value:?}");           // Debug — needs #[derive(Debug)]
+println!("{value:#?}");          // Debug, pretty-printed
+eprintln!("to stderr");
+ +

Debug vs Display

+

Two independent traits. A type can have one, both, or neither.

+
{}    {value}     needs Display — text for END USERS
+{:?}  {value:?}   needs Debug   — text for PROGRAMMERS
+{:#?} {value:#?}  needs Debug   — same, multi-line
+
let s = String::from("hi\tthere");
+println!("{s}");     // hi	there        <- raw, for a user
+println!("{s:?}");   // "hi\tthere"     <- quoted + escaped, exact
+
#[derive(Debug)]                 // compiler writes Debug for you
+struct Config { name: String }
+
+impl fmt::Display for Point {    // Display is NEVER derivable — write by hand
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        write!(f, "({}, {})", self.x, self.y)
+    }
+}
+

Why no derive(Display): how a value should look to a human is your program's decision, not something the compiler can guess. Debug has one obvious form (type name + fields), so it can be generated.

+

Why Vec has Debug but no Display: there is no single correct way to show a list to a user (commas? bullets? brackets?), so std refuses to pick. You choose:

+
println!("{v:?}");                 // ["a", "b"]     — diagnostics
+println!("{}", v.join(", "));      // a, b           — you pick the format
+

Errors you will see:

+
error[E0277]: `Vec<String>` doesn't implement `std::fmt::Display`
+  = note: in format strings you may be able to use `{:?}` instead
+
+error[E0277]: `Point` doesn't implement `Debug`
+  = note: add `#[derive(Debug)]` to `Point` or manually `impl Debug for Point`
+

Rule of thumb: use {:?} while developing and in logs. Write a Display impl only when a real person reads the output. Custom error types want both — Display for the caller's message, Debug for the log.

+

Book: 5.2 (derive Debug) · 10.2 Traits · std::fmt docs

+ +

Tests

+
#[cfg(test)]              // compiled by `cargo test`, not by `cargo build`
+mod tests {
+    use super::*;         // the child module needs its parent's items
+
+    #[test]                             // no arguments, no return value
+    fn a_task_starts_as_todo() {
+        assert_eq!(Task::new(1, "x", Low).status, Status::Todo);
+    }
+}
+

A test fails by panicking. Each test runs on its own thread; the harness marks it failed when that thread dies. So every assertion macro is a wrapper that panics, and unwrap/expect in a test body is a legitimate assertion.

+
assert!(cond)              // prints the SOURCE TEXT of cond
+assert!(cond, "id {} was wrong", id)     // extra args go to format!
+assert_eq!(a, b)           // prints both values as `left` / `right`
+assert_ne!(a, b)           // same, passes when they differ
+

assert_eq! needs PartialEq (to compare) and Debug (to print) on the values — that is what the #[derive(Debug, PartialEq)] on your own types is for. Prefer it over assert!(a == b), which throws the values away. An assertion inside a loop needs a message naming the case.

+

Two ways to test a failure — one per way your code fails:

+
#[test]
+#[should_panic(expected = "at most 30")]   // substring of the panic message
+fn refuses_a_high_target() { Thermostat::new(99); }
+
+#[test]
+fn a_reload_sees_what_was_saved() -> Result<(), TaskError> {
+    store.save(&path)?;                    // `?` for SETUP that must work
+    assert!(Store::load(&bad).is_err());   // is_err for the tested failure
+    Ok(())
+}
+

Always give should_panic its expected, or any panic passes the test. #[should_panic] and a Result return cannot be combined; assert with is_err() or unwrap_err() instead. A Result test's error type only needs Debug.

+

Two homes, two levels of access:

+ + + + + + + +
Unit testIntegration test
Lives inbottom of the source filetests/<name>.rs
Compiled aspart of your crateits own separate crate
Can reachprivate items toothe public API only
Needs#[cfg(test)] + use super::*use mycrate::…
Run alonecargo test --libcargo test --test name
+

Nothing in src/main.rs is testable — a binary crate exposes nothing to use. Keep main thin, put the logic in the library, and take the output destination as a parameter so a test can read it:

+
pub fn run(args: &[String], store: &mut Store, out: &mut impl Write)
+    -> Result<(), TaskError>
+{
+    writeln!(out, "added task {}", id)?;   // never println! in library code
+}
+
+run(&args, &mut store, &mut io::stdout().lock())?;   // in main
+let mut out: Vec<u8> = Vec::new();                   // in a test
+run(&args, &mut store, &mut out)?;
+assert_eq!(String::from_utf8(out)?, "added task 1\n");
+

Helpers shared by two integration files go in tests/common/mod.rs, never tests/common.rs — a plain file there is compiled as its own test crate and shows up as a stray running 0 tests section.

+
tests/
+├── common/mod.rs     mod common;  then  common::three_tasks()
+├── cli.rs            #![allow(dead_code)] in mod.rs: each crate uses some
+└── mine.rs
+

Tests run in parallel and share nothing safely — no shared file, no current directory, no environment variable. Give every file-touching test its own path (process id + an AtomicU32 counter, inside env::temp_dir()).

+
cargo test                     # everything
+cargo test warmer              # every test whose FULL name contains it
+cargo test --lib               # unit tests only   (--test cli = one file)
+cargo test -- --show-output    # also print stdout of tests that PASSED
+cargo test -- --ignored        # only the #[ignore] ones
+cargo test -- --test-threads=1 # no parallelism — diagnose, do not fix
+

Flags before -- go to cargo, flags after it go to the test binary. The module path is part of a test's name (task::tests::…), so filtering on a module name runs that module.

+
running 4 tests
+test tests::refuses_a_high_target - should panic ... ok
+test tests::slow_one ... ignored, only when the range changes
+
+test result: ok. 3 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out
+// measured = nightly benchmarks · filtered out = excluded by your filter
+

Errors you will see:

+
error[E0433]: cannot find type `Thermostat` in this scope   // no use super::*
+error[E0603]: function `capped` is private        // private fn, from tests/
+error[E0616]: field `target` of struct `Thermostat` is private
+  help: a method `target` also exists, call it with parentheses
+

Judge a suite by the bugs it kills, not by the number of tests. Change one operator in the code by hand, run the suite, and put it back: if nothing went red, that behaviour is untested.

+

Book: 11.1 Writing tests · 11.2 Running them · 11.3 Organization

+ + + + diff --git a/restauran/.gitignore b/restauran/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/restauran/.gitignore @@ -0,0 +1 @@ +/target diff --git a/restauran/Cargo.lock b/restauran/Cargo.lock new file mode 100644 index 0000000..acf7e49 --- /dev/null +++ b/restauran/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "restauran" +version = "0.1.0" diff --git a/restauran/Cargo.toml b/restauran/Cargo.toml new file mode 100644 index 0000000..d5cec5f --- /dev/null +++ b/restauran/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "restauran" +version = "0.1.0" +edition = "2024" + +[dependencies] diff --git a/restauran/src/front_of_house.rs b/restauran/src/front_of_house.rs new file mode 100644 index 0000000..d0a8154 --- /dev/null +++ b/restauran/src/front_of_house.rs @@ -0,0 +1 @@ +pub mod hosting; diff --git a/restauran/src/front_of_house/hosting.rs b/restauran/src/front_of_house/hosting.rs new file mode 100644 index 0000000..d65f3af --- /dev/null +++ b/restauran/src/front_of_house/hosting.rs @@ -0,0 +1 @@ +pub fn add_to_waitlist() {} diff --git a/restauran/src/lib.rs b/restauran/src/lib.rs new file mode 100644 index 0000000..dc51784 --- /dev/null +++ b/restauran/src/lib.rs @@ -0,0 +1,31 @@ +mod front_of_house; +// { +// pub mod hosting { +// pub fn add_to_waitlist() {} +// +// fn seat_at_table() {} +// } +// +// mod serving { +// fn take_order() {} +// fn serve_order() {} +// fn take_payment() {} +// } +// } + +pub fn eat_at_restaurant() { + let arr: [u16; 10] = [0; 10]; + // Absolute path + crate::front_of_house::hosting::add_to_waitlist(); + + // Relative path + front_of_house::hosting::add_to_waitlist(); +} + +use crate::front_of_house::hosting; + +mod customer { + pub fn eat_at_restaurant() { + super::hosting::add_to_waitlist(); + } +} diff --git a/slice/.gitignore b/slice/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/slice/.gitignore @@ -0,0 +1 @@ +/target diff --git a/slice/Cargo.lock b/slice/Cargo.lock new file mode 100644 index 0000000..4237bb5 --- /dev/null +++ b/slice/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "slice" +version = "0.1.0" diff --git a/slice/Cargo.toml b/slice/Cargo.toml new file mode 100644 index 0000000..4e9326d --- /dev/null +++ b/slice/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "slice" +version = "0.1.0" +edition = "2024" + +[dependencies] diff --git a/slice/src/main.rs b/slice/src/main.rs new file mode 100644 index 0000000..bc3073e --- /dev/null +++ b/slice/src/main.rs @@ -0,0 +1,19 @@ +fn main() { + let mut s = String::from("hello world"); + let word = first_word(&s); + + println!("{word}"); + + s.clear(); +} + +fn first_word(s: &str) -> &str { + let bytes = s.as_bytes(); + + for (i, &item) in bytes.iter().enumerate() { + if item == b' ' { + return &s[..i]; + } + } + &s[..] +} diff --git a/tasks/.env b/tasks/.env new file mode 100644 index 0000000..8d87e4f --- /dev/null +++ b/tasks/.env @@ -0,0 +1 @@ +TASK_FILE=t.txt diff --git a/tasks/.gitignore b/tasks/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/tasks/.gitignore @@ -0,0 +1 @@ +/target diff --git a/tasks/Cargo.lock b/tasks/Cargo.lock new file mode 100644 index 0000000..566edc6 --- /dev/null +++ b/tasks/Cargo.lock @@ -0,0 +1,16 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "dotenv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" + +[[package]] +name = "tasks" +version = "0.1.0" +dependencies = [ + "dotenv", +] diff --git a/tasks/Cargo.toml b/tasks/Cargo.toml new file mode 100644 index 0000000..c37fcf8 --- /dev/null +++ b/tasks/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "tasks" +version = "0.1.0" +edition = "2024" + +[dependencies] +dotenv = "0.15.0" diff --git a/tasks/src/cli.rs b/tasks/src/cli.rs new file mode 100644 index 0000000..1929a68 --- /dev/null +++ b/tasks/src/cli.rs @@ -0,0 +1,43 @@ +use std::io::Write; + +use crate::{command::Command, error::TaskError, store::Store, task::Priority}; + +pub fn run(args: &[String], memory: &mut Store, out: &mut impl Write) -> Result<(), TaskError> { + match Command::parse(args)? { + Command::Add { title, priority } => { + let n = memory.add(&title, priority); + writeln!(out, "added task {}", n)?; + Ok(()) + } + Command::List => { + memory + .tasks() + .iter() + .try_for_each(|x| writeln!(out, "{}", x))?; + Ok(()) + } + Command::Done { id } => { + memory.complete(id)?; + writeln!(out, "completed {}", id)?; + Ok(()) + } + Command::Remove { id } => { + memory.remove(id)?; + writeln!(out, "removed {}", id)?; + Ok(()) + } + Command::Stats => { + let counts = memory.count_by_priority(); + for p in [Priority::High, Priority::Medium, Priority::Low] { + let count = counts.get(&p).copied().unwrap_or(0); + writeln!(out, "{:<6} {}", p.label(), count)?; + } + Ok(()) + } + Command::Clear => { + let n = memory.remove_completed(); + writeln!(out, "cleared {} completed", n)?; + Ok(()) + } + } +} diff --git a/tasks/src/command.rs b/tasks/src/command.rs new file mode 100644 index 0000000..ecfbeb3 --- /dev/null +++ b/tasks/src/command.rs @@ -0,0 +1,46 @@ +use crate::{ + error::TaskError::{self, UnknownCommand}, + task::Priority, +}; + +#[derive(Debug, PartialEq)] +pub enum Command { + Add { title: String, priority: Priority }, + List, + Done { id: u32 }, + Remove { id: u32 }, + Stats, + Clear, +} + +impl Command { + pub fn parse(args: &[String]) -> Result { + let first_arg = args.first().ok_or(TaskError::NoCommand)?; + match first_arg.to_lowercase().as_str() { + "add" => { + let title = args.get(1).ok_or(TaskError::MissingTitle)?; + let priority = match args.get(2) { + Some(text) => text, + None => "medium", + }; + Ok(Command::Add { + title: title.clone(), + priority: Priority::parse(priority) + .ok_or(TaskError::BadPriority(priority.to_string()))?, + }) + } + "list" => Ok(Command::List), + "done" => { + let id = args.get(1).ok_or(TaskError::MissingId)?.parse()?; + Ok(Command::Done { id }) + } + "remove" => { + let id = args.get(1).ok_or(TaskError::MissingId)?.parse()?; + Ok(Command::Remove { id }) + } + "stats" => Ok(Command::Stats), + "clear" => Ok(Command::Clear), + e => Err(UnknownCommand(e.to_string())), + } + } +} diff --git a/tasks/src/error.rs b/tasks/src/error.rs new file mode 100644 index 0000000..2dcfff8 --- /dev/null +++ b/tasks/src/error.rs @@ -0,0 +1,73 @@ +use std::{error::Error, fmt::Display, io, num::ParseIntError}; + +#[derive(Debug)] +pub enum TaskError { + NoCommand, // no arguments at all + UnknownCommand(String), // the word the user actually typed + MissingTitle, // `add` with nothing after it + MissingId, // `done` / `remove` with nothing after it + BadPriority(String), // the priority word that was not low/medium/high + BadId(ParseIntError), // `done abc` — wraps std's parse failure + NotFound(u32), // the id that was not in the store + BadLine(String), // a saved line that cannot be read back — carries the line + Io(io::Error), // the file could not be read or written — wraps std's error +} + +impl Display for TaskError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TaskError::NoCommand => write!(f, "no command given"), + TaskError::UnknownCommand(e) => write!(f, "unknown command: {}", e), + TaskError::MissingTitle => write!(f, "add needs a title"), + TaskError::MissingId => write!(f, "this command needs a task id"), + TaskError::BadPriority(e) => write!(f, "unknown priority: {}", e), + TaskError::BadId(_) => write!(f, "task id must be a number"), + TaskError::NotFound(e) => write!(f, "no task with id {}", e), + TaskError::BadLine(e) => write!(f, "cannot read saved line: {}", e), + TaskError::Io(_) => write!(f, "cannot read or write the task file"), + } + } +} + +impl PartialEq for TaskError { + fn eq(&self, other: &Self) -> bool { + use TaskError::*; + match (self, other) { + (UnknownCommand(a), UnknownCommand(b)) + | (BadPriority(a), BadPriority(b)) + | (BadLine(a), BadLine(b)) => a == b, + (BadId(a), BadId(b)) => a == b, + (NotFound(a), NotFound(b)) => a == b, + (Io(a), Io(b)) => a.kind() == b.kind(), // compare the kind, not the error + _ => std::mem::discriminant(self) == std::mem::discriminant(other), + } + } +} + +impl Error for TaskError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + TaskError::Io(e) => Some(e), + TaskError::BadId(e) => Some(e), + TaskError::BadPriority(_) + | TaskError::UnknownCommand(_) + | TaskError::MissingTitle + | TaskError::NoCommand + | TaskError::MissingId + | TaskError::NotFound(_) + | TaskError::BadLine(_) => None, + } + } +} + +impl From for TaskError { + fn from(value: ParseIntError) -> Self { + TaskError::BadId(value) + } +} + +impl From for TaskError { + fn from(value: io::Error) -> Self { + TaskError::Io(value) + } +} diff --git a/tasks/src/lib.rs b/tasks/src/lib.rs new file mode 100644 index 0000000..4ee6760 --- /dev/null +++ b/tasks/src/lib.rs @@ -0,0 +1,6 @@ +pub mod cli; +pub mod command; +pub mod error; +pub mod stats; +pub mod store; +pub mod task; diff --git a/tasks/src/main.rs b/tasks/src/main.rs new file mode 100644 index 0000000..1d04780 --- /dev/null +++ b/tasks/src/main.rs @@ -0,0 +1,26 @@ +use dotenv::dotenv; +use std::env::{self, args}; +use std::io::{self}; +use std::{path::PathBuf, process}; +use tasks::cli; +use tasks::error::TaskError; +use tasks::store::Store; +fn main() { + dotenv().ok(); + let path = PathBuf::from(env::var("TASKS_FILE").unwrap_or_else(|_| "tasks.txt".to_string())); + let mut memory = Store::load(&path).unwrap_or_else(|e| fail(e)); + let args: Vec = args().skip(1).collect(); + + if let Err(e) = cli::run(&args, &mut memory, &mut io::stdout().lock()) { + fail(e) + } + + if let Err(e) = memory.save(&path) { + fail(e) + } +} + +fn fail(e: TaskError) -> ! { + eprintln!("error: {}", e); + process::exit(1); +} diff --git a/tasks/src/stats.rs b/tasks/src/stats.rs new file mode 100644 index 0000000..ebfb1b3 --- /dev/null +++ b/tasks/src/stats.rs @@ -0,0 +1,26 @@ +use std::{collections::HashMap, hash::Hash}; + +pub fn tally(items: &[T], key: F) -> HashMap +where + K: Eq + Hash, + F: Fn(&T) -> K, +{ + let mut counts = HashMap::new(); + for item in items { + *counts.entry(key(item)).or_insert(0) += 1; + } + counts +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn owned_key() { + let words = ["apple", "avocado", "banana"]; + let counts = tally(&words, |w| w.chars().next().unwrap().to_string()); + assert_eq!(counts.get("a"), Some(&2), "keys: {:?}", counts); + assert_eq!(counts.len(), 2, "keys: {:?}", counts); + } +} diff --git a/tasks/src/store.rs b/tasks/src/store.rs new file mode 100644 index 0000000..d295a18 --- /dev/null +++ b/tasks/src/store.rs @@ -0,0 +1,115 @@ +use std::collections::HashMap; +use std::fs; +use std::io::ErrorKind; +use std::path::Path; + +use crate::error::TaskError; +use crate::stats; +use crate::task::Priority; +use crate::task::Status; +use crate::task::Task; + +#[derive(Debug, PartialEq)] +pub struct Store { + tasks: Vec, + id: u32, +} + +impl Store { + pub fn new() -> Store { + Store { + tasks: Vec::new(), + id: 1, + } + } + + pub fn add(&mut self, title: &str, priority: Priority) -> u32 { + self.tasks.push(Task::new(self.id, title, priority)); + let id = self.id; + self.id += 1; + id + } + + pub fn complete(&mut self, id: u32) -> Result<(), TaskError> { + let task = self + .tasks + .iter_mut() + .find(|task| task.id == id) + .ok_or(TaskError::NotFound(id))?; + + task.status = Status::Done; + Ok(()) + } + + pub fn remove(&mut self, id: u32) -> Result<(), TaskError> { + let idx = self.tasks.iter().position(|x| x.id == id); + match idx { + Some(i) => { + self.tasks.remove(i); + Ok(()) + } + None => Err(TaskError::NotFound(id)), + } + } + + pub fn tasks(&self) -> &[Task] { + &self.tasks[..] + } + + pub fn find(&self, id: u32) -> Option<&Task> { + self.tasks.iter().find(|task| task.id == id) + // for task in self.tasks.iter() { + // if task.id == id { + // return Some(task); + // } + // } + // None + } + + pub fn save(&self, path: &Path) -> Result<(), TaskError> { + let contents = self + .tasks + .iter() + .map(|x| format!("{}\n", x.to_line())) + .collect::(); + + Ok(fs::write(path, contents)?) + } + + pub fn load(path: &Path) -> Result { + let contents = match fs::read_to_string(path) { + Ok(s) => s, + Err(e) if e.kind() == ErrorKind::NotFound => String::new(), + Err(e) => return Err(TaskError::Io(e)), + }; + + let mut memory = Store::new(); + + memory.tasks = contents + .lines() + .map(|s| s.parse()) + .collect::, TaskError>>()?; + + let max_id: u32 = memory.tasks.iter().map(|t| t.id).max().unwrap_or(0); + memory.id = max_id + 1; + Ok(memory) + } + + pub fn count_by_priority(&self) -> HashMap { + stats::tally(&self.tasks, |t| t.priority) + } + + pub fn titles_with(&self, priority: Priority) -> Vec<&str> { + self.tasks + .iter() + .filter(|x| x.priority == priority) + .map(|x| x.title.as_str()) + .collect() + } + pub fn remove_completed(&mut self) -> usize { + let n = self.tasks.len(); + self.tasks.retain(|x| x.status != Status::Done); + + n - self.tasks.len() + } +} diff --git a/tasks/src/task.rs b/tasks/src/task.rs new file mode 100644 index 0000000..ff8dafa --- /dev/null +++ b/tasks/src/task.rs @@ -0,0 +1,147 @@ +use std::{fmt, str::FromStr}; + +use crate::error::TaskError; + +#[derive(Debug, PartialEq)] +pub enum Status { + Todo, + InProgress, + Done, +} + +#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)] +pub enum Priority { + Low, + Medium, + High, +} + +#[derive(Debug, PartialEq)] +pub struct Task { + pub id: u32, + pub title: String, + pub priority: Priority, + pub status: Status, +} + +impl fmt::Display for Task { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{} [{}] {} ({})", + self.id, + self.status.label(), + self.title, + self.priority.label() + ) + } +} + +impl Status { + /// todo, in-progress, done + pub fn label(&self) -> &str { + match self { + Status::Todo => "todo", + Status::InProgress => "in-progress", + Status::Done => "done", + } + } + pub fn parse(status: &str) -> Option { + match status.to_lowercase().as_str() { + "in-progress" => Some(Status::InProgress), + "done" => Some(Status::Done), + "todo" => Some(Status::Todo), + _ => None, + } + } +} + +impl Priority { + pub fn label(&self) -> &str { + match self { + Priority::Low => "low", + Priority::Medium => "medium", + Priority::High => "high", + } + } + + pub fn parse(text: &str) -> Option { + match text.to_lowercase().as_str() { + "low" => Some(Priority::Low), + "medium" => Some(Priority::Medium), + "high" => Some(Priority::High), + _ => None, + } + } +} + +impl Task { + /// default status is todo + // yo what is happening right here + pub fn new(id: u32, title: &str, priority: Priority) -> Task { + Task { + id, + title: title.to_string(), + priority, + status: Status::Todo, + } + } + + /// id|status|priority|title + pub fn to_line(&self) -> String { + let line: String = format!( + "{}|{}|{}|{}", + self.id, + self.status.label(), + self.priority.label(), + self.title + ); + line + } +} + +impl FromStr for Task { + type Err = TaskError; + fn from_str(s: &str) -> Result { + let items: Vec<&str> = s.splitn(4, "|").collect(); + let bad_line = || TaskError::BadLine(s.to_string()); + if items.len() != 4 { + return Err(bad_line()); + } + Ok(Task { + id: items[0].parse().map_err(|_| bad_line())?, + status: Status::parse(items[1]).ok_or_else(bad_line)?, + priority: Priority::parse(items[2]).ok_or_else(bad_line)?, + title: items[3].to_string(), + }) + } +} + +#[cfg(test)] +mod test { + + use super::*; + + #[test] + fn line_format() { + let t = Task::new(1, "eat something", Priority::Low); + assert_eq!(t.to_line(), "1|todo|low|eat something"); + let line = t.to_line(); + assert_eq!(line.parse(), Ok(t)); + } + + #[test] + fn status_label() { + for t in [Status::Todo, Status::InProgress, Status::Done] { + assert_eq!(Status::parse(t.label()), Some(t)); + } + } + + #[test] + fn user_display() { + let mut t = Task::new(1, "eat something", Priority::Low); + assert_eq!(t.to_string(), "1 [low] eat something (todo)"); + t.status = Status::Done; + assert_eq!(t.to_string(), "1 [low] eat something (done)"); + } +} diff --git a/tasks/t.txt b/tasks/t.txt new file mode 100644 index 0000000..e69de29 diff --git a/tasks/tasks.txt b/tasks/tasks.txt new file mode 100644 index 0000000..54b7db1 --- /dev/null +++ b/tasks/tasks.txt @@ -0,0 +1,2 @@ +1|done|high|mandi +2|todo|low|makan diff --git a/tasks/tests/collections.rs b/tasks/tests/collections.rs new file mode 100644 index 0000000..8b4623f --- /dev/null +++ b/tasks/tests/collections.rs @@ -0,0 +1,166 @@ +// The 0008 specification, as executable tests. Do not edit this file — make it pass. +// Copy to: tasks/tests/collections.rs +// Run with: cargo test + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU32, Ordering}; +use tasks::stats::tally; +use tasks::store::Store; +use tasks::task::Priority; + +// a fresh path per test, inside the OS temp dir — no file is ever left in your crate +fn temp_path() -> PathBuf { + static N: AtomicU32 = AtomicU32::new(0); + let n = N.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!("tasks-coll-{}-{}.txt", std::process::id(), n)) +} + +// ---------- tally: one generic function, any element type, any key type ---------- + +#[test] +fn tally_counts_how_often_each_key_appears() { + let words = ["red", "blue", "red", "green", "red"]; + let counts = tally(&words, |word| *word); + assert_eq!(counts[&"red"], 3); + assert_eq!(counts[&"blue"], 1); + assert_eq!(counts.len(), 3); +} + +#[test] +fn tally_accepts_a_key_type_that_is_not_the_element_type() { + let words = ["a", "bb", "cc", "ddd"]; + let by_length = tally(&words, |word| word.len()); + assert_eq!(by_length, HashMap::from([(1, 1), (2, 2), (3, 1)])); +} + +#[test] +fn tally_of_nothing_is_an_empty_map() { + let nothing: [u32; 0] = []; + assert!(tally(¬hing, |n| *n).is_empty()); +} + +// ---------- counting the store, built on that same function ---------- + +#[test] +fn count_by_priority_counts_every_priority_present() { + let mut store = Store::new(); + store.add("buy milk", Priority::High); + store.add("call bank", Priority::High); + store.add("water plants", Priority::Low); + + assert_eq!( + store.count_by_priority(), + HashMap::from([(Priority::High, 2), (Priority::Low, 1)]) + ); +} + +#[test] +fn count_by_priority_has_no_entry_for_an_absent_priority() { + let mut store = Store::new(); + store.add("buy milk", Priority::High); + + let counts = store.count_by_priority(); + assert_eq!(counts.len(), 1, "absent priorities must not appear as zero"); + assert_eq!(counts.get(&Priority::Low), None); +} + +#[test] +fn count_by_priority_of_an_empty_store_is_empty() { + assert!(Store::new().count_by_priority().is_empty()); +} + +// ---------- selecting titles: filter, map, collect ---------- + +#[test] +fn titles_with_returns_matching_titles_in_order() { + let mut store = Store::new(); + store.add("buy milk", Priority::High); + store.add("water plants", Priority::Low); + store.add("call bank", Priority::High); + + assert_eq!(store.titles_with(Priority::High), ["buy milk", "call bank"]); +} + +#[test] +fn titles_with_returns_nothing_when_no_task_matches() { + let mut store = Store::new(); + store.add("buy milk", Priority::High); + + assert!(store.titles_with(Priority::Medium).is_empty()); +} + +// ---------- clearing out finished work: retain ---------- + +#[test] +fn remove_completed_drops_done_tasks_and_reports_how_many() { + let mut store = Store::new(); + let first = store.add("buy milk", Priority::High); + store.add("water plants", Priority::Low); + let third = store.add("call bank", Priority::Medium); + store.complete(first).unwrap(); + store.complete(third).unwrap(); + + assert_eq!(store.remove_completed(), 2); + assert_eq!(store.titles_with(Priority::Low), ["water plants"]); + assert_eq!(store.tasks().len(), 1); +} + +#[test] +fn remove_completed_removes_nothing_when_nothing_is_done() { + let mut store = Store::new(); + store.add("buy milk", Priority::High); + store.add("call bank", Priority::Low); + + assert_eq!(store.remove_completed(), 0); + assert_eq!(store.tasks().len(), 2); +} + +#[test] +fn remove_completed_keeps_the_remaining_ids_unchanged() { + let mut store = Store::new(); + let first = store.add("buy milk", Priority::High); + store.add("call bank", Priority::Low); + store.complete(first).unwrap(); + store.remove_completed(); + + assert_eq!(store.tasks()[0].id, 2, "surviving tasks keep their own id"); + assert_eq!( + store.add("new one", Priority::Low), + 3, + "and the counter is untouched" + ); +} + +// ---------- load: one collect, and the line rules that come with it ---------- + +#[test] +fn a_file_without_a_trailing_newline_still_loads() { + let path = temp_path(); + std::fs::write(&path, "1|todo|low|fine").unwrap(); + + let store = Store::load(&path).unwrap(); + assert_eq!(store.tasks().len(), 1); +} + +#[test] +fn a_blank_line_inside_the_file_is_a_bad_line() { + let path = temp_path(); + std::fs::write(&path, "1|todo|low|fine\n\n2|todo|low|also fine\n").unwrap(); + + let err = Store::load(&path).unwrap_err(); + assert_eq!(err.to_string(), "cannot read saved line: "); +} + +#[test] +fn one_bad_line_loses_the_whole_load_not_part_of_it() { + let path = temp_path(); + std::fs::write(&path, "1|todo|low|fine\nrubbish\n3|todo|low|also fine\n").unwrap(); + + assert!(Store::load(&path).is_err()); + // the file itself is untouched by a failed load — nothing was half-written + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + "1|todo|low|fine\nrubbish\n3|todo|low|also fine\n" + ); +} diff --git a/tasks/tests/common/mod.rs b/tasks/tests/common/mod.rs new file mode 100755 index 0000000..5ecb44b --- /dev/null +++ b/tasks/tests/common/mod.rs @@ -0,0 +1,29 @@ +#![allow(dead_code)] + +use std::{ + path::PathBuf, + sync::atomic::{AtomicU32, Ordering}, +}; + +use tasks::store::Store; +use tasks::task::Priority; + +pub fn temp_path() -> PathBuf { + static N: AtomicU32 = AtomicU32::new(0); + let n = N.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!("tasks-{}-{}.txt", std::process::id(), n)) +} + +pub fn args(list: &[&str]) -> Vec { + list.iter().map(|s| s.to_string()).collect() +} + +/// One task per priority, ids 1..=3, task 1 already completed. +pub fn three_tasks() -> Store { + let mut store = Store::new(); + store.add("low task", Priority::Low); + store.add("medium task", Priority::Medium); + store.add("high task", Priority::High); + store.complete(1).expect("task 1 was just added"); + store +} diff --git a/tasks/tests/errors.rs b/tasks/tests/errors.rs new file mode 100644 index 0000000..7c9ec33 --- /dev/null +++ b/tasks/tests/errors.rs @@ -0,0 +1,104 @@ +// The 0006 specification, as executable tests. Do not edit this file — make it pass. +// Copy to: tasks/tests/errors.rs +// Run with: cargo test + +use std::error::Error; +use tasks::command::Command; +use tasks::error::TaskError; +use tasks::store::Store; +use tasks::task::Priority; + +fn args(list: &[&str]) -> Vec { + list.iter().map(|s| s.to_string()).collect() +} + +// ---------- one variant per way of failing ---------- + +#[test] +fn parse_errors_name_the_exact_failure() { + assert_eq!(Command::parse(&args(&[])).unwrap_err(), TaskError::NoCommand); + assert_eq!( + Command::parse(&args(&["fly"])).unwrap_err(), + TaskError::UnknownCommand("fly".to_string()) + ); + assert_eq!( + Command::parse(&args(&["add"])).unwrap_err(), + TaskError::MissingTitle + ); + assert_eq!( + Command::parse(&args(&["add", "x", "urgent"])).unwrap_err(), + TaskError::BadPriority("urgent".to_string()) + ); + assert_eq!( + Command::parse(&args(&["done"])).unwrap_err(), + TaskError::MissingId + ); +} + +#[test] +fn store_reports_which_id_was_missing() { + let mut store = Store::new(); + store.add("only task", Priority::Low); + assert_eq!(store.complete(99).unwrap_err(), TaskError::NotFound(99)); + assert_eq!(store.remove(7).unwrap_err(), TaskError::NotFound(7)); +} + +// ---------- Display: the sentence the user reads ---------- + +#[test] +fn messages_are_lowercase_and_unpunctuated() { + assert_eq!(TaskError::NoCommand.to_string(), "no command given"); + assert_eq!( + TaskError::UnknownCommand("fly".to_string()).to_string(), + "unknown command: fly" + ); + assert_eq!(TaskError::MissingTitle.to_string(), "add needs a title"); + assert_eq!( + TaskError::MissingId.to_string(), + "this command needs a task id" + ); + assert_eq!( + TaskError::BadPriority("urgent".to_string()).to_string(), + "unknown priority: urgent" + ); + assert_eq!(TaskError::NotFound(9).to_string(), "no task with id 9"); +} + +// ---------- From + ? : the parse error is wrapped, not thrown away ---------- + +#[test] +fn a_bad_id_wraps_the_parse_error() { + let err = Command::parse(&args(&["done", "abc"])).unwrap_err(); + assert!(matches!(err, TaskError::BadId(_)), "expected BadId"); + assert!( + Command::parse(&args(&["remove", "-1"])).is_err(), + "a negative id is not a u32" + ); +} + +#[test] +fn a_wrapped_error_is_reported_once_and_reachable() { + let err = Command::parse(&args(&["done", "abc"])).unwrap_err(); + // Display says your sentence and does not repeat std's. + assert_eq!(err.to_string(), "task id must be a number"); + // std's sentence is still reachable, through Error::source(). + let inner = err.source().expect("BadId must expose its source"); + assert_eq!(inner.to_string(), "invalid digit found in string"); +} + +#[test] +fn errors_that_carry_nothing_have_no_source() { + assert!(TaskError::NoCommand.source().is_none()); + assert!(TaskError::NotFound(1).source().is_none()); +} + +// ---------- the Error trait, and the bounds the ecosystem expects ---------- + +#[test] +fn task_error_is_a_real_error() { + fn assert_usable_as_error() {} + assert_usable_as_error::(); + + let boxed: Box = Box::new(TaskError::NotFound(1)); + assert_eq!(boxed.to_string(), "no task with id 1"); +} diff --git a/tasks/tests/mine.rs b/tasks/tests/mine.rs new file mode 100644 index 0000000..286a6f6 --- /dev/null +++ b/tasks/tests/mine.rs @@ -0,0 +1,47 @@ +use std::fs; + +use tasks::error::TaskError; +use tasks::store::Store; +use tasks::task::Status; + +use crate::common::{temp_path, three_tasks}; + +mod common; + +#[test] +fn completing_task_that_are_done_is_not_an_error() -> Result<(), TaskError> { + let mut m = three_tasks(); + + m.complete(1)?; + + let t = m.find(1).unwrap(); + assert_eq!(t.status, Status::Done); + + Ok(()) +} + +#[test] +fn reload_saved_working() -> Result<(), TaskError> { + let mut m = three_tasks(); + let p = temp_path(); + m.remove_completed(); + m.save(&p)?; + + let m2 = Store::load(&p)?; + assert_eq!(m2, m); + Ok(()) +} + +#[test] +fn smaller_store_smaller_saved_files() -> Result<(), TaskError> { + let mut m = three_tasks(); + let p = temp_path(); + m.save(&p)?; + + m.remove_completed(); + m.save(&p)?; + + let s = fs::read_to_string(p)?; + assert_eq!(s.lines().count(), 2); + Ok(()) +} diff --git a/tasks/tests/persist.rs b/tasks/tests/persist.rs new file mode 100644 index 0000000..11ddc8c --- /dev/null +++ b/tasks/tests/persist.rs @@ -0,0 +1,106 @@ +// The 0007 specification, as executable tests. Do not edit this file — make it pass. +// Copy to: tasks/tests/persist.rs +// Run with: cargo test + +use std::error::Error; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU32, Ordering}; +use tasks::error::TaskError; +use tasks::store::Store; +use tasks::task::{Priority, Status, Task}; + +// a fresh path per test, inside the OS temp dir — no file is ever left in your crate +fn temp_path() -> PathBuf { + static N: AtomicU32 = AtomicU32::new(0); + let n = N.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!("tasks-test-{}-{}.txt", std::process::id(), n)) +} + +// ---------- a Task survives the trip to text and back ---------- + +#[test] +fn a_task_becomes_a_line_and_a_line_becomes_a_task() { + let task = Task { + id: 3, + title: String::from("buy milk"), + priority: Priority::High, + status: Status::Done, + }; + assert_eq!(task.to_line(), "3|done|high|buy milk"); + assert_eq!("3|done|high|buy milk".parse::().unwrap(), task); +} + +#[test] +fn a_title_may_contain_the_separator() { + let line = "1|todo|low|read a|b testing"; + let task: Task = line.parse().unwrap(); + assert_eq!(task.title, "read a|b testing"); + assert_eq!(task.to_line(), line); +} + +#[test] +fn a_corrupt_line_names_itself() { + for bad in ["", "nonsense", "1|todo|low", "x|todo|low|t", "1|sleeping|low|t", "1|todo|urgent|t"] { + assert_eq!( + bad.parse::().unwrap_err(), + TaskError::BadLine(bad.to_string()), + "line {:?} should be reported as a bad line", + bad + ); + } +} + +// ---------- the store round trip ---------- + +#[test] +fn every_task_survives_save_then_load() { + let path = temp_path(); + let mut store = Store::new(); + store.add("buy milk", Priority::High); + let second = store.add("call bank", Priority::Medium); + store.complete(second).unwrap(); + store.save(&path).unwrap(); + + let loaded = Store::load(&path).unwrap(); + assert_eq!(loaded.tasks(), store.tasks()); +} + +#[test] +fn ids_do_not_restart_after_a_reload() { + let path = temp_path(); + let mut store = Store::new(); + store.add("first", Priority::Low); + store.add("second", Priority::Low); + store.save(&path).unwrap(); + + let mut loaded = Store::load(&path).unwrap(); + assert_eq!(loaded.add("third", Priority::Low), 3); +} + +#[test] +fn a_missing_file_is_an_empty_store_not_an_error() { + let path = temp_path(); // never created + let store = Store::load(&path).expect("first run must not fail"); + assert!(store.tasks().is_empty()); +} + +#[test] +fn a_corrupt_file_fails_loudly() { + let path = temp_path(); + std::fs::write(&path, "1|todo|low|fine\nrubbish\n").unwrap(); + assert_eq!( + Store::load(&path).unwrap_err(), + TaskError::BadLine(String::from("rubbish")) + ); +} + +// ---------- io::Error becomes TaskError, and keeps its cause ---------- + +#[test] +fn a_write_that_cannot_happen_is_an_io_error() { + let path = temp_path().join("no-such-dir").join("t.txt"); + let err = Store::new().save(&path).unwrap_err(); + assert!(matches!(err, TaskError::Io(_)), "got {:?}", err); + assert_eq!(err.to_string(), "cannot read or write the task file"); + assert!(err.source().is_some(), "the io::Error must stay reachable"); +} diff --git a/tasks/tests/spec.rs b/tasks/tests/spec.rs new file mode 100644 index 0000000..9da7dfb --- /dev/null +++ b/tasks/tests/spec.rs @@ -0,0 +1,184 @@ +// The specification, as executable tests. Do not edit this file — make it pass. +// Copy to: tasks/tests/spec.rs +// Run with: cargo test + +use tasks::command::Command; +use tasks::store::Store; +use tasks::task::{Priority, Status, Task}; + +/// Helper: build an argument list the way main() would pass it in +/// (program name already stripped). +fn args(list: &[&str]) -> Vec { + list.iter().map(|s| s.to_string()).collect() +} + +// ---------- task.rs ---------- + +#[test] +fn new_task_starts_as_todo() { + let task = Task::new(1, "write the spec", Priority::High); + assert_eq!(task.id, 1); + assert_eq!(task.title, "write the spec"); + assert_eq!(task.status, Status::Todo); + assert_eq!(task.priority, Priority::High); +} + +#[test] +fn status_labels() { + assert_eq!(Status::Todo.label(), "todo"); + assert_eq!(Status::InProgress.label(), "in-progress"); + assert_eq!(Status::Done.label(), "done"); +} + +#[test] +fn priority_labels() { + assert_eq!(Priority::Low.label(), "low"); + assert_eq!(Priority::Medium.label(), "medium"); + assert_eq!(Priority::High.label(), "high"); +} + +#[test] +fn priority_parses_known_words_only() { + assert_eq!(Priority::parse("low"), Some(Priority::Low)); + assert_eq!(Priority::parse("medium"), Some(Priority::Medium)); + assert_eq!(Priority::parse("high"), Some(Priority::High)); + assert_eq!(Priority::parse("urgent"), None); + assert_eq!(Priority::parse(""), None); +} + +// ---------- command.rs ---------- + +#[test] +fn parses_add_with_default_priority() { + let command = Command::parse(&args(&["add", "buy milk"])).unwrap(); + assert_eq!( + command, + Command::Add { + title: "buy milk".to_string(), + priority: Priority::Medium, + } + ); +} + +#[test] +fn parses_add_with_explicit_priority() { + let command = Command::parse(&args(&["add", "ship it", "high"])).unwrap(); + assert_eq!( + command, + Command::Add { + title: "ship it".to_string(), + priority: Priority::High, + } + ); +} + +#[test] +fn parses_list_done_and_remove() { + assert_eq!(Command::parse(&args(&["list"])).unwrap(), Command::List); + assert_eq!( + Command::parse(&args(&["done", "7"])).unwrap(), + Command::Done { id: 7 } + ); + assert_eq!( + Command::parse(&args(&["remove", "12"])).unwrap(), + Command::Remove { id: 12 } + ); +} + +#[test] +fn rejects_bad_input() { + assert!(Command::parse(&args(&[])).is_err(), "no command at all"); + assert!(Command::parse(&args(&["fly"])).is_err(), "unknown command"); + assert!(Command::parse(&args(&["add"])).is_err(), "add with no title"); + assert!( + Command::parse(&args(&["add", "x", "urgent"])).is_err(), + "unknown priority word" + ); + assert!(Command::parse(&args(&["done"])).is_err(), "done with no id"); + assert!( + Command::parse(&args(&["done", "abc"])).is_err(), + "id is not a number" + ); + assert!( + Command::parse(&args(&["remove", "-1"])).is_err(), + "negative id is not a u32" + ); +} + +// ---------- store.rs ---------- + +#[test] +fn add_returns_ids_starting_at_one() { + let mut store = Store::new(); + assert_eq!(store.add("first", Priority::Low), 1); + assert_eq!(store.add("second", Priority::Low), 2); + assert_eq!(store.add("third", Priority::Low), 3); +} + +#[test] +fn tasks_come_back_in_insertion_order() { + let mut store = Store::new(); + store.add("first", Priority::Low); + store.add("second", Priority::High); + + let listed = store.tasks(); + assert_eq!(listed.len(), 2); + assert_eq!(listed[0].title, "first"); + assert_eq!(listed[1].title, "second"); +} + +#[test] +fn new_store_is_empty() { + let store = Store::new(); + assert!(store.tasks().is_empty()); +} + +#[test] +fn complete_sets_status_to_done() { + let mut store = Store::new(); + let id = store.add("do the thing", Priority::Medium); + + assert_eq!(store.find(id).unwrap().status, Status::Todo); + assert!(store.complete(id).is_ok()); + assert_eq!(store.find(id).unwrap().status, Status::Done); +} + +#[test] +fn complete_unknown_id_is_an_error() { + let mut store = Store::new(); + store.add("only task", Priority::Low); + assert!(store.complete(99).is_err()); +} + +#[test] +fn remove_deletes_only_that_task() { + let mut store = Store::new(); + let first = store.add("first", Priority::Low); + let second = store.add("second", Priority::Low); + + assert!(store.remove(first).is_ok()); + assert_eq!(store.tasks().len(), 1); + assert!(store.find(first).is_none()); + assert!(store.find(second).is_some()); +} + +#[test] +fn remove_unknown_id_is_an_error() { + let mut store = Store::new(); + assert!(store.remove(1).is_err()); +} + +#[test] +fn find_returns_none_for_missing_id() { + let store = Store::new(); + assert!(store.find(1).is_none()); +} + +#[test] +fn ids_are_not_reused_after_remove() { + let mut store = Store::new(); + let first = store.add("first", Priority::Low); + store.remove(first).unwrap(); + let second = store.add("second", Priority::Low); + assert_ne!(first, second, "a removed id must not be handed out again"); +} diff --git a/variables/.gitignore b/variables/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/variables/.gitignore @@ -0,0 +1 @@ +/target diff --git a/variables/Cargo.lock b/variables/Cargo.lock new file mode 100644 index 0000000..f26e54f --- /dev/null +++ b/variables/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "variables" +version = "0.1.0" diff --git a/variables/Cargo.toml b/variables/Cargo.toml new file mode 100644 index 0000000..c093eb6 --- /dev/null +++ b/variables/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "variables" +version = "0.1.0" +edition = "2024" + +[dependencies] diff --git a/variables/src/main.rs b/variables/src/main.rs new file mode 100644 index 0000000..5711982 --- /dev/null +++ b/variables/src/main.rs @@ -0,0 +1,26 @@ +fn main() { + let mut x = 5; + println!("the value of x is {x}"); + x = 3; + { + let x = x * 2; + println!("The value of x in the inner scope is: {x}"); + } + + println!("the value of x is {x}"); + + const THREE_HOURS_IN_SECONDS: u32 = 60 * 60 * 3; + let tup: (i32, f64, u8) = (500, 6.4, 1); + + let (x, y, z) = tup; + + let a: [i32; 5] = [1, 2, 3, 4, 5]; + let b: [i32; 5] = [0; 5]; + + println!("number {}", tup.0); + another(); +} + +fn another() { + println!("another one"); +}