use std::path::PathBuf;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ConfigError {
#[error("failed to read config file {}: {source}", path.display())]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to parse config file {}: {source}", Self::parse_path(path))]
Parse {
path: PathBuf,
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
},
#[error("unknown top-level configuration keys: {}", keys.join(", "))]
UnknownFields {
keys: Vec<String>,
},
#[error("environment variable `{key}` is not valid Unicode: {source}")]
EnvVar {
key: String,
#[source]
source: std::env::VarError,
},
#[error("failed to start file watcher: {source}")]
WatchFailed {
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
},
#[error("config watcher lock poisoned; watcher state is unusable")]
Poisoned,
}
impl ConfigError {
fn parse_path(path: &std::path::Path) -> std::borrow::Cow<'_, str> {
if path.as_os_str().is_empty() {
std::borrow::Cow::Borrowed("(merged)")
} else {
path.to_string_lossy()
}
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
#[test]
fn display_is_informative() {
let io = ConfigError::Io {
path: "/etc/app.toml".into(),
source: std::io::Error::new(std::io::ErrorKind::NotFound, "missing"),
};
assert!(io.to_string().contains("/etc/app.toml"));
assert!(io.to_string().contains("missing"));
let parse = ConfigError::Parse {
path: "cfg.toml".into(),
source: "expected `=`".into(),
};
assert_eq!(
parse.to_string(),
"failed to parse config file cfg.toml: expected `=`"
);
let merged = ConfigError::Parse {
path: PathBuf::new(),
source: "invalid type".into(),
};
assert!(merged.to_string().contains("(merged)"));
assert!(merged.to_string().contains("invalid type"));
let unknown = ConfigError::UnknownFields {
keys: vec!["typo_a".into(), "typo_b".into()],
};
assert_eq!(
unknown.to_string(),
"unknown top-level configuration keys: typo_a, typo_b"
);
let env = ConfigError::EnvVar {
key: "APP_TOKEN".into(),
source: std::env::VarError::NotPresent,
};
assert!(env.to_string().contains("APP_TOKEN"));
let watch = ConfigError::WatchFailed {
source: "inotify limit reached".into(),
};
assert!(watch.to_string().contains("inotify limit reached"));
assert_eq!(
ConfigError::Poisoned.to_string(),
"config watcher lock poisoned; watcher state is unusable"
);
}
#[test]
fn errors_are_std_errors() {
let boxed: Box<dyn std::error::Error> = Box::new(ConfigError::Poisoned);
assert!(!boxed.to_string().is_empty());
}
}