Skip to main content

assay_core/errors/
diagnostic.rs

1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Serialize, Deserialize)]
4pub struct Diagnostic {
5    pub code: String,
6    pub severity: String,
7    pub source: String,
8    pub message: String,
9    pub context: serde_json::Value,
10    pub fix_steps: Vec<String>,
11}
12
13/// Severity icons, written as escapes so the source stays ASCII and the exact
14/// codepoints (including the variation selector on the warning sign) are visible.
15const ICON_ERROR: &str = "\u{274c} ";
16const ICON_WARN: &str = "\u{26a0}\u{fe0f} ";
17
18/// The severity vocabulary a `Diagnostic` may carry.
19///
20/// One definition, because there were two and they disagreed: `assay-cli` matched
21/// case-insensitively and emitted `warn`, `assay-core`'s SARIF builder matched
22/// exactly and emitted `warning` (#2033). A value spelled `Warning` reached exit
23/// classification as a warning and Code Scanning as a note.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum Severity {
26    Error,
27    Warning,
28    Note,
29}
30
31impl Severity {
32    /// Parse a severity, or `None` when the value is not in the vocabulary.
33    ///
34    /// `None` rather than a default on purpose. Both previous implementations
35    /// ended in an unconditional fallback to the *least* severe level, so a
36    /// misspelled or newly introduced severity dropped out of the error set and
37    /// could turn a failing run into exit 0 (#2025). An unrecognized severity is
38    /// not a note; it is unknown, and callers must decide deliberately.
39    pub fn parse(value: &str) -> Option<Self> {
40        if value.eq_ignore_ascii_case("error") {
41            return Some(Self::Error);
42        }
43        if value.eq_ignore_ascii_case("warn") || value.eq_ignore_ascii_case("warning") {
44            return Some(Self::Warning);
45        }
46        if value.eq_ignore_ascii_case("note") || value.eq_ignore_ascii_case("info") {
47            return Some(Self::Note);
48        }
49        None
50    }
51
52    /// The spelling the CLI uses in its own output and comparisons.
53    pub fn as_cli_str(self) -> &'static str {
54        match self {
55            Self::Error => "error",
56            Self::Warning => "warn",
57            Self::Note => "note",
58        }
59    }
60
61    /// The SARIF `level`, which spells warnings differently from the CLI.
62    pub fn as_sarif_level(self) -> &'static str {
63        match self {
64            Self::Error => "error",
65            Self::Warning => "warning",
66            Self::Note => "note",
67        }
68    }
69}
70
71impl Diagnostic {
72    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
73        Self {
74            code: code.into(),
75            severity: "error".into(), // Default to error
76            source: "unknown".into(),
77            message: message.into(),
78            context: serde_json::json!({}),
79            fix_steps: vec![],
80        }
81    }
82
83    pub fn with_severity(mut self, severity: impl Into<String>) -> Self {
84        self.severity = severity.into();
85        self
86    }
87
88    pub fn with_source(mut self, source: impl Into<String>) -> Self {
89        self.source = source.into();
90        self
91    }
92
93    pub fn with_context(mut self, context: serde_json::Value) -> Self {
94        self.context = context;
95        self
96    }
97
98    pub fn with_fix_step(mut self, step: impl Into<String>) -> Self {
99        self.fix_steps.push(step.into());
100        self
101    }
102
103    /// Decorated rendering for an interactive terminal.
104    pub fn format_terminal(&self) -> String {
105        let icon = if self.severity == "warn" {
106            ICON_WARN
107        } else {
108            ICON_ERROR
109        };
110        self.render(icon)
111    }
112
113    /// Undecorated rendering for pipes, CI logs and files.
114    ///
115    /// This is not a synonym for `format_terminal`. Callers reach for it when the
116    /// sink is not a terminal, where an emoji is noise a log grep has to work
117    /// around rather than information.
118    pub fn format_plain(&self) -> String {
119        self.render("")
120    }
121
122    fn render(&self, prefix: &str) -> String {
123        let mut s = format!("{}[{}] {}\n", prefix, self.code, self.message);
124        s.push_str(&format!("  source: {}\n", self.source));
125
126        // Simple pretty print for context if not empty object
127        if !self.context.is_null() && self.context.as_object().is_some_and(|o| !o.is_empty()) {
128            if let Ok(json) = serde_json::to_string_pretty(&self.context) {
129                // Indent context
130                for line in json.lines() {
131                    s.push_str(&format!("  {}\n", line));
132                }
133            }
134        }
135
136        if !self.fix_steps.is_empty() {
137            s.push_str("\nFix:\n");
138            for (i, step) in self.fix_steps.iter().enumerate() {
139                s.push_str(&format!("  {}. {}\n", i + 1, step));
140            }
141        }
142        s
143    }
144}
145
146impl std::fmt::Display for Diagnostic {
147    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148        write!(f, "{}", self.format_terminal())
149    }
150}
151
152impl std::error::Error for Diagnostic {}
153
154// Common error codes
155pub mod codes {
156    // Errors. Their exit class is declared once, in `ERROR_EXIT_CLASSES` below — not here, and
157    // never by how a code is spelled.
158    pub const E_CFG_PARSE: &str = "E_CFG_PARSE";
159    pub const E_CFG_SCHEMA: &str = "E_CFG_SCHEMA";
160    pub const E_PATH_NOT_FOUND: &str = "E_PATH_NOT_FOUND";
161    pub const E_TRACE_MISS: &str = "E_TRACE_MISS";
162    pub const E_TRACE_INVALID: &str = "E_TRACE_INVALID";
163    pub const E_BASE_MISMATCH: &str = "E_BASE_MISMATCH";
164    pub const E_REPLAY_STRICT_MISSING: &str = "E_REPLAY_STRICT_MISSING";
165    pub const E_EMB_DIMS: &str = "E_EMB_DIMS";
166    pub const E_POLICY_VIOLATION: &str = "E_POLICY_VIOLATION";
167
168    // Warnings. These carry `severity: "warn"`, which is what keeps a run of them at exit 0, so
169    // they are deliberately absent from `ERROR_EXIT_CLASSES`.
170    /// A test that asserts nothing: no `expected:` block and no `assertions:`, so it
171    /// passes for any response. An `expected:` block written out as empty is rejected
172    /// at parse time as `E_CFG_PARSE` instead.
173    pub const W_CFG_VACUOUS_EXPECTED: &str = "W_CFG_VACUOUS_EXPECTED";
174    pub const W_BASE_FINGERPRINT: &str = "W_BASE_FINGERPRINT";
175    pub const W_CACHE_CONFUSION: &str = "W_CACHE_CONFUSION";
176}
177
178/// How an error diagnostic classifies for process exit.
179///
180/// The registry owns this. An exit decision must never be inferred from how a code is spelled: the
181/// CLI used to match code prefixes, and that list had drifted until `E_TRACE_MISS` exited 1 while
182/// `E_PATH_NOT_FOUND` exited 2 for the same missing-trace condition, and a fourth prefix
183/// (`E_TRACE_SCHEMA`) matched no code that has ever existed.
184///
185/// The variants name a class, not an exit number. Which number a class maps to belongs to whichever
186/// binary is exiting.
187#[derive(Debug, Clone, Copy, PartialEq, Eq)]
188pub enum ExitClass {
189    /// The run could not be evaluated as specified: config, paths, traces, baselines, embeddings.
190    Config,
191    /// The subject under test failed a check.
192    Test,
193    /// Not a code in [`codes`]. This is a variant rather than a default so that an unclassified
194    /// code is a state a caller can see and test for, instead of the silent result of no match.
195    Unregistered,
196}
197
198/// The exit class of every registered error code — the single place the `// Errors (Exit 2)`
199/// comment above used to state in prose.
200///
201/// Warning codes are absent by design: severity already decides that a run of warnings exits 0, so
202/// a warning never reaches a class lookup.
203pub const ERROR_EXIT_CLASSES: &[(&str, ExitClass)] = &[
204    (codes::E_CFG_PARSE, ExitClass::Config),
205    (codes::E_CFG_SCHEMA, ExitClass::Config),
206    (codes::E_PATH_NOT_FOUND, ExitClass::Config),
207    (codes::E_TRACE_MISS, ExitClass::Config),
208    (codes::E_TRACE_INVALID, ExitClass::Config),
209    (codes::E_BASE_MISMATCH, ExitClass::Config),
210    (codes::E_REPLAY_STRICT_MISSING, ExitClass::Config),
211    (codes::E_EMB_DIMS, ExitClass::Config),
212    // Currently constructed nowhere. Classified per the registry's own declaration rather than per
213    // what the name suggests; whether a policy violation is a config or a test outcome is a
214    // question for the registry ADR, not for this table.
215    (codes::E_POLICY_VIOLATION, ExitClass::Config),
216];
217
218/// The exit class of `code`, or [`ExitClass::Unregistered`] when the registry does not know it.
219///
220/// Codes reach this from outside the registry — `assay_core::validate` forwards policy-engine
221/// verdict codes verbatim, and an unexpected trace error is reported as a bare `E_UNKNOWN`. Those
222/// are unregistered rather than misclassified, and the caller decides what that means.
223pub fn exit_class(code: &str) -> ExitClass {
224    ERROR_EXIT_CLASSES
225        .iter()
226        .find(|(registered, _)| *registered == code)
227        .map(|(_, class)| *class)
228        .unwrap_or(ExitClass::Unregistered)
229}
230
231#[cfg(test)]
232mod exit_class_tests {
233    use super::*;
234
235    #[test]
236    fn unknown_codes_are_unregistered_not_defaulted() {
237        assert_eq!(exit_class("E_UNKNOWN"), ExitClass::Unregistered);
238        assert_eq!(exit_class("E_ARG_SCHEMA"), ExitClass::Unregistered);
239        // Never a code, only ever a prefix in the CLI's old match.
240        assert_eq!(exit_class("E_TRACE_SCHEMA"), ExitClass::Unregistered);
241    }
242
243    #[test]
244    fn no_code_is_classified_twice() {
245        let mut seen: Vec<&str> = ERROR_EXIT_CLASSES.iter().map(|(c, _)| *c).collect();
246        let before = seen.len();
247        seen.sort_unstable();
248        seen.dedup();
249        assert_eq!(seen.len(), before, "duplicate entry in ERROR_EXIT_CLASSES");
250    }
251
252    /// Pins every error constant to a class. A tenth constant added to `codes` without an entry
253    /// here is not caught — the table is `&str`-keyed, so nothing forces exhaustiveness at compile
254    /// time. Making that impossible needs the code enum tracked in #2028; this test holds the nine
255    /// that exist today.
256    #[test]
257    fn every_error_constant_has_a_class() {
258        for code in [
259            codes::E_CFG_PARSE,
260            codes::E_CFG_SCHEMA,
261            codes::E_PATH_NOT_FOUND,
262            codes::E_TRACE_MISS,
263            codes::E_TRACE_INVALID,
264            codes::E_BASE_MISMATCH,
265            codes::E_REPLAY_STRICT_MISSING,
266            codes::E_EMB_DIMS,
267            codes::E_POLICY_VIOLATION,
268        ] {
269            assert_eq!(
270                exit_class(code),
271                ExitClass::Config,
272                "{code} is unclassified"
273            );
274        }
275    }
276
277    /// Warning codes are deliberately absent: severity decides that a run of warnings exits 0, so a
278    /// warning never reaches a class lookup. If one ever did, unregistered is the honest answer.
279    #[test]
280    fn warning_codes_are_not_in_the_table() {
281        assert_eq!(
282            exit_class(codes::W_CFG_VACUOUS_EXPECTED),
283            ExitClass::Unregistered
284        );
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    fn sample() -> Diagnostic {
293        Diagnostic::new(codes::E_CFG_PARSE, "mapping values are not allowed here")
294            .with_source("config")
295            .with_context(serde_json::json!({ "path": "assay.yaml" }))
296            .with_fix_step("Run: assay doctor --config assay.yaml")
297    }
298
299    #[test]
300    fn plain_carries_no_terminal_decoration() {
301        let plain = sample().format_plain();
302        assert!(
303            plain.is_ascii(),
304            "plain output must stay ASCII for CI logs: {plain:?}"
305        );
306        assert!(plain.starts_with("[E_CFG_PARSE]"));
307
308        let warn_plain = sample().with_severity("warn").format_plain();
309        assert!(warn_plain.is_ascii(), "warnings must be plain too");
310    }
311
312    #[test]
313    fn terminal_carries_the_severity_icon() {
314        let error = sample().format_terminal();
315        assert!(error.starts_with(ICON_ERROR));
316
317        let warn = sample().with_severity("warn").format_terminal();
318        assert!(warn.starts_with(ICON_WARN));
319    }
320
321    #[test]
322    fn the_prefix_is_the_only_difference() {
323        let d = sample();
324        assert_eq!(
325            d.format_terminal().strip_prefix(ICON_ERROR),
326            Some(d.format_plain().as_str())
327        );
328    }
329
330    #[test]
331    fn body_carries_code_source_context_and_fix() {
332        let plain = sample().format_plain();
333        assert!(plain.contains("E_CFG_PARSE"));
334        assert!(plain.contains("source: config"));
335        assert!(plain.contains("assay.yaml"));
336        assert!(plain.contains("1. Run: assay doctor --config assay.yaml"));
337    }
338}
339
340#[cfg(test)]
341mod severity_tests {
342    use super::Severity;
343
344    #[test]
345    fn an_unrecognized_severity_is_unknown_rather_than_a_note() {
346        // The defect this replaces: both implementations ended in an
347        // unconditional fallback to the least severe level, so a value nobody
348        // recognized dropped out of the error set and could turn a failing run
349        // into exit 0. `None` forces callers to decide.
350        assert_eq!(Severity::parse("cirtical"), None);
351        assert_eq!(Severity::parse(""), None);
352        assert_eq!(Severity::parse("fatal"), None);
353    }
354
355    #[test]
356    fn one_vocabulary_for_both_spellings() {
357        // assay-cli matched case-insensitively; the SARIF builder matched
358        // exactly. `Warning` reached exit classification as a warning and Code
359        // Scanning as a note.
360        for spelling in ["warn", "warning", "WARN", "Warning", "WARNING"] {
361            assert_eq!(
362                Severity::parse(spelling),
363                Some(Severity::Warning),
364                "{spelling}"
365            );
366        }
367        for spelling in ["error", "ERROR", "Error"] {
368            assert_eq!(
369                Severity::parse(spelling),
370                Some(Severity::Error),
371                "{spelling}"
372            );
373        }
374        for spelling in ["note", "info", "INFO"] {
375            assert_eq!(
376                Severity::parse(spelling),
377                Some(Severity::Note),
378                "{spelling}"
379            );
380        }
381    }
382
383    #[test]
384    fn the_two_surfaces_spell_warnings_differently_on_purpose() {
385        assert_eq!(Severity::Warning.as_cli_str(), "warn");
386        assert_eq!(Severity::Warning.as_sarif_level(), "warning");
387    }
388}