Skip to main content

buildfix_domain/
planner.rs

1use crate::fixers;
2use crate::ports::RepoView;
3use anyhow::Context;
4use buildfix_domain_policy::apply_plan_policy;
5#[cfg(test)]
6use buildfix_domain_policy::{
7    apply_allow_deny, apply_params, args_fingerprint, deterministic_op_id, enforce_caps, glob_match,
8};
9#[cfg(test)]
10use buildfix_fixer_api::PlannerConfig;
11use buildfix_fixer_api::{PlanContext, ReceiptSet};
12use buildfix_receipts::LoadedReceipt;
13use buildfix_types::plan::{
14    BuildfixPlan, PlanInput, PlanOp, PlanPolicy, PlanSummary, RepoInfo, SafetyCounts,
15};
16use buildfix_types::receipt::ToolInfo;
17use std::collections::BTreeSet;
18
19pub struct Planner {
20    fixers: Vec<Box<dyn buildfix_fixer_api::Fixer>>,
21}
22
23impl Default for Planner {
24    fn default() -> Self {
25        Self::new()
26    }
27}
28
29impl Planner {
30    pub fn new() -> Self {
31        Self {
32            fixers: fixers::builtin_fixers(),
33        }
34    }
35
36    pub fn with_fixers(fixers: Vec<Box<dyn buildfix_fixer_api::Fixer>>) -> Self {
37        Self { fixers }
38    }
39
40    pub fn plan(
41        &self,
42        ctx: &PlanContext,
43        repo: &dyn RepoView,
44        receipts: &[LoadedReceipt],
45        tool: ToolInfo,
46    ) -> anyhow::Result<BuildfixPlan> {
47        let policy = PlanPolicy {
48            allow: ctx.config.allow.clone(),
49            deny: ctx.config.deny.clone(),
50            allow_guarded: ctx.config.allow_guarded,
51            allow_unsafe: ctx.config.allow_unsafe,
52            allow_dirty: ctx.config.allow_dirty,
53            max_ops: ctx.config.max_ops,
54            max_files: ctx.config.max_files,
55            max_patch_bytes: ctx.config.max_patch_bytes,
56        };
57
58        let repo_info = RepoInfo {
59            root: ctx.repo_root.to_string(),
60            head_sha: None,
61            dirty: None,
62        };
63
64        let mut plan = BuildfixPlan::new(tool, repo_info, policy);
65        plan.inputs = receipts.iter().map(to_plan_input).collect();
66
67        let receipt_set = ReceiptSet::from_loaded(receipts);
68
69        let mut ops: Vec<PlanOp> = Vec::new();
70        for fixer in &self.fixers {
71            let mut f = fixer
72                .plan(ctx, repo, &receipt_set)
73                .with_context(|| "fixer.plan")?;
74            ops.append(&mut f);
75        }
76
77        apply_plan_policy(&ctx.config, &mut ops)?;
78
79        plan.summary = summarize(&ops);
80        plan.ops = ops;
81        Ok(plan)
82    }
83}
84
85fn to_plan_input(r: &LoadedReceipt) -> PlanInput {
86    match &r.receipt {
87        Ok(env) => PlanInput {
88            path: r.path.to_string(),
89            schema: Some(env.schema.clone()),
90            tool: Some(env.tool.name.clone()),
91        },
92        Err(_) => PlanInput {
93            path: r.path.to_string(),
94            schema: None,
95            tool: None,
96        },
97    }
98}
99
100fn summarize(ops: &[PlanOp]) -> PlanSummary {
101    let ops_total = ops.len() as u64;
102    let ops_blocked = ops.iter().filter(|o| o.blocked).count() as u64;
103    let files_touched = ops
104        .iter()
105        .map(|o| o.target.path.as_str())
106        .collect::<BTreeSet<_>>()
107        .len() as u64;
108
109    let mut safe = 0u64;
110    let mut guarded = 0u64;
111    let mut unsafe_count = 0u64;
112    for op in ops {
113        match op.safety {
114            buildfix_types::ops::SafetyClass::Safe => safe += 1,
115            buildfix_types::ops::SafetyClass::Guarded => guarded += 1,
116            buildfix_types::ops::SafetyClass::Unsafe => unsafe_count += 1,
117        }
118    }
119
120    PlanSummary {
121        ops_total,
122        ops_blocked,
123        files_touched,
124        patch_bytes: None,
125        safety_counts: Some(SafetyCounts {
126            safe,
127            guarded,
128            unsafe_count,
129        }),
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use buildfix_receipts::LoadedReceipt;
137    use buildfix_types::ops::{OpKind, OpTarget, SafetyClass};
138    use buildfix_types::plan::{PlanOp, Rationale, blocked_tokens};
139    use buildfix_types::receipt::{Finding, Location, ReceiptEnvelope, RunInfo, ToolInfo, Verdict};
140    use camino::Utf8PathBuf;
141    use std::collections::HashMap;
142
143    fn make_op(fix_key: &str, target: &str, kind: OpKind) -> PlanOp {
144        PlanOp {
145            id: String::new(),
146            safety: SafetyClass::Safe,
147            blocked: false,
148            blocked_reason: None,
149            blocked_reason_token: None,
150            target: OpTarget {
151                path: target.to_string(),
152            },
153            kind,
154            rationale: Rationale {
155                fix_key: fix_key.to_string(),
156                description: None,
157                findings: vec![],
158            },
159            params_required: vec![],
160            preview: None,
161        }
162    }
163
164    #[test]
165    fn glob_match_handles_star_and_question() {
166        assert!(glob_match("a*b", "ab"));
167        assert!(glob_match("a*b", "acb"));
168        assert!(!glob_match("a?b", "ab"));
169        assert!(glob_match("a?b", "acb"));
170        assert!(glob_match("cargo.*", "cargo.workspace_resolver_v2"));
171        assert!(!glob_match("cargo.?1", "cargo.111"));
172    }
173
174    #[test]
175    fn args_fingerprint_is_order_independent() {
176        let mut map1 = serde_json::Map::new();
177        map1.insert("b".to_string(), serde_json::json!(1));
178        map1.insert("a".to_string(), serde_json::json!({"z": 2, "y": 3}));
179
180        let mut map2 = serde_json::Map::new();
181        map2.insert("a".to_string(), serde_json::json!({"y": 3, "z": 2}));
182        map2.insert("b".to_string(), serde_json::json!(1));
183
184        let fp1 = args_fingerprint(&Some(serde_json::Value::Object(map1)));
185        let fp2 = args_fingerprint(&Some(serde_json::Value::Object(map2)));
186        assert_eq!(fp1, fp2);
187    }
188
189    #[test]
190    fn deterministic_op_id_is_stable() {
191        let mut args1 = serde_json::Map::new();
192        args1.insert("b".to_string(), serde_json::json!(1));
193        args1.insert("a".to_string(), serde_json::json!(2));
194
195        let mut args2 = serde_json::Map::new();
196        args2.insert("a".to_string(), serde_json::json!(2));
197        args2.insert("b".to_string(), serde_json::json!(1));
198
199        let op1 = make_op(
200            "cargo.workspace_resolver_v2",
201            "Cargo.toml",
202            OpKind::TomlTransform {
203                rule_id: "ensure_workspace_resolver_v2".to_string(),
204                args: Some(serde_json::Value::Object(args1)),
205            },
206        );
207        let op2 = make_op(
208            "cargo.workspace_resolver_v2",
209            "Cargo.toml",
210            OpKind::TomlTransform {
211                rule_id: "ensure_workspace_resolver_v2".to_string(),
212                args: Some(serde_json::Value::Object(args2)),
213            },
214        );
215        let op3 = make_op(
216            "cargo.workspace_resolver_v2",
217            "other/Cargo.toml",
218            OpKind::TomlTransform {
219                rule_id: "ensure_workspace_resolver_v2".to_string(),
220                args: None,
221            },
222        );
223
224        assert_eq!(deterministic_op_id(&op1), deterministic_op_id(&op2));
225        assert_ne!(deterministic_op_id(&op1), deterministic_op_id(&op3));
226    }
227
228    #[test]
229    fn apply_params_fills_transform_args() {
230        let mut op = make_op(
231            "cargo.path_dep_add_version",
232            "Cargo.toml",
233            OpKind::TomlTransform {
234                rule_id: "ensure_path_dep_has_version".to_string(),
235                args: None,
236            },
237        );
238        op.params_required = vec!["version".to_string()];
239
240        let mut params = HashMap::new();
241        params.insert("version".to_string(), "1.2.3".to_string());
242
243        let mut ops = vec![op];
244        apply_params(&params, &mut ops);
245
246        assert!(ops[0].params_required.is_empty());
247        assert!(!ops[0].blocked);
248        match &ops[0].kind {
249            OpKind::TomlTransform { args: Some(v), .. } => {
250                assert_eq!(v["version"], serde_json::json!("1.2.3"));
251            }
252            _ => panic!("expected toml transform with args"),
253        }
254    }
255
256    #[test]
257    fn apply_params_blocks_when_missing() {
258        let mut op = make_op(
259            "cargo.normalize_rust_version",
260            "Cargo.toml",
261            OpKind::TomlTransform {
262                rule_id: "set_package_rust_version".to_string(),
263                args: None,
264            },
265        );
266        op.params_required = vec!["rust_version".to_string()];
267
268        let mut ops = vec![op];
269        apply_params(&HashMap::new(), &mut ops);
270
271        assert!(ops[0].blocked);
272        assert_eq!(
273            ops[0].blocked_reason_token.as_deref(),
274            Some(blocked_tokens::MISSING_PARAMS)
275        );
276    }
277
278    #[test]
279    fn apply_allow_deny_blocks_by_policy() {
280        let mut ops = vec![make_op(
281            "cargo.workspace_resolver_v2",
282            "Cargo.toml",
283            OpKind::TomlRemove {
284                toml_path: vec!["workspace".to_string()],
285            },
286        )];
287        apply_allow_deny(&[], &["cargo.*".to_string()], &mut ops);
288        assert!(ops[0].blocked);
289        assert_eq!(
290            ops[0].blocked_reason_token.as_deref(),
291            Some(blocked_tokens::DENYLIST)
292        );
293
294        let mut ops = vec![make_op(
295            "cargo.workspace_resolver_v2",
296            "Cargo.toml",
297            OpKind::TomlRemove {
298                toml_path: vec!["workspace".to_string()],
299            },
300        )];
301        apply_allow_deny(&["depguard.*".to_string()], &[], &mut ops);
302        assert!(ops[0].blocked);
303        assert_eq!(
304            ops[0].blocked_reason_token.as_deref(),
305            Some(blocked_tokens::ALLOWLIST_MISSING)
306        );
307    }
308
309    #[test]
310    fn apply_allow_deny_allows_when_allowlist_matches() {
311        let mut ops = vec![make_op(
312            "cargo.workspace_resolver_v2",
313            "Cargo.toml",
314            OpKind::TomlRemove {
315                toml_path: vec!["workspace".to_string()],
316            },
317        )];
318
319        apply_allow_deny(&["cargo.*".to_string()], &[], &mut ops);
320        assert!(!ops[0].blocked);
321        assert!(ops[0].blocked_reason.is_none());
322        assert!(ops[0].blocked_reason_token.is_none());
323    }
324
325    #[test]
326    fn apply_allow_deny_does_not_override_existing_block() {
327        let mut ops = vec![make_op(
328            "cargo.workspace_resolver_v2",
329            "Cargo.toml",
330            OpKind::TomlRemove {
331                toml_path: vec!["workspace".to_string()],
332            },
333        )];
334        ops[0].blocked = true;
335        ops[0].blocked_reason = Some("preblocked".to_string());
336        ops[0].blocked_reason_token = Some("custom_token".to_string());
337
338        apply_allow_deny(&["cargo.*".to_string()], &["cargo.*".to_string()], &mut ops);
339
340        assert!(ops[0].blocked);
341        assert_eq!(ops[0].blocked_reason.as_deref(), Some("preblocked"));
342        assert_eq!(ops[0].blocked_reason_token.as_deref(), Some("custom_token"));
343    }
344
345    #[test]
346    fn enforce_caps_blocks_all_ops() {
347        let mut ops = vec![
348            make_op(
349                "cargo.workspace_resolver_v2",
350                "Cargo.toml",
351                OpKind::TomlRemove {
352                    toml_path: vec!["workspace".to_string()],
353                },
354            ),
355            make_op(
356                "cargo.workspace_resolver_v2",
357                "other/Cargo.toml",
358                OpKind::TomlRemove {
359                    toml_path: vec!["workspace".to_string()],
360                },
361            ),
362        ];
363
364        let cfg = PlannerConfig {
365            max_ops: Some(1),
366            ..Default::default()
367        };
368        enforce_caps(&cfg, &mut ops).expect("enforce caps");
369        assert!(ops.iter().all(|op| op.blocked));
370        assert_eq!(
371            ops[0].blocked_reason_token.as_deref(),
372            Some(blocked_tokens::MAX_OPS)
373        );
374
375        let mut ops = vec![
376            make_op(
377                "cargo.workspace_resolver_v2",
378                "Cargo.toml",
379                OpKind::TomlRemove {
380                    toml_path: vec!["workspace".to_string()],
381                },
382            ),
383            make_op(
384                "cargo.workspace_resolver_v2",
385                "other/Cargo.toml",
386                OpKind::TomlRemove {
387                    toml_path: vec!["workspace".to_string()],
388                },
389            ),
390        ];
391        let cfg = PlannerConfig {
392            max_files: Some(1),
393            ..Default::default()
394        };
395        enforce_caps(&cfg, &mut ops).expect("enforce caps");
396        assert!(ops.iter().all(|op| op.blocked));
397        assert_eq!(
398            ops[0].blocked_reason_token.as_deref(),
399            Some(blocked_tokens::MAX_FILES)
400        );
401    }
402
403    #[test]
404    fn receipt_set_filters_and_sorts_findings() {
405        let receipt_a = ReceiptEnvelope {
406            schema: "sensor.report.v1".to_string(),
407            tool: ToolInfo {
408                name: "builddiag".to_string(),
409                version: None,
410                repo: None,
411                commit: None,
412            },
413            run: RunInfo::default(),
414            verdict: Verdict::default(),
415            findings: vec![Finding {
416                severity: Default::default(),
417                check_id: Some("check".to_string()),
418                code: Some("code".to_string()),
419                message: None,
420                location: Some(Location {
421                    path: Utf8PathBuf::from("b/Cargo.toml"),
422                    line: Some(2),
423                    column: None,
424                }),
425                fingerprint: None,
426                data: None,
427            }],
428            capabilities: None,
429            data: None,
430        };
431
432        let receipt_b = ReceiptEnvelope {
433            schema: "sensor.report.v1".to_string(),
434            tool: ToolInfo {
435                name: "builddiag".to_string(),
436                version: None,
437                repo: None,
438                commit: None,
439            },
440            run: RunInfo::default(),
441            verdict: Verdict::default(),
442            findings: vec![Finding {
443                severity: Default::default(),
444                check_id: Some("check".to_string()),
445                code: Some("code".to_string()),
446                message: None,
447                location: Some(Location {
448                    path: Utf8PathBuf::from("a/Cargo.toml"),
449                    line: Some(1),
450                    column: None,
451                }),
452                fingerprint: None,
453                data: None,
454            }],
455            capabilities: None,
456            data: None,
457        };
458
459        let loaded = vec![
460            LoadedReceipt {
461                path: Utf8PathBuf::from("artifacts/builddiag/report-b.json"),
462                sensor_id: "builddiag".to_string(),
463                receipt: Ok(receipt_a),
464            },
465            LoadedReceipt {
466                path: Utf8PathBuf::from("artifacts/builddiag/report-a.json"),
467                sensor_id: "builddiag".to_string(),
468                receipt: Ok(receipt_b),
469            },
470        ];
471
472        let set = ReceiptSet::from_loaded(&loaded);
473        let findings = set.matching_findings(&["builddiag"], &["check"], &["code"]);
474        assert_eq!(findings.len(), 2);
475        assert_eq!(findings[0].path.as_deref(), Some("a/Cargo.toml"));
476        assert_eq!(findings[1].path.as_deref(), Some("b/Cargo.toml"));
477    }
478
479    #[test]
480    fn receipt_set_matches_when_filters_empty() {
481        let receipt = ReceiptEnvelope {
482            schema: "sensor.report.v1".to_string(),
483            tool: ToolInfo {
484                name: "builddiag".to_string(),
485                version: None,
486                repo: None,
487                commit: None,
488            },
489            run: RunInfo::default(),
490            verdict: Verdict::default(),
491            findings: vec![Finding {
492                severity: Default::default(),
493                check_id: Some("check".to_string()),
494                code: Some("code".to_string()),
495                message: None,
496                location: Some(Location {
497                    path: Utf8PathBuf::from("Cargo.toml"),
498                    line: Some(1),
499                    column: None,
500                }),
501                fingerprint: None,
502                data: None,
503            }],
504            capabilities: None,
505            data: None,
506        };
507
508        let loaded = vec![LoadedReceipt {
509            path: Utf8PathBuf::from("artifacts/builddiag/report.json"),
510            sensor_id: "builddiag".to_string(),
511            receipt: Ok(receipt),
512        }];
513
514        let set = ReceiptSet::from_loaded(&loaded);
515
516        let all = set.matching_findings(&["builddiag"], &[], &[]);
517        assert_eq!(all.len(), 1);
518
519        let check_only = set.matching_findings(&["builddiag"], &["check"], &[]);
520        assert_eq!(check_only.len(), 1);
521
522        let code_only = set.matching_findings(&["builddiag"], &[], &["code"]);
523        assert_eq!(code_only.len(), 1);
524
525        let mismatch = set.matching_findings(&["builddiag"], &[], &["other"]);
526        assert!(mismatch.is_empty());
527    }
528}