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` names where the failure happened. For a file parse failure it
33    /// is the file involved. For a merge/deserialization mismatch it is the
34    /// origin of the offending **value**: the file that supplied it,
35    /// `<env:VARIABLE>` for a value supplied by an environment layer (the
36    /// full, pre-strip variable name), or `<overrides>` for the in-process
37    /// override layer — so a deployment's bad environment variable is
38    /// reported as itself, not as the file it happened to override. The
39    /// path is empty when nothing more specific applies (no file layer
40    /// contributed and the failing value's layer could not be determined);
41    /// Display renders the empty path as `(merged)`.
42    #[error("failed to parse config file {}: {source}", Self::parse_path(path))]
43    Parse {
44        /// The file involved, or empty for a non-file deserialization
45        /// mismatch (see above).
46        path: PathBuf,
47        /// The parser or serde error.
48        #[source]
49        source: Box<dyn std::error::Error + Send + Sync>,
50    },
51    /// [`load_strict`](crate::ConfigBuilder::load_strict) found top-level
52    /// keys that no field of the target type consumed. Nested unknown keys
53    /// are deliberately not reported (see `load_strict`'s documentation for
54    /// the precise contract).
55    #[error("unknown top-level configuration keys: {}", keys.join(", "))]
56    UnknownFields {
57        /// The unconsumed top-level keys, sorted and deduplicated.
58        keys: Vec<String>,
59    },
60    /// An environment variable matching the layer prefix carries a value
61    /// that is not valid Unicode. Variables whose *key* is not valid
62    /// Unicode can never match a `str` prefix and are skipped instead.
63    #[error("environment variable `{key}` is not valid Unicode: {source}")]
64    EnvVar {
65        /// The (valid-Unicode) variable name.
66        key: String,
67        /// The underlying variable error.
68        #[source]
69        source: std::env::VarError,
70    },
71    /// File watching could not start — the platform watcher could not be
72    /// created, the path could not be registered (typically: it does not
73    /// exist), or the watcher thread could not be spawned. Produced only by
74    /// [`watch`](crate::watch) (feature `hot-reload`).
75    #[error("failed to start file watcher: {source}")]
76    WatchFailed {
77        /// The underlying watcher error.
78        #[source]
79        source: Box<dyn std::error::Error + Send + Sync>,
80    },
81    /// The watcher's shared error-slot lock was poisoned by a panic in the
82    /// watcher thread. Treat as fatal: reload state is indeterminate and
83    /// the watcher must be stopped and rebuilt.
84    #[error("config watcher lock poisoned; watcher state is unusable")]
85    Poisoned,
86}
87
88impl ConfigError {
89    /// Renders `Parse`'s path, collapsing the empty path to `(merged)`.
90    fn parse_path(path: &std::path::Path) -> std::borrow::Cow<'_, str> {
91        if path.as_os_str().is_empty() {
92            std::borrow::Cow::Borrowed("(merged)")
93        } else {
94            path.to_string_lossy()
95        }
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    #![allow(clippy::unwrap_used, clippy::expect_used)]
102    use super::*;
103
104    #[test]
105    fn display_is_informative() {
106        let io = ConfigError::Io {
107            path: "/etc/app.toml".into(),
108            source: std::io::Error::new(std::io::ErrorKind::NotFound, "missing"),
109        };
110        assert!(io.to_string().contains("/etc/app.toml"));
111        assert!(io.to_string().contains("missing"));
112
113        let parse = ConfigError::Parse {
114            path: "cfg.toml".into(),
115            source: "expected `=`".into(),
116        };
117        assert_eq!(
118            parse.to_string(),
119            "failed to parse config file cfg.toml: expected `=`"
120        );
121
122        let merged = ConfigError::Parse {
123            path: PathBuf::new(),
124            source: "invalid type".into(),
125        };
126        assert!(merged.to_string().contains("(merged)"));
127        assert!(merged.to_string().contains("invalid type"));
128
129        let unknown = ConfigError::UnknownFields {
130            keys: vec!["typo_a".into(), "typo_b".into()],
131        };
132        assert_eq!(
133            unknown.to_string(),
134            "unknown top-level configuration keys: typo_a, typo_b"
135        );
136
137        let env = ConfigError::EnvVar {
138            key: "APP_TOKEN".into(),
139            source: std::env::VarError::NotPresent,
140        };
141        assert!(env.to_string().contains("APP_TOKEN"));
142
143        let watch = ConfigError::WatchFailed {
144            source: "inotify limit reached".into(),
145        };
146        assert!(watch.to_string().contains("inotify limit reached"));
147
148        assert_eq!(
149            ConfigError::Poisoned.to_string(),
150            "config watcher lock poisoned; watcher state is unusable"
151        );
152    }
153
154    #[test]
155    fn errors_are_std_errors() {
156        let boxed: Box<dyn std::error::Error> = Box::new(ConfigError::Poisoned);
157        assert!(!boxed.to_string().is_empty());
158    }
159}