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"

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-3

Three mechanics wire the modules together, and you’ll use all three today:

  • mod parser; in main.rs tells the compiler to pull in src/parser.rs as a module.
  • pub on 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.

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.

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

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

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)
  • 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).expect("…"); 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.