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 walk serves every question below. The old shape called `source_of`
162    // per leaf key, and each call re-read and re-parsed every source once
163    // per key; the snapshot now carries the answer with it.
164    let snapshot = 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: snapshot
172                .source_of(path)
173                .cloned()
174                .unwrap_or(crate::Origin::Unknown),
175        })
176        .collect();
177
178    Ok(Report {
179        key: spec.key.to_owned(),
180        resolved,
181        // An aliased old path is a *known* key rather than an ignored one: the
182        // detector exists to catch typos, and an alias that silenced it would
183        // make `pool.szie` a supported spelling.
184        unknown: unknown_keys(&snapshot, fields, &aliased_keys(spec)),
185        unknown_checked: !fields.is_empty(),
186        // `load` rather than `Snapshot::extract`, so the message names the file
187        // at fault — which is the whole point of running this.
188        failure: crate::loader::load::<T>(spec)
189            .err()
190            .map(|error| error.to_string()),
191    })
192}
193
194/// The top-level keys aliases make legitimate.
195fn aliased_keys(spec: &LoadSpec<'_>) -> Vec<String> {
196    spec.aliases
197        .map(crate::Aliases::known_keys)
198        .unwrap_or_default()
199}
200
201fn unknown_keys(snapshot: &Snapshot, fields: &[&str], aliased: &[String]) -> Vec<UnknownKey> {
202    if fields.is_empty() {
203        return Vec::new();
204    }
205
206    snapshot
207        .top_level_keys()
208        .into_iter()
209        .filter(|key| !fields.contains(&key.as_str()))
210        .filter(|key| !aliased.iter().any(|alias| alias == key))
211        .map(|key| UnknownKey {
212            suggestion: closest(&key, fields),
213            path: key,
214        })
215        .collect()
216}
217
218/// The nearest field name, when it is near enough to be a typo rather than a
219/// different word.
220///
221/// The threshold scales with the name: one edit in `id`, three in
222/// `connection_timeout`. A fixed distance would either miss typos in long names
223/// or propose nonsense for short ones.
224fn closest(key: &str, fields: &[&str]) -> Option<String> {
225    let budget = (key.len() / 4).max(1);
226
227    fields
228        .iter()
229        .map(|field| (distance(key, field), *field))
230        .filter(|(distance, _)| *distance <= budget)
231        .min_by_key(|(distance, _)| *distance)
232        .map(|(_, field)| field.to_owned())
233}
234
235/// Optimal string alignment distance: Levenshtein, plus transposition.
236///
237/// Counting a swap as one edit rather than two is the whole reason to prefer it
238/// here — `prot` for `port` is the single most common way to mistype a key, and
239/// plain Levenshtein scores it the same as two unrelated substitutions.
240fn distance(left: &str, right: &str) -> usize {
241    let left: Vec<char> = left.chars().collect();
242    let right: Vec<char> = right.chars().collect();
243
244    // Three rows: the one before last is what makes a transposition visible.
245    let mut before_last = vec![0; right.len() + 1];
246    let mut previous: Vec<usize> = (0..=right.len()).collect();
247    let mut current = vec![0; right.len() + 1];
248
249    for i in 0..left.len() {
250        current[0] = i + 1;
251
252        for j in 0..right.len() {
253            let substitution = usize::from(left[i] != right[j]);
254
255            current[j + 1] = (previous[j] + substitution)
256                .min(previous[j + 1] + 1)
257                .min(current[j] + 1);
258
259            // The two characters are each other's, the other way round.
260            if i > 0 && j > 0 && left[i] == right[j - 1] && left[i - 1] == right[j] {
261                current[j + 1] = current[j + 1].min(before_last[j - 1] + 1);
262            }
263        }
264
265        std::mem::swap(&mut before_last, &mut previous);
266        std::mem::swap(&mut previous, &mut current);
267    }
268
269    previous[right.len()]
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn a_close_miss_is_suggested() {
278        assert_eq!(closest("hsot", &["host", "port"]).as_deref(), Some("host"));
279        assert_eq!(closest("prot", &["host", "port"]).as_deref(), Some("port"));
280        assert_eq!(closest("hosts", &["host", "port"]).as_deref(), Some("host"));
281    }
282
283    #[test]
284    fn an_unrelated_key_suggests_nothing() {
285        assert_eq!(closest("elephant", &["host", "port"]), None);
286    }
287
288    #[test]
289    fn the_budget_scales_with_the_name() {
290        // One edit is always allowed, even in a two-letter name.
291        assert_eq!(closest("od", &["id"]).as_deref(), Some("id"));
292        // Three edits in a long name still reads as a typo.
293        assert_eq!(
294            closest("conection_timout", &["connection_timeout"]).as_deref(),
295            Some("connection_timeout")
296        );
297        // Two edits in a short one does not.
298        assert_eq!(closest("xy", &["id"]), None);
299    }
300
301    #[test]
302    fn distance_counts_edits() {
303        assert_eq!(distance("", ""), 0);
304        assert_eq!(distance("host", "host"), 0);
305        assert_eq!(distance("host", ""), 4);
306        assert_eq!(distance("port", "sport"), 1, "one insertion");
307        assert_eq!(distance("port", "pxrt"), 1, "one substitution");
308    }
309
310    #[test]
311    fn a_transposition_is_one_edit_not_two() {
312        // The point of the alignment variant: this is how keys get mistyped.
313        assert_eq!(distance("port", "prot"), 1);
314        assert_eq!(distance("host", "hsot"), 1);
315    }
316
317    #[test]
318    fn an_unknown_key_renders_its_suggestion() {
319        let unknown = UnknownKey {
320            path: "hsot".to_owned(),
321            suggestion: Some("host".to_owned()),
322        };
323
324        assert_eq!(
325            unknown.to_string(),
326            "hsot: unknown key, did you mean `host`?"
327        );
328    }
329}