Skip to main content

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}