Skip to content

1. Read a config file

Read a file with one key = value setting on each line, and return a Config with a port (a u16) and a database_url.

  1. 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(),
    }
    }
  2. OKanyhow with 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"))?,
    })
    }
  3. 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.

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 the io::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 “called Option::unwrap() on a None value”, with no line number.
  • parse().unwrap(): a port that is not a number, or is larger than 65535.
  • port.unwrap() and database_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.

Each version compared with the Worse version
CriterionOKBetter
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.
  • 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, anyhow with context gives a clear message for little code. Use it in main, 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.