Skip to main content

buildfix_receipts_clippy/
lib.rs

1use anyhow::Result;
2use buildfix_adapter_sdk::{Adapter, AdapterError, AdapterMetadata, ReceiptBuilder};
3use buildfix_types::receipt::{Finding, Location, ReceiptEnvelope, Severity, VerdictStatus};
4use camino::Utf8PathBuf;
5use serde::Deserialize;
6use std::path::Path;
7
8pub struct ClippyAdapter {
9    sensor_id: String,
10}
11
12impl ClippyAdapter {
13    pub fn new() -> Self {
14        Self {
15            sensor_id: "clippy".to_string(),
16        }
17    }
18}
19
20impl Default for ClippyAdapter {
21    fn default() -> Self {
22        Self::new()
23    }
24}
25
26impl Adapter for ClippyAdapter {
27    fn sensor_id(&self) -> &str {
28        &self.sensor_id
29    }
30
31    fn load(&self, path: &Path) -> Result<ReceiptEnvelope, AdapterError> {
32        let content = std::fs::read_to_string(path).map_err(AdapterError::Io)?;
33        convert_clippy_json(&content, &self.sensor_id)
34    }
35}
36
37impl AdapterMetadata for ClippyAdapter {
38    fn name(&self) -> &str {
39        "clippy"
40    }
41
42    fn version(&self) -> &str {
43        env!("CARGO_PKG_VERSION")
44    }
45
46    fn supported_schemas(&self) -> &[&str] {
47        &["clippy.message.v1"]
48    }
49}
50
51fn convert_clippy_json(content: &str, sensor_id: &str) -> Result<ReceiptEnvelope, AdapterError> {
52    let mut findings = Vec::new();
53    let mut error_count = 0u64;
54    let mut warning_count = 0u64;
55
56    for line in content.lines() {
57        let trimmed = line.trim();
58        if trimmed.is_empty() {
59            continue;
60        }
61
62        let message: ClippyMessage = match serde_json::from_str(trimmed) {
63            Ok(msg) => msg,
64            Err(_) => continue,
65        };
66
67        let Some(reason) = message.reason.as_deref() else {
68            continue;
69        };
70
71        if reason != "compiler-message" {
72            continue;
73        }
74
75        let Some(msg) = message.message else {
76            continue;
77        };
78
79        let severity = map_severity(&msg.level);
80        match severity {
81            Severity::Error => error_count += 1,
82            Severity::Warn => warning_count += 1,
83            _ => {}
84        }
85
86        let check_id = msg.code.as_ref().map(|c| {
87            if c.starts_with("clippy::") {
88                c.replace("::", ".")
89            } else {
90                c.clone()
91            }
92        });
93
94        let location = extract_location(&msg);
95
96        findings.push(Finding {
97            severity,
98            check_id: check_id.clone(),
99            code: msg.code,
100            message: Some(msg.message),
101            location,
102            fingerprint: None,
103            data: None,
104            ..Default::default()
105        });
106    }
107
108    let status = if error_count > 0 {
109        VerdictStatus::Fail
110    } else if warning_count > 0 {
111        VerdictStatus::Warn
112    } else {
113        VerdictStatus::Pass
114    };
115
116    let mut builder = ReceiptBuilder::new(sensor_id)
117        .with_schema("clippy.message.v1")
118        .with_status(status)
119        .with_counts(findings.len() as u64, error_count, warning_count);
120
121    for finding in findings {
122        builder = builder.with_finding(finding);
123    }
124
125    Ok(builder.build())
126}
127
128fn map_severity(level: &Option<String>) -> Severity {
129    match level.as_deref() {
130        Some("error") => Severity::Error,
131        Some("warning") | Some("warn") => Severity::Warn,
132        _ => Severity::Warn,
133    }
134}
135
136fn extract_location(msg: &ClippyMessageContent) -> Option<Location> {
137    for span in &msg.spans {
138        if span.file_name.is_empty() {
139            continue;
140        }
141
142        return Some(Location {
143            path: Utf8PathBuf::from(&span.file_name),
144            line: span.line_start,
145            column: span.column_start,
146        });
147    }
148
149    None
150}
151
152#[derive(Debug, Deserialize, Default)]
153#[allow(dead_code)]
154struct ClippyMessage {
155    #[serde(default)]
156    reason: Option<String>,
157    #[serde(default)]
158    package_id: Option<String>,
159    #[serde(default)]
160    target: Option<ClippyTarget>,
161    #[serde(default)]
162    message: Option<ClippyMessageContent>,
163}
164
165#[derive(Debug, Deserialize, Default)]
166#[allow(dead_code)]
167#[serde(rename_all = "kebab-case")]
168struct ClippyTarget {
169    #[serde(default)]
170    kind: Vec<String>,
171    #[serde(default)]
172    name: String,
173    #[serde(default)]
174    src_path: Option<String>,
175}
176
177#[derive(Debug, Deserialize, Default)]
178struct ClippyMessageContent {
179    #[serde(default)]
180    code: Option<String>,
181    #[serde(default)]
182    level: Option<String>,
183    #[serde(default)]
184    message: String,
185    #[serde(default)]
186    spans: Vec<ClippySpan>,
187}
188
189#[derive(Debug, Deserialize, Default)]
190#[allow(dead_code)]
191struct ClippySpan {
192    #[serde(default)]
193    file_name: String,
194    #[serde(default)]
195    line_start: Option<u64>,
196    #[serde(default)]
197    line_end: Option<u64>,
198    #[serde(default)]
199    column_start: Option<u64>,
200    #[serde(default)]
201    column_end: Option<u64>,
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn test_adapter_sensor_id() {
210        let adapter = ClippyAdapter::new();
211        assert_eq!(adapter.sensor_id(), "clippy");
212    }
213
214    #[test]
215    fn test_convert_clippy_json_with_message() {
216        let json = r#"{"reason": "compiler-message", "package_id": "my_crate 0.1.0 (path+file:///path/to/crate)", "target": {"kind": ["lib"], "name": "my_crate", "src_path": "/path/to/src/lib.rs"}, "message": {"code": "clippy::unused_imports", "level": "warning", "message": "unused import: `foo`", "spans": [{"file_name": "src/lib.rs", "line_start": 1, "line_end": 1, "column_start": 1, "column_end": 10}]}}
217"#;
218
219        let receipt = convert_clippy_json(json, "clippy").unwrap();
220
221        assert_eq!(receipt.findings.len(), 1);
222        let finding = &receipt.findings[0];
223        assert_eq!(finding.severity, Severity::Warn);
224        assert_eq!(finding.check_id, Some("clippy.unused_imports".to_string()));
225        assert_eq!(finding.message, Some("unused import: `foo`".to_string()));
226        assert_eq!(
227            finding.location.as_ref().unwrap().path.as_str(),
228            "src/lib.rs"
229        );
230        assert_eq!(finding.location.as_ref().unwrap().line, Some(1));
231    }
232
233    #[test]
234    fn test_convert_clippy_json_maps_severity() {
235        let json = r#"{"reason": "compiler-message", "message": {"code": "clippy::error", "level": "error", "message": "Error message", "spans": []}}
236{"reason": "compiler-message", "message": {"code": "clippy::warning", "level": "warning", "message": "Warning message", "spans": []}}
237{"reason": "compiler-message", "message": {"code": "clippy::warn", "level": "warn", "message": "Warn message", "spans": []}}
238"#;
239
240        let receipt = convert_clippy_json(json, "clippy").unwrap();
241
242        assert_eq!(receipt.findings.len(), 3);
243        assert_eq!(receipt.findings[0].severity, Severity::Error);
244        assert_eq!(receipt.findings[1].severity, Severity::Warn);
245        assert_eq!(receipt.findings[2].severity, Severity::Warn);
246    }
247
248    #[test]
249    fn test_convert_clippy_json_calculates_counts() {
250        let json = r#"{"reason": "compiler-message", "message": {"code": "clippy::E0001", "level": "error", "message": "Error 1", "spans": []}}
251{"reason": "compiler-message", "message": {"code": "clippy::E0002", "level": "error", "message": "Error 2", "spans": []}}
252{"reason": "compiler-message", "message": {"code": "clippy::W0001", "level": "warning", "message": "Warning", "spans": []}}
253"#;
254
255        let receipt = convert_clippy_json(json, "clippy").unwrap();
256
257        assert_eq!(receipt.verdict.status, VerdictStatus::Fail);
258        assert_eq!(receipt.verdict.counts.findings, 3);
259        assert_eq!(receipt.verdict.counts.errors, 2);
260        assert_eq!(receipt.verdict.counts.warnings, 1);
261    }
262
263    #[test]
264    fn test_convert_clippy_json_empty_passes() {
265        let json = r#""#;
266
267        let receipt = convert_clippy_json(json, "clippy").unwrap();
268
269        assert_eq!(receipt.findings.len(), 0);
270        assert_eq!(receipt.verdict.status, VerdictStatus::Pass);
271    }
272
273    #[test]
274    fn test_convert_clippy_json_skips_non_messages() {
275        let json = r#"{"reason": "build-finished", "message": {"code": null, "level": "note", "message": "Build finished", "spans": []}}
276{"reason": "compiler-message", "message": {"code": "clippy::warning", "level": "warning", "message": "Actual warning", "spans": []}}
277"#;
278
279        let receipt = convert_clippy_json(json, "clippy").unwrap();
280
281        assert_eq!(receipt.findings.len(), 1);
282        assert_eq!(
283            receipt.findings[0].message,
284            Some("Actual warning".to_string())
285        );
286    }
287
288    #[test]
289    fn test_convert_clippy_json_check_id_format() {
290        let json = r#"{"reason": "compiler-message", "message": {"code": "clippy::double_comparison", "level": "warning", "message": "compare", "spans": [{"file_name": "src/main.rs", "line_start": 10, "line_end": 10, "column_start": 5, "column_end": 15}]}}
291"#;
292
293        let receipt = convert_clippy_json(json, "clippy").unwrap();
294
295        assert_eq!(
296            receipt.findings[0].check_id,
297            Some("clippy.double_comparison".to_string())
298        );
299    }
300}