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