Skip to main content

candle_graph/
diagnostics.rs

1//! Rustc-like diagnostics rendered from unified model findings.
2
3use serde::Serialize;
4
5use crate::model_ir::{Finding, FindingSeverity, ModelIr};
6
7/// How finding diagnostics are rendered on stderr.
8#[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
43/// Collect diagnostics for every finding in `model`, sorted by (file, line, code, message).
44pub 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
69/// Render diagnostics for stderr.
70pub 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
128/// Parse `path`, `path:line`, or `path:line:col` from a finding source string.
129fn 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
153/// True when model findings include Error or Warning severity.
154pub 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
163/// True when model findings include a proven defect (`Error` + `Confidence::Proven`).
164///
165/// Coverage gaps (`Unknown` / `Warning`) must not fail `--strict` / `cargo candle-graph check`.
166pub 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
174/// Findings that match any `--deny` rule with proven confidence.
175///
176/// Explicit deny rules gate even when severity is `Warning` (e.g. proven
177/// `zero-times-infinity` on a loss path classified as local-only before deny).
178pub 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}