Skip to main content

meta_ast/interface/
report.rs

1//! Diagnostic reporting and the exit policy.
2//!
3//! An analysis run reports every diagnostic it collected and then decides the
4//! process status from the requested policy. The reporting is separate from the
5//! serialization, so `--fail-on` never changes the emitted document.
6
7use crate::error::{Diagnostic, Error, Severity};
8
9/// How many diagnostics make a run fail.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
11#[value(rename_all = "lower")]
12pub enum FailOn {
13    /// Never fail on diagnostics.
14    Never,
15    /// Fail when the run reports an error diagnostic.
16    #[default]
17    Error,
18    /// Fail when the run reports an error or a warning diagnostic.
19    Warning,
20}
21
22impl FailOn {
23    /// True when the collected diagnostics trip the policy.
24    pub fn tripped(self, errors: usize, warnings: usize) -> bool {
25        match self {
26            FailOn::Never => false,
27            FailOn::Error => errors > 0,
28            FailOn::Warning => errors > 0 || warnings > 0,
29        }
30    }
31}
32
33/// Log every diagnostic and apply the policy.
34///
35/// Returns [`Error::Diagnostics`] when the policy is tripped, so the caller can
36/// map it to the analysis exit code.
37pub fn report_diagnostics(diagnostics: &[Diagnostic], fail_on: FailOn) -> Result<(), Error> {
38    let mut errors = 0usize;
39    let mut warnings = 0usize;
40
41    for diagnostic in diagnostics {
42        let path = diagnostic.path.display().to_string();
43        let range = diagnostic
44            .source_range
45            .as_ref()
46            .map(|range| range.start.line + 1);
47        match diagnostic.severity {
48            Severity::Error => {
49                errors += 1;
50                tracing::error!(path = %path, line = ?range, "{}", diagnostic.message);
51            }
52            Severity::Warning => {
53                warnings += 1;
54                tracing::warn!(path = %path, line = ?range, "{}", diagnostic.message);
55            }
56        }
57    }
58
59    if fail_on.tripped(errors, warnings) {
60        return Err(Error::Diagnostics { errors, warnings });
61    }
62
63    Ok(())
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69    use std::path::PathBuf;
70
71    fn diagnostic(severity: Severity) -> Diagnostic {
72        Diagnostic {
73            path: PathBuf::from("a.py"),
74            severity,
75            message: "problem".into(),
76            source_range: None,
77        }
78    }
79
80    #[test]
81    fn never_accepts_errors() {
82        let diagnostics = [diagnostic(Severity::Error)];
83        assert!(report_diagnostics(&diagnostics, FailOn::Never).is_ok());
84    }
85
86    #[test]
87    fn error_policy_ignores_warnings() {
88        let diagnostics = [diagnostic(Severity::Warning)];
89        assert!(report_diagnostics(&diagnostics, FailOn::Error).is_ok());
90    }
91
92    #[test]
93    fn error_policy_fails_on_an_error() {
94        let diagnostics = [diagnostic(Severity::Warning), diagnostic(Severity::Error)];
95        let error = report_diagnostics(&diagnostics, FailOn::Error).unwrap_err();
96        assert!(matches!(
97            error,
98            Error::Diagnostics {
99                errors: 1,
100                warnings: 1
101            }
102        ));
103    }
104
105    #[test]
106    fn warning_policy_fails_on_a_warning() {
107        let diagnostics = [diagnostic(Severity::Warning)];
108        assert!(report_diagnostics(&diagnostics, FailOn::Warning).is_err());
109    }
110
111    #[test]
112    fn clean_run_never_fails() {
113        assert!(report_diagnostics(&[], FailOn::Warning).is_ok());
114    }
115}