Rust Error Enum with Source Chains

Model configuration failures with a custom Rust error enum, Display and Error implementations, From conversion, and preserved parse causes.

RustData Integrity

Code

snippet
use std::error::Error;
use std::fmt;
use std::io;
use std::num::ParseIntError;

#[derive(Debug)]
enum ConfigError {
    Missing(&'static str),
    Malformed { detail: String },
    InvalidPort { value: String, source: ParseIntError },
    OutOfRange(u16),
    Io(io::Error),
}

impl fmt::Display for ConfigError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ConfigError::Missing(key) => write!(f, "missing required key `{key}`"),
            ConfigError::Malformed { detail } => write!(f, "malformed config: {detail}"),
            ConfigError::InvalidPort { value, .. } => write!(f, "invalid port `{value}`"),
            ConfigError::OutOfRange(port) => write!(f, "port {port} must be between 1 and 65535"),
            ConfigError::Io(err) => write!(f, "io error: {err}"),
        }
    }
}

impl Error for ConfigError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        match self {
            ConfigError::InvalidPort { source, .. } => Some(source),
            ConfigError::Io(err) => Some(err),
            ConfigError::Missing(_) | ConfigError::Malformed { .. } | ConfigError::OutOfRange(_) => None,
        }
    }
}

impl From<io::Error> for ConfigError {
    fn from(err: io::Error) -> Self {
        ConfigError::Io(err)
    }
}

#[derive(Debug, PartialEq)]
struct Config {
    host: String,
    port: u16,
}

fn parse_config(raw: &str) -> Result<Config, ConfigError> {
    let mut host = None;
    let mut port = None;

    for raw_line in raw.lines() {
        let line = raw_line.trim();
        if line.is_empty() || line.starts_with('#') {
            continue;
        }
        let (key, value) = line
            .split_once('=')
            .ok_or_else(|| ConfigError::Malformed { detail: format!("expected key=value, got `{line}`") })?;
        let key = key.trim();
        let value = value.trim();

        match key {
            "host" => {
                if value.is_empty() {
                    return Err(ConfigError::Missing("host"));
                }
                host = Some(value.to_string());
            }
            "port" => {
                let parsed = value
                    .parse::<u16>()
                    .map_err(|source| ConfigError::InvalidPort { value: value.to_string(), source })?;
                if parsed == 0 {
                    return Err(ConfigError::OutOfRange(parsed));
                }
                port = Some(parsed);
            }
            other => {
                return Err(ConfigError::Malformed { detail: format!("unsupported key `{other}`") });
            }
        }
    }

    Ok(Config {
        host: host.ok_or(ConfigError::Missing("host"))?,
        port: port.ok_or(ConfigError::Missing("port"))?,
    })
}

fn load(path: &str) -> Result<Config, ConfigError> {
    let raw = std::fs::read_to_string(path)?;
    parse_config(&raw)
}

fn main() {
    let valid = "host = 127.0.0.1\nport = 8080\n";
    println!("valid: {:?}", parse_config(valid));

    for raw in [
        "port = 8080\n",
        "host = localhost\nport = 70000\n",
        "host = localhost\nport = zero\n",
    ] {
        match parse_config(raw) {
            Ok(config) => println!("unexpected success: {config:?}"),
            Err(err) => {
                println!("error: {err}");
                if let Some(source) = err.source() {
                    println!("  caused by: {source}");
                }
            }
        }
    }

    let missing = load("does-not-exist.conf");
    println!("missing file became io error: {}", matches!(missing, Err(ConfigError::Io(_))));
}