Day 1 — Setup & Ownership
The Rust Mindset gave you the one rule — one owner per value, shared
XOR mutable for references — as an idea. Today it stops being an idea. We scaffold logwise, read a
log file, and immediately bump into the question the whole language is built around: who owns these
bytes, and who is just borrowing them? By the end you’ll have a binary that parses arguments, reads a
file, turns each line into a typed LogLine, and prints it back. (See the
project overview for the full three-day arc.)
Scaffold the crate
Section titled “Scaffold the crate”A Rust project is a crate. cargo new makes one:
cd code # the playbook's companion-code directory cargo new logwise --binThat gives you a Cargo.toml (the manifest — name, edition, dependencies) and a src/main.rs with a
fn main. The --bin flag says “a runnable program”, not a library. Two commands you’ll lean on all
day:
cargo run # compile and run cargo check # type-check only — much faster, your inner-loop commandWe need two dependencies: clap for argument parsing and thiserror for errors (Day 2). Add them
with cargo add, which edits the manifest for you:
cargo add clap --features derive cargo add thiserrorThe resulting Cargo.toml:
[package] name = "logwise" version = "0.1.0" edition = "2021"
[dependencies] clap = { version = "4", features = ["derive"] } thiserror = "2"Parse arguments with clap
Section titled “Parse arguments with clap”A CLI is a function whose arguments come from the shell. We could read std::env::args() by hand and
match strings, but that path is a swamp of edge cases (missing values, --help, typos). Instead we
describe the arguments as a struct and let clap’s derive macro generate the parser, the --help
text, and --version. Here is the real src/cli.rs:
use std::path::PathBuf; use clap::Parser;
use crate::analyze::Filter; use crate::parser::Level;
/// Parse a log file, filter by level and/or pattern, and print a summary. #[derive(Parser, Debug)] #[command(name = "logwise", version, about)] pub struct Cli { /// Path to the log file to analyze. pub file: PathBuf,
/// Keep only lines at this level or higher: trace, debug, info, warn, error. #[arg(short, long, value_name = "LEVEL")] pub level: Option<Level>,
/// Keep only lines whose message contains this substring. #[arg(short, long, value_name = "TEXT")] pub pattern: Option<String>,
/// Make `--pattern` match case-insensitively. #[arg(short = 'i', long)] pub ignore_case: bool,
/// Print each matching line before the summary. #[arg(short, long)] pub verbose: bool,
/// Stop with an error on the first line that fails to parse. #[arg(long)] pub strict: bool, }Each field becomes one argument. file has no #[arg], so it’s a positional argument (required).
The Option<…> fields are optional flags; bool fields are switches that are false unless present.
The doc comments (///) become the help text — write them for a human, get documentation for free.
Today we only consume file; level, pattern, and the rest light up on Days 2–3.
In main you turn the process arguments into a Cli with one call:
use clap::Parser; let cli = Cli::parse(); // reads std::env::args(), or prints --help and exitsRead the file — and meet the owner
Section titled “Read the file — and meet the owner”Now the heart of Day 1. Reading a file forces the ownership question. One standard-library call slurps the whole file into memory:
let contents: String = std::fs::read_to_string(&cli.file)?;contents is a String: an owned, growable, heap-allocated buffer of UTF-8 bytes. The String
value itself is three words on the stack — a pointer to the heap data, a length, and a capacity — and
it owns the bytes on the heap. When contents goes out of scope at the end of main, Rust frees that
buffer automatically. One owner; the compiler inserts the cleanup. (The ? is Day 2’s job — for now,
read it as “give me the String or bail.”)
Now we want to look at the file line by line without copying:
for raw in contents.lines() { println!("{raw}"); }contents.lines() hands out &str values — borrows that point into contents’s heap buffer.
A &str is a “fat pointer”: a start address plus a length, 16 bytes total, and zero bytes of the line
are copied. Each raw is a window onto memory contents owns. That’s the entire trick: String owns,
&str borrows, and the borrow checker guarantees no raw can outlive the contents it points into — so
you can never read a freed buffer.
contents : String (owns the heap buffer) ┌──────────────────────────────────────────────┐ │ 2026..Z INFO server up\n2026..Z WARN cache.. │ heap, freed when `contents` drops └───▲───────────────▲──────────────────────────┘ │ &str │ &str ← borrows: {ptr, len}, 16 bytes each, no copy "2026..server up" "2026..cache.."Model the data: an enum and a struct
Section titled “Model the data: an enum and a struct”A raw &str is shapeless. We want types that make illegal states unrepresentable. A log line has a
severity, and there are exactly five severities — that’s an enum:
/// A severity level, ordered from least to most severe. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Level { Trace, Debug, Info, Warn, Error, }The variants are listed in ascending severity on purpose: the derived Ord makes Level::Warn > Level::Info true, which is exactly what “show me everything at WARN or above” will need on Day 2. The
#[derive(...)] line asks the compiler to write the obvious implementations for us — Copy (a Level
is a single byte, cheap to duplicate), equality, ordering, hashing, and a Debug view for {:?}.
A whole parsed line is a bundle of fields — that’s a struct:
/// One parsed log line. #[derive(Debug, Clone, PartialEq, Eq)] pub struct LogLine { pub timestamp: String, pub level: Level, pub message: String, }Notice the ownership choice: LogLine holds owned Strings, not borrowed &strs. A LogLine
copies out only the two pieces it wants to keep and owns them outright, so it can outlive the big file
buffer it came from. (You can make a struct borrow with a lifetime — message: &'a str — and Project 2
will, when it’s worth it. Here, owning is simpler and the copies are tiny.)
A first parse, by pattern matching
Section titled “A first parse, by pattern matching”To build a LogLine from a &str, we split off the timestamp, the level token, and the rest. match
is how you take an enum apart — and converting the level text into a Level is the first real match:
fn level_from(token: &str) -> Level { match token { "TRACE" => Level::Trace, "DEBUG" => Level::Debug, "INFO" => Level::Info, "WARN" => Level::Warn, "ERROR" => Level::Error, _ => Level::Info, // Day 1 placeholder — Day 2 makes this fallible } }match is exhaustive: leave out a variant and the compiler refuses to build until you handle it (or
add the _ catch-all). That exhaustiveness is a feature — add a sixth Level later and rustc will
point at every match that now has a hole. A Day-1 parse_line ties it together:
fn parse_line(raw: &str) -> LogLine { let mut fields = raw.split_whitespace(); let timestamp = fields.next().unwrap_or("").to_string(); let level = level_from(fields.next().unwrap_or("INFO")); let message = fields.collect::<Vec<_>>().join(" "); LogLine { timestamp, level, message } }This is deliberately naive — it papers over missing fields and unknown levels with defaults. That’s
exactly the kind of “it’ll be fine” code Day 2 replaces with honest Results. For now, wire it into
main and print each parsed line:
for raw in contents.lines() { let line = parse_line(raw); println!("{} {:?} {}", line.timestamp, line.level, line.message); }Run cargo run -- samples/app.log and you’ll see the file echoed back through your own types. You’ve
gone from bytes on disk to LogLine values — owned, typed, and yours.
Under the hood — String vs &str, in memory
Section titled “Under the hood — String vs &str, in memory”These two types confuse everyone for about a day, then never again once you see them as memory:
String (owned) &str (borrowed view) ┌────────┬────────┬──────────┐ ┌────────┬────────┐ │ ptr │ len=11 │ cap=16 │ │ ptr │ len=11 │ └───┬────┴────────┴──────────┘ └───┬────┴────────┘ │ owns + can grow │ points into someone else's bytes ▼ ▼ heap: [server up.....] (no heap of its own)Stringis three words (pointer, length, capacity); it owns a heap buffer and may grow it.&stris two words (pointer, length); it owns nothing and cannot grow — it’s a read-only window.&Stringauto-coerces to&str(via deref coercion), so a function that takes&straccepts both — which is why you write parameters as&str, the more general borrow.
The practical rule: take &str, return/store String. Borrow to read; own to keep. logwise lives by
it — parse_line borrows a &str, and the LogLine it returns owns its Strings.
What you built, and what the compiler protected you from
Section titled “What you built, and what the compiler protected you from”The thread for Project 1 is what does building this force you to understand — and what is Rust’s compiler
protecting you from? Day 1’s answer is sharp: reading a file forced you to decide who owns the bytes.
You owned them once in a String, handed out &str borrows for free, and copied only the fields a
LogLine keeps. In return the compiler guaranteed no borrow outlives its buffer — deleting, at compile
time, the use-after-free and buffer-over-read bugs that produced Heartbleed. Tomorrow those naive
unwrap_or defaults become honest errors. Next: Day 2 · Errors &
I/O →.
Check your understanding
Section titled “Check your understanding”- What does
cargo new logwise --bincreate, and how doescargo checkdiffer fromcargo runin your inner loop? - In
let contents = std::fs::read_to_string(&cli.file)?;followed byfor raw in contents.lines(), which value owns the file bytes and what is eachraw? How many bytes of each line get copied? - Why are the
Levelvariants declared in the orderTrace, Debug, Info, Warn, Errorrather than alphabetically — what does the derivedOrdgive you? LogLinestoresmessage: String(owned) instead ofmessage: &str(borrowed). Give one concrete reason owning is the right call here.- A
matchonLevelthat omitsLevel::Traceand has no_arm won’t compile. Why is that exhaustiveness a feature rather than an annoyance?
Show answers
- It scaffolds a binary crate: a
Cargo.tomlmanifest plussrc/main.rswith afn main.cargo checkonly type-checks (no code generation), so it’s much faster and is your inner-loop command;cargo runcompiles and executes the program. contents(theString) owns the heap buffer of file bytes; eachrawis a&str— a borrowed{pointer, length}window pointing into that buffer. Zero payload bytes are copied per line; a&stris just 16 bytes of pointer + length.- So the derived
Ordorders them by severity (Trace < Debug < Info < Warn < Error). That makesline.level >= Level::Warna correct “at this level or higher” test — the filter Day 2 needs — for free, without a hand-written comparison. - Because an owned
Stringlets theLogLineoutlive the large file buffer it was parsed from; a borrowed&strwould tie everyLogLineto the lifetime ofcontents. (Also acceptable: it keeps the type free of lifetime parameters, and the copied fields are tiny.) matchis exhaustive, so the compiler proves you’ve handled every case. If you later add a sixthLevel,rustcflags everymatchthat now has a gap — turning “I forgot a case” from a silent runtime bug into a compile error you fix in seconds.