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}