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}
35
36/// Every configured layer's answer for one path, lowest precedence first.
37///
38/// Produced by [`explain`](crate::explain) or a generated `explain()`.
39/// `Display` renders the table; the rows are public for anything that wants
40/// to format its own.
41#[derive(Clone)]
42pub struct Explanation {
43    path: String,
44    rows: Vec<Contribution>,
45}
46
47impl Explanation {
48    pub(crate) fn new(path: String, rows: Vec<Contribution>) -> Self {
49        Self { path, rows }
50    }
51
52    /// The path this explains.
53    #[must_use]
54    pub fn path(&self) -> &str {
55        &self.path
56    }
57
58    /// Every configured layer's row, lowest precedence first.
59    #[must_use]
60    pub fn rows(&self) -> &[Contribution] {
61        &self.rows
62    }
63
64    /// The winning contribution — the highest-precedence layer that supplies
65    /// anything — or `None` when nothing does.
66    #[must_use]
67    pub fn winner(&self) -> Option<&Contribution> {
68        self.rows.iter().rev().find(|row| row.value.is_some())
69    }
70
71    /// This explanation with every value replaced by `***`.
72    ///
73    /// The origins stay: *where* a secret comes from is the useful half, and
74    /// it is exactly the half that is safe to show.
75    #[must_use]
76    pub fn redacted(mut self) -> Self {
77        for row in &mut self.rows {
78            if row.value.is_some() {
79                row.value = Some("***".to_owned());
80            }
81        }
82
83        self
84    }
85}
86
87// Hand-written, value-free: `Display` is the sanctioned way to see the
88// values — you asked for a table. `{:?}` is what lands in logs by habit,
89// and a routine `debug!(?explanation)` must not become the leak the rest
90// of the crate exists to prevent.
91impl fmt::Debug for Contribution {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        f.debug_struct("Contribution")
94            .field("layer", &self.layer)
95            .field("origin", &self.origin)
96            .field("value", &self.value.as_ref().map(|_| "..."))
97            .finish()
98    }
99}
100
101impl fmt::Debug for Explanation {
102    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103        f.debug_struct("Explanation")
104            .field("path", &self.path)
105            .field("rows", &self.rows)
106            .finish()
107    }
108}
109
110impl fmt::Display for Explanation {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        match self.winner() {
113            Some(winner) => writeln!(
114                f,
115                "{} = {}",
116                self.path,
117                winner.value.as_deref().unwrap_or("***")
118            )?,
119            None => writeln!(f, "{}: nothing supplies it", self.path)?,
120        }
121        writeln!(f)?;
122
123        let sources: Vec<String> = self
124            .rows
125            .iter()
126            .map(|row| {
127                row.origin
128                    .as_ref()
129                    .map_or_else(|| "—".to_owned(), Origin::to_string)
130            })
131            .collect();
132
133        let layer_width = self
134            .rows
135            .iter()
136            .map(|row| row.layer.len())
137            .chain(["layer".len()])
138            .max()
139            .unwrap_or(0);
140        let source_width = sources
141            .iter()
142            .map(String::len)
143            .chain(["source".len()])
144            .max()
145            .unwrap_or(0);
146
147        writeln!(
148            f,
149            "{:layer_width$}  {:source_width$}  value",
150            "layer", "source"
151        )?;
152
153        let winner_at = self.rows.iter().rposition(|row| row.value.is_some());
154
155        for (index, (row, source)) in self.rows.iter().zip(&sources).enumerate() {
156            let value = row.value.as_deref().unwrap_or("absent");
157            let marker = if Some(index) == winner_at {
158                "   ← winner"
159            } else {
160                ""
161            };
162
163            writeln!(
164                f,
165                "{:layer_width$}  {source:source_width$}  {value}{marker}",
166                row.layer
167            )?;
168        }
169
170        Ok(())
171    }
172}
173
174/// Explains `path`: every configured layer's answer, lowest precedence first.
175///
176/// Reads every source once per layer, the same way a load would. A layer the
177/// spec does not configure gets no row — a table of nine `absent`s would
178/// bury the answer.
179pub(crate) fn explain(spec: &LoadSpec<'_>, path: &str) -> Result<Explanation, Error> {
180    let mut rows = Vec::new();
181
182    for (name, figment) in crate::loader::layer_figments(spec)? {
183        let value = figment.find_value(path).ok();
184        let origin = value
185            .is_some()
186            .then(|| crate::loader::origin_in(&figment, path));
187
188        rows.push(Contribution {
189            layer: name,
190            origin,
191            value: value.map(|value| render(&value)),
192        });
193    }
194
195    // Aliases are a gap-fill, not a layer, and they can *win*: a value
196    // that only an alias supplies shows up in no per-layer probe, and an
197    // alias deliberately displaces a runtime default at its destination.
198    // Both cases are caught the same way — ask the composed load and, when
199    // its answer comes from somewhere no per-layer row claims, give the
200    // alias a row of its own (above every raw layer, which is where it
201    // actually sits).
202    let merged = crate::loader::merged(spec)?;
203
204    if let Ok(value) = merged.find_value(path) {
205        let origin = crate::loader::origin_in(&merged, path);
206        let walk_winner = rows.iter().rev().find(|row| row.value.is_some());
207        let disagrees = match walk_winner {
208            None => true,
209            Some(winner) => {
210                !matches!(origin, Origin::Unknown) && winner.origin.as_ref() != Some(&origin)
211            }
212        };
213
214        if disagrees {
215            rows.push(Contribution {
216                layer: "alias",
217                origin: Some(origin),
218                value: Some(render(&value)),
219            });
220        }
221    }
222
223    Ok(Explanation::new(path.to_owned(), rows))
224}
225
226/// A short, single-line rendering — the value for scalars, the shape for
227/// containers.
228fn render(value: &Value) -> String {
229    match value {
230        Value::String(_, string) => string.clone(),
231        Value::Char(_, character) => character.to_string(),
232        Value::Bool(_, boolean) => boolean.to_string(),
233        Value::Num(_, number) => number
234            .to_i128()
235            .map(|whole| whole.to_string())
236            .or_else(|| number.to_u128().map(|whole| whole.to_string()))
237            .or_else(|| number.to_f64().map(|float| float.to_string()))
238            .unwrap_or_else(|| "a number".to_owned()),
239        Value::Empty(..) => "null".to_owned(),
240        Value::Dict(_, table) => format!("a table ({} keys)", table.len()),
241        Value::Array(_, items) => format!("a list ({} items)", items.len()),
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    fn row(layer: &'static str, value: Option<&str>) -> Contribution {
250        Contribution {
251            layer,
252            origin: value.map(|_| Origin::Runtime("default")),
253            value: value.map(str::to_owned),
254        }
255    }
256
257    #[test]
258    fn the_winner_is_the_highest_layer_that_supplies_anything() {
259        let explanation = Explanation::new(
260            "port".to_owned(),
261            vec![
262                row("default", Some("3000")),
263                row("file", Some("8080")),
264                row("environment", None),
265            ],
266        );
267
268        assert_eq!(explanation.winner().unwrap().layer, "file");
269    }
270
271    #[test]
272    fn redaction_blanks_values_and_keeps_origins() {
273        let explanation = Explanation::new(
274            "token".to_owned(),
275            vec![row("file", Some("hunter2")), row("environment", None)],
276        )
277        .redacted();
278
279        let rendered = explanation.to_string();
280
281        assert!(!rendered.contains("hunter2"), "{rendered}");
282        assert!(rendered.contains("***"), "{rendered}");
283        assert!(explanation.rows()[0].origin.is_some());
284        assert_eq!(explanation.rows()[1].value, None, "absent stays absent");
285    }
286
287    #[test]
288    fn the_table_marks_the_winner() {
289        let explanation = Explanation::new(
290            "port".to_owned(),
291            vec![row("default", Some("3000")), row("file", Some("8080"))],
292        );
293
294        let rendered = explanation.to_string();
295        let winner_line = rendered
296            .lines()
297            .find(|line| line.contains("← winner"))
298            .expect("one row is marked");
299
300        assert!(winner_line.starts_with("file"), "{rendered}");
301        assert!(rendered.starts_with("port = 8080"), "{rendered}");
302    }
303}