Skip to main content

dynamic_config/
check.rs

1//! Answering "will this boot, and where is each value coming from?" without
2//! booting.
3//!
4//! A deployment's configuration is assembled from files in three directories,
5//! an environment, two runtime layers and possibly a profile. When it is wrong,
6//! the useful question is not *what does the struct look like* — it is *which
7//! layer set this, and did I typo a key*. That is a different report from a
8//! successful load, and it has to work when the load **fails**.
9//!
10//! ## What it deliberately does not print
11//!
12//! Values. A report that showed them would be pasted into an issue tracker with
13//! the database password in it, undoing `#[config(secret)]` exactly as a naive
14//! reload diff would. Paths and origins say where to look; the file already
15//! holds the value.
16//!
17//! ## What unknown-key detection can and cannot catch
18//!
19//! `Report` compares the resolved section's **top-level** keys against the
20//! struct's field names, so `db.hsot` is caught and `db.pool.mx_size` is not:
21//! a proc-macro sees the field's *type name*, not its fields, so nothing here
22//! knows what lives inside `pool`.
23//!
24//! Detection is skipped entirely when a field carries `#[serde(flatten)]`,
25//! because a flattened field legitimately absorbs keys the outer struct never
26//! names — reporting those as typos would be worse than reporting nothing.
27
28use std::fmt;
29
30use crate::error::{Error, Origin};
31use crate::snapshot::Snapshot;
32use crate::source::LoadSpec;
33
34/// A key the configuration supplies that the struct does not name.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct UnknownKey {
37    /// The key, relative to the section.
38    pub path: String,
39    /// The closest field name, when one is close enough to be a likely typo.
40    pub suggestion: Option<String>,
41}
42
43impl fmt::Display for UnknownKey {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        write!(f, "{}: unknown key", self.path)?;
46
47        if let Some(suggestion) = &self.suggestion {
48            write!(f, ", did you mean `{suggestion}`?")?;
49        }
50
51        Ok(())
52    }
53}
54
55/// Where one key's value comes from.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct Resolved {
58    /// The key, relative to the section.
59    pub path: String,
60    /// The layer that supplied it.
61    pub origin: Origin,
62}
63
64impl fmt::Display for Resolved {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        write!(f, "{:<28} {}", self.path, self.origin)
67    }
68}
69
70/// What a configuration resolves to, and whether it would load.
71///
72/// Built by [`check`], and by the `check()` the macro generates. Prints as a
73/// short report; the fields are public for anything that wants to render it
74/// differently.
75#[derive(Debug, Clone)]
76pub struct Report {
77    /// The section this describes.
78    pub key: String,
79    /// Every key the sources supply, with the layer that won.
80    pub resolved: Vec<Resolved>,
81    /// Keys the struct does not name. Empty when detection was skipped.
82    pub unknown: Vec<UnknownKey>,
83    /// Whether unknown-key detection ran at all.
84    ///
85    /// `false` when there was no field list to compare against — a
86    /// schemaless configuration, a bare [`Builder::new`](crate::Builder::new),
87    /// or a struct with a `#[serde(flatten)]` field, which legitimately
88    /// absorbs keys the outer type never names.
89    ///
90    /// It is a separate answer from an empty [`unknown`](Self::unknown)
91    /// because the two mean opposite things: "every key is known" and
92    /// "nobody looked". The [`Display`](fmt::Display) rendering says which,
93    /// and `check` on a configuration with no schema must not read as an
94    /// all-clear.
95    pub unknown_checked: bool,
96    /// Why the configuration would fail to load, if it would.
97    pub failure: Option<String>,
98}
99
100impl Report {
101    /// Whether a load would succeed and no key looks like a typo.
102    ///
103    /// A report where detection never ran can still be clean: there is
104    /// nothing wrong with a configuration that declares no schema, and
105    /// answering `false` would make the flag useless for every schemaless
106    /// caller. [`unknown_checked`](Self::unknown_checked) is how a caller
107    /// asks the other question.
108    #[must_use]
109    pub fn is_clean(&self) -> bool {
110        self.failure.is_none() && self.unknown.is_empty()
111    }
112}
113
114impl fmt::Display for Report {
115    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116        writeln!(f, "[{}]", self.key)?;
117
118        if self.resolved.is_empty() {
119            writeln!(f, "  (nothing supplies this section)")?;
120        }
121
122        for resolved in &self.resolved {
123            writeln!(f, "  {resolved}")?;
124        }
125
126        if !self.unknown.is_empty() {
127            writeln!(f)?;
128
129            for unknown in &self.unknown {
130                writeln!(f, "  {unknown}")?;
131            }
132        }
133
134        // Said out loud, because the alternative is a report that looks
135        // like an all-clear when nothing was compared.
136        if !self.unknown_checked {
137            writeln!(f, "\n  unknown keys: not checked (no field list)")?;
138        }
139
140        match &self.failure {
141            Some(failure) => write!(f, "\n  would not load: {failure}"),
142            None => write!(f, "\n  would load"),
143        }
144    }
145}
146
147/// Builds a report for `spec`.
148///
149/// `fields` are the struct's field names, used for unknown-key detection; pass
150/// an empty slice to skip it. The generated `check()` passes the real ones.
151///
152/// # Errors
153///
154/// Only if the sources cannot be read or parsed at all. A configuration that
155/// parses but would not deserialize is a successful report with
156/// [`failure`](Report::failure) set — that is the case worth reporting on.
157pub fn check<T>(spec: &LoadSpec<'_>, fields: &[&str]) -> Result<Report, Error>
158where
159    T: serde::de::DeserializeOwned,
160{
161    // One build serves every question below. The old shape called
162    // `source_of` per leaf key, and each call rebuilt the figment — re-reading
163    // and re-parsing every source once per key.
164    let (snapshot, figment) = crate::loader::resolved(spec)?;
165    let paths = snapshot.leaf_paths();
166
167    let resolved = paths
168        .iter()
169        .map(|path| Resolved {
170            path: path.clone(),
171            origin: crate::loader::origin_in(&figment, path, spec.nest),
172        })
173        .collect();
174
175    Ok(Report {
176        key: spec.key.to_owned(),
177        resolved,
178        // An aliased old path is a *known* key rather than an ignored one: the
179        // detector exists to catch typos, and an alias that silenced it would
180        // make `pool.szie` a supported spelling.
181        unknown: unknown_keys(&snapshot, fields, &aliased_keys(spec)),
182        unknown_checked: !fields.is_empty(),
183        // `load` rather than `Snapshot::extract`, so the message names the file
184        // at fault — which is the whole point of running this.
185        failure: crate::loader::load::<T>(spec)
186            .err()
187            .map(|error| error.to_string()),
188    })
189}
190
191/// The top-level keys aliases make legitimate.
192fn aliased_keys(spec: &LoadSpec<'_>) -> Vec<String> {
193    spec.aliases
194        .map(crate::Aliases::known_keys)
195        .unwrap_or_default()
196}
197
198fn unknown_keys(snapshot: &Snapshot, fields: &[&str], aliased: &[String]) -> Vec<UnknownKey> {
199    if fields.is_empty() {
200        return Vec::new();
201    }
202
203    snapshot
204        .top_level_keys()
205        .into_iter()
206        .filter(|key| !fields.contains(&key.as_str()))
207        .filter(|key| !aliased.iter().any(|alias| alias == key))
208        .map(|key| UnknownKey {
209            suggestion: closest(&key, fields),
210            path: key,
211        })
212        .collect()
213}
214
215/// The nearest field name, when it is near enough to be a typo rather than a
216/// different word.
217///
218/// The threshold scales with the name: one edit in `id`, three in
219/// `connection_timeout`. A fixed distance would either miss typos in long names
220/// or propose nonsense for short ones.
221fn closest(key: &str, fields: &[&str]) -> Option<String> {
222    let budget = (key.len() / 4).max(1);
223
224    fields
225        .iter()
226        .map(|field| (distance(key, field), *field))
227        .filter(|(distance, _)| *distance <= budget)
228        .min_by_key(|(distance, _)| *distance)
229        .map(|(_, field)| field.to_owned())
230}
231
232/// Optimal string alignment distance: Levenshtein, plus transposition.
233///
234/// Counting a swap as one edit rather than two is the whole reason to prefer it
235/// here — `prot` for `port` is the single most common way to mistype a key, and
236/// plain Levenshtein scores it the same as two unrelated substitutions.
237fn distance(left: &str, right: &str) -> usize {
238    let left: Vec<char> = left.chars().collect();
239    let right: Vec<char> = right.chars().collect();
240
241    // Three rows: the one before last is what makes a transposition visible.
242    let mut before_last = vec![0; right.len() + 1];
243    let mut previous: Vec<usize> = (0..=right.len()).collect();
244    let mut current = vec![0; right.len() + 1];
245
246    for i in 0..left.len() {
247        current[0] = i + 1;
248
249        for j in 0..right.len() {
250            let substitution = usize::from(left[i] != right[j]);
251
252            current[j + 1] = (previous[j] + substitution)
253                .min(previous[j + 1] + 1)
254                .min(current[j] + 1);
255
256            // The two characters are each other's, the other way round.
257            if i > 0 && j > 0 && left[i] == right[j - 1] && left[i - 1] == right[j] {
258                current[j + 1] = current[j + 1].min(before_last[j - 1] + 1);
259            }
260        }
261
262        std::mem::swap(&mut before_last, &mut previous);
263        std::mem::swap(&mut previous, &mut current);
264    }
265
266    previous[right.len()]
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    #[test]
274    fn a_close_miss_is_suggested() {
275        assert_eq!(closest("hsot", &["host", "port"]).as_deref(), Some("host"));
276        assert_eq!(closest("prot", &["host", "port"]).as_deref(), Some("port"));
277        assert_eq!(closest("hosts", &["host", "port"]).as_deref(), Some("host"));
278    }
279
280    #[test]
281    fn an_unrelated_key_suggests_nothing() {
282        assert_eq!(closest("elephant", &["host", "port"]), None);
283    }
284
285    #[test]
286    fn the_budget_scales_with_the_name() {
287        // One edit is always allowed, even in a two-letter name.
288        assert_eq!(closest("od", &["id"]).as_deref(), Some("id"));
289        // Three edits in a long name still reads as a typo.
290        assert_eq!(
291            closest("conection_timout", &["connection_timeout"]).as_deref(),
292            Some("connection_timeout")
293        );
294        // Two edits in a short one does not.
295        assert_eq!(closest("xy", &["id"]), None);
296    }
297
298    #[test]
299    fn distance_counts_edits() {
300        assert_eq!(distance("", ""), 0);
301        assert_eq!(distance("host", "host"), 0);
302        assert_eq!(distance("host", ""), 4);
303        assert_eq!(distance("port", "sport"), 1, "one insertion");
304        assert_eq!(distance("port", "pxrt"), 1, "one substitution");
305    }
306
307    #[test]
308    fn a_transposition_is_one_edit_not_two() {
309        // The point of the alignment variant: this is how keys get mistyped.
310        assert_eq!(distance("port", "prot"), 1);
311        assert_eq!(distance("host", "hsot"), 1);
312    }
313
314    #[test]
315    fn an_unknown_key_renders_its_suggestion() {
316        let unknown = UnknownKey {
317            path: "hsot".to_owned(),
318            suggestion: Some("host".to_owned()),
319        };
320
321        assert_eq!(
322            unknown.to_string(),
323            "hsot: unknown key, did you mean `host`?"
324        );
325    }
326}