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    /// Why the configuration would fail to load, if it would.
84    pub failure: Option<String>,
85}
86
87impl Report {
88    /// Whether a load would succeed and no key looks like a typo.
89    #[must_use]
90    pub fn is_clean(&self) -> bool {
91        self.failure.is_none() && self.unknown.is_empty()
92    }
93}
94
95impl fmt::Display for Report {
96    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97        writeln!(f, "[{}]", self.key)?;
98
99        if self.resolved.is_empty() {
100            writeln!(f, "  (nothing supplies this section)")?;
101        }
102
103        for resolved in &self.resolved {
104            writeln!(f, "  {resolved}")?;
105        }
106
107        if !self.unknown.is_empty() {
108            writeln!(f)?;
109
110            for unknown in &self.unknown {
111                writeln!(f, "  {unknown}")?;
112            }
113        }
114
115        match &self.failure {
116            Some(failure) => write!(f, "\n  would not load: {failure}"),
117            None => write!(f, "\n  would load"),
118        }
119    }
120}
121
122/// Builds a report for `spec`.
123///
124/// `fields` are the struct's field names, used for unknown-key detection; pass
125/// an empty slice to skip it. The generated `check()` passes the real ones.
126///
127/// # Errors
128///
129/// Only if the sources cannot be read or parsed at all. A configuration that
130/// parses but would not deserialize is a successful report with
131/// [`failure`](Report::failure) set — that is the case worth reporting on.
132pub fn check<T>(spec: &LoadSpec<'_>, fields: &[&str]) -> Result<Report, Error>
133where
134    T: serde::de::DeserializeOwned,
135{
136    // One build serves every question below. The old shape called
137    // `source_of` per leaf key, and each call rebuilt the figment — re-reading
138    // and re-parsing every source once per key.
139    let (snapshot, figment) = crate::loader::resolved(spec)?;
140    let paths = snapshot.leaf_paths();
141
142    let resolved = paths
143        .iter()
144        .map(|path| Resolved {
145            path: path.clone(),
146            origin: crate::loader::origin_in(&figment, path),
147        })
148        .collect();
149
150    Ok(Report {
151        key: spec.key.to_owned(),
152        resolved,
153        // An aliased old path is a *known* key rather than an ignored one: the
154        // detector exists to catch typos, and an alias that silenced it would
155        // make `pool.szie` a supported spelling.
156        unknown: unknown_keys(&snapshot, fields, &aliased_keys(spec)),
157        // `load` rather than `Snapshot::extract`, so the message names the file
158        // at fault — which is the whole point of running this.
159        failure: crate::loader::load::<T>(spec)
160            .err()
161            .map(|error| error.to_string()),
162    })
163}
164
165/// The top-level keys aliases make legitimate.
166fn aliased_keys(spec: &LoadSpec<'_>) -> Vec<String> {
167    spec.aliases
168        .map(crate::Aliases::known_keys)
169        .unwrap_or_default()
170}
171
172fn unknown_keys(snapshot: &Snapshot, fields: &[&str], aliased: &[String]) -> Vec<UnknownKey> {
173    if fields.is_empty() {
174        return Vec::new();
175    }
176
177    snapshot
178        .top_level_keys()
179        .into_iter()
180        .filter(|key| !fields.contains(&key.as_str()))
181        .filter(|key| !aliased.iter().any(|alias| alias == key))
182        .map(|key| UnknownKey {
183            suggestion: closest(&key, fields),
184            path: key,
185        })
186        .collect()
187}
188
189/// The nearest field name, when it is near enough to be a typo rather than a
190/// different word.
191///
192/// The threshold scales with the name: one edit in `id`, three in
193/// `connection_timeout`. A fixed distance would either miss typos in long names
194/// or propose nonsense for short ones.
195fn closest(key: &str, fields: &[&str]) -> Option<String> {
196    let budget = (key.len() / 4).max(1);
197
198    fields
199        .iter()
200        .map(|field| (distance(key, field), *field))
201        .filter(|(distance, _)| *distance <= budget)
202        .min_by_key(|(distance, _)| *distance)
203        .map(|(_, field)| field.to_owned())
204}
205
206/// Optimal string alignment distance: Levenshtein, plus transposition.
207///
208/// Counting a swap as one edit rather than two is the whole reason to prefer it
209/// here — `prot` for `port` is the single most common way to mistype a key, and
210/// plain Levenshtein scores it the same as two unrelated substitutions.
211fn distance(left: &str, right: &str) -> usize {
212    let left: Vec<char> = left.chars().collect();
213    let right: Vec<char> = right.chars().collect();
214
215    // Three rows: the one before last is what makes a transposition visible.
216    let mut before_last = vec![0; right.len() + 1];
217    let mut previous: Vec<usize> = (0..=right.len()).collect();
218    let mut current = vec![0; right.len() + 1];
219
220    for i in 0..left.len() {
221        current[0] = i + 1;
222
223        for j in 0..right.len() {
224            let substitution = usize::from(left[i] != right[j]);
225
226            current[j + 1] = (previous[j] + substitution)
227                .min(previous[j + 1] + 1)
228                .min(current[j] + 1);
229
230            // The two characters are each other's, the other way round.
231            if i > 0 && j > 0 && left[i] == right[j - 1] && left[i - 1] == right[j] {
232                current[j + 1] = current[j + 1].min(before_last[j - 1] + 1);
233            }
234        }
235
236        std::mem::swap(&mut before_last, &mut previous);
237        std::mem::swap(&mut previous, &mut current);
238    }
239
240    previous[right.len()]
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[test]
248    fn a_close_miss_is_suggested() {
249        assert_eq!(closest("hsot", &["host", "port"]).as_deref(), Some("host"));
250        assert_eq!(closest("prot", &["host", "port"]).as_deref(), Some("port"));
251        assert_eq!(closest("hosts", &["host", "port"]).as_deref(), Some("host"));
252    }
253
254    #[test]
255    fn an_unrelated_key_suggests_nothing() {
256        assert_eq!(closest("elephant", &["host", "port"]), None);
257    }
258
259    #[test]
260    fn the_budget_scales_with_the_name() {
261        // One edit is always allowed, even in a two-letter name.
262        assert_eq!(closest("od", &["id"]).as_deref(), Some("id"));
263        // Three edits in a long name still reads as a typo.
264        assert_eq!(
265            closest("conection_timout", &["connection_timeout"]).as_deref(),
266            Some("connection_timeout")
267        );
268        // Two edits in a short one does not.
269        assert_eq!(closest("xy", &["id"]), None);
270    }
271
272    #[test]
273    fn distance_counts_edits() {
274        assert_eq!(distance("", ""), 0);
275        assert_eq!(distance("host", "host"), 0);
276        assert_eq!(distance("host", ""), 4);
277        assert_eq!(distance("port", "sport"), 1, "one insertion");
278        assert_eq!(distance("port", "pxrt"), 1, "one substitution");
279    }
280
281    #[test]
282    fn a_transposition_is_one_edit_not_two() {
283        // The point of the alignment variant: this is how keys get mistyped.
284        assert_eq!(distance("port", "prot"), 1);
285        assert_eq!(distance("host", "hsot"), 1);
286    }
287
288    #[test]
289    fn an_unknown_key_renders_its_suggestion() {
290        let unknown = UnknownKey {
291            path: "hsot".to_owned(),
292            suggestion: Some("host".to_owned()),
293        };
294
295        assert_eq!(
296            unknown.to_string(),
297            "hsot: unknown key, did you mean `host`?"
298        );
299    }
300}