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.
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
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.
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
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.
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
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?
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.
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?
Two details worth burning in:
&[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.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
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.
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.