buildfix_receipts_cargo_spellcheck/
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 CargoSpellcheckAdapter {
9 sensor_id: String,
10}
11
12impl CargoSpellcheckAdapter {
13 pub fn new() -> Self {
14 Self {
15 sensor_id: "cargo-spellcheck".to_string(),
16 }
17 }
18}
19
20impl Default for CargoSpellcheckAdapter {
21 fn default() -> Self {
22 Self::new()
23 }
24}
25
26impl Adapter for CargoSpellcheckAdapter {
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_spellcheck_json(&content, &self.sensor_id)
34 }
35}
36
37impl AdapterMetadata for CargoSpellcheckAdapter {
38 fn name(&self) -> &str {
39 "cargo-spellcheck"
40 }
41
42 fn version(&self) -> &str {
43 env!("CARGO_PKG_VERSION")
44 }
45
46 fn supported_schemas(&self) -> &[&str] {
47 &["cargo-spellcheck.report.v1"]
48 }
49}
50
51fn convert_spellcheck_json(
52 content: &str,
53 sensor_id: &str,
54) -> Result<ReceiptEnvelope, AdapterError> {
55 let report: SpellcheckReport = serde_json::from_str(content).map_err(AdapterError::Json)?;
56
57 let mut findings = Vec::new();
58
59 for finding in &report.findings {
60 let check_id = if finding.kind.contains("Misspelled") {
61 "docs.spelling_error".to_string()
62 } else {
63 "spellcheck.spelling".to_string()
64 };
65
66 let message = if let Some(ref suggestion) = finding.suggestion {
67 format!(
68 "Spelling error: '{}' (found in '{}'). Did you mean: '{}'?",
69 finding.words.join(", "),
70 finding.context,
71 suggestion
72 )
73 } else {
74 format!(
75 "Spelling error: '{}' (found in '{}')",
76 finding.words.join(", "),
77 finding.context
78 )
79 };
80
81 findings.push(Finding {
82 severity: Severity::Warn,
83 check_id: Some(check_id),
84 code: None,
85 message: Some(message),
86 location: Some(Location {
87 path: Utf8PathBuf::from(&finding.file),
88 line: Some(finding.line),
89 column: Some(finding.column),
90 }),
91 fingerprint: None,
92 data: Some(serde_json::json!({
93 "kind": finding.kind,
94 "context": finding.context,
95 "suggestion": finding.suggestion,
96 "words": finding.words,
97 })),
98 ..Default::default()
99 });
100 }
101
102 let status = if findings.is_empty() {
103 VerdictStatus::Pass
104 } else {
105 VerdictStatus::Warn
106 };
107
108 let mut builder = ReceiptBuilder::new(sensor_id)
109 .with_schema("cargo-spellcheck.findings.v1")
110 .with_status(status)
111 .with_counts(findings.len() as u64, 0, findings.len() as u64);
112
113 for finding in findings {
114 builder = builder.with_finding(finding);
115 }
116
117 Ok(builder.build())
118}
119
120#[derive(Debug, Deserialize)]
121#[allow(dead_code)]
122struct SpellcheckReport {
123 findings: Vec<SpellcheckFinding>,
124 summary: SpellcheckSummary,
125}
126
127#[derive(Debug, Deserialize)]
128#[allow(dead_code)]
129struct SpellcheckFinding {
130 file: String,
131 line: u64,
132 column: u64,
133 kind: String,
134 context: String,
135 #[serde(default)]
136 suggestion: Option<String>,
137 words: Vec<String>,
138}
139
140#[derive(Debug, Deserialize)]
141#[allow(dead_code)]
142struct SpellcheckSummary {
143 total: u64,
144 files: u64,
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150
151 #[test]
152 fn test_adapter_sensor_id() {
153 let adapter = CargoSpellcheckAdapter::new();
154 assert_eq!(adapter.sensor_id(), "cargo-spellcheck");
155 }
156
157 #[test]
158 fn test_convert_spellcheck_json() {
159 let json = r#"{
160 "findings": [
161 {
162 "file": "src/lib.rs",
163 "line": 10,
164 "column": 5,
165 "kind": "Misspelled",
166 "context": "This is a documint",
167 "suggestion": "document",
168 "words": ["documint"]
169 }
170 ],
171 "summary": {
172 "total": 1,
173 "files": 1
174 }
175}"#;
176
177 let receipt = convert_spellcheck_json(json, "cargo-spellcheck").unwrap();
178
179 assert_eq!(receipt.findings.len(), 1);
180 let finding = &receipt.findings[0];
181 assert_eq!(finding.severity, Severity::Warn);
182 assert_eq!(finding.check_id, Some("docs.spelling_error".to_string()));
183 assert!(finding.message.is_some());
184 let location = finding.location.as_ref().unwrap();
185 assert_eq!(location.path.as_str(), "src/lib.rs");
186 assert_eq!(location.line, Some(10));
187 assert_eq!(location.column, Some(5));
188 }
189
190 #[test]
191 fn test_convert_spellcheck_json_with_multiple_findings() {
192 let json = r#"{
193 "findings": [
194 {
195 "file": "src/lib.rs",
196 "line": 10,
197 "column": 5,
198 "kind": "Misspelled",
199 "context": "This is a documint",
200 "suggestion": "document",
201 "words": ["documint"]
202 },
203 {
204 "file": "src/main.rs",
205 "line": 20,
206 "column": 15,
207 "kind": "Misspelled",
208 "context": "functoin",
209 "suggestion": "function",
210 "words": ["functoin"]
211 }
212 ],
213 "summary": {
214 "total": 2,
215 "files": 2
216 }
217}"#;
218
219 let receipt = convert_spellcheck_json(json, "cargo-spellcheck").unwrap();
220
221 assert_eq!(receipt.findings.len(), 2);
222 assert_eq!(receipt.verdict.status, VerdictStatus::Warn);
223 assert_eq!(receipt.verdict.counts.findings, 2);
224 assert_eq!(receipt.verdict.counts.warnings, 2);
225 }
226
227 #[test]
228 fn test_convert_spellcheck_json_empty_passes() {
229 let json = r#"{
230 "findings": [],
231 "summary": {
232 "total": 0,
233 "files": 0
234 }
235}"#;
236
237 let receipt = convert_spellcheck_json(json, "cargo-spellcheck").unwrap();
238
239 assert_eq!(receipt.findings.len(), 0);
240 assert_eq!(receipt.verdict.status, VerdictStatus::Pass);
241 }
242
243 #[test]
244 fn test_convert_spellcheck_json_without_suggestion() {
245 let json = r#"{
246 "findings": [
247 {
248 "file": "src/lib.rs",
249 "line": 10,
250 "column": 5,
251 "kind": "UnknownWord",
252 "context": "Some unknown word",
253 "words": ["unknownword"]
254 }
255 ],
256 "summary": {
257 "total": 1,
258 "files": 1
259 }
260}"#;
261
262 let receipt = convert_spellcheck_json(json, "cargo-spellcheck").unwrap();
263
264 assert_eq!(receipt.findings.len(), 1);
265 let finding = &receipt.findings[0];
266 assert_eq!(finding.check_id, Some("spellcheck.spelling".to_string()));
267 }
268
269 #[test]
270 fn test_load_from_file() {
271 let adapter = CargoSpellcheckAdapter::new();
272 let receipt = adapter
273 .load(Path::new("tests/fixtures/report.json"))
274 .expect("should load fixture");
275
276 assert!(!receipt.findings.is_empty());
277 }
278}