Skip to content

← The Expert Edge: Idiom, Review and Capstones step 8 of 14

Hard End-to-End

Capstone: a fully-typed fallible parser

Repair a config parser so that its error type is honest, its messages compose correctly, and it cannot panic.

pub fn parse_config(lines: Vec<String>, max_port: i64) -> Result<Vec<(String, i64)>, String>

This is the integration test for error handling. Every technique in it is easy on its own — a custom enum, a Display impl, a source() chain, a From impl, a TryFrom newtype, collect::<Result<_, _>>(). Composing them is where the understanding shows, and specifically: keeping the Display layers non-overlapping while the source() chain still renders correctly is the part people get wrong.

Read this before anything else

Result<_, String> in the signature is a marshalling concession for this harness, not a design. The real error type here is ConfigError, and parse_entries — which returns it — is the function a caller should actually use. parse_config only flattens it into text at the very boundary so a JSON test can assert on it.

Stringly-typed errors are the thing this problem is trying to teach you out of. A caller who receives a String cannot ask “was this a range problem?”, cannot retry selectively, cannot map it to an HTTP status, and gets to discover your wording changes at runtime. A caller who receives a ConfigError can match on it.

What it parses

key = value lines. Blank lines and lines whose first non-space character is # are ignored. Every value must be an i64 inside 0..=max_port, and a port entry must be present. Output is the pairs in input order.

pub enum ConfigError {
    MissingKey { key: String },
    BadInt { key: String, source: ParseIntError },
    OutOfRange { key: String, value: i64, max: i64 },
}

Structured data, not strings — key is a String field, not something baked into a message. That is the difference between an error a program can inspect and an error a human can only read.

Three defects, and what each one teaches

1. The doubled chain. The Display impl for BadInt writes its own message and appends {source}, while source() still returns that same ParseIntError. Anything that walks the chain therefore prints the inner message twice:

key `port` is not an integer: invalid digit found in string: invalid digit found in string

::: question What is the rule that stops this from happening? Each Display impl prints exactly its own layer, and nothing below it.

A Display impl has no idea whether its caller is going to walk source(). {e} on its own prints one line; anyhow‘s {:#}, eyre‘s reports, tracing and every hand-written chain renderer walk the sources themselves. If your Display already inlined the cause, all of those double it — and the fault is invisible until someone uses a different renderer than you tested with.

The Rust API guidelines put the same thing as a style rule: an error message is a lowercase sentence fragment with no trailing punctuation, because it is going to be pasted into the middle of something longer. key … is not an integer composes; Error: Could not parse the port! does not.

The expected output asserts the exact string, so a doubled chain fails loudly — which is the only reason this defect is catchable at all. :::

2. An unwrap in a Result-returning function. entry calls parts.next().unwrap(), and it genuinely cannot fail: splitn always yields at least one item. It is still wrong, and this file denies clippy::unwrap_used, clippy::unwrap_in_result and clippy::panic_in_result_fn so it fails the build.

The argument is not “this particular unwrap will blow up”. It is that a function whose signature says Result is making a promise: the caller handles every failure. An unwrap inside it breaks that promise silently, and the next person to edit the function — who does not know why the invariant held — has no warning. A parser that can panic is a parser that can take down a server on malformed input, and “malformed input” is the entire job description of a parser. unwrap_or costs nothing here and keeps the promise.

3. A missing # Errors section. clippy::missing_errors_doc is denied, so a public function returning Result must document what it returns on failure. Not decoration: it is the only place the failure modes are written down, since the type alone says String and nothing more.

The two pieces of design worth stealing

The inner error type and From. value_of parses a number and checks a range, but it does not know which key it belongs to. So it returns a small private ValueError, and impl From<ParseIntError> for ValueError is what makes raw.parse()? — a bare ? with no map_err — compile. The key is attached one level up with with_key. Two layers, each of which knows exactly what it knows and nothing more.

This is the general shape. ? converts through From, so designing your From impls is designing where errors gain context.

The validated newtype. Bounded::try_from((value, max)) returns a Bounded or fails. Downstream code that holds a Bounded does not re-check the range and has no way to construct one that is out of range. That is the point of TryFrom: move the check to the boundary once, and let the type carry the proof from then on. It is parse, don't validate with Rust spelling.

Two more lints worth knowing, not used here

clippy::from_over_into will tell you to write impl From<A> for B rather than impl Into<B> for A — the blanket impl gives you Into for free, and not the other way round. clippy::result_unit_err will tell you that Result<T, ()> throws away the only useful thing about a failure.