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#[derive(Debug, Clone)]
65pub struct MatchedFinding {
66    pub finding: FindingRef,
67    pub data: Option<serde_json::Value>,
68}
69
70#[derive(Debug, Clone)]
71pub struct ReceiptRecord {
72    #[allow(dead_code)]
73    pub sensor_id: String,
74    pub path: camino::Utf8PathBuf,
75    pub envelope: buildfix_types::receipt::ReceiptEnvelope,
76}
77
78/// In-memory queryable set of loaded receipts.
79#[derive(Debug, Clone)]
80pub struct ReceiptSet {
81    receipts: Vec<ReceiptRecord>,
82}
83
84impl ReceiptSet {
85    pub fn from_loaded(loaded: &[LoadedReceipt]) -> Self {
86        let mut receipts = Vec::new();
87        for r in loaded {
88            if let Ok(env) = &r.receipt {
89                receipts.push(ReceiptRecord {
90                    sensor_id: r.sensor_id.clone(),
91                    path: r.path.clone(),
92                    envelope: env.clone(),
93                });
94            }
95        }
96        receipts.sort_by(|a, b| a.path.cmp(&b.path));
97        Self { receipts }
98    }
99
100    pub fn matching_findings(
101        &self,
102        tool_prefixes: &[&str],
103        check_ids: &[&str],
104        codes: &[&str],
105    ) -> Vec<FindingRef> {
106        self.matching_findings_with_data(tool_prefixes, check_ids, codes)
107            .into_iter()
108            .map(|m| m.finding)
109            .collect()
110    }
111
112    pub fn matching_findings_with_data(
113        &self,
114        tool_prefixes: &[&str],
115        check_ids: &[&str],
116        codes: &[&str],
117    ) -> Vec<MatchedFinding> {
118        let mut out = Vec::new();
119
120        for r in &self.receipts {
121            let tool = r.envelope.tool.name.as_str();
122            if !tool_prefixes.iter().any(|p| tool.starts_with(p)) {
123                continue;
124            }
125
126            for f in &r.envelope.findings {
127                let check_ok = if check_ids.is_empty() {
128                    true
129                } else {
130                    f.check_id
131                        .as_deref()
132                        .map(|c| check_ids.contains(&c))
133                        .unwrap_or(false)
134                };
135
136                let code_ok = if codes.is_empty() {
137                    true
138                } else {
139                    f.code
140                        .as_deref()
141                        .map(|c| codes.contains(&c))
142                        .unwrap_or(false)
143                };
144
145                if !check_ok || !code_ok {
146                    continue;
147                }
148
149                out.push(MatchedFinding {
150                    finding: FindingRef {
151                        source: tool.to_string(),
152                        check_id: f.check_id.clone(),
153                        code: f.code.clone().unwrap_or_else(|| "-".to_string()),
154                        path: f.location.as_ref().map(|loc| loc.path.to_string()),
155                        line: f.location.as_ref().and_then(|loc| loc.line),
156                        fingerprint: f.fingerprint.clone(),
157                    },
158                    data: f.data.clone(),
159                });
160            }
161        }
162
163        out.sort_by_key(|m| stable_finding_key(&m.finding));
164        out
165    }
166}
167
168fn stable_finding_key(f: &FindingRef) -> String {
169    let loc = f
170        .path
171        .as_ref()
172        .map(|p| format!("{}:{}", p, f.line.unwrap_or(0)))
173        .unwrap_or_else(|| "no_location".to_string());
174
175    format!(
176        "{}/{}/{}|{}",
177        f.source,
178        f.check_id.clone().unwrap_or_default(),
179        f.code,
180        loc
181    )
182}