Skip to content

Day 2 — Errors & I/O

Day 1 ended with a parser that lied: unwrap_or("INFO") quietly turned a corrupt line into a fake INFO, and reading a missing file would just panic. Today we make logwise honest. Rust’s headline move here is that absence and failure are ordinary values you must handle — Option and Result — not exceptions that unwind from nowhere. We’ll build a custom error type with thiserror, harden the file I/O, split the crate into modules, and end with parsing and filtering that survive real-world garbage.

Most languages have a billion-dollar hole: a value that might be “nothing” (null) looks exactly like a value that’s there, so you forget the check and crash at runtime. Rust closes the hole with two enums in the standard library:

enum Option<T> { Some(T), None } // a value, or nothing
enum Result<T, E> { Ok(T), Err(E) } // a success, or an error

Because “nothing” and “error” are variants you must pattern-match, the compiler won’t let you use a T until you’ve dealt with the None/Err case. The check isn’t optional discipline — it’s the type system. Result is even marked #[must_use], so ignoring one is a warning.

str::split_whitespace().next() returns Option<&str> — there might be no next token. std::fs::read_to_string returns Result<String, io::Error> — the disk might say no. Day 1 papered over both; today we handle them.

What can go wrong in logwise? Two distinct things, at two different altitudes:

  • A single line is malformed (missing a field, an unknown level). That’s recoverable — we’d usually rather count it and keep going.
  • The whole file can’t be read, or a line is bad in --strict mode. That’s fatal — the run ends.

Model them as two enums in src/error.rs. We use the thiserror crate, whose derive writes the Display text and the std::error::Error plumbing (including the source chain) from annotations:

use std::path::PathBuf;
use thiserror::Error;
/// Something went wrong while turning one text line into a LogLine.
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ParseError {
#[error("line is empty")]
EmptyLine,
#[error("missing `{0}` field")]
MissingField(&'static str),
#[error("unknown log level `{0}`")]
UnknownLevel(String),
}
/// A fatal error that ends the whole run.
#[derive(Debug, Error)]
pub enum LogError {
#[error("could not read log file `{path}`")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("could not parse line {line_no}")]
Parse {
line_no: usize,
#[source]
source: ParseError,
},
}

The #[error("...")] strings are what the user sees; {0} and {path} interpolate the variant’s fields. The #[source] attribute marks the underlying error so tools (and our own main) can walk the cause chain: “could not read log file x.log” → caused by → “No such file or directory (os error 2)”.

The ? operator: propagate, don’t pyramid

Section titled “The ? operator: propagate, don’t pyramid”

Handling every error with a match would bury the happy path under a pyramid of error code. The ? operator collapses it: on Ok/Some it unwraps the value; on Err/None it returns early from the function with that error. Here’s the real parse_line from src/parser.rs — every fallible step ends in ?:

pub fn parse_line(raw: &str) -> Result<LogLine, ParseError> {
let line = raw.trim();
if line.is_empty() {
return Err(ParseError::EmptyLine);
}
let mut fields = line.split_whitespace();
let timestamp = fields
.next()
.ok_or(ParseError::MissingField("timestamp"))? // Option -> Result -> unwrap or return
.to_string();
let level = fields
.next()
.ok_or(ParseError::MissingField("level"))?
.parse::<Level>()?; // FromStr error is already a ParseError
let message = fields.collect::<Vec<_>>().join(" ");
if message.is_empty() {
return Err(ParseError::MissingField("message"));
}
Ok(LogLine { timestamp, level, message })
}

Two conversions worth naming. Option::ok_or turns a None into an Err(...) so ? has a Result to work with — that’s how you escalate “missing” into “error with a reason”. And "INFO".parse::<Level>() returns Result<Level, ParseError> because we taught Level to parse itself:

impl FromStr for Level {
type Err = ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_ascii_uppercase().as_str() {
"TRACE" => Ok(Level::Trace),
"DEBUG" => Ok(Level::Debug),
"INFO" => Ok(Level::Info),
"WARN" | "WARNING" => Ok(Level::Warn),
"ERROR" | "ERR" => Ok(Level::Error),
_ => Err(ParseError::UnknownLevel(s.to_string())),
}
}
}

One FromStr impl now powers both the file parser and the --level flag — clap uses it to parse --level error into a Level. The uppercase normalization means INFO, info, and Info all work wherever a level appears. That’s the payoff of giving your data a type: the parsing logic lives in one place.

Under the hood — what ? actually compiles to

Section titled “Under the hood — what ? actually compiles to”

? is not magic; it desugars to a match plus a From conversion. This:

let level = some_str.parse::<Level>()?;

is roughly:

let level = match some_str.parse::<Level>() {
Ok(v) => v,
Err(e) => return Err(From::from(e)), // convert e into the fn's error type, then return
};

The From::from(e) is the hook: if the function returns Result<_, BigError> and e is a SmallError, ? will convert it provided BigError: From<SmallError> exists (which #[from] generates). When the error types already match — as in parse_line, where every ? yields a ParseError and the function returns ParseError — the conversion is a no-op. That’s the whole mechanism: a match, an early return, and a From.

Day 1’s read_to_string(&cli.file)? worked, but a missing file produced a bare io::Error with no path. Wrap it so the error knows which file it was. From src/main.rs:

use std::path::Path;
/// Read a whole file into an owned String, attaching the path to any error.
fn read_file(path: &Path) -> Result<String, LogError> {
std::fs::read_to_string(path).map_err(|source| LogError::Io {
path: path.to_path_buf(),
source,
})
}

Result::map_err transforms the error case while leaving Ok untouched — here it upgrades a context-free io::Error into our LogError::Io carrying the path. Then main reports the whole chain:

fn main() -> ExitCode {
let cli = Cli::parse();
match run(&cli) {
Ok(()) => ExitCode::SUCCESS,
Err(err) => { report_error(&err); ExitCode::FAILURE }
}
}
fn report_error(err: &LogError) {
eprintln!("logwise: {err}");
let mut source = std::error::Error::source(err);
while let Some(cause) = source {
eprintln!(" caused by: {cause}");
source = cause.source();
}
}

main returns ExitCode, so a failed run exits non-zero (shells and CI care). run does the real work and returns Result, so it gets to use ? freely. Try it:

$ logwise no-such-file.log
logwise: could not read log file `no-such-file.log`
caused by: No such file or directory (os error 2)

That two-line message — what failed and why — is the #[source] chain paying off.

The crate has outgrown one file. Rust’s module system maps cleanly to files: a mod name; in main.rs pulls in src/name.rs. The top of src/main.rs declares the tree, and use brings names into scope:

mod analyze; // -> src/analyze.rs
mod cli; // -> src/cli.rs
mod error; // -> src/error.rs
mod parser; // -> src/parser.rs
use crate::cli::Cli;
use crate::error::LogError;

Each module decides what’s public with pub. parse_line, Level, and LogLine are pub in parser.rs so analyze.rs can call them via use crate::parser::{parse_line, Level, LogLine};. Modules aren’t bureaucracy — they’re how you keep the parser ignorant of the CLI and the CLI ignorant of the aggregation, so each piece stays testable on its own.

Now the second half of “robust parsing and filtering”. The user’s --level and --pattern flags become a Filter (in src/analyze.rs) that decides whether a LogLine is interesting:

#[derive(Debug, Default, Clone)]
pub struct Filter {
pub min_level: Option<Level>, // keep lines at this level or higher
pub pattern: Option<String>, // keep lines whose message contains this
pub ignore_case: bool,
}
impl Filter {
pub fn matches(&self, line: &LogLine) -> bool {
if let Some(min) = self.min_level {
if line.level < min {
return false; // uses the severity Ord from Day 1
}
}
if let Some(pattern) = &self.pattern {
let hit = if self.ignore_case {
line.message.to_lowercase().contains(&pattern.to_lowercase())
} else {
line.message.contains(pattern.as_str())
};
if !hit {
return false;
}
}
true // an empty filter matches everything
}
}

if let Some(min) = self.min_level is match for the one case you care about: “if a minimum level was given, bind it to min”. Each active clause can only reject a line; pass them all and it matches. This is where Day 1’s deliberate variant order pays off — line.level < min is a correct severity comparison because Trace < Debug < … < Error. The Cli hands its flags over to a Filter:

impl Cli {
pub fn to_filter(&self) -> Filter {
Filter {
min_level: self.level,
pattern: self.pattern.clone(),
ignore_case: self.ignore_case,
}
}
}

logwise now parses honestly and filters correctly. What it doesn’t do yet is aggregate — counting per level, totaling matches, and surviving a file with some bad lines among the good. That’s a job for iterators, which is exactly Day 3.

What you built, and what the compiler protected you from

Section titled “What you built, and what the compiler protected you from”

The thread again: what does building this force you to understand — and what is Rust protecting you from? Today the build forced you to treat absence and failure as types. Option made “no next token” impossible to ignore; Result and ? made the error path a value that must be handled or propagated; a custom enum gave each failure a name and a cause. In return the compiler protects you from the most expensive bug in the catalog — the silently skipped check that returns success anyway, the shape behind goto fail. You can’t drop a Result, you can’t fall through an exhaustive match, and you can’t read a Some you never confirmed. Next we make logwise fast and proven: Day 3 · Iterators & Tests →.

  1. Option<T> and Result<T, E> are “just enums”. Why does that single fact close the null-pointer hole that other languages leave open?
  2. In parse_line, what does fields.next().ok_or(ParseError::MissingField("level"))? do, step by step, when there is no next token?
  3. The code attaches the file path with LogError::Io { path, source } instead of using thiserror’s #[from] on a bare io::Error. What do you gain, and what is the general principle?
  4. Rewrite let level = s.parse::<Level>()?; as the match it desugars to. Where does the error-type conversion happen, and why is it a no-op inside parse_line?
  5. Filter::matches returns true for an empty filter and uses line.level < min. Which Day 1 decision makes that < comparison mean “less severe than”?
Show answers
  1. Because “nothing” (None) and “error” (Err) are variants you must pattern-match, the compiler won’t let you use the inner T until you’ve handled the empty/failing case. The check is enforced by the type system, not by programmer discipline — and Result is #[must_use], so even ignoring one is a warning.
  2. fields.next() yields Option<&str>. ok_or(...) converts None into Err(ParseError::MissingField ("level")) (and Some(s) into Ok(s)). Then ? sees the Err and returns early from parse_line with that error; on Some it would unwrap the &str instead.
  3. You gain an error that names which file failed, not just “file not found”. The principle: errors are most useful when they carry the context of the operation that failed, and that context (the path, a line number) usually has to be added at the call site with map_err/explicit fields.
  4. let level = match s.parse::<Level>() { Ok(v) => v, Err(e) => return Err(From::from(e)) };. The conversion is the From::from(e) into the function’s error type. It’s a no-op here because s.parse::<Level>() already yields a ParseError and parse_line returns ParseError — same type, nothing to convert.
  5. Day 1 declared the Level variants in ascending severity (Trace, Debug, Info, Warn, Error) and derived Ord. So line.level < min is literally “this line’s severity is below the threshold”, exactly what filtering out everything below --level requires.