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(¶ms, &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}