dynamic_config/error.rs
1//! The crate's single error type.
2//!
3//! No backend error type is ever exposed: a `figment::Error` or a
4//! `serde_json::Error` is translated into an [`Error`] at the boundary, so
5//! switching backends never changes a caller's signatures.
6
7use std::fmt;
8use std::path::PathBuf;
9
10/// Broad category of a configuration failure.
11///
12/// Matching on this is enough to decide how to react; the human-readable
13/// detail lives in the [`Display`](fmt::Display) output.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15#[non_exhaustive]
16pub enum ErrorKind {
17 /// A configured file exists but could not be read.
18 Io,
19 /// A file was read but is not valid in its format.
20 Parse,
21 /// A required value was not supplied by any source.
22 Missing,
23 /// A value was supplied but cannot become the requested type.
24 Type,
25 /// An environment variable could not be interpreted.
26 Env,
27 /// Every value parsed, but the configuration as a whole was rejected.
28 Invalid,
29 /// A remote store could not be read.
30 Remote,
31 /// A credential was rejected, or could not be obtained.
32 ///
33 /// Distinct from [`Remote`](Self::Remote), which is the store being
34 /// unreachable: **that one may fix itself by waiting; this one will not.**
35 /// A watch loop can back off on the first and stop on the second.
36 ///
37 /// The credential itself is never part of the message.
38 Auth,
39 /// An encrypted file could not be decrypted.
40 Decrypt,
41 /// The active backend failed for a reason of its own.
42 Backend,
43}
44
45impl ErrorKind {
46 /// A short, stable label. Useful for metrics and log fields.
47 #[must_use]
48 pub fn as_str(self) -> &'static str {
49 match self {
50 Self::Io => "io",
51 Self::Parse => "parse",
52 Self::Missing => "missing",
53 Self::Type => "type",
54 Self::Env => "env",
55 Self::Invalid => "invalid",
56 Self::Remote => "remote",
57 Self::Auth => "auth",
58 Self::Decrypt => "decrypt",
59 Self::Backend => "backend",
60 }
61 }
62}
63
64/// Where a value came from.
65///
66/// Attached to errors so the first question of every configuration bug —
67/// *which source set this?* — is answered by the message itself.
68#[derive(Debug, Clone, PartialEq, Eq)]
69#[non_exhaustive]
70pub enum Origin {
71 /// A file on disk.
72 File(PathBuf),
73 /// An environment variable, named in full.
74 Env(String),
75 /// An in-memory source supplied by the caller.
76 Inline,
77 /// A remote store, as it described itself.
78 Remote(String),
79 /// A value set from code: `"default"` or `"override"`.
80 Runtime(&'static str),
81 /// Provenance could not be determined.
82 Unknown,
83}
84
85impl fmt::Display for Origin {
86 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87 match self {
88 Self::File(path) => write!(f, "in {}", path.display()),
89 Self::Env(name) => write!(f, "from {name}"),
90 Self::Inline => f.write_str("in an inline source"),
91 Self::Remote(store) => write!(f, "from {store}"),
92 Self::Runtime(layer) => write!(f, "set as {layer}"),
93 Self::Unknown => f.write_str("origin unknown"),
94 }
95 }
96}
97
98/// A configuration error.
99///
100/// Boxed internally so that `Result<T, Error>` stays small on the hot path —
101/// `load` is called on every reload, and most calls succeed.
102pub struct Error {
103 inner: Box<Inner>,
104}
105
106struct Inner {
107 kind: ErrorKind,
108 /// Key path from the config root, outermost segment first.
109 path: Vec<String>,
110 origin: Origin,
111 message: String,
112}
113
114impl Error {
115 /// Builds an error with no path and no known origin.
116 pub(crate) fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
117 Self {
118 inner: Box::new(Inner {
119 kind,
120 path: Vec::new(),
121 origin: Origin::Unknown,
122 message: message.into(),
123 }),
124 }
125 }
126
127 /// A configuration that parsed but failed its own validation.
128 ///
129 /// Called by the code `#[dynamic_config(.., validate)]` generates, from
130 /// whatever `validate` resolves to at the call site.
131 pub fn invalid<E: fmt::Display>(error: E) -> Self {
132 Self::new(ErrorKind::Invalid, error.to_string())
133 }
134
135 /// Maps a validation result into a load failure.
136 ///
137 /// Takes the whole `Result` rather than the error, so the generated code is
138 /// one expression regardless of what `validate` returns.
139 ///
140 /// # Errors
141 ///
142 /// If `outcome` is `Err`.
143 pub fn ok_or_invalid<T, E: fmt::Display>(outcome: Result<T, E>) -> Result<(), Self> {
144 outcome.map(|_| ()).map_err(Self::invalid)
145 }
146
147 /// A remote store that could not be read.
148 ///
149 /// For implementors of [`RemoteSource`](crate::RemoteSource), so a network
150 /// failure is categorised the same way whichever store it came from.
151 pub fn remote<E: fmt::Display>(error: E) -> Self {
152 Self::new(ErrorKind::Remote, error.to_string())
153 }
154
155 /// A credential a remote store refused, or one that could not be obtained.
156 ///
157 /// Also for [`RemoteSource`](crate::RemoteSource) implementors, and the
158 /// line between this and [`remote`](Self::remote) is the one worth getting
159 /// right: this is for what waiting cannot cure — a 401 or a 403, a token
160 /// that expired and could not be replaced. A network failure *while*
161 /// fetching a token is [`remote`](Self::remote), because the store may yet
162 /// answer; a credential file that is not there is an
163 /// [`Io`](ErrorKind::Io) problem, and one that is there but malformed is a
164 /// [`Parse`](ErrorKind::Parse) problem.
165 ///
166 /// Where a store cannot tell its own 403 from a proxy's, prefer
167 /// [`remote`](Self::remote): a wrong `Auth` stops a watch loop that would
168 /// have recovered.
169 ///
170 /// `error` must not name the credential. It reaches logs.
171 pub fn auth<E: fmt::Display>(error: E) -> Self {
172 Self::new(ErrorKind::Auth, error.to_string())
173 }
174
175 /// A failure decrypting an encrypted config file.
176 ///
177 /// For [`Decryptor`](crate::Decryptor) implementations, so a scheme this
178 /// crate has never heard of still reports through the same category.
179 pub fn decrypt<E: fmt::Display>(error: E) -> Self {
180 Self::new(ErrorKind::Decrypt, error.to_string())
181 }
182
183 /// A path whose extension names no format this build can write.
184 #[must_use]
185 pub fn unsupported(path: &std::path::Path) -> Self {
186 Self::new(
187 ErrorKind::Backend,
188 "the extension names no supported format; expected `.json`, `.toml`, \
189 `.yaml` or `.yml`",
190 )
191 .with_origin(Origin::File(path.to_owned()))
192 }
193
194 /// The error's category.
195 #[must_use]
196 pub fn kind(&self) -> ErrorKind {
197 self.inner.kind
198 }
199
200 /// The dotted key path this error occurred at, empty at the root.
201 #[must_use]
202 pub fn path(&self) -> String {
203 self.inner.path.join(".")
204 }
205
206 /// Where the offending value came from.
207 #[must_use]
208 pub fn origin(&self) -> &Origin {
209 &self.inner.origin
210 }
211
212 /// The detail message, without the path or origin decoration.
213 #[must_use]
214 pub fn message(&self) -> &str {
215 &self.inner.message
216 }
217
218 /// Pushes a key onto the front of the path.
219 ///
220 /// Deserialization unwinds from the innermost field outwards, so each
221 /// enclosing map prepends its own key and the path assembles itself in the
222 /// right order without any of them knowing the full path.
223 pub(crate) fn prepend_key(mut self, key: impl Into<String>) -> Self {
224 self.inner.path.insert(0, key.into());
225 self
226 }
227
228 /// Records provenance, unless something more specific is already known.
229 pub(crate) fn with_origin(mut self, origin: Origin) -> Self {
230 if self.inner.origin == Origin::Unknown {
231 self.inner.origin = origin;
232 }
233 self
234 }
235}
236
237impl fmt::Display for Error {
238 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
239 if self.inner.path.is_empty() {
240 f.write_str(&self.inner.message)?;
241 } else {
242 write!(f, "{}: {}", self.path(), self.inner.message)?;
243 }
244
245 if self.inner.origin != Origin::Unknown {
246 write!(f, " ({})", self.inner.origin)?;
247 }
248
249 Ok(())
250 }
251}
252
253impl fmt::Debug for Error {
254 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
255 f.debug_struct("Error")
256 .field("kind", &self.inner.kind)
257 .field("path", &self.path())
258 .field("origin", &self.inner.origin)
259 .field("message", &self.inner.message)
260 .finish()
261 }
262}
263
264impl std::error::Error for Error {}
265
266impl serde::de::Error for Error {
267 fn custom<T: fmt::Display>(msg: T) -> Self {
268 Self::new(ErrorKind::Type, msg.to_string())
269 }
270
271 fn missing_field(field: &'static str) -> Self {
272 Self::new(ErrorKind::Missing, "missing value").prepend_key(field)
273 }
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279 use serde::de::Error as _;
280
281 #[test]
282 fn prepend_key_assembles_the_path_outermost_first() {
283 let error = Error::new(ErrorKind::Type, "boom")
284 .prepend_key("max")
285 .prepend_key("pool")
286 .prepend_key("db");
287
288 assert_eq!(error.path(), "db.pool.max");
289 }
290
291 #[test]
292 fn display_includes_the_path_and_the_origin() {
293 let error = Error::new(ErrorKind::Type, "invalid type")
294 .prepend_key("port")
295 .with_origin(Origin::Env("APP_DB_PORT".to_owned()));
296
297 assert_eq!(error.to_string(), "port: invalid type (from APP_DB_PORT)");
298 }
299
300 #[test]
301 fn display_omits_both_decorations_when_they_are_absent() {
302 let error = Error::new(ErrorKind::Parse, "unexpected end of input");
303
304 assert_eq!(error.to_string(), "unexpected end of input");
305 }
306
307 #[test]
308 fn the_first_origin_recorded_wins() {
309 let error = Error::new(ErrorKind::Type, "boom")
310 .with_origin(Origin::Inline)
311 .with_origin(Origin::Env("APP_X".to_owned()));
312
313 assert_eq!(error.origin(), &Origin::Inline);
314 }
315
316 /// The whole value of the variant is that it is *not* `Remote`: a caller
317 /// backs off on one and stops on the other, so they must never collapse
318 /// into each other.
319 #[test]
320 fn a_refused_credential_is_its_own_kind_rather_than_a_flavour_of_remote() {
321 let refused = Error::auth("the store refused the credential");
322 let unreachable = Error::remote("the store is unreachable");
323
324 assert_eq!(refused.kind(), ErrorKind::Auth);
325 assert_eq!(refused.kind().as_str(), "auth");
326 assert_ne!(refused.kind(), unreachable.kind());
327 assert_eq!(refused.to_string(), "the store refused the credential");
328 }
329
330 #[test]
331 fn a_missing_field_carries_its_name_and_kind() {
332 let error = Error::missing_field("host");
333
334 assert_eq!(error.kind(), ErrorKind::Missing);
335 assert_eq!(error.path(), "host");
336 }
337}