We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← The Expert Edge: Idiom, Review and Capstones step 8 of 14
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.
Stuck?
Rust reference solution
Sign in to attempt this problem and reveal the reference solution.