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}