1use serde::Serialize;
4
5use crate::model_ir::{Finding, FindingSeverity, ModelIr};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum MessageFormat {
10 Human,
11 Json,
12}
13
14#[derive(Debug, Clone, Serialize)]
15pub struct Diagnostic {
16 pub code: String,
17 pub severity: &'static str,
18 pub message: String,
19 #[serde(skip_serializing_if = "Option::is_none")]
20 pub file: Option<String>,
21 #[serde(skip_serializing_if = "Option::is_none")]
22 pub line: Option<usize>,
23 #[serde(skip_serializing_if = "Option::is_none")]
24 pub column: Option<usize>,
25 pub confidence: String,
26}
27
28impl Diagnostic {
29 pub fn from_finding(finding: &Finding) -> Self {
30 let (file, line, column) = parse_source_location(finding.source.as_deref());
31 Self {
32 code: finding.rule.clone(),
33 severity: severity_label(&finding.severity),
34 message: finding.message.clone(),
35 file,
36 line,
37 column,
38 confidence: format!("{:?}", finding.confidence).to_ascii_lowercase(),
39 }
40 }
41}
42
43pub fn from_model(model: &ModelIr) -> Vec<Diagnostic> {
45 let mut diagnostics: Vec<Diagnostic> = model
46 .findings
47 .iter()
48 .map(Diagnostic::from_finding)
49 .collect();
50 diagnostics.sort_by(|a, b| {
51 (
52 a.file.as_deref().unwrap_or(""),
53 a.line.unwrap_or(0),
54 a.column.unwrap_or(0),
55 a.code.as_str(),
56 a.message.as_str(),
57 )
58 .cmp(&(
59 b.file.as_deref().unwrap_or(""),
60 b.line.unwrap_or(0),
61 b.column.unwrap_or(0),
62 b.code.as_str(),
63 b.message.as_str(),
64 ))
65 });
66 diagnostics
67}
68
69pub fn render(diagnostics: &[Diagnostic], format: MessageFormat) -> String {
71 match format {
72 MessageFormat::Human => render_human(diagnostics),
73 MessageFormat::Json => {
74 let mut out = serde_json::to_string_pretty(diagnostics).unwrap_or_else(|_| "[]".into());
75 out.push('\n');
76 out
77 }
78 }
79}
80
81fn render_human(diagnostics: &[Diagnostic]) -> String {
82 let mut out = String::new();
83 for diagnostic in diagnostics {
84 out.push_str(diagnostic.severity);
85 out.push_str(": ");
86 out.push_str(&diagnostic.message);
87 out.push('\n');
88 if let Some(file) = &diagnostic.file {
89 out.push_str(" --> ");
90 out.push_str(file);
91 if let Some(line) = diagnostic.line {
92 out.push(':');
93 out.push_str(&line.to_string());
94 if let Some(column) = diagnostic.column {
95 out.push(':');
96 out.push_str(&column.to_string());
97 }
98 }
99 out.push('\n');
100 }
101 out.push_str(" = code: ");
102 out.push_str(&diagnostic.code);
103 out.push('\n');
104 }
105 if !diagnostics.is_empty() {
106 let errors = diagnostics.iter().filter(|d| d.severity == "error").count();
107 let warnings = diagnostics
108 .iter()
109 .filter(|d| d.severity == "warning")
110 .count();
111 let notes = diagnostics.iter().filter(|d| d.severity == "note").count();
112 out.push('\n');
113 out.push_str(&format!(
114 "candle-graph check: {errors} error(s), {warnings} warning(s), {notes} note(s)\n"
115 ));
116 }
117 out
118}
119
120fn severity_label(severity: &FindingSeverity) -> &'static str {
121 match severity {
122 FindingSeverity::Error => "error",
123 FindingSeverity::Warning => "warning",
124 FindingSeverity::Information => "note",
125 }
126}
127
128fn parse_source_location(source: Option<&str>) -> (Option<String>, Option<usize>, Option<usize>) {
130 let Some(source) = source.filter(|value| !value.is_empty()) else {
131 return (None, None, None);
132 };
133 let parts: Vec<&str> = source.rsplitn(3, ':').collect();
134 match parts.as_slice() {
135 [col, line, file]
136 if line.parse::<usize>().is_ok()
137 && col.parse::<usize>().is_ok()
138 && (file.contains('/') || file.contains('\\') || file.ends_with(".rs")) =>
139 {
140 (
141 Some((*file).to_string()),
142 line.parse().ok(),
143 col.parse().ok(),
144 )
145 }
146 [line, file] if line.parse::<usize>().is_ok() => {
147 (Some((*file).to_string()), line.parse().ok(), None)
148 }
149 _ => (Some(source.to_string()), None, None),
150 }
151}
152
153pub fn has_failing_findings(model: &ModelIr) -> bool {
155 model.findings.iter().any(|finding| {
156 matches!(
157 finding.severity,
158 FindingSeverity::Error | FindingSeverity::Warning
159 )
160 })
161}
162
163pub fn has_proven_defect_findings(model: &ModelIr) -> bool {
167 use crate::model_ir::Confidence;
168 model.findings.iter().any(|finding| {
169 matches!(finding.severity, FindingSeverity::Error)
170 && matches!(finding.confidence, Confidence::Proven)
171 })
172}
173
174pub fn denied_findings<'a>(model: &'a ModelIr, deny_rules: &[String]) -> Vec<&'a Finding> {
179 use crate::model_ir::Confidence;
180 if deny_rules.is_empty() {
181 return Vec::new();
182 }
183 model
184 .findings
185 .iter()
186 .filter(|finding| {
187 matches!(finding.confidence, Confidence::Proven)
188 && deny_rules
189 .iter()
190 .any(|rule| rule_matches(rule, &finding.rule))
191 })
192 .collect()
193}
194
195pub fn has_denied_findings(model: &ModelIr, deny_rules: &[String]) -> bool {
196 !denied_findings(model, deny_rules).is_empty()
197}
198
199fn rule_matches(rule: &str, finding_rule: &str) -> bool {
200 let rule = rule.trim().to_ascii_lowercase();
201 let finding_rule = finding_rule.to_ascii_lowercase();
202 rule == finding_rule || finding_rule.starts_with(&format!("{rule}-"))
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208 use crate::model_ir::{Confidence, StableId};
209
210 #[test]
211 fn parses_rustc_style_locations() {
212 let (file, line, col) = parse_source_location(Some("src/model.rs:12:4"));
213 assert_eq!(file.as_deref(), Some("src/model.rs"));
214 assert_eq!(line, Some(12));
215 assert_eq!(col, Some(4));
216 }
217
218 #[test]
219 fn human_render_includes_summary() {
220 let finding = Finding {
221 id: StableId::new("finding", ["demo"]),
222 rule: "demo-rule".into(),
223 severity: FindingSeverity::Warning,
224 confidence: Confidence::Proven,
225 message: "something looks off".into(),
226 source: Some("src/lib.rs:1:1".into()),
227 related: Vec::new(),
228 evidence: Vec::new(),
229 };
230 let text = render(&[Diagnostic::from_finding(&finding)], MessageFormat::Human);
231 assert!(text.contains("warning: something looks off"));
232 assert!(text.contains("--> src/lib.rs:1:1"));
233 assert!(text.contains("1 warning(s)"));
234 }
235}