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:

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:

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.