Skip to main content

buildfix_fixer_api/
lib.rs

1use anyhow::Result;
2use buildfix_receipts::LoadedReceipt;
3use buildfix_types::ops::SafetyClass;
4use buildfix_types::plan::FindingRef;
5use serde::Serialize;
6
7/// Metadata describing a fixer for listing/documentation.
8#[derive(Debug, Clone, Serialize)]
9pub struct FixerMeta {
10    /// Unique key for this fixer (e.g., "cargo.workspace_resolver_v2").
11    pub fix_key: &'static str,
12    /// Brief human-readable description.
13    pub description: &'static str,
14    /// Safety classification for this fixer's ops.
15    pub safety: SafetyClass,
16    /// Tool prefixes consumed by this fixer's checks.
17    pub consumes_sensors: &'static [&'static str],
18    /// Check IDs consumed by this fixer's checks.
19    pub consumes_check_ids: &'static [&'static str],
20}
21
22/// Shared repository view used by all fixers.
23pub trait RepoView {
24    fn root(&self) -> &camino::Utf8Path;
25
26    fn read_to_string(&self, rel: &camino::Utf8Path) -> Result<String>;
27
28    fn exists(&self, rel: &camino::Utf8Path) -> bool;
29}
30
31/// Shared planning input passed into fixers.
32#[derive(Debug, Clone, Default)]
33pub struct PlannerConfig {
34    pub allow: Vec<String>,
35    pub deny: Vec<String>,
36    pub allow_guarded: bool,
37    pub allow_unsafe: bool,
38    pub allow_dirty: bool,
39    pub max_ops: Option<u64>,
40    pub max_files: Option<u64>,
41    pub max_patch_bytes: Option<u64>,
42    pub params: std::collections::HashMap<String, String>,
43}
44
45#[derive(Debug, Clone)]
46pub struct PlanContext {
47    pub repo_root: camino::Utf8PathBuf,
48    pub artifacts_dir: camino::Utf8PathBuf,
49    pub config: PlannerConfig,
50}
51
52/// Contract each fixer implements.
53pub trait Fixer {
54    fn meta(&self) -> FixerMeta;
55
56    fn plan(
57        &self,
58        ctx: &PlanContext,
59        repo: &dyn RepoView,
60        receipts: &ReceiptSet,
61    ) -> anyhow::Result<Vec<buildfix_types::plan::PlanOp>>;
62}
63
64/// A finding matched from receipts with its associated data and evidence.
65///
66/// Evidence fields (`confidence`, `provenance`, `context`) enable fixers
67/// to make informed decisions about safety classification.
68#[derive(Debug, Clone)]
69pub struct MatchedFinding {
70    /// The core finding reference with source, check_id, code, and location.
71    pub finding: FindingRef,
72    /// Tool-specific payload data.
73    pub data: Option<serde_json::Value>,
74    /// Confidence score (0.0 to 1.0) indicating certainty of the finding.
75    pub confidence: Option<f64>,
76    /// Provenance chain describing how the finding was derived.
77    pub provenance: Option<buildfix_types::receipt::Provenance>,
78    /// Context metadata including analysis depth and workspace consensus.
79    pub context: Option<buildfix_types::receipt::FindingContext>,
80}
81
82#[derive(Debug, Clone)]
83pub struct ReceiptRecord {
84    #[allow(dead_code)]
85    pub sensor_id: String,
86    pub path: camino::Utf8PathBuf,
87    pub envelope: buildfix_types::receipt::ReceiptEnvelope,
88}
89
90/// In-memory queryable set of loaded receipts.
91#[derive(Debug, Clone)]
92pub struct ReceiptSet {
93    receipts: Vec<ReceiptRecord>,
94}
95
96impl ReceiptSet {
97    pub fn from_loaded(loaded: &[LoadedReceipt]) -> Self {
98        let mut receipts = Vec::new();
99        for r in loaded {
100            if let Ok(env) = &r.receipt {
101                receipts.push(ReceiptRecord {
102                    sensor_id: r.sensor_id.clone(),
103                    path: r.path.clone(),
104                    envelope: env.clone(),
105                });
106            }
107        }
108        receipts.sort_by(|a, b| a.path.cmp(&b.path));
109        Self { receipts }
110    }
111
112    pub fn matching_findings(
113        &self,
114        tool_prefixes: &[&str],
115        check_ids: &[&str],
116        codes: &[&str],
117    ) -> Vec<FindingRef> {
118        self.matching_findings_with_data(tool_prefixes, check_ids, codes)
119            .into_iter()
120            .map(|m| m.finding)
121            .collect()
122    }
123
124    pub fn matching_findings_with_data(
125        &self,
126        tool_prefixes: &[&str],
127        check_ids: &[&str],
128        codes: &[&str],
129    ) -> Vec<MatchedFinding> {
130        let mut out = Vec::new();
131
132        for r in &self.receipts {
133            let tool = r.envelope.tool.name.as_str();
134            if !tool_prefixes.iter().any(|p| tool.starts_with(p)) {
135                continue;
136            }
137
138            for f in &r.envelope.findings {
139                let check_ok = if check_ids.is_empty() {
140                    true
141                } else {
142                    f.check_id
143                        .as_deref()
144                        .map(|c| check_ids.contains(&c))
145                        .unwrap_or(false)
146                };
147
148                let code_ok = if codes.is_empty() {
149                    true
150                } else {
151                    f.code
152                        .as_deref()
153                        .map(|c| codes.contains(&c))
154                        .unwrap_or(false)
155                };
156
157                if !check_ok || !code_ok {
158                    continue;
159                }
160
161                out.push(MatchedFinding {
162                    finding: FindingRef {
163                        source: tool.to_string(),
164                        check_id: f.check_id.clone(),
165                        code: f.code.clone().unwrap_or_else(|| "-".to_string()),
166                        path: f.location.as_ref().map(|loc| loc.path.to_string()),
167                        line: f.location.as_ref().and_then(|loc| loc.line),
168                        fingerprint: f.fingerprint.clone(),
169                    },
170                    data: f.data.clone(),
171                    confidence: f.confidence,
172                    provenance: f.provenance.clone(),
173                    context: f.context.clone(),
174                });
175            }
176        }
177
178        out.sort_by_key(|m| stable_finding_key(&m.finding));
179        out
180    }
181}
182
183fn stable_finding_key(f: &FindingRef) -> String {
184    let loc = f
185        .path
186        .as_ref()
187        .map(|p| format!("{}:{}", p, f.line.unwrap_or(0)))
188        .unwrap_or_else(|| "no_location".to_string());
189
190    format!(
191        "{}/{}/{}|{}",
192        f.source,
193        f.check_id.clone().unwrap_or_default(),
194        f.code,
195        loc
196    )
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use buildfix_types::receipt::{Finding, Location, ReceiptEnvelope, Severity, ToolInfo};
203
204    fn make_receipt(sensor_id: &str, findings: Vec<Finding>) -> ReceiptEnvelope {
205        ReceiptEnvelope {
206            schema: "test".to_string(),
207            tool: ToolInfo {
208                name: sensor_id.to_string(),
209                version: None,
210                repo: None,
211                commit: None,
212            },
213            run: Default::default(),
214            verdict: Default::default(),
215            findings,
216            capabilities: None,
217            data: None,
218        }
219    }
220
221    fn make_finding(check_id: &str, code: Option<&str>) -> Finding {
222        Finding {
223            severity: Severity::Error,
224            check_id: Some(check_id.to_string()),
225            code: code.map(String::from),
226            message: None,
227            location: Some(Location {
228                path: "Cargo.toml".into(),
229                line: Some(1),
230                column: None,
231            }),
232            fingerprint: None,
233            data: None,
234            confidence: None,
235            provenance: None,
236            context: None,
237        }
238    }
239
240    #[test]
241    fn test_matching_findings_exact_match() {
242        let receipt = make_receipt(
243            "cargo-deny",
244            vec![make_finding("licenses.unlicensed", None)],
245        );
246        let loaded = vec![buildfix_receipts::LoadedReceipt {
247            path: "artifacts/cargo-deny/report.json".into(),
248            sensor_id: "cargo-deny".to_string(),
249            receipt: Ok(receipt),
250        }];
251        let set = ReceiptSet::from_loaded(&loaded);
252
253        let matches = set.matching_findings(&["cargo-deny"], &["licenses.unlicensed"], &[]);
254        assert_eq!(matches.len(), 1);
255    }
256
257    #[test]
258    fn test_matching_findings_no_match_wrong_check_id() {
259        let receipt = make_receipt("cargo-deny", vec![make_finding("bans.multi", None)]);
260        let loaded = vec![buildfix_receipts::LoadedReceipt {
261            path: "artifacts/cargo-deny/report.json".into(),
262            sensor_id: "cargo-deny".to_string(),
263            receipt: Ok(receipt),
264        }];
265        let set = ReceiptSet::from_loaded(&loaded);
266
267        let matches = set.matching_findings(&["cargo-deny"], &["licenses.unlicensed"], &[]);
268        assert!(matches.is_empty());
269    }
270
271    #[test]
272    fn test_matching_findings_filters_by_code() {
273        let finding = make_finding("deps.path_requires_version", Some("missing_version"));
274        let receipt = make_receipt("depguard", vec![finding]);
275        let loaded = vec![buildfix_receipts::LoadedReceipt {
276            path: "artifacts/depguard/report.json".into(),
277            sensor_id: "depguard".to_string(),
278            receipt: Ok(receipt),
279        }];
280        let set = ReceiptSet::from_loaded(&loaded);
281
282        let matches = set.matching_findings(
283            &["depguard"],
284            &["deps.path_requires_version"],
285            &["missing_version"],
286        );
287        assert_eq!(matches.len(), 1);
288
289        let no_code_matches = set.matching_findings(
290            &["depguard"],
291            &["deps.path_requires_version"],
292            &["other_code"],
293        );
294        assert!(no_code_matches.is_empty());
295    }
296
297    #[test]
298    fn test_matching_findings_empty_filters_match_all() {
299        let receipt = make_receipt(
300            "cargo-deny",
301            vec![
302                make_finding("licenses.unlicensed", None),
303                make_finding("bans.multi", None),
304            ],
305        );
306        let loaded = vec![buildfix_receipts::LoadedReceipt {
307            path: "artifacts/cargo-deny/report.json".into(),
308            sensor_id: "cargo-deny".to_string(),
309            receipt: Ok(receipt),
310        }];
311        let set = ReceiptSet::from_loaded(&loaded);
312
313        // Empty check_ids should match all
314        let matches = set.matching_findings(&["cargo-deny"], &[], &[]);
315        assert_eq!(matches.len(), 2);
316    }
317
318    #[test]
319    fn test_matching_findings_skips_erroneous_receipts() {
320        let loaded = vec![
321            buildfix_receipts::LoadedReceipt {
322                path: "artifacts/cargo-deny/report.json".into(),
323                sensor_id: "cargo-deny".to_string(),
324                receipt: Err(buildfix_receipts::ReceiptLoadError::Io {
325                    message: "not found".to_string(),
326                }),
327            },
328            buildfix_receipts::LoadedReceipt {
329                path: "artifacts/depguard/report.json".into(),
330                sensor_id: "depguard".to_string(),
331                receipt: Ok(make_receipt(
332                    "depguard",
333                    vec![make_finding("deps.path_requires_version", None)],
334                )),
335            },
336        ];
337        let set = ReceiptSet::from_loaded(&loaded);
338
339        let matches = set.matching_findings(&["depguard"], &["deps.path_requires_version"], &[]);
340        assert_eq!(matches.len(), 1);
341    }
342
343    #[test]
344    fn test_matching_findings_with_data() {
345        let finding = Finding {
346            severity: Severity::Error,
347            check_id: Some("test.check".to_string()),
348            code: Some("code1".to_string()),
349            message: None,
350            location: Some(Location {
351                path: "test.toml".into(),
352                line: Some(10),
353                column: None,
354            }),
355            fingerprint: None,
356            data: Some(serde_json::json!({"key": "value"})),
357            ..Default::default()
358        };
359        let receipt = make_receipt("test-tool", vec![finding]);
360        let loaded = vec![buildfix_receipts::LoadedReceipt {
361            path: "artifacts/test-tool/report.json".into(),
362            sensor_id: "test-tool".to_string(),
363            receipt: Ok(receipt),
364        }];
365        let set = ReceiptSet::from_loaded(&loaded);
366
367        let matches = set.matching_findings_with_data(&["test-tool"], &["test.check"], &["code1"]);
368        assert_eq!(matches.len(), 1);
369        assert_eq!(
370            matches[0].data.as_ref().unwrap().get("key").unwrap(),
371            "value"
372        );
373    }
374}