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    /// An encrypted file could not be decrypted.
32    Decrypt,
33    /// The active backend failed for a reason of its own.
34    Backend,
35}
36
37impl ErrorKind {
38    /// A short, stable label. Useful for metrics and log fields.
39    #[must_use]
40    pub fn as_str(self) -> &'static str {
41        match self {
42            Self::Io => "io",
43            Self::Parse => "parse",
44            Self::Missing => "missing",
45            Self::Type => "type",
46            Self::Env => "env",
47            Self::Invalid => "invalid",
48            Self::Remote => "remote",
49            Self::Decrypt => "decrypt",
50            Self::Backend => "backend",
51        }
52    }
53}
54
55/// Where a value came from.
56///
57/// Attached to errors so the first question of every configuration bug —
58/// *which source set this?* — is answered by the message itself.
59#[derive(Debug, Clone, PartialEq, Eq)]
60#[non_exhaustive]
61pub enum Origin {
62    /// A file on disk.
63    File(PathBuf),
64    /// An environment variable, named in full.
65    Env(String),
66    /// An in-memory source supplied by the caller.
67    Inline,
68    /// A remote store, as it described itself.
69    Remote(String),
70    /// A value set from code: `"default"` or `"override"`.
71    Runtime(&'static str),
72    /// Provenance could not be determined.
73    Unknown,
74}
75
76impl fmt::Display for Origin {
77    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78        match self {
79            Self::File(path) => write!(f, "in {}", path.display()),
80            Self::Env(name) => write!(f, "from {name}"),
81            Self::Inline => f.write_str("in an inline source"),
82            Self::Remote(store) => write!(f, "from {store}"),
83            Self::Runtime(layer) => write!(f, "set as {layer}"),
84            Self::Unknown => f.write_str("origin unknown"),
85        }
86    }
87}
88
89/// A configuration error.
90///
91/// Boxed internally so that `Result<T, Error>` stays small on the hot path —
92/// `load` is called on every reload, and most calls succeed.
93pub struct Error {
94    inner: Box<Inner>,
95}
96
97struct Inner {
98    kind: ErrorKind,
99    /// Key path from the config root, outermost segment first.
100    path: Vec<String>,
101    origin: Origin,
102    message: String,
103}
104
105impl Error {
106    /// Builds an error with no path and no known origin.
107    pub(crate) fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
108        Self {
109            inner: Box::new(Inner {
110                kind,
111                path: Vec::new(),
112                origin: Origin::Unknown,
113                message: message.into(),
114            }),
115        }
116    }
117
118    /// A configuration that parsed but failed its own validation.
119    ///
120    /// Called by the code `#[dynamic_config(.., validate)]` generates, from
121    /// whatever `validate` resolves to at the call site.
122    pub fn invalid<E: fmt::Display>(error: E) -> Self {
123        Self::new(ErrorKind::Invalid, error.to_string())
124    }
125
126    /// Maps a validation result into a load failure.
127    ///
128    /// Takes the whole `Result` rather than the error, so the generated code is
129    /// one expression regardless of what `validate` returns.
130    ///
131    /// # Errors
132    ///
133    /// If `outcome` is `Err`.
134    pub fn ok_or_invalid<T, E: fmt::Display>(outcome: Result<T, E>) -> Result<(), Self> {
135        outcome.map(|_| ()).map_err(Self::invalid)
136    }
137
138    /// A remote store that could not be read.
139    ///
140    /// For implementors of [`RemoteSource`](crate::RemoteSource), so a network
141    /// failure is categorised the same way whichever store it came from.
142    pub fn remote<E: fmt::Display>(error: E) -> Self {
143        Self::new(ErrorKind::Remote, error.to_string())
144    }
145
146    /// A failure decrypting an encrypted config file.
147    ///
148    /// For [`Decryptor`](crate::Decryptor) implementations, so a scheme this
149    /// crate has never heard of still reports through the same category.
150    pub fn decrypt<E: fmt::Display>(error: E) -> Self {
151        Self::new(ErrorKind::Decrypt, error.to_string())
152    }
153
154    /// A path whose extension names no format this build can write.
155    #[must_use]
156    pub fn unsupported(path: &std::path::Path) -> Self {
157        Self::new(
158            ErrorKind::Backend,
159            "the extension names no supported format; expected `.json`, `.toml`, \
160             `.yaml` or `.yml`",
161        )
162        .with_origin(Origin::File(path.to_owned()))
163    }
164
165    /// The error's category.
166    #[must_use]
167    pub fn kind(&self) -> ErrorKind {
168        self.inner.kind
169    }
170
171    /// The dotted key path this error occurred at, empty at the root.
172    #[must_use]
173    pub fn path(&self) -> String {
174        self.inner.path.join(".")
175    }
176
177    /// Where the offending value came from.
178    #[must_use]
179    pub fn origin(&self) -> &Origin {
180        &self.inner.origin
181    }
182
183    /// The detail message, without the path or origin decoration.
184    #[must_use]
185    pub fn message(&self) -> &str {
186        &self.inner.message
187    }
188
189    /// Pushes a key onto the front of the path.
190    ///
191    /// Deserialization unwinds from the innermost field outwards, so each
192    /// enclosing map prepends its own key and the path assembles itself in the
193    /// right order without any of them knowing the full path.
194    pub(crate) fn prepend_key(mut self, key: impl Into<String>) -> Self {
195        self.inner.path.insert(0, key.into());
196        self
197    }
198
199    /// Records provenance, unless something more specific is already known.
200    pub(crate) fn with_origin(mut self, origin: Origin) -> Self {
201        if self.inner.origin == Origin::Unknown {
202            self.inner.origin = origin;
203        }
204        self
205    }
206}
207
208impl fmt::Display for Error {
209    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
210        if self.inner.path.is_empty() {
211            f.write_str(&self.inner.message)?;
212        } else {
213            write!(f, "{}: {}", self.path(), self.inner.message)?;
214        }
215
216        if self.inner.origin != Origin::Unknown {
217            write!(f, " ({})", self.inner.origin)?;
218        }
219
220        Ok(())
221    }
222}
223
224impl fmt::Debug for Error {
225    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
226        f.debug_struct("Error")
227            .field("kind", &self.inner.kind)
228            .field("path", &self.path())
229            .field("origin", &self.inner.origin)
230            .field("message", &self.inner.message)
231            .finish()
232    }
233}
234
235impl std::error::Error for Error {}
236
237impl serde::de::Error for Error {
238    fn custom<T: fmt::Display>(msg: T) -> Self {
239        Self::new(ErrorKind::Type, msg.to_string())
240    }
241
242    fn missing_field(field: &'static str) -> Self {
243        Self::new(ErrorKind::Missing, "missing value").prepend_key(field)
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use serde::de::Error as _;
251
252    #[test]
253    fn prepend_key_assembles_the_path_outermost_first() {
254        let error = Error::new(ErrorKind::Type, "boom")
255            .prepend_key("max")
256            .prepend_key("pool")
257            .prepend_key("db");
258
259        assert_eq!(error.path(), "db.pool.max");
260    }
261
262    #[test]
263    fn display_includes_the_path_and_the_origin() {
264        let error = Error::new(ErrorKind::Type, "invalid type")
265            .prepend_key("port")
266            .with_origin(Origin::Env("APP_DB_PORT".to_owned()));
267
268        assert_eq!(error.to_string(), "port: invalid type (from APP_DB_PORT)");
269    }
270
271    #[test]
272    fn display_omits_both_decorations_when_they_are_absent() {
273        let error = Error::new(ErrorKind::Parse, "unexpected end of input");
274
275        assert_eq!(error.to_string(), "unexpected end of input");
276    }
277
278    #[test]
279    fn the_first_origin_recorded_wins() {
280        let error = Error::new(ErrorKind::Type, "boom")
281            .with_origin(Origin::Inline)
282            .with_origin(Origin::Env("APP_X".to_owned()));
283
284        assert_eq!(error.origin(), &Origin::Inline);
285    }
286
287    #[test]
288    fn a_missing_field_carries_its_name_and_kind() {
289        let error = Error::missing_field("host");
290
291        assert_eq!(error.kind(), ErrorKind::Missing);
292        assert_eq!(error.path(), "host");
293    }
294}