Skip to main content

config_kit/
error.rs

1//! Errors produced while loading, validating, and watching configuration.
2
3use std::path::PathBuf;
4
5/// Loading, parsing, strict-validation, and watching failures.
6///
7/// Documented failure modes are part of the API contract; every variant
8/// lists the condition that produces it. The enum is `#[non_exhaustive]`,
9/// so matches require a wildcard arm.
10#[derive(Debug, thiserror::Error)]
11#[non_exhaustive]
12pub enum ConfigError {
13    /// Reading a configuration file failed at the OS level — the file does
14    /// not exist, lacks read permission, or the path is a directory.
15    /// Produced by [`file`](crate::ConfigLayer::file) layers and by
16    /// [`watch`](crate::watch) when the watched file cannot be read back.
17    #[error("failed to read config file {}: {source}", path.display())]
18    Io {
19        /// The file that could not be read.
20        path: PathBuf,
21        /// The underlying OS error.
22        #[source]
23        source: std::io::Error,
24    },
25    /// One of:
26    ///
27    /// - a configuration file failed to parse in its format,
28    /// - the file's extension names a format whose feature is disabled
29    ///   (`json`, `yaml`),
30    /// - the merged configuration did not deserialize into the target type.
31    ///
32    /// `path` is the file involved; for a merge/deserialization mismatch it
33    /// is the most recently read file layer (or empty when no file layer
34    /// contributed, e.g. a type mismatch introduced by env or overrides
35    /// alone).
36    #[error("failed to parse config file {}: {source}", Self::parse_path(path))]
37    Parse {
38        /// The file involved, or empty for a non-file deserialization
39        /// mismatch (see above).
40        path: PathBuf,
41        /// The parser or serde error.
42        #[source]
43        source: Box<dyn std::error::Error + Send + Sync>,
44    },
45    /// [`load_strict`](crate::ConfigBuilder::load_strict) found top-level
46    /// keys that no field of the target type consumed. Nested unknown keys
47    /// are deliberately not reported (see `load_strict`'s documentation for
48    /// the precise contract).
49    #[error("unknown top-level configuration keys: {}", keys.join(", "))]
50    UnknownFields {
51        /// The unconsumed top-level keys, sorted and deduplicated.
52        keys: Vec<String>,
53    },
54    /// An environment variable matching the layer prefix carries a value
55    /// that is not valid Unicode. Variables whose *key* is not valid
56    /// Unicode can never match a `str` prefix and are skipped instead.
57    #[error("environment variable `{key}` is not valid Unicode: {source}")]
58    EnvVar {
59        /// The (valid-Unicode) variable name.
60        key: String,
61        /// The underlying variable error.
62        #[source]
63        source: std::env::VarError,
64    },
65    /// File watching could not start — the platform watcher could not be
66    /// created, the path could not be registered (typically: it does not
67    /// exist), or the watcher thread could not be spawned. Produced only by
68    /// [`watch`](crate::watch) (feature `hot-reload`).
69    #[error("failed to start file watcher: {source}")]
70    WatchFailed {
71        /// The underlying watcher error.
72        #[source]
73        source: Box<dyn std::error::Error + Send + Sync>,
74    },
75    /// The watcher's shared error-slot lock was poisoned by a panic in the
76    /// watcher thread. Treat as fatal: reload state is indeterminate and
77    /// the watcher must be stopped and rebuilt.
78    #[error("config watcher lock poisoned; watcher state is unusable")]
79    Poisoned,
80}
81
82impl ConfigError {
83    /// Renders `Parse`'s path, collapsing the empty path to `(merged)`.
84    fn parse_path(path: &std::path::Path) -> std::borrow::Cow<'_, str> {
85        if path.as_os_str().is_empty() {
86            std::borrow::Cow::Borrowed("(merged)")
87        } else {
88            path.to_string_lossy()
89        }
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    #![allow(clippy::unwrap_used, clippy::expect_used)]
96    use super::*;
97
98    #[test]
99    fn display_is_informative() {
100        let io = ConfigError::Io {
101            path: "/etc/app.toml".into(),
102            source: std::io::Error::new(std::io::ErrorKind::NotFound, "missing"),
103        };
104        assert!(io.to_string().contains("/etc/app.toml"));
105        assert!(io.to_string().contains("missing"));
106
107        let parse = ConfigError::Parse {
108            path: "cfg.toml".into(),
109            source: "expected `=`".into(),
110        };
111        assert_eq!(
112            parse.to_string(),
113            "failed to parse config file cfg.toml: expected `=`"
114        );
115
116        let merged = ConfigError::Parse {
117            path: PathBuf::new(),
118            source: "invalid type".into(),
119        };
120        assert!(merged.to_string().contains("(merged)"));
121        assert!(merged.to_string().contains("invalid type"));
122
123        let unknown = ConfigError::UnknownFields {
124            keys: vec!["typo_a".into(), "typo_b".into()],
125        };
126        assert_eq!(
127            unknown.to_string(),
128            "unknown top-level configuration keys: typo_a, typo_b"
129        );
130
131        let env = ConfigError::EnvVar {
132            key: "APP_TOKEN".into(),
133            source: std::env::VarError::NotPresent,
134        };
135        assert!(env.to_string().contains("APP_TOKEN"));
136
137        let watch = ConfigError::WatchFailed {
138            source: "inotify limit reached".into(),
139        };
140        assert!(watch.to_string().contains("inotify limit reached"));
141
142        assert_eq!(
143            ConfigError::Poisoned.to_string(),
144            "config watcher lock poisoned; watcher state is unusable"
145        );
146    }
147
148    #[test]
149    fn errors_are_std_errors() {
150        let boxed: Box<dyn std::error::Error> = Box::new(ConfigError::Poisoned);
151        assert!(!boxed.to_string().is_empty());
152    }
153}