use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity {
Info,
Warning,
Error,
}
pub fn any_at_or_above(findings: &[Finding], threshold: Severity) -> bool {
findings.iter().any(|finding| finding.severity >= threshold)
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("unknown severity `{value}` (expected one of: {})", expected.join(", "))]
pub struct UnknownSeverity {
pub value: String,
pub expected: &'static [&'static str],
}
impl Severity {
pub const ALL: [Severity; 3] = [Severity::Info, Severity::Warning, Severity::Error];
pub const NAMES: [&'static str; 3] = [
Self::ALL[0].as_str(),
Self::ALL[1].as_str(),
Self::ALL[2].as_str(),
];
pub const fn as_str(self) -> &'static str {
match self {
Severity::Info => "info",
Severity::Warning => "warning",
Severity::Error => "error",
}
}
}
impl FromStr for Severity {
type Err = UnknownSeverity;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Severity::ALL
.into_iter()
.find(|sev| sev.as_str() == s)
.ok_or_else(|| UnknownSeverity {
value: s.to_owned(),
expected: &Severity::NAMES,
})
}
}
impl std::fmt::Display for Severity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Finding {
pub kind: String,
pub severity: Severity,
pub file_path: String,
pub line: u32,
pub column: Option<u32>,
pub message: String,
pub suggestion: Option<String>,
pub asserts_compile_failure: bool,
pub fingerprint: Option<String>,
}
impl Finding {
pub fn deterministic(
kind: String,
severity: Severity,
file_path: String,
line: u32,
column: Option<u32>,
message: String,
suggestion: Option<String>,
) -> Self {
Self {
kind,
severity,
file_path,
line,
column,
message,
suggestion,
asserts_compile_failure: false,
fingerprint: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LlmSeverity {
Critical,
High,
Medium,
Low,
Info,
}
impl LlmSeverity {
pub const ALL: [LlmSeverity; 5] = [
LlmSeverity::Critical,
LlmSeverity::High,
LlmSeverity::Medium,
LlmSeverity::Low,
LlmSeverity::Info,
];
pub const NAMES: [&'static str; 5] = [
Self::ALL[0].as_str(),
Self::ALL[1].as_str(),
Self::ALL[2].as_str(),
Self::ALL[3].as_str(),
Self::ALL[4].as_str(),
];
pub const fn as_str(self) -> &'static str {
match self {
LlmSeverity::Critical => "critical",
LlmSeverity::High => "high",
LlmSeverity::Medium => "medium",
LlmSeverity::Low => "low",
LlmSeverity::Info => "info",
}
}
pub const fn to_severity(self) -> Severity {
match self {
LlmSeverity::Critical | LlmSeverity::High => Severity::Error,
LlmSeverity::Medium => Severity::Warning,
LlmSeverity::Low | LlmSeverity::Info => Severity::Info,
}
}
pub fn alternation() -> String {
Self::NAMES.join("|")
}
}
impl FromStr for LlmSeverity {
type Err = UnknownSeverity;
fn from_str(s: &str) -> Result<Self, Self::Err> {
LlmSeverity::ALL
.into_iter()
.find(|level| level.as_str() == s)
.ok_or_else(|| UnknownSeverity {
value: s.to_owned(),
expected: &LlmSeverity::NAMES,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn severity_orders_lowest_first() {
assert!(Severity::Info < Severity::Warning);
assert!(Severity::Warning < Severity::Error);
}
#[test]
fn all_is_in_rank_order_and_complete() {
assert!(Severity::ALL.is_sorted());
}
#[test]
fn gating_at_error_admits_only_error() {
let threshold = Severity::Error;
assert!(Severity::Error >= threshold);
assert!(Severity::Warning < threshold);
assert!(Severity::Info < threshold);
}
#[test]
fn wire_names_round_trip() {
for sev in Severity::ALL {
assert_eq!(sev.as_str().parse::<Severity>(), Ok(sev));
assert_eq!(sev.to_string(), sev.as_str());
}
}
#[test]
fn unknown_severity_is_an_error_not_a_default() {
let err = "critical".parse::<Severity>().unwrap_err();
assert_eq!(err.value, "critical");
assert!(err.to_string().contains("critical"));
assert!(err.to_string().contains("info, warning, error"));
}
#[test]
fn parsing_is_case_sensitive() {
assert!("ERROR".parse::<Severity>().is_err());
}
#[test]
fn a_rejected_llm_severity_quotes_the_llm_vocabulary_not_dreps() {
let err = "blocker".parse::<LlmSeverity>().unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("critical, high, medium, low, info"),
"got {msg}"
);
assert!(
!msg.contains("warning"),
"must not quote drep's scale: {msg}"
);
let err = "blocker".parse::<Severity>().unwrap_err();
let msg = err.to_string();
assert!(msg.contains("info, warning, error"), "got {msg}");
assert!(
!msg.contains("critical"),
"must not quote the LLM scale: {msg}"
);
}
#[test]
fn llm_severity_wire_names_round_trip() {
for level in LlmSeverity::ALL {
assert_eq!(level.as_str().parse::<LlmSeverity>(), Ok(level));
}
assert!("blocker".parse::<LlmSeverity>().is_err());
}
#[test]
fn llm_severity_collapses_onto_the_three_level_vocabulary() {
let mapped: Vec<Severity> = LlmSeverity::ALL
.into_iter()
.map(LlmSeverity::to_severity)
.collect();
assert_eq!(
mapped,
vec![
Severity::Error,
Severity::Error,
Severity::Warning,
Severity::Info,
Severity::Info
]
);
}
#[test]
fn alternation_is_derived_from_all_not_written_out() {
assert_eq!(LlmSeverity::alternation(), "critical|high|medium|low|info");
for level in LlmSeverity::ALL {
assert!(LlmSeverity::alternation().contains(level.as_str()));
}
}
}