smart_config_commands/
debug.rs

1use std::{
2    any,
3    collections::{HashMap, HashSet},
4    io::{self, Write as _},
5};
6
7use anstream::stream::{AsLockedWrite, RawStream};
8use anstyle::{AnsiColor, Color, Style};
9use smart_config::{
10    ConfigRepository, ParseError, ParseErrors,
11    metadata::ConfigMetadata,
12    value::{FileFormat, ValueOrigin, WithOrigin},
13    visit::{ConfigVisitor, VisitConfig},
14};
15
16use crate::{
17    CONFIG_PATH, ParamRef, Printer,
18    utils::{STRING, write_json_value, write_value},
19};
20
21const SECTION: Style = Style::new().bold();
22const ARROW: Style = Style::new().bold();
23const INACTIVE: Style = Style::new().italic();
24const RUST: Style = Style::new().dimmed();
25const JSON_FILE: Style = Style::new()
26    .bg_color(Some(Color::Ansi(AnsiColor::Cyan)))
27    .fg_color(None);
28const YAML_FILE: Style = Style::new()
29    .bg_color(Some(Color::Ansi(AnsiColor::Green)))
30    .fg_color(None);
31const DOTENV_FILE: Style = Style::new()
32    .bg_color(Some(Color::Ansi(AnsiColor::Magenta)))
33    .fg_color(None);
34const ERROR_LABEL: Style = Style::new()
35    .bold()
36    .bg_color(Some(Color::Ansi(AnsiColor::Red)))
37    .fg_color(None);
38
39#[derive(Debug)]
40struct ParamValuesVisitor {
41    config: &'static ConfigMetadata,
42    variant: Option<usize>,
43    param_values: HashMap<usize, serde_json::Value>,
44}
45
46impl ParamValuesVisitor {
47    fn new(config: &'static ConfigMetadata) -> Self {
48        Self {
49            config,
50            variant: None,
51            param_values: HashMap::new(),
52        }
53    }
54}
55
56impl ConfigVisitor for ParamValuesVisitor {
57    fn visit_tag(&mut self, variant_index: usize) {
58        self.variant = Some(variant_index);
59    }
60
61    fn visit_param(&mut self, param_index: usize, value: &dyn any::Any) {
62        let param = self.config.params[param_index];
63        let json = if param.type_description().contains_secrets() {
64            "[REDACTED]".into()
65        } else {
66            param.deserializer.serialize_param(value)
67        };
68        self.param_values.insert(param_index, json);
69    }
70
71    fn visit_nested_config(&mut self, _config_index: usize, _config: &dyn VisitConfig) {
72        // Don't recurse into nested configs, we debug them separately
73    }
74}
75
76/// Type ID of the config + path to the config / param.
77type ErrorKey = (any::TypeId, String);
78
79#[derive(Debug)]
80struct ConfigErrors {
81    by_param: HashMap<ErrorKey, Vec<ParseError>>,
82    by_config: HashMap<ErrorKey, Vec<ParseError>>,
83}
84
85impl ConfigErrors {
86    fn new(repo: &ConfigRepository<'_>) -> Self {
87        let mut by_param = HashMap::<_, Vec<_>>::new();
88        let mut by_config = HashMap::<_, Vec<_>>::new();
89        for config_parser in repo.iter() {
90            if !config_parser.config().is_top_level() {
91                // The config should be parsed as a part of the parent config. Filtering out these configs
92                // might not be sufficient to prevent error duplication because a parent config may be inserted after
93                // the config itself, so we perform additional deduplication below.
94                continue;
95            }
96
97            if let Err(errors) = config_parser.parse_opt() {
98                // Only insert errors for a certain param / config if errors for it were not encountered before.
99                let mut new_params = HashSet::new();
100                let mut new_configs = HashSet::new();
101
102                for err in errors {
103                    let key = (err.config().ty.id(), err.path().to_owned());
104                    if err.param().is_some() {
105                        if !by_param.contains_key(&key) || new_params.contains(&key) {
106                            by_param.entry(key.clone()).or_default().push(err);
107                            new_params.insert(key);
108                        }
109                    } else if !by_config.contains_key(&key) || new_configs.contains(&key) {
110                        by_config.entry(key.clone()).or_default().push(err);
111                        new_configs.insert(key);
112                    }
113                }
114            }
115        }
116        Self {
117            by_param,
118            by_config,
119        }
120    }
121}
122
123impl From<ConfigErrors> for Result<(), ParseErrors> {
124    fn from(errors: ConfigErrors) -> Self {
125        let errors = errors
126            .by_config
127            .into_values()
128            .chain(errors.by_param.into_values())
129            .flatten();
130        errors.collect()
131    }
132}
133
134impl<W: RawStream + AsLockedWrite> Printer<W> {
135    /// Prints debug info for all param values in the provided `repo`. If params fail to deserialize,
136    /// corresponding error(s) are output as well.
137    ///
138    /// # Errors
139    ///
140    /// - Propagates I/O errors.
141    /// - Returns the exhaustive parsing result. Depending on the application, some parsing errors (e.g., missing params for optional configs)
142    ///   may not be fatal.
143    #[allow(clippy::missing_panics_doc)] // false positive
144    pub fn print_debug(
145        self,
146        repo: &ConfigRepository<'_>,
147        mut filter: impl FnMut(ParamRef<'_>) -> bool,
148    ) -> io::Result<Result<(), ParseErrors>> {
149        let mut writer = self.writer;
150        if repo.sources().is_empty() {
151            writeln!(&mut writer, "configuration is empty")?;
152            return Ok(Ok(()));
153        }
154
155        writeln!(&mut writer, "{SECTION}Configuration sources:{SECTION:#}")?;
156        for source in repo.sources() {
157            write!(&mut writer, "- ")?;
158            write_origin(&mut writer, &source.origin)?;
159            writeln!(&mut writer, ", {} param(s)", source.param_count)?;
160        }
161
162        writeln!(&mut writer)?;
163        writeln!(&mut writer, "{SECTION}Values:{SECTION:#}")?;
164
165        let errors = ConfigErrors::new(repo);
166        let merged = repo.merged();
167        for config_parser in repo.iter() {
168            let config = config_parser.config();
169            let config_name = config.metadata().ty.name_in_code();
170            let config_id = (config.metadata().ty.id(), config.prefix().to_owned());
171
172            if let Some(errors) = errors.by_config.get(&config_id) {
173                writeln!(
174                    writer,
175                    "{CONFIG_PATH}{}{CONFIG_PATH:#} {RUST}[Rust: {config_name}]{RUST:#}, config",
176                    config.prefix()
177                )?;
178                write_de_errors(&mut writer, errors)?;
179            }
180
181            let (variant, mut param_values) =
182                if let Ok(Some(boxed_config)) = config_parser.parse_opt() {
183                    let visitor_fn = config.metadata().visitor;
184                    let mut visitor = ParamValuesVisitor::new(config.metadata());
185                    visitor_fn(boxed_config.as_ref(), &mut visitor);
186                    (visitor.variant, visitor.param_values)
187                } else {
188                    (None, HashMap::new())
189                };
190
191            let variant = variant.map(|idx| {
192                // `unwrap()` is safe by construction: if there's an active variant, the config must have a tag
193                let tag = config.metadata().tag.unwrap();
194                let name = tag.variants[idx].name;
195                // Add the tag value, using the fact that the tag is always the last param in the config.
196                let tag_param_idx = config.metadata().params.len() - 1;
197                param_values.insert(tag_param_idx, name.into());
198
199                ActiveTagVariant {
200                    canonical_path: ParamRef {
201                        config,
202                        param: tag.param,
203                    }
204                    .canonical_path(),
205                    name,
206                }
207            });
208
209            for (param_idx, param) in config.metadata().params.iter().enumerate() {
210                let param_ref = ParamRef { config, param };
211                if !filter(param_ref) {
212                    continue;
213                }
214                let canonical_path = param_ref.canonical_path();
215
216                let raw_value = merged.pointer(&canonical_path);
217                let param_value = param_values.get(&param_idx);
218                let mut param_written = false;
219                if param_value.is_some() || raw_value.is_some() {
220                    write_param(
221                        &mut writer,
222                        param_ref,
223                        &canonical_path,
224                        param_value,
225                        raw_value,
226                        variant.as_ref(),
227                    )?;
228                    param_written = true;
229                }
230
231                let param_id = (config_id.0, canonical_path.clone());
232                if let Some(errors) = errors.by_param.get(&param_id) {
233                    if !param_written {
234                        let field_name = param.rust_field_name;
235                        let rust_variant = if let Some(variant) = param.tag_variant {
236                            format!("::{}", variant.rust_name)
237                        } else {
238                            String::new()
239                        };
240                        writeln!(
241                            writer,
242                            "{canonical_path} {RUST}[Rust: {config_name}{rust_variant}.{field_name}]{RUST:#}"
243                        )?;
244                    }
245                    write_de_errors(&mut writer, errors)?;
246                }
247            }
248        }
249        Ok(errors.into())
250    }
251}
252
253fn write_origin(writer: &mut impl io::Write, origin: &ValueOrigin) -> io::Result<()> {
254    match origin {
255        ValueOrigin::EnvVars => {
256            write!(writer, "{DOTENV_FILE}env{DOTENV_FILE:#}")
257        }
258        ValueOrigin::File { name, format } => {
259            let style = match format {
260                FileFormat::Json => JSON_FILE,
261                FileFormat::Yaml => YAML_FILE,
262                FileFormat::Dotenv => DOTENV_FILE,
263                _ => Style::new(),
264            };
265            write!(writer, "{style}{format}:{style:#}{name}")
266        }
267        ValueOrigin::Path { source, path } => {
268            if matches!(source.as_ref(), ValueOrigin::EnvVars) {
269                write!(writer, "{DOTENV_FILE}env:{DOTENV_FILE:#}{path:?}")
270            } else {
271                write_origin(writer, source)?;
272                if !path.is_empty() {
273                    write!(writer, " {ARROW}->{ARROW:#} .{path}")?;
274                }
275                Ok(())
276            }
277        }
278        ValueOrigin::Synthetic { source, transform } => {
279            write_origin(writer, source)?;
280            write!(writer, " {ARROW}->{ARROW:#} {transform}")
281        }
282        _ => write!(writer, "{origin}"),
283    }
284}
285
286#[derive(Debug)]
287struct ActiveTagVariant {
288    canonical_path: String,
289    name: &'static str,
290}
291
292fn write_param(
293    writer: &mut impl io::Write,
294    param_ref: ParamRef<'_>,
295    path: &str,
296    visited_value: Option<&serde_json::Value>,
297    raw_value: Option<&WithOrigin>,
298    active_variant: Option<&ActiveTagVariant>,
299) -> io::Result<()> {
300    let activity_style = if visited_value.is_some() {
301        Style::new()
302    } else {
303        INACTIVE
304    };
305    let rust_variant = if let Some(variant) = param_ref.param.tag_variant {
306        format!("::{}", variant.rust_name)
307    } else {
308        String::new()
309    };
310
311    write!(
312        writer,
313        "{activity_style}{path}{activity_style:#} {RUST}[Rust: {}{rust_variant}.{}]{RUST:#}",
314        param_ref.config.metadata().ty.name_in_code(),
315        param_ref.param.rust_field_name
316    )?;
317
318    if let Some(value) = visited_value {
319        write!(writer, " = ")?;
320        write_json_value(writer, value, 0)?;
321        writeln!(writer)?;
322    } else {
323        writeln!(writer)?;
324    }
325
326    if let (Some(param_variant), Some(active_variant)) =
327        (param_ref.param.tag_variant, active_variant)
328    {
329        let tag_path = &active_variant.canonical_path;
330        let param_variant_name = param_variant.name;
331        let (label, eq) = if param_variant_name == active_variant.name {
332            ("Active", "==")
333        } else {
334            ("Inactive", "!=")
335        };
336        writeln!(
337            writer,
338            "  {label}: {tag_path} {eq} {STRING}'{param_variant_name}'{STRING:#}"
339        )?;
340    }
341
342    if let Some(value) = raw_value {
343        write!(writer, "  Raw: ")?;
344        write_value(writer, value, 2)?;
345        writeln!(writer)?;
346        write!(writer, "  Origin: ")?;
347        write_origin(writer, &value.origin)?;
348        writeln!(writer)?;
349    }
350    Ok(())
351}
352
353fn write_de_errors(writer: &mut impl io::Write, errors: &[ParseError]) -> io::Result<()> {
354    if errors.len() == 1 {
355        write!(writer, "  {ERROR_LABEL}Error:{ERROR_LABEL:#} ")?;
356        write_de_error(writer, &errors[0])
357    } else {
358        writeln!(writer, "  {ERROR_LABEL}Errors:{ERROR_LABEL:#}")?;
359        for err in errors {
360            write!(writer, "  - ")?;
361            write_de_error(writer, err)?;
362        }
363        Ok(())
364    }
365}
366
367fn write_de_error(writer: &mut impl io::Write, err: &ParseError) -> io::Result<()> {
368    writeln!(writer, "{}", err.inner())?;
369    if let Some(validation) = err.validation() {
370        writeln!(writer, "    {SECTION}validation:{SECTION:#} {validation}")?;
371    }
372    writeln!(
373        writer,
374        "    at {SECTION}{path}{SECTION:#}",
375        path = err.path()
376    )?;
377    if !matches!(err.origin(), ValueOrigin::Unknown) {
378        write!(writer, "    ")?;
379        write_origin(writer, err.origin())?;
380        writeln!(writer)?;
381    }
382    Ok(())
383}