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}