1use std::fmt;
14
15use figment::value::Value;
16
17use crate::error::{Error, Origin};
18use crate::source::LoadSpec;
19
20#[derive(Clone)]
22#[non_exhaustive]
23pub struct Contribution {
24 pub layer: &'static str,
26 pub origin: Option<Origin>,
30 pub value: Option<String>,
34 pub aliased_from: Option<String>,
42}
43
44impl Contribution {
45 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#[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 #[must_use]
73 pub fn path(&self) -> &str {
74 &self.path
75 }
76
77 #[must_use]
79 pub fn rows(&self) -> &[Contribution] {
80 &self.rows
81 }
82
83 #[must_use]
86 pub fn winner(&self) -> Option<&Contribution> {
87 self.rows.iter().rev().find(|row| row.value.is_some())
88 }
89
90 #[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
106impl 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
200pub(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 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 aliased_from: aliased_from(spec, path),
252 });
253 }
254 }
255
256 Ok(Explanation::new(path.to_owned(), rows))
257}
258
259fn 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
270fn 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}