1use std::fmt;
14
15use crate::error::{Error, Origin};
16use crate::source::LoadSpec;
17
18#[derive(Clone)]
20#[non_exhaustive]
21pub struct Contribution {
22 pub layer: &'static str,
24 pub origin: Option<Origin>,
28 pub value: Option<String>,
32 pub aliased_from: Option<String>,
40}
41
42impl Contribution {
43 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#[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 #[must_use]
71 pub fn path(&self) -> &str {
72 &self.path
73 }
74
75 #[must_use]
77 pub fn rows(&self) -> &[Contribution] {
78 &self.rows
79 }
80
81 #[must_use]
84 pub fn winner(&self) -> Option<&Contribution> {
85 self.rows.iter().rev().find(|row| row.value.is_some())
86 }
87
88 #[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
104impl 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
198pub(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 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 aliased_from: aliased_from(spec, path),
261 });
262 }
263 }
264
265 Ok(Explanation::new(path.to_owned(), rows))
266}
267
268fn 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
279fn 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}