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.
Two types that delete whole bug classes
Section titled “Two types that delete whole bug classes”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 errorBecause “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.
A custom error enum with thiserror
Section titled “A custom error enum with thiserror”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
--strictmode. 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.
Robust file I/O
Section titled “Robust file I/O”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.
Split into modules
Section titled “Split into modules”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.
Build the filter
Section titled “Build the filter”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 →.
Check your understanding
Section titled “Check your understanding”Option<T>andResult<T, E>are “just enums”. Why does that single fact close the null-pointer hole that other languages leave open?- In
parse_line, what doesfields.next().ok_or(ParseError::MissingField("level"))?do, step by step, when there is no next token? - The code attaches the file path with
LogError::Io { path, source }instead of using thiserror’s#[from]on a bareio::Error. What do you gain, and what is the general principle? - Rewrite
let level = s.parse::<Level>()?;as thematchit desugars to. Where does the error-type conversion happen, and why is it a no-op insideparse_line? Filter::matchesreturnstruefor an empty filter and usesline.level < min. Which Day 1 decision makes that<comparison mean “less severe than”?
Show answers
- Because “nothing” (
None) and “error” (Err) are variants you must pattern-match, the compiler won’t let you use the innerTuntil you’ve handled the empty/failing case. The check is enforced by the type system, not by programmer discipline — andResultis#[must_use], so even ignoring one is a warning. fields.next()yieldsOption<&str>.ok_or(...)convertsNoneintoErr(ParseError::MissingField ("level"))(andSome(s)intoOk(s)). Then?sees theErrand returns early fromparse_linewith that error; onSomeit would unwrap the&strinstead.- 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. let level = match s.parse::<Level>() { Ok(v) => v, Err(e) => return Err(From::from(e)) };. The conversion is theFrom::from(e)into the function’s error type. It’s a no-op here becauses.parse::<Level>()already yields aParseErrorandparse_linereturnsParseError— same type, nothing to convert.- Day 1 declared the
Levelvariants in ascending severity (Trace, Debug, Info, Warn, Error) and derivedOrd. Soline.level < minis literally “this line’s severity is below the threshold”, exactly what filtering out everything below--levelrequires.