Your First Rust Program
Goal: you can read the gate lab line by line and explain every construct in it — including why one deliberate line panics.
The whole program
labs/gate-hello/src/main.rs (short version):
use std::env;
fn main() {
let args: Vec<String> = env::args().skip(1).collect();
match args.first().map(String::as_str) {
Some("--add") => {
let a: i64 = args.get(1).expect("usage: gate-hello --add <A> <B>")
.parse().expect("first operand must be an integer");
let b: i64 = args.get(2).expect("usage: gate-hello --add <A> <B>")
.parse().expect("second operand must be an integer");
println!("{a} + {b} = {}", a + b);
}
Some("--crash") => {
let missing: Option<i64> = env::var("DELIBERATELY_MISSING")
.ok()
.and_then(|s| s.parse().ok());
println!("{}", missing.expect("set DELIBERATELY_MISSING=<number> to avoid this panic"));
}
_ => {
println!("Hello from Rust {} — toolchain works!", env!("CARGO_PKG_VERSION"));
}
}
}
What each piece teaches
| Line | Concept | Notes |
|---|---|---|
use std::env; | imports | bring the env module into scope |
env::args().skip(1).collect() | iterators | args() yields Strings; skip(1) drops the binary name |
args.first().map(String::as_str) | Option | empty args → None, no out-of-bounds possible |
match ... { Some("--add") => ..., _ => ... } | match | exhaustive by design — forget a case and it will not compile |
let a: i64 = ... .parse().expect(...) | turbofish-free inference | parse::<i64>() via the type annotation; parse returns Result |
.expect("...") | fail fast | fine in labs/CLIs; never in library or service code — that is M2’s topic |
env!("CARGO_PKG_VERSION") | compile-time env | baked in by cargo from Cargo.toml |
{a} + {b} in the string | inline formatting | format captures, stable since 1.58 |
Panic vs error — the distinction that shapes everything
Run the two failure modes side by side:
cargo run -- --crash # panics: process dies, rc=101
DELIBERATELY_MISSING=7 cargo run -- --crash # value existed: rc=0
- A panic is for programmer bugs — impossible states, broken invariants. Fail fast, loud, with a message.
- An error value (
Result) is for expected failure — bad input, missing file, unreachable network. The type system forces the caller to handle it.
Why is --crash a panic then? Because it simulates a broken invariant for teaching.
In real code, a CLI reading user input should return ExitCode errors (M1) and a
library should return Result (M2). Our Axum services never unwrap() user input —
that discipline starts here.
Tests live next to the code
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
#[test]
fn binary_has_a_version() {
assert!(!env!("CARGO_PKG_VERSION").is_empty());
}
}
}
#[cfg(test)]— compiled only forcargo test, zero cost in release builds.#[test]— the runner discovers and runs it; a panic inside = failure.assert!/assert_eq!— the workhorses. No separate test framework needed.
Exercises
- Trace it — add
dbg!(&args);as the first line ofmain, run all three modes, and write down whatdbg!prints (file, line, expression, value). - Extend — add a
--echobranch that prints all remaining args joined by spaces. - Feel the compiler — remove the
_ =>arm. Try to compile. Read the error. Put it back. That error ismatchexhaustiveness — you will learn to love it.
Done when: all three modes work, the removed-_-arm compile error is something you
can explain in one sentence, and the house loop is green.