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
13const ICON_ERROR: &str = "\u{274c} ";
16const ICON_WARN: &str = "\u{26a0}\u{fe0f} ";
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum Severity {
26 Error,
27 Warning,
28 Note,
29}
30
31impl Severity {
32 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 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 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(), 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 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 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 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 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
154pub mod codes {
156 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
188pub enum ExitClass {
189 Config,
191 Test,
193 Unregistered,
196}
197
198pub 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 (codes::E_POLICY_VIOLATION, ExitClass::Config),
216];
217
218pub 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 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 #[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 #[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 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 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}