buildfix_receipts_clippy/
lib.rs1use 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}