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.
cargo test. 17 tests define done. They will all fail at first — that is correct. Make them go green one at a time.
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:
"buy milk"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:
| Command | What it does |
|---|---|
add <title> [priority] | Creates a task. Priority is optional and defaults to medium. Prints the new id. |
list | Prints 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:
task.rs. It knows nothing about command lines.command.rs. It knows nothing about storage.store.rs. It knows nothing about command lines either.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.
$ 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.
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.
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
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.rspub 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.rspub 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.
| Input | Result |
|---|---|
["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.rspub 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:
add.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.rsNot covered by the tests — this part is yours to judge. It must:
Command::parse.match the resulting Command and call the right Store method.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.
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.
A vending machine, not a task list. Same shapes, different domain — you cannot paste any of this.
// 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;
#[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,
}
}
}
#[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"),
}
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
}
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}")),
}
// 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}"))?;
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.
eprintln!("error: {message}"); // stderr, not stdout
std::process::exit(1);
Not stages — just the order that keeps the compiler useful. Run cargo test after each.
lib.rs + task.rs. Four tests should go green. This proves your module wiring works before any logic exists.command.rs. Four more. The rejects_bad_input test is the interesting one.store.rs. The remaining nine.main.rs. Untested — verify by running the session from the top of this page yourself.Ten minutes stuck on the same thing first. Earlier than that and you are buying a smaller lesson.
Three separate rules, and mixing them up causes most of these errors:
src/ except main.rs), reach a sibling module with use crate::task::Priority;.main.rs and in tests/, you are in a different crate. Use the package name: use tasks::task::Priority;.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.
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.
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:
for task in &mut self.tasks, and mutate when the id matches.usize, which is Copy and holds no borrow), let that borrow end, then index to mutate or remove.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?
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.
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.
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.
cargo test → 17 passed; 0 failed.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.Only after 17/17. Each one drags in the next chapter you will need for backend work:
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.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.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.list by priority, high first. Needs #[derive(PartialOrd, Ord)] and awareness that variant declaration order defines the ordering.?, io::Error, and real error mixing stop being an exercise.