Skip to main content

dynamic_config/
explain.rs

1//! Why a value is what it is — every layer's answer, not just the winner's.
2//!
3//! [`source_of`](crate::source_of) answers *which layer won*. The question a
4//! production incident actually asks is *why*: what did every layer have to
5//! say, and who beat whom. An [`Explanation`] is that table.
6//!
7//! Unlike every other diagnostic in this crate, an explanation **contains
8//! values** — that is its point; you asked. Fields marked
9//! `#[config(secret)]` stay `***` in the generated `explain()`, and
10//! [`Explanation::redacted`] blanks all of them for a path the caller knows
11//! to be sensitive.
12
13use std::fmt;
14
15use figment::value::Value;
16
17use crate::error::{Error, Origin};
18use crate::source::LoadSpec;
19
20/// One layer's answer for one path.
21#[derive(Clone)]
22#[non_exhaustive]
23pub struct Contribution {
24    /// The layer, by its name in the precedence order.
25    pub layer: &'static str,
26    /// Where within the layer the value came from.
27    ///
28    /// `None` when the layer supplies nothing at this path.
29    pub origin: Option<Origin>,
30    /// The value, rendered short — `None` when the layer supplies nothing,
31    /// `***` when redacted. Tables and lists render as their shape
32    /// (`a table (3 keys)`), not their contents.
33    pub value: Option<String>,
34    /// The old path an alias carried this value from, on the `alias` row.
35    ///
36    /// `None` on every other layer. The spelling is the one the alias was
37    /// declared with, so a key that moved between sections says so:
38    /// `db::timeout`. [`origin`](Self::origin) names the file either way; this
39    /// is the other half of the answer, and the half a reader needs before
40    /// they can find the value in it.
41    pub aliased_from: Option<String>,
42}
43
44impl Contribution {
45    /// The layer's name in the table, with the old path when an alias carried
46    /// the value: `alias db::timeout`.
47    fn label(&self) -> std::borrow::Cow<'_, str> {
48        match &self.aliased_from {
49            Some(from) => std::borrow::Cow::Owned(format!("{} {from}", self.layer)),
50            None => std::borrow::Cow::Borrowed(self.layer),
51        }
52    }
53}
54
55/// Every configured layer's answer for one path, lowest precedence first.
56///
57/// Produced by [`explain`](crate::explain) or a generated `explain()`.
58/// `Display` renders the table; the rows are public for anything that wants
59/// to format its own.
60#[derive(Clone)]
61pub struct Explanation {
62    path: String,
63    rows: Vec<Contribution>,
64}
65
66impl Explanation {
67    pub(crate) fn new(path: String, rows: Vec<Contribution>) -> Self {
68        Self { path, rows }
69    }
70
71    /// The path this explains.
72    #[must_use]
73    pub fn path(&self) -> &str {
74        &self.path
75    }
76
77    /// Every configured layer's row, lowest precedence first.
78    #[must_use]
79    pub fn rows(&self) -> &[Contribution] {
80        &self.rows
81    }
82
83    /// The winning contribution — the highest-precedence layer that supplies
84    /// anything — or `None` when nothing does.
85    #[must_use]
86    pub fn winner(&self) -> Option<&Contribution> {
87        self.rows.iter().rev().find(|row| row.value.is_some())
88    }
89
90    /// This explanation with every value replaced by `***`.
91    ///
92    /// The origins stay: *where* a secret comes from is the useful half, and
93    /// it is exactly the half that is safe to show.
94    #[must_use]
95    pub fn redacted(mut self) -> Self {
96        for row in &mut self.rows {
97            if row.value.is_some() {
98                row.value = Some("***".to_owned());
99            }
100        }
101
102        self
103    }
104}
105
106// Hand-written, value-free: `Display` is the sanctioned way to see the
107// values — you asked for a table. `{:?}` is what lands in logs by habit,
108// and a routine `debug!(?explanation)` must not become the leak the rest
109// of the crate exists to prevent.
110impl fmt::Debug for Contribution {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        f.debug_struct("Contribution")
113            .field("layer", &self.layer)
114            .field("origin", &self.origin)
115            .field("value", &self.value.as_ref().map(|_| "..."))
116            .field("aliased_from", &self.aliased_from)
117            .finish()
118    }
119}
120
121impl fmt::Debug for Explanation {
122    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123        f.debug_struct("Explanation")
124            .field("path", &self.path)
125            .field("rows", &self.rows)
126            .finish()
127    }
128}
129
130impl fmt::Display for Explanation {
131    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132        match self.winner() {
133            Some(winner) => writeln!(
134                f,
135                "{} = {}",
136                self.path,
137                winner.value.as_deref().unwrap_or("***")
138            )?,
139            None => writeln!(f, "{}: nothing supplies it", self.path)?,
140        }
141        writeln!(f)?;
142
143        let sources: Vec<String> = self
144            .rows
145            .iter()
146            .map(|row| {
147                row.origin
148                    .as_ref()
149                    .map_or_else(|| "—".to_owned(), Origin::to_string)
150            })
151            .collect();
152
153        let labels: Vec<String> = self
154            .rows
155            .iter()
156            .map(|row| row.label().into_owned())
157            .collect();
158
159        let layer_width = labels
160            .iter()
161            .map(String::len)
162            .chain(["layer".len()])
163            .max()
164            .unwrap_or(0);
165        let source_width = sources
166            .iter()
167            .map(String::len)
168            .chain(["source".len()])
169            .max()
170            .unwrap_or(0);
171
172        writeln!(
173            f,
174            "{:layer_width$}  {:source_width$}  value",
175            "layer", "source"
176        )?;
177
178        let winner_at = self.rows.iter().rposition(|row| row.value.is_some());
179
180        for (index, ((row, source), label)) in
181            self.rows.iter().zip(&sources).zip(&labels).enumerate()
182        {
183            let value = row.value.as_deref().unwrap_or("absent");
184            let marker = if Some(index) == winner_at {
185                "   ← winner"
186            } else {
187                ""
188            };
189
190            writeln!(
191                f,
192                "{label:layer_width$}  {source:source_width$}  {value}{marker}"
193            )?;
194        }
195
196        Ok(())
197    }
198}
199
200/// Explains `path`: every configured layer's answer, lowest precedence first.
201///
202/// Reads every source once per layer, the same way a load would. A layer the
203/// spec does not configure gets no row — a table of nine `absent`s would
204/// bury the answer.
205pub(crate) fn explain(spec: &LoadSpec<'_>, path: &str) -> Result<Explanation, Error> {
206    let mut rows = Vec::new();
207
208    for (name, figment) in crate::loader::layer_figments(spec)? {
209        let value = figment.find_value(path).ok();
210        let origin = value
211            .is_some()
212            .then(|| crate::loader::origin_in(&figment, path, spec.nest));
213
214        rows.push(Contribution {
215            layer: name,
216            origin,
217            value: value.map(|value| render(&value)),
218            aliased_from: None,
219        });
220    }
221
222    // Aliases are a gap-fill, not a layer, and they can *win*: a value
223    // that only an alias supplies shows up in no per-layer probe, and an
224    // alias deliberately displaces a runtime default at its destination.
225    // Both cases are caught the same way — ask the composed load and, when
226    // its answer comes from somewhere no per-layer row claims, give the
227    // alias a row of its own (above every raw layer, which is where it
228    // actually sits).
229    let merged = crate::loader::merged(spec)?;
230
231    if let Ok(value) = merged.find_value(path) {
232        let origin = crate::loader::origin_in(&merged, path, spec.nest);
233        let walk_winner = rows.iter().rev().find(|row| row.value.is_some());
234        let disagrees = match walk_winner {
235            None => true,
236            Some(winner) => {
237                !matches!(origin, Origin::Unknown) && winner.origin.as_ref() != Some(&origin)
238            }
239        };
240
241        if disagrees {
242            rows.push(Contribution {
243                layer: "alias",
244                origin: Some(origin),
245                value: Some(render(&value)),
246                // Which alias fired is not something the merged figment can
247                // be asked — it reports the *supplier*, deliberately — so it
248                // is read back off the declaration. The last one to fill this
249                // path is the one that won, and the pass fills in the order
250                // the pairs come in.
251                aliased_from: aliased_from(spec, path),
252            });
253        }
254    }
255
256    Ok(Explanation::new(path.to_owned(), rows))
257}
258
259/// The old path aliased to `path`, if one is.
260///
261/// The last hop of a chain, which is the one that filled this path.
262fn aliased_from(spec: &LoadSpec<'_>, path: &str) -> Option<String> {
263    spec.aliases?
264        .pairs()
265        .into_iter()
266        .find(|(_, to)| to == path)
267        .map(|(from, _)| from)
268}
269
270/// A short, single-line rendering — the value for scalars, the shape for
271/// containers.
272fn render(value: &Value) -> String {
273    match value {
274        Value::String(_, string) => string.clone(),
275        Value::Char(_, character) => character.to_string(),
276        Value::Bool(_, boolean) => boolean.to_string(),
277        Value::Num(_, number) => number
278            .to_i128()
279            .map(|whole| whole.to_string())
280            .or_else(|| number.to_u128().map(|whole| whole.to_string()))
281            .or_else(|| number.to_f64().map(|float| float.to_string()))
282            .unwrap_or_else(|| "a number".to_owned()),
283        Value::Empty(..) => "null".to_owned(),
284        Value::Dict(_, table) => format!("a table ({} keys)", table.len()),
285        Value::Array(_, items) => format!("a list ({} items)", items.len()),
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    fn row(layer: &'static str, value: Option<&str>) -> Contribution {
294        Contribution {
295            layer,
296            origin: value.map(|_| Origin::Runtime("default")),
297            value: value.map(str::to_owned),
298            aliased_from: None,
299        }
300    }
301
302    #[test]
303    fn an_alias_row_names_the_old_path_in_the_layer_column() {
304        let explanation = Explanation::new(
305            "timeout".to_owned(),
306            vec![Contribution {
307                layer: "alias",
308                origin: Some(Origin::Inline),
309                value: Some("30".to_owned()),
310                aliased_from: Some("db::timeout".to_owned()),
311            }],
312        );
313
314        assert!(
315            explanation.to_string().contains("alias db::timeout"),
316            "{explanation}"
317        );
318    }
319
320    #[test]
321    fn the_winner_is_the_highest_layer_that_supplies_anything() {
322        let explanation = Explanation::new(
323            "port".to_owned(),
324            vec![
325                row("default", Some("3000")),
326                row("file", Some("8080")),
327                row("environment", None),
328            ],
329        );
330
331        assert_eq!(explanation.winner().unwrap().layer, "file");
332    }
333
334    #[test]
335    fn redaction_blanks_values_and_keeps_origins() {
336        let explanation = Explanation::new(
337            "token".to_owned(),
338            vec![row("file", Some("hunter2")), row("environment", None)],
339        )
340        .redacted();
341
342        let rendered = explanation.to_string();
343
344        assert!(!rendered.contains("hunter2"), "{rendered}");
345        assert!(rendered.contains("***"), "{rendered}");
346        assert!(explanation.rows()[0].origin.is_some());
347        assert_eq!(explanation.rows()[1].value, None, "absent stays absent");
348    }
349
350    #[test]
351    fn the_table_marks_the_winner() {
352        let explanation = Explanation::new(
353            "port".to_owned(),
354            vec![row("default", Some("3000")), row("file", Some("8080"))],
355        );
356
357        let rendered = explanation.to_string();
358        let winner_line = rendered
359            .lines()
360            .find(|line| line.contains("← winner"))
361            .expect("one row is marked");
362
363        assert!(winner_line.starts_with("file"), "{rendered}");
364        assert!(rendered.starts_with("port = 8080"), "{rendered}");
365    }
366}