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"The project skeleton
Section titled “The project skeleton”Before any real logic, set up the whole file layout — base types and empty shells — so you can see how
logwise fits together and nothing you write later points at a file that doesn’t exist yet. logwise is
four small modules under src/, each its own file, tied together by mod declarations in main.rs:
src/ main.rs # entry point: declares the modules, then runs the pipeline cli.rs # Cli — the command-line arguments (clap turns this into a parser) parser.rs # Level, LogLine — the data model (raw text -> typed values) analyze.rs # Filter — a stub today; filtering & aggregation arrive Days 2-3Three mechanics wire the modules together, and you’ll use all three today:
mod parser;inmain.rstells the compiler to pull insrc/parser.rsas a module.pubon an item (pub enum Level) makes it visible outside its own file.use crate::parser::Level;brings that name into another file’s scope.
The dependencies point one way, so build the data types first, then the code that uses them. Here’s what links to what today, and what Day 2 adds:
Day 1 links Day 2 adds ─────────── ────────── main ──uses──▶ cli::Cli cli::Cli ──▶ parser::Level (the --level flag) main ──uses──▶ parser::{Level, analyze::Filter ──▶ parser::Level (filter by severity) LogLine} main ──declares──▶ analyze, cli, parser (the `mod` lines)Now create each file with its base content.
src/parser.rs — the data model: two types, no functions yet.
/// 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, }
/// One parsed log line. #[derive(Debug, Clone, PartialEq, Eq)] pub struct LogLine { pub timestamp: String, pub level: Level, pub message: String, }src/analyze.rs — a placeholder Filter, just a name for cli.rs to point at. Day 2 gives it
fields and a matches method.
/// The command-line filter. A stub today; Day 2 adds its fields and logic. #[derive(Debug, Default, Clone)] pub struct Filter;src/cli.rs — the argument struct. The field-by-field walkthrough is the next section.
use std::path::PathBuf;
use clap::Parser;
/// 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 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, }One flag is missing on purpose: --level. Filtering by severity means clap has to turn the text
warn into a Level, and teaching Level to parse itself (FromStr) is a
Day 2 job — so the level field joins Cli then. Today the struct
depends on nothing in parser.rs.
src/main.rs — declares the module tree and, for now, only parses the arguments. You’ll grow its
body across Days 1-3.
mod analyze; mod cli; mod parser;
use clap::Parser;
use crate::cli::Cli;
fn main() { let _cli = Cli::parse(); // Day 1: read the file and print each parsed line. // Days 2-3: honest errors, filtering, and a summary. }Run cargo check — it’s green (with a few never used warnings; expected, since nothing is wired
up yet). You now have a compiling skeleton: a data model in parser.rs, a stub Filter, the argument
struct in cli.rs, and a main that already handles --help. The rest of Day 1 walks each file and
fills in the behavior.
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 the Cli struct you set up in the skeleton and let clap’s derive macro
generate the parser, the --help text, and --version — all from that struct and its doc comments.
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; pattern and the switches are declared now but wired up on Days 2–3 (and
--level joins them on Day 2). Turning the process arguments into a Cli is the single Cli::parse()
call already sitting in main — it reads std::env::args(), or prints --help and exits.
Read 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) .expect("could not read the log 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. (On Day 1 we .expect() — print a
message and crash if the read fails, which keeps main returning (). Day 2 replaces it with the ?
operator and a real error type.)
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: the enum and the struct
Section titled “Model the data: the enum and the struct”You already put two types in parser.rs during setup — now the why behind their shape. A raw &str
is shapeless; we want types that make illegal states unrepresentable. A log line has a severity, and
there are exactly five — so Level is an enum, and its 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 {:?}.
LogLine bundles the fields of one parsed line into a struct. Notice the ownership choice: it holds
owned Strings, not borrowed &strs, so a LogLine copies out only the pieces it keeps and 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.
These two functions live in parser.rs, next to the types they build (level_from stays private;
parse_line is pub so main can call it):
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:
pub 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. Now grow the skeleton’s
placeholder main into the real Day 1 main.rs: read the file, then turn each line into a LogLine and
print it. Notice how the modules link — main pulls Cli from cli and parse_line from parser:
mod analyze; mod cli; mod parser;
use clap::Parser;
use crate::cli::Cli; use crate::parser::parse_line;
fn main() { let cli = Cli::parse(); let contents = std::fs::read_to_string(&cli.file) .expect("could not read the log file");
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).expect("…");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.