use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Diagnostic {
pub code: String,
pub severity: String,
pub source: String,
pub message: String,
pub context: serde_json::Value,
pub fix_steps: Vec<String>,
}
const ICON_ERROR: &str = "\u{274c} ";
const ICON_WARN: &str = "\u{26a0}\u{fe0f} ";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Error,
Warning,
Note,
}
impl Severity {
pub fn parse(value: &str) -> Option<Self> {
if value.eq_ignore_ascii_case("error") {
return Some(Self::Error);
}
if value.eq_ignore_ascii_case("warn") || value.eq_ignore_ascii_case("warning") {
return Some(Self::Warning);
}
if value.eq_ignore_ascii_case("note") || value.eq_ignore_ascii_case("info") {
return Some(Self::Note);
}
None
}
pub fn as_cli_str(self) -> &'static str {
match self {
Self::Error => "error",
Self::Warning => "warn",
Self::Note => "note",
}
}
pub fn as_sarif_level(self) -> &'static str {
match self {
Self::Error => "error",
Self::Warning => "warning",
Self::Note => "note",
}
}
}
impl Diagnostic {
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
severity: "error".into(), source: "unknown".into(),
message: message.into(),
context: serde_json::json!({}),
fix_steps: vec![],
}
}
pub fn with_severity(mut self, severity: impl Into<String>) -> Self {
self.severity = severity.into();
self
}
pub fn with_source(mut self, source: impl Into<String>) -> Self {
self.source = source.into();
self
}
pub fn with_context(mut self, context: serde_json::Value) -> Self {
self.context = context;
self
}
pub fn with_fix_step(mut self, step: impl Into<String>) -> Self {
self.fix_steps.push(step.into());
self
}
pub fn format_terminal(&self) -> String {
let icon = if self.severity == "warn" {
ICON_WARN
} else {
ICON_ERROR
};
self.render(icon)
}
pub fn format_plain(&self) -> String {
self.render("")
}
fn render(&self, prefix: &str) -> String {
let mut s = format!("{}[{}] {}\n", prefix, self.code, self.message);
s.push_str(&format!(" source: {}\n", self.source));
if !self.context.is_null() && self.context.as_object().is_some_and(|o| !o.is_empty()) {
if let Ok(json) = serde_json::to_string_pretty(&self.context) {
for line in json.lines() {
s.push_str(&format!(" {}\n", line));
}
}
}
if !self.fix_steps.is_empty() {
s.push_str("\nFix:\n");
for (i, step) in self.fix_steps.iter().enumerate() {
s.push_str(&format!(" {}. {}\n", i + 1, step));
}
}
s
}
}
impl std::fmt::Display for Diagnostic {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.format_terminal())
}
}
impl std::error::Error for Diagnostic {}
pub mod codes {
pub const E_CFG_PARSE: &str = "E_CFG_PARSE";
pub const E_CFG_SCHEMA: &str = "E_CFG_SCHEMA";
pub const E_PATH_NOT_FOUND: &str = "E_PATH_NOT_FOUND";
pub const E_TRACE_MISS: &str = "E_TRACE_MISS";
pub const E_TRACE_INVALID: &str = "E_TRACE_INVALID";
pub const E_BASE_MISMATCH: &str = "E_BASE_MISMATCH";
pub const E_REPLAY_STRICT_MISSING: &str = "E_REPLAY_STRICT_MISSING";
pub const E_EMB_DIMS: &str = "E_EMB_DIMS";
pub const E_POLICY_VIOLATION: &str = "E_POLICY_VIOLATION";
pub const W_CFG_VACUOUS_EXPECTED: &str = "W_CFG_VACUOUS_EXPECTED";
pub const W_BASE_FINGERPRINT: &str = "W_BASE_FINGERPRINT";
pub const W_CACHE_CONFUSION: &str = "W_CACHE_CONFUSION";
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExitClass {
Config,
Test,
Unregistered,
}
pub const ERROR_EXIT_CLASSES: &[(&str, ExitClass)] = &[
(codes::E_CFG_PARSE, ExitClass::Config),
(codes::E_CFG_SCHEMA, ExitClass::Config),
(codes::E_PATH_NOT_FOUND, ExitClass::Config),
(codes::E_TRACE_MISS, ExitClass::Config),
(codes::E_TRACE_INVALID, ExitClass::Config),
(codes::E_BASE_MISMATCH, ExitClass::Config),
(codes::E_REPLAY_STRICT_MISSING, ExitClass::Config),
(codes::E_EMB_DIMS, ExitClass::Config),
(codes::E_POLICY_VIOLATION, ExitClass::Config),
];
pub fn exit_class(code: &str) -> ExitClass {
ERROR_EXIT_CLASSES
.iter()
.find(|(registered, _)| *registered == code)
.map(|(_, class)| *class)
.unwrap_or(ExitClass::Unregistered)
}
#[cfg(test)]
mod exit_class_tests {
use super::*;
#[test]
fn unknown_codes_are_unregistered_not_defaulted() {
assert_eq!(exit_class("E_UNKNOWN"), ExitClass::Unregistered);
assert_eq!(exit_class("E_ARG_SCHEMA"), ExitClass::Unregistered);
assert_eq!(exit_class("E_TRACE_SCHEMA"), ExitClass::Unregistered);
}
#[test]
fn no_code_is_classified_twice() {
let mut seen: Vec<&str> = ERROR_EXIT_CLASSES.iter().map(|(c, _)| *c).collect();
let before = seen.len();
seen.sort_unstable();
seen.dedup();
assert_eq!(seen.len(), before, "duplicate entry in ERROR_EXIT_CLASSES");
}
#[test]
fn every_error_constant_has_a_class() {
for code in [
codes::E_CFG_PARSE,
codes::E_CFG_SCHEMA,
codes::E_PATH_NOT_FOUND,
codes::E_TRACE_MISS,
codes::E_TRACE_INVALID,
codes::E_BASE_MISMATCH,
codes::E_REPLAY_STRICT_MISSING,
codes::E_EMB_DIMS,
codes::E_POLICY_VIOLATION,
] {
assert_eq!(
exit_class(code),
ExitClass::Config,
"{code} is unclassified"
);
}
}
#[test]
fn warning_codes_are_not_in_the_table() {
assert_eq!(
exit_class(codes::W_CFG_VACUOUS_EXPECTED),
ExitClass::Unregistered
);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Diagnostic {
Diagnostic::new(codes::E_CFG_PARSE, "mapping values are not allowed here")
.with_source("config")
.with_context(serde_json::json!({ "path": "assay.yaml" }))
.with_fix_step("Run: assay doctor --config assay.yaml")
}
#[test]
fn plain_carries_no_terminal_decoration() {
let plain = sample().format_plain();
assert!(
plain.is_ascii(),
"plain output must stay ASCII for CI logs: {plain:?}"
);
assert!(plain.starts_with("[E_CFG_PARSE]"));
let warn_plain = sample().with_severity("warn").format_plain();
assert!(warn_plain.is_ascii(), "warnings must be plain too");
}
#[test]
fn terminal_carries_the_severity_icon() {
let error = sample().format_terminal();
assert!(error.starts_with(ICON_ERROR));
let warn = sample().with_severity("warn").format_terminal();
assert!(warn.starts_with(ICON_WARN));
}
#[test]
fn the_prefix_is_the_only_difference() {
let d = sample();
assert_eq!(
d.format_terminal().strip_prefix(ICON_ERROR),
Some(d.format_plain().as_str())
);
}
#[test]
fn body_carries_code_source_context_and_fix() {
let plain = sample().format_plain();
assert!(plain.contains("E_CFG_PARSE"));
assert!(plain.contains("source: config"));
assert!(plain.contains("assay.yaml"));
assert!(plain.contains("1. Run: assay doctor --config assay.yaml"));
}
}
#[cfg(test)]
mod severity_tests {
use super::Severity;
#[test]
fn an_unrecognized_severity_is_unknown_rather_than_a_note() {
assert_eq!(Severity::parse("cirtical"), None);
assert_eq!(Severity::parse(""), None);
assert_eq!(Severity::parse("fatal"), None);
}
#[test]
fn one_vocabulary_for_both_spellings() {
for spelling in ["warn", "warning", "WARN", "Warning", "WARNING"] {
assert_eq!(
Severity::parse(spelling),
Some(Severity::Warning),
"{spelling}"
);
}
for spelling in ["error", "ERROR", "Error"] {
assert_eq!(
Severity::parse(spelling),
Some(Severity::Error),
"{spelling}"
);
}
for spelling in ["note", "info", "INFO"] {
assert_eq!(
Severity::parse(spelling),
Some(Severity::Note),
"{spelling}"
);
}
}
#[test]
fn the_two_surfaces_spell_warnings_differently_on_purpose() {
assert_eq!(Severity::Warning.as_cli_str(), "warn");
assert_eq!(Severity::Warning.as_sarif_level(), "warning");
}
}