Skip to main content

buildfix_receipts_cargo_spellcheck/
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 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}