1use anyhow::Result;
2use buildfix_receipts::LoadedReceipt;
3use buildfix_types::ops::SafetyClass;
4use buildfix_types::plan::FindingRef;
5use serde::Serialize;
6
7#[derive(Debug, Clone, Serialize)]
9pub struct FixerMeta {
10 pub fix_key: &'static str,
12 pub description: &'static str,
14 pub safety: SafetyClass,
16 pub consumes_sensors: &'static [&'static str],
18 pub consumes_check_ids: &'static [&'static str],
20}
21
22pub 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#[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
52pub 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)]
69pub struct MatchedFinding {
70 pub finding: FindingRef,
72 pub data: Option<serde_json::Value>,
74 pub confidence: Option<f64>,
76 pub provenance: Option<buildfix_types::receipt::Provenance>,
78 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#[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 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}