1use std::str::FromStr;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
21pub enum Severity {
22 Info,
23 Warning,
24 Error,
25}
26
27pub fn any_at_or_above(findings: &[Finding], threshold: Severity) -> bool {
34 findings.iter().any(|finding| finding.severity >= threshold)
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
42#[error("unknown severity `{value}` (expected one of: {})", expected.join(", "))]
43pub struct UnknownSeverity {
44 pub value: String,
46 pub expected: &'static [&'static str],
55}
56
57impl Severity {
58 pub const ALL: [Severity; 3] = [Severity::Info, Severity::Warning, Severity::Error];
60
61 pub const NAMES: [&'static str; 3] = [
67 Self::ALL[0].as_str(),
68 Self::ALL[1].as_str(),
69 Self::ALL[2].as_str(),
70 ];
71
72 pub const fn as_str(self) -> &'static str {
77 match self {
78 Severity::Info => "info",
79 Severity::Warning => "warning",
80 Severity::Error => "error",
81 }
82 }
83}
84
85impl FromStr for Severity {
86 type Err = UnknownSeverity;
87
88 fn from_str(s: &str) -> Result<Self, Self::Err> {
89 Severity::ALL
90 .into_iter()
91 .find(|sev| sev.as_str() == s)
92 .ok_or_else(|| UnknownSeverity {
93 value: s.to_owned(),
94 expected: &Severity::NAMES,
95 })
96 }
97}
98
99impl std::fmt::Display for Severity {
100 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101 f.write_str(self.as_str())
102 }
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct Finding {
108 pub kind: String,
111 pub severity: Severity,
112 pub file_path: String,
113 pub line: u32,
114 pub column: Option<u32>,
115 pub message: String,
116 pub suggestion: Option<String>,
119 pub asserts_compile_failure: bool,
122 pub fingerprint: Option<String>,
125}
126
127impl Finding {
128 pub fn deterministic(
131 kind: String,
132 severity: Severity,
133 file_path: String,
134 line: u32,
135 column: Option<u32>,
136 message: String,
137 suggestion: Option<String>,
138 ) -> Self {
139 Self {
140 kind,
141 severity,
142 file_path,
143 line,
144 column,
145 message,
146 suggestion,
147 asserts_compile_failure: false,
148 fingerprint: None,
149 }
150 }
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159pub enum LlmSeverity {
160 Critical,
161 High,
162 Medium,
163 Low,
164 Info,
165}
166
167impl LlmSeverity {
168 pub const ALL: [LlmSeverity; 5] = [
170 LlmSeverity::Critical,
171 LlmSeverity::High,
172 LlmSeverity::Medium,
173 LlmSeverity::Low,
174 LlmSeverity::Info,
175 ];
176
177 pub const REVIEW: [LlmSeverity; 3] = [
179 LlmSeverity::Critical,
180 LlmSeverity::High,
181 LlmSeverity::Medium,
182 ];
183
184 pub const NAMES: [&'static str; 5] = [
187 Self::ALL[0].as_str(),
188 Self::ALL[1].as_str(),
189 Self::ALL[2].as_str(),
190 Self::ALL[3].as_str(),
191 Self::ALL[4].as_str(),
192 ];
193
194 pub const REVIEW_NAMES: [&'static str; 3] = [
196 Self::REVIEW[0].as_str(),
197 Self::REVIEW[1].as_str(),
198 Self::REVIEW[2].as_str(),
199 ];
200
201 pub const fn as_str(self) -> &'static str {
203 match self {
204 LlmSeverity::Critical => "critical",
205 LlmSeverity::High => "high",
206 LlmSeverity::Medium => "medium",
207 LlmSeverity::Low => "low",
208 LlmSeverity::Info => "info",
209 }
210 }
211
212 pub const fn to_severity(self) -> Severity {
214 match self {
215 LlmSeverity::Critical | LlmSeverity::High => Severity::Error,
216 LlmSeverity::Medium => Severity::Warning,
217 LlmSeverity::Low | LlmSeverity::Info => Severity::Info,
218 }
219 }
220
221 pub fn review_alternation() -> String {
223 Self::REVIEW_NAMES.join("|")
224 }
225}
226
227impl FromStr for LlmSeverity {
228 type Err = UnknownSeverity;
229
230 fn from_str(s: &str) -> Result<Self, Self::Err> {
231 LlmSeverity::ALL
232 .into_iter()
233 .find(|level| level.as_str() == s)
234 .ok_or_else(|| UnknownSeverity {
235 value: s.to_owned(),
236 expected: &LlmSeverity::NAMES,
237 })
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244
245 #[test]
246 fn severity_orders_lowest_first() {
247 assert!(Severity::Info < Severity::Warning);
248 assert!(Severity::Warning < Severity::Error);
249 }
250
251 #[test]
252 fn all_is_in_rank_order_and_complete() {
253 assert!(Severity::ALL.is_sorted());
256 }
257
258 #[test]
259 fn gating_at_error_admits_only_error() {
260 let threshold = Severity::Error;
261 assert!(Severity::Error >= threshold);
262 assert!(Severity::Warning < threshold);
263 assert!(Severity::Info < threshold);
264 }
265
266 #[test]
267 fn wire_names_round_trip() {
268 for sev in Severity::ALL {
269 assert_eq!(sev.as_str().parse::<Severity>(), Ok(sev));
270 assert_eq!(sev.to_string(), sev.as_str());
271 }
272 }
273
274 #[test]
275 fn unknown_severity_is_an_error_not_a_default() {
276 let err = "critical".parse::<Severity>().unwrap_err();
277 assert_eq!(err.value, "critical");
278 assert!(err.to_string().contains("critical"));
279 assert!(err.to_string().contains("info, warning, error"));
282 }
283
284 #[test]
285 fn parsing_is_case_sensitive() {
286 assert!("ERROR".parse::<Severity>().is_err());
289 }
290
291 #[test]
292 fn a_rejected_llm_severity_quotes_the_llm_vocabulary_not_dreps() {
293 let err = "blocker".parse::<LlmSeverity>().unwrap_err();
297 let msg = err.to_string();
298 assert!(
299 msg.contains("critical, high, medium, low, info"),
300 "got {msg}"
301 );
302 assert!(
303 !msg.contains("warning"),
304 "must not quote drep's scale: {msg}"
305 );
306
307 let err = "blocker".parse::<Severity>().unwrap_err();
308 let msg = err.to_string();
309 assert!(msg.contains("info, warning, error"), "got {msg}");
310 assert!(
311 !msg.contains("critical"),
312 "must not quote the LLM scale: {msg}"
313 );
314 }
315
316 #[test]
317 fn llm_severity_wire_names_round_trip() {
318 for level in LlmSeverity::ALL {
319 assert_eq!(level.as_str().parse::<LlmSeverity>(), Ok(level));
320 }
321 assert!("blocker".parse::<LlmSeverity>().is_err());
322 }
323
324 #[test]
325 fn llm_severity_collapses_onto_the_three_level_vocabulary() {
326 let mapped: Vec<Severity> = LlmSeverity::ALL
328 .into_iter()
329 .map(LlmSeverity::to_severity)
330 .collect();
331 assert_eq!(
332 mapped,
333 vec![
334 Severity::Error,
335 Severity::Error,
336 Severity::Warning,
337 Severity::Info,
338 Severity::Info
339 ]
340 );
341 }
342
343 #[test]
344 fn review_vocabulary_excludes_advisory_levels() {
345 assert_eq!(LlmSeverity::review_alternation(), "critical|high|medium");
346 assert_eq!(LlmSeverity::REVIEW_NAMES, ["critical", "high", "medium"]);
347 }
348}