1. Read a config file
The task
Section titled “The task”Read a file with one key = value setting on each line, and return a Config with a port (a u16) and a database_url.
The versions
Section titled “The versions”WorseFive
unwrap()calls. A missing file, a blank line, a bad port, or a missing setting panics with a message that does not say which.pub fn load_config(path: &Path) -> Config {let text = std::fs::read_to_string(path).unwrap();let mut port = None;let mut database_url = None;for line in text.lines() {let (key, value) = line.split_once('=').unwrap();match key.trim() {"port" => port = Some(value.trim().parse().unwrap()),"database_url" => database_url = Some(value.trim().to_string()),_ => {}}}Config {port: port.unwrap(),database_url: database_url.unwrap(),}}OK
anyhowwith context: every failure is an error with a message that says what and where. Callers can only print it.pub fn load_config(path: &Path) -> Result<Config> {let text = std::fs::read_to_string(path).with_context(|| format!("cannot read config file {}", path.display()))?;let mut port = None;let mut database_url = None;for (number, line) in text.lines().enumerate() {let Some((key, value)) = line.split_once('=') else {bail!("line {}: expected `key = value`", number + 1);};match key.trim() {"port" => {let parsed = value.trim().parse().with_context(|| {format!("line {}: port is not a number from 0 to 65535", number + 1)})?;port = Some(parsed);}"database_url" => database_url = Some(value.trim().to_string()),_ => {}}}Ok(Config {port: port.ok_or_else(|| anyhow!("missing setting: port"))?,database_url: database_url.ok_or_else(|| anyhow!("missing setting: database_url"))?,})}BetterA typed error. Callers can match on the kind of failure, and the parsing can be tested without a file.
pub fn load_config(path: &Path) -> Result<Config, ConfigError> {let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {path: path.to_path_buf(),source,})?;parse_config(&text)}pub fn parse_config(text: &str) -> Result<Config, ConfigError> {let mut port = None;let mut database_url = None;for (index, line) in text.lines().enumerate() {let line_number = index + 1;let (key, value) = line.split_once('=').ok_or(ConfigError::Syntax { line: line_number })?;match key.trim() {"port" => {let parsed = value.trim().parse().map_err(|source| ConfigError::BadPort {line: line_number,source,})?;port = Some(parsed);}"database_url" => database_url = Some(value.trim().to_string()),_ => {}}}Ok(Config {port: port.ok_or(ConfigError::Missing("port"))?,database_url: database_url.ok_or(ConfigError::Missing("database_url"))?,})}
The error type of the “Better” version, written with the thiserror crate:
#[derive(Debug, thiserror::Error)]pub enum ConfigError { #[error("cannot read config file {path}")] Read { path: PathBuf, #[source] source: std::io::Error, }, #[error("line {line}: expected `key = value`")] Syntax { line: usize }, #[error("line {line}: port is not a number from 0 to 65535")] BadPort { line: usize, #[source] source: ParseIntError, }, #[error("missing setting: {0}")] Missing(&'static str),}#[error("...")] generates the Display message. #[source] marks the field that Error::source() returns, so a logger can print the cause chain. See Result, not exceptions.
What the “Worse” version does wrong
Section titled “What the “Worse” version does wrong”Each unwrap() is a different failure, and each one panics:
read_to_string(path).unwrap(): the file is missing, or not readable. The panic message has theio::Error(for example “No such file or directory”), but not the path.split_once('=').unwrap(): any line without=. A blank line at the end of a hand-edited file is enough. The message is “calledOption::unwrap()on aNonevalue”, with no line number.parse().unwrap(): a port that is not a number, or is larger than 65535.port.unwrap()anddatabase_url.unwrap(): a setting is missing.
The tests in examples/better-vs-worse/src/e01_read_config.rs show the blank-line panic. This is a typical AI pattern: the code works with the sample file in the prompt, and fails with the first real one. See S02: unwrap() / expect() in non-test code.
Verdict
Section titled “Verdict”| Criterion | OK | Better |
|---|---|---|
| Correctness | Better | Better |
| Both reject bad input with an error. "Worse" stops the program. | ||
| Failure behavior | Better | Better |
| Both say what failed and where (file, line, key). "Better" also lets the caller match on the kind. | ||
| Performance | Same | Same |
| Reading a small file dominates. The error paths do not matter. | ||
| Readability | Same | Worse |
| "OK" is about as short as "Worse". "Better" needs an error enum of 20 lines. | ||
| API ergonomics | Better | Better |
anyhow::Result is easy to use in an application. "Better" is a stable, typed API, and parse_config is testable without a file. | ||
Trade-offs
Section titled “Trade-offs”- In an application, “OK” is often the best choice. If the only thing that the program does with a config error is print it and exit,
anyhowwith context gives a clear message for little code. Use it inmain, CLI tools, and startup code. - In a library, or where callers react differently to different failures, use the typed error. For example, a server that uses defaults when the file is missing, but refuses to start when a value is invalid.
- “Worse” is acceptable in a test, or in a throw-away script that you run once. Even there,
expect("...")with a message costs little.