Skip to content

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.)

A Rust project is a crate. cargo new makes one:

Terminal window
cd code # the playbook's companion-code directory
cargo new logwise --bin

That 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:

Terminal window
cargo run # compile and run
cargo check # type-check only — much faster, your inner-loop command

We need two dependencies: clap for argument parsing and thiserror for errors (Day 2). Add them with cargo add, which edits the manifest for you:

Terminal window
cargo add clap --features derive
cargo add thiserror

The resulting Cargo.toml:

[package]
name = "logwise"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "4", features = ["derive"] }
thiserror = "2"

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 exits

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.."

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.)

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)
  • String is three words (pointer, length, capacity); it owns a heap buffer and may grow it.
  • &str is two words (pointer, length); it owns nothing and cannot grow — it’s a read-only window.
  • &String auto-coerces to &str (via deref coercion), so a function that takes &str accepts 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 →.

  1. What does cargo new logwise --bin create, and how does cargo check differ from cargo run in your inner loop?
  2. In let contents = std::fs::read_to_string(&cli.file)?; followed by for raw in contents.lines(), which value owns the file bytes and what is each raw? How many bytes of each line get copied?
  3. Why are the Level variants declared in the order Trace, Debug, Info, Warn, Error rather than alphabetically — what does the derived Ord give you?
  4. LogLine stores message: String (owned) instead of message: &str (borrowed). Give one concrete reason owning is the right call here.
  5. A match on Level that omits Level::Trace and has no _ arm won’t compile. Why is that exhaustiveness a feature rather than an annoyance?
Show answers
  1. It scaffolds a binary crate: a Cargo.toml manifest plus src/main.rs with a fn main. cargo check only type-checks (no code generation), so it’s much faster and is your inner-loop command; cargo run compiles and executes the program.
  2. contents (the String) owns the heap buffer of file bytes; each raw is a &str — a borrowed {pointer, length} window pointing into that buffer. Zero payload bytes are copied per line; a &str is just 16 bytes of pointer + length.
  3. So the derived Ord orders them by severity (Trace < Debug < Info < Warn < Error). That makes line.level >= Level::Warn a correct “at this level or higher” test — the filter Day 2 needs — for free, without a hand-written comparison.
  4. Because an owned String lets the LogLine outlive the large file buffer it was parsed from; a borrowed &str would tie every LogLine to the lifetime of contents. (Also acceptable: it keeps the type free of lifetime parameters, and the copied fields are tiny.)
  5. match is exhaustive, so the compiler proves you’ve handled every case. If you later add a sixth Level, rustc flags every match that now has a gap — turning “I forgot a case” from a silent runtime bug into a compile error you fix in seconds.