1use crate::ports::{GitPort, ReceiptSource, WritePort};
7use crate::settings::{ApplySettings, PlanSettings};
8use anyhow::Context;
9use buildfix_artifacts::{
10 ArtifactWriter, write_apply_artifacts as write_apply_artifacts_io,
11 write_plan_artifacts as write_plan_artifacts_io,
12};
13use buildfix_domain::{FsRepoView, PlanContext, Planner, PlannerConfig};
14use buildfix_edit::{
15 ApplyOptions, AttachPreconditionsOptions, apply_plan, attach_preconditions, preview_patch,
16};
17use buildfix_hash::sha256_hex;
18use buildfix_receipts::LoadedReceipt;
19#[cfg(feature = "reporting")]
20use buildfix_report::{build_apply_report, build_plan_report};
21use buildfix_types::apply::{AutoCommitInfo, BuildfixApply};
22use buildfix_types::plan::BuildfixPlan;
23use buildfix_types::receipt::ToolInfo;
24use buildfix_types::report::BuildfixReport;
25#[cfg(not(feature = "reporting"))]
26use buildfix_types::report::{
27 InputFailure, ReportArtifacts, ReportCapabilities, ReportCounts, ReportFinding, ReportRunInfo,
28 ReportSeverity, ReportStatus, ReportToolInfo, ReportVerdict,
29};
30use buildfix_types::wire::PlanV1;
31#[cfg(not(feature = "reporting"))]
32use chrono::Utc;
33#[cfg(not(feature = "reporting"))]
34use std::collections::BTreeSet;
35use tracing::debug;
36
37#[derive(Debug, thiserror::Error)]
39pub enum ToolError {
40 #[error("policy block")]
41 PolicyBlock,
42 #[error("{0:#}")]
43 Internal(#[from] anyhow::Error),
44}
45
46pub struct PlanOutcome {
48 pub plan: BuildfixPlan,
49 pub report: BuildfixReport,
50 pub patch: String,
51 pub policy_block: bool,
52}
53
54pub fn run_plan(
59 settings: &PlanSettings,
60 receipts_port: &dyn ReceiptSource,
61 git: &dyn GitPort,
62 tool: ToolInfo,
63) -> Result<PlanOutcome, ToolError> {
64 let planner_cfg = PlannerConfig {
65 allow: settings.allow.clone(),
66 deny: settings.deny.clone(),
67 allow_guarded: settings.allow_guarded,
68 allow_unsafe: settings.allow_unsafe,
69 allow_dirty: settings.allow_dirty,
70 max_ops: settings.max_ops,
71 max_files: settings.max_files,
72 max_patch_bytes: settings.max_patch_bytes,
73 params: settings.params.clone(),
74 };
75
76 let receipts = receipts_port.load_receipts()?;
77
78 let planner = Planner::new();
79 let ctx = PlanContext {
80 repo_root: settings.repo_root.clone(),
81 artifacts_dir: settings.artifacts_dir.clone(),
82 config: planner_cfg.clone(),
83 };
84 let repo = FsRepoView::new(settings.repo_root.clone());
85
86 let mut plan = planner
87 .plan(&ctx, &repo, &receipts, tool.clone())
88 .context("generate plan")?;
89
90 if settings.require_clean_hashes {
92 let attach_opts = AttachPreconditionsOptions {
93 include_git_head: settings.git_head_precondition,
94 };
95 attach_preconditions(&settings.repo_root, &mut plan, &attach_opts)
96 .context("attach preconditions")?;
97 } else {
98 plan.preconditions.files.clear();
99 }
100
101 if let Ok(Some(sha)) = git.head_sha(&settings.repo_root) {
103 plan.repo.head_sha = Some(sha.clone());
104 if settings.git_head_precondition {
105 plan.preconditions.head_sha = Some(sha);
106 }
107 }
108 if let Ok(Some(dirty)) = git.is_dirty(&settings.repo_root) {
109 plan.repo.dirty = Some(dirty);
110 plan.preconditions.dirty = Some(dirty);
111 }
112
113 let preview_opts = ApplyOptions {
115 dry_run: true,
116 allow_guarded: true,
117 allow_unsafe: true,
118 backup_enabled: false,
119 backup_dir: None,
120 backup_suffix: settings.backup_suffix.clone(),
121 params: settings.params.clone(),
122 };
123 let mut patch =
124 preview_patch(&settings.repo_root, &plan, &preview_opts).context("preview patch")?;
125
126 let patch_bytes = patch.len() as u64;
128 plan.summary.patch_bytes = Some(patch_bytes);
129
130 if let Some(max_bytes) = planner_cfg.max_patch_bytes
131 && patch_bytes > max_bytes
132 {
133 for op in plan.ops.iter_mut() {
134 op.blocked = true;
135 op.blocked_reason = Some(format!(
136 "caps exceeded: max_patch_bytes {} > {} allowed",
137 patch_bytes, max_bytes
138 ));
139 op.blocked_reason_token =
140 Some(buildfix_types::plan::blocked_tokens::MAX_PATCH_BYTES.to_string());
141 }
142 plan.summary.ops_blocked = plan.ops.len() as u64;
143 plan.summary.patch_bytes = Some(0);
144 patch.clear();
145 }
146
147 let report = report_from_plan(&plan, tool, &receipts);
148 let policy_block = plan.ops.iter().any(|o| o.blocked);
149
150 Ok(PlanOutcome {
151 plan,
152 report,
153 patch,
154 policy_block,
155 })
156}
157
158#[cfg(feature = "artifact-writer")]
160pub fn write_plan_artifacts(
161 outcome: &PlanOutcome,
162 out_dir: &camino::Utf8Path,
163 writer: &dyn WritePort,
164) -> anyhow::Result<()> {
165 let adapter = CoreArtifactWriter { writer };
166 write_plan_artifacts_io(
167 &outcome.plan,
168 &outcome.report,
169 &outcome.patch,
170 out_dir,
171 &adapter,
172 )
173}
174
175#[cfg(not(feature = "artifact-writer"))]
176pub fn write_plan_artifacts(
177 _outcome: &PlanOutcome,
178 _out_dir: &camino::Utf8Path,
179 _writer: &dyn WritePort,
180) -> anyhow::Result<()> {
181 anyhow::bail!("artifact-writer feature is disabled for buildfix-core")
182}
183
184pub struct ApplyOutcome {
186 pub apply: BuildfixApply,
187 pub report: BuildfixReport,
188 pub patch: String,
189 pub policy_block: bool,
190}
191
192pub fn run_apply(
194 settings: &ApplySettings,
195 git: &dyn GitPort,
196 tool: ToolInfo,
197) -> Result<ApplyOutcome, ToolError> {
198 let plan_path = settings.out_dir.join("plan.json");
199 let plan_str =
200 std::fs::read_to_string(&plan_path).with_context(|| format!("read {}", plan_path))?;
201 let plan_sha = sha256_hex(plan_str.as_bytes());
202
203 let plan: BuildfixPlan = match serde_json::from_str::<PlanV1>(&plan_str) {
204 Ok(wire) => BuildfixPlan::from(wire),
205 Err(err) => {
206 debug!("plan.json is not wire format: {}", err);
207 serde_json::from_str(&plan_str).context("parse plan.json")?
208 }
209 };
210
211 let head_before = git.head_sha(&settings.repo_root).ok().flatten();
212 let dirty_before = git.is_dirty(&settings.repo_root).ok().flatten();
213
214 let opts = ApplyOptions {
215 dry_run: settings.dry_run,
216 allow_guarded: settings.allow_guarded,
217 allow_unsafe: settings.allow_unsafe,
218 backup_enabled: settings.backup_enabled,
219 backup_dir: Some(settings.out_dir.join("backups")),
220 backup_suffix: settings.backup_suffix.clone(),
221 params: settings.params.clone(),
222 };
223
224 let mut policy_block_dirty = false;
225 let mut dirty_block_message = "dirty working tree".to_string();
226
227 if !settings.dry_run && !settings.allow_dirty && dirty_before == Some(true) {
229 policy_block_dirty = true;
230 }
231
232 if settings.auto_commit && !settings.dry_run && dirty_before != Some(false) {
234 policy_block_dirty = true;
235 dirty_block_message = "auto-commit requires clean git working tree".to_string();
236 }
237
238 let (mut apply, patch) = if policy_block_dirty {
239 let mut apply = empty_apply_from_plan(&plan, &settings.repo_root, tool.clone(), &plan_path);
240 let dirty_actual = match dirty_before {
241 Some(true) => "dirty".to_string(),
242 Some(false) => "clean".to_string(),
243 None => "unknown".to_string(),
244 };
245 apply.preconditions.verified = false;
246 apply
247 .preconditions
248 .mismatches
249 .push(buildfix_types::apply::PreconditionMismatch {
250 path: "<working_tree>".to_string(),
251 expected: "clean".to_string(),
252 actual: dirty_actual,
253 });
254 for op in &plan.ops {
255 apply.results.push(buildfix_types::apply::ApplyResult {
256 op_id: op.id.clone(),
257 status: buildfix_types::apply::ApplyStatus::Blocked,
258 message: Some(dirty_block_message.clone()),
259 blocked_reason: Some(dirty_block_message.clone()),
260 blocked_reason_token: Some(
261 buildfix_types::plan::blocked_tokens::DIRTY_WORKING_TREE.to_string(),
262 ),
263 files: vec![],
264 });
265 }
266 apply.summary.blocked = plan.ops.len() as u64;
267 (apply, String::new())
268 } else {
269 apply_plan(&settings.repo_root, &plan, tool.clone(), &opts).context("apply plan")?
270 };
271
272 apply.plan_ref = buildfix_types::apply::PlanRef {
274 path: plan_path.to_string(),
275 sha256: Some(plan_sha.clone()),
276 };
277 apply.repo = buildfix_types::apply::ApplyRepoInfo {
278 root: settings.repo_root.to_string(),
279 head_sha_before: head_before.clone(),
280 head_sha_after: head_before,
281 dirty_before,
282 dirty_after: dirty_before,
283 };
284
285 if settings.auto_commit {
286 let mut auto_commit = AutoCommitInfo {
287 enabled: true,
288 attempted: false,
289 committed: false,
290 commit_sha: None,
291 message: settings.commit_message.clone(),
292 skip_reason: None,
293 };
294
295 if settings.dry_run {
296 auto_commit.skip_reason = Some("dry-run: auto-commit skipped".to_string());
297 } else if apply.summary.applied == 0 {
298 auto_commit.skip_reason = Some("no applied ops to commit".to_string());
299 } else if apply.summary.blocked > 0
300 || apply.summary.failed > 0
301 || !apply.preconditions.verified
302 {
303 auto_commit.skip_reason =
304 Some("apply not fully successful; skipping auto-commit".to_string());
305 } else {
306 auto_commit.attempted = true;
307 let message = settings
308 .commit_message
309 .clone()
310 .unwrap_or_else(|| default_auto_commit_message(&plan_path, &plan_sha, &apply));
311 auto_commit.message = Some(message.clone());
312
313 match git.commit_all(&settings.repo_root, &message) {
314 Ok(Some(commit_sha)) => {
315 auto_commit.committed = true;
316 auto_commit.commit_sha = Some(commit_sha.clone());
317 apply.repo.head_sha_after = Some(commit_sha);
318 }
319 Ok(None) => {
320 auto_commit.skip_reason = Some("no changes were committed".to_string());
321 }
322 Err(err) => {
323 return Err(ToolError::Internal(anyhow::anyhow!(
324 "auto-commit failed: {}",
325 err
326 )));
327 }
328 }
329 }
330
331 apply.auto_commit = Some(auto_commit);
332 }
333
334 apply.repo.dirty_after = git.is_dirty(&settings.repo_root).ok().flatten();
335 if apply.repo.head_sha_after.is_none() {
336 apply.repo.head_sha_after = git.head_sha(&settings.repo_root).ok().flatten();
337 }
338
339 let report = report_from_apply(&apply, tool);
340 let policy_block = buildfix_edit::check_policy_block(&apply, settings.dry_run).is_some();
341
342 Ok(ApplyOutcome {
343 apply,
344 report,
345 patch,
346 policy_block,
347 })
348}
349
350#[cfg(feature = "artifact-writer")]
352pub fn write_apply_artifacts(
353 outcome: &ApplyOutcome,
354 out_dir: &camino::Utf8Path,
355 writer: &dyn WritePort,
356) -> anyhow::Result<()> {
357 let adapter = CoreArtifactWriter { writer };
358 write_apply_artifacts_io(
359 &outcome.apply,
360 &outcome.report,
361 &outcome.patch,
362 out_dir,
363 &adapter,
364 )
365}
366
367struct CoreArtifactWriter<'a> {
368 writer: &'a dyn WritePort,
369}
370
371impl<'a> ArtifactWriter for CoreArtifactWriter<'a> {
372 fn write_file(&self, path: &camino::Utf8Path, contents: &[u8]) -> anyhow::Result<()> {
373 self.writer.write_file(path, contents)
374 }
375
376 fn create_dir_all(&self, path: &camino::Utf8Path) -> anyhow::Result<()> {
377 self.writer.create_dir_all(path)
378 }
379}
380
381#[cfg(not(feature = "artifact-writer"))]
382pub fn write_apply_artifacts(
383 _outcome: &ApplyOutcome,
384 _out_dir: &camino::Utf8Path,
385 _writer: &dyn WritePort,
386) -> anyhow::Result<()> {
387 anyhow::bail!("artifact-writer feature is disabled for buildfix-core")
388}
389
390#[cfg(feature = "reporting")]
391pub(crate) fn report_from_plan(
392 plan: &BuildfixPlan,
393 tool: ToolInfo,
394 receipts: &[LoadedReceipt],
395) -> BuildfixReport {
396 build_plan_report(plan, tool, receipts)
397}
398
399#[cfg(feature = "reporting")]
400pub(crate) fn report_from_apply(apply: &BuildfixApply, tool: ToolInfo) -> BuildfixReport {
401 build_apply_report(apply, tool)
402}
403
404#[cfg(not(feature = "reporting"))]
405pub(crate) fn report_from_plan(
406 plan: &BuildfixPlan,
407 tool: ToolInfo,
408 receipts: &[LoadedReceipt],
409) -> BuildfixReport {
410 let capabilities = build_capabilities(receipts);
411 let has_failed_inputs = !capabilities.inputs_failed.is_empty();
412
413 let status = if plan.ops.is_empty() && !has_failed_inputs {
414 ReportStatus::Pass
415 } else {
416 ReportStatus::Warn
417 };
418
419 let mut reasons = Vec::new();
420 if has_failed_inputs {
421 reasons.push("partial_inputs".to_string());
422 }
423
424 let mut findings: Vec<ReportFinding> = Vec::new();
425 for failure in &capabilities.inputs_failed {
426 findings.push(ReportFinding {
427 severity: ReportSeverity::Warn,
428 check_id: Some("inputs".to_string()),
429 code: "receipt_load_failed".to_string(),
430 message: format!(
431 "Receipt failed to load: {} ({})",
432 failure.path, failure.reason
433 ),
434 location: None,
435 fingerprint: Some(format!("inputs/receipt_load_failed/{}", failure.path)),
436 data: None,
437 });
438 }
439
440 let warn_count = plan.ops.len() as u64 + capabilities.inputs_failed.len() as u64;
441
442 BuildfixReport {
443 schema: buildfix_types::schema::SENSOR_REPORT_V1.to_string(),
444 tool: ReportToolInfo {
445 name: tool.name,
446 version: tool.version.unwrap_or_else(|| "unknown".to_string()),
447 commit: tool.commit,
448 },
449 run: ReportRunInfo {
450 started_at: Utc::now().to_rfc3339(),
451 ended_at: Some(Utc::now().to_rfc3339()),
452 duration_ms: Some(0),
453 git_head_sha: plan.repo.head_sha.clone(),
454 },
455 verdict: ReportVerdict {
456 status,
457 counts: ReportCounts {
458 info: 0,
459 warn: warn_count,
460 error: 0,
461 },
462 reasons,
463 },
464 findings,
465 capabilities: Some(capabilities),
466 artifacts: Some(ReportArtifacts {
467 plan: Some("plan.json".to_string()),
468 apply: None,
469 patch: Some("patch.diff".to_string()),
470 comment: Some("comment.md".to_string()),
471 }),
472 data: Some({
473 let ops_applicable = plan
474 .summary
475 .ops_total
476 .saturating_sub(plan.summary.ops_blocked);
477 let fix_available = ops_applicable > 0;
478 let mut plan_data = serde_json::json!({
479 "ops_total": plan.summary.ops_total,
480 "ops_blocked": plan.summary.ops_blocked,
481 "ops_applicable": ops_applicable,
482 "fix_available": fix_available,
483 "files_touched": plan.summary.files_touched,
484 "patch_bytes": plan.summary.patch_bytes,
485 "plan_available": !plan.ops.is_empty(),
486 });
487 if let Some(sc) = &plan.summary.safety_counts {
488 plan_data["safety_counts"] = serde_json::json!({
489 "safe": sc.safe,
490 "guarded": sc.guarded,
491 "unsafe": sc.unsafe_count,
492 });
493 }
494 let tokens: BTreeSet<&str> = plan
495 .ops
496 .iter()
497 .filter_map(|o| o.blocked_reason_token.as_deref())
498 .collect();
499 let top: Vec<&str> = tokens.into_iter().take(5).collect();
500 if !top.is_empty() {
501 plan_data["blocked_reason_tokens_top"] = serde_json::json!(top);
502 }
503 serde_json::json!({
504 "buildfix": {
505 "plan": plan_data
506 }
507 })
508 }),
509 }
510}
511
512#[cfg(not(feature = "reporting"))]
513fn build_capabilities(receipts: &[LoadedReceipt]) -> ReportCapabilities {
514 let mut inputs_available = Vec::new();
515 let mut inputs_failed = Vec::new();
516 let mut check_ids = BTreeSet::new();
517 let mut scopes = BTreeSet::new();
518
519 for r in receipts {
520 match &r.receipt {
521 Ok(receipt) => {
522 inputs_available.push(r.path.to_string());
523 if let Some(caps) = &receipt.capabilities {
524 check_ids.extend(caps.check_ids.iter().cloned());
525 scopes.extend(caps.scopes.iter().cloned());
526 }
527 for finding in &receipt.findings {
528 if let Some(check_id) = finding.check_id.as_ref()
529 && !check_id.is_empty()
530 {
531 check_ids.insert(check_id.clone());
532 }
533 }
534 }
535 Err(e) => {
536 inputs_failed.push(InputFailure {
537 path: r.path.to_string(),
538 reason: e.to_string(),
539 });
540 }
541 }
542 }
543
544 inputs_available.sort();
545 inputs_failed.sort_by(|a, b| a.path.cmp(&b.path));
546
547 ReportCapabilities {
548 check_ids: check_ids.into_iter().collect(),
549 scopes: scopes.into_iter().collect(),
550 partial: !inputs_failed.is_empty(),
551 reason: if !inputs_failed.is_empty() {
552 Some("some receipts failed to load".to_string())
553 } else {
554 None
555 },
556 inputs_available,
557 inputs_failed,
558 }
559}
560
561#[cfg(not(feature = "reporting"))]
562pub(crate) fn report_from_apply(apply: &BuildfixApply, tool: ToolInfo) -> BuildfixReport {
563 let status = if apply.summary.failed > 0 {
564 ReportStatus::Fail
565 } else if apply.summary.blocked > 0 {
566 ReportStatus::Warn
567 } else if apply.summary.applied > 0 {
568 ReportStatus::Pass
569 } else {
570 ReportStatus::Warn
571 };
572
573 BuildfixReport {
574 schema: buildfix_types::schema::SENSOR_REPORT_V1.to_string(),
575 tool: ReportToolInfo {
576 name: tool.name,
577 version: tool.version.unwrap_or_else(|| "unknown".to_string()),
578 commit: tool.commit,
579 },
580 run: ReportRunInfo {
581 started_at: Utc::now().to_rfc3339(),
582 ended_at: Some(Utc::now().to_rfc3339()),
583 duration_ms: Some(0),
584 git_head_sha: apply.repo.head_sha_after.clone(),
585 },
586 verdict: ReportVerdict {
587 status,
588 counts: ReportCounts {
589 info: apply.summary.applied,
590 warn: apply.summary.blocked,
591 error: apply.summary.failed,
592 },
593 reasons: vec![],
594 },
595 findings: vec![],
596 capabilities: None,
597 artifacts: Some(ReportArtifacts {
598 plan: Some("plan.json".to_string()),
599 apply: Some("apply.json".to_string()),
600 patch: Some("patch.diff".to_string()),
601 comment: None,
602 }),
603 data: Some({
604 let mut apply_data = serde_json::json!({
605 "attempted": apply.summary.attempted,
606 "applied": apply.summary.applied,
607 "blocked": apply.summary.blocked,
608 "failed": apply.summary.failed,
609 "files_modified": apply.summary.files_modified,
610 "apply_performed": apply.summary.applied > 0,
611 });
612 if let Some(auto_commit) = &apply.auto_commit {
613 apply_data["auto_commit"] = serde_json::json!({
614 "enabled": auto_commit.enabled,
615 "attempted": auto_commit.attempted,
616 "committed": auto_commit.committed,
617 "commit_sha": auto_commit.commit_sha,
618 "message": auto_commit.message,
619 "skip_reason": auto_commit.skip_reason,
620 });
621 }
622
623 serde_json::json!({
624 "buildfix": {
625 "apply": apply_data
626 }
627 })
628 }),
629 }
630}
631
632fn empty_apply_from_plan(
633 _plan: &BuildfixPlan,
634 repo_root: &camino::Utf8Path,
635 tool: ToolInfo,
636 plan_path: &camino::Utf8Path,
637) -> BuildfixApply {
638 let repo_info = buildfix_types::apply::ApplyRepoInfo {
639 root: repo_root.to_string(),
640 head_sha_before: None,
641 head_sha_after: None,
642 dirty_before: None,
643 dirty_after: None,
644 };
645 let plan_ref = buildfix_types::apply::PlanRef {
646 path: plan_path.to_string(),
647 sha256: None,
648 };
649 BuildfixApply::new(tool, repo_info, plan_ref)
650}
651
652fn default_auto_commit_message(
653 plan_path: &camino::Utf8Path,
654 plan_sha: &str,
655 apply: &BuildfixApply,
656) -> String {
657 let short_sha = if plan_sha.len() >= 12 {
658 &plan_sha[..12]
659 } else {
660 plan_sha
661 };
662 format!(
663 "buildfix: apply plan {}\n\nplan={}\nops_applied={}\nfiles_modified={}",
664 short_sha, plan_path, apply.summary.applied, apply.summary.files_modified
665 )
666}
667
668#[cfg(test)]
669mod tests {
670 use super::*;
671 use crate::settings::RunMode;
672 use buildfix_receipts::{LoadedReceipt, ReceiptLoadError};
673 use buildfix_types::ops::{OpKind, OpTarget, SafetyClass};
674 use buildfix_types::plan::{
675 PlanOp, PlanPolicy, PlanSummary, Rationale, RepoInfo, SafetyCounts,
676 };
677 use buildfix_types::receipt::{
678 Finding, Location, ReceiptCapabilities, ReceiptEnvelope, RunInfo, ToolInfo, Verdict,
679 };
680 use buildfix_types::report::ReportStatus;
681 use buildfix_types::wire::PlanV1;
682 use camino::{Utf8Path, Utf8PathBuf};
683 use std::collections::HashMap;
684 use std::sync::Mutex;
685 use tempfile::TempDir;
686
687 #[derive(Default)]
688 struct StubGitPort {
689 head: Option<String>,
690 dirty: Option<bool>,
691 }
692
693 impl GitPort for StubGitPort {
694 fn head_sha(&self, _repo_root: &Utf8Path) -> anyhow::Result<Option<String>> {
695 Ok(self.head.clone())
696 }
697
698 fn is_dirty(&self, _repo_root: &Utf8Path) -> anyhow::Result<Option<bool>> {
699 Ok(self.dirty)
700 }
701 }
702
703 struct CommitGitPort {
704 head_before: Option<String>,
705 head_after: Option<String>,
706 dirty_before: Option<bool>,
707 dirty_after: Option<bool>,
708 commit_sha: Option<String>,
709 commit_calls: Mutex<u64>,
710 }
711
712 impl Default for CommitGitPort {
713 fn default() -> Self {
714 Self {
715 head_before: None,
716 head_after: None,
717 dirty_before: Some(false),
718 dirty_after: Some(false),
719 commit_sha: None,
720 commit_calls: Mutex::new(0),
721 }
722 }
723 }
724
725 impl GitPort for CommitGitPort {
726 fn head_sha(&self, _repo_root: &Utf8Path) -> anyhow::Result<Option<String>> {
727 let committed = *self.commit_calls.lock().expect("commit calls") > 0;
728 if committed {
729 Ok(self.head_after.clone())
730 } else {
731 Ok(self.head_before.clone())
732 }
733 }
734
735 fn is_dirty(&self, _repo_root: &Utf8Path) -> anyhow::Result<Option<bool>> {
736 let committed = *self.commit_calls.lock().expect("commit calls") > 0;
737 if committed {
738 Ok(self.dirty_after)
739 } else {
740 Ok(self.dirty_before)
741 }
742 }
743
744 fn commit_all(
745 &self,
746 _repo_root: &Utf8Path,
747 _message: &str,
748 ) -> anyhow::Result<Option<String>> {
749 let mut calls = self.commit_calls.lock().expect("commit calls");
750 *calls += 1;
751 Ok(self.commit_sha.clone())
752 }
753 }
754
755 #[derive(Default)]
756 struct MemWritePort {
757 files: Mutex<HashMap<String, Vec<u8>>>,
758 dirs: Mutex<Vec<String>>,
759 }
760
761 impl WritePort for MemWritePort {
762 fn write_file(&self, path: &Utf8Path, contents: &[u8]) -> anyhow::Result<()> {
763 let key = path.as_str().replace('\\', "/");
764 self.files
765 .lock()
766 .expect("lock files")
767 .insert(key, contents.to_vec());
768 Ok(())
769 }
770
771 fn create_dir_all(&self, path: &Utf8Path) -> anyhow::Result<()> {
772 let key = path.as_str().replace('\\', "/");
773 self.dirs.lock().expect("lock dirs").push(key);
774 Ok(())
775 }
776 }
777
778 struct FailingReceiptSource;
779
780 impl ReceiptSource for FailingReceiptSource {
781 fn load_receipts(&self) -> anyhow::Result<Vec<LoadedReceipt>> {
782 Err(anyhow::anyhow!("receipt load failed"))
783 }
784 }
785
786 fn tool() -> ToolInfo {
787 ToolInfo {
788 name: "buildfix".into(),
789 version: Some("0.0.0-test".into()),
790 repo: None,
791 commit: None,
792 }
793 }
794
795 fn make_plan(ops: Vec<PlanOp>, safety_counts: Option<SafetyCounts>) -> BuildfixPlan {
796 let mut plan = BuildfixPlan::new(
797 tool(),
798 RepoInfo {
799 root: ".".into(),
800 head_sha: None,
801 dirty: None,
802 },
803 PlanPolicy::default(),
804 );
805 let ops_total = ops.len() as u64;
806 let ops_blocked = ops.iter().filter(|o| o.blocked).count() as u64;
807 plan.summary = PlanSummary {
808 ops_total,
809 ops_blocked,
810 files_touched: 1,
811 patch_bytes: Some(0),
812 safety_counts,
813 };
814 plan.ops = ops;
815 plan
816 }
817
818 fn make_op(safety: SafetyClass, blocked: bool, blocked_reason: Option<&str>) -> PlanOp {
819 make_op_with_token(safety, blocked, blocked_reason, None)
820 }
821
822 fn make_op_with_token(
823 safety: SafetyClass,
824 blocked: bool,
825 blocked_reason: Option<&str>,
826 blocked_reason_token: Option<&str>,
827 ) -> PlanOp {
828 PlanOp {
829 id: "test-op".into(),
830 safety,
831 blocked,
832 blocked_reason: blocked_reason.map(|s| s.to_string()),
833 blocked_reason_token: blocked_reason_token.map(|s| s.to_string()),
834 target: OpTarget {
835 path: "Cargo.toml".into(),
836 },
837 kind: OpKind::TomlSet {
838 toml_path: vec!["workspace".into(), "resolver".into()],
839 value: serde_json::json!("2"),
840 },
841 rationale: Rationale {
842 fix_key: "test".into(),
843 description: None,
844 findings: vec![],
845 },
846 params_required: vec![],
847 preview: None,
848 }
849 }
850
851 fn create_temp_repo(manifest_contents: &str) -> (TempDir, Utf8PathBuf) {
852 let temp = TempDir::new().expect("temp dir");
853 let root = Utf8PathBuf::from_path_buf(temp.path().to_path_buf()).expect("utf8");
854 std::fs::write(root.join("Cargo.toml"), manifest_contents).expect("write manifest");
855 (temp, root)
856 }
857
858 fn resolver_receipt() -> LoadedReceipt {
859 let receipt = ReceiptEnvelope {
860 schema: "sensor.report.v1".to_string(),
861 tool: ToolInfo {
862 name: "builddiag".to_string(),
863 version: Some("1.0.0".to_string()),
864 repo: None,
865 commit: None,
866 },
867 run: RunInfo::default(),
868 verdict: Verdict::default(),
869 findings: vec![Finding {
870 severity: Default::default(),
871 check_id: Some("workspace.resolver_v2".to_string()),
872 code: Some("RESOLVER".to_string()),
873 message: None,
874 location: Some(Location {
875 path: Utf8PathBuf::from("Cargo.toml"),
876 line: Some(1),
877 column: None,
878 }),
879 fingerprint: None,
880 data: None,
881 }],
882 capabilities: None,
883 data: None,
884 };
885
886 LoadedReceipt {
887 path: Utf8PathBuf::from("artifacts/builddiag/report.json"),
888 sensor_id: "builddiag".to_string(),
889 receipt: Ok(receipt),
890 }
891 }
892
893 fn build_plan_settings(root: &Utf8Path) -> PlanSettings {
894 PlanSettings {
895 repo_root: root.to_path_buf(),
896 artifacts_dir: root.join("artifacts"),
897 out_dir: root.join("artifacts/buildfix"),
898 allow: Vec::new(),
899 deny: Vec::new(),
900 allow_guarded: false,
901 allow_unsafe: false,
902 allow_dirty: false,
903 max_ops: None,
904 max_files: None,
905 max_patch_bytes: None,
906 params: HashMap::new(),
907 require_clean_hashes: true,
908 git_head_precondition: false,
909 backup_suffix: ".buildfix.bak".to_string(),
910 mode: RunMode::Standalone,
911 }
912 }
913
914 fn make_apply_settings(root: &Utf8Path, out_dir: &Utf8Path) -> ApplySettings {
915 ApplySettings {
916 repo_root: root.to_path_buf(),
917 out_dir: out_dir.to_path_buf(),
918 dry_run: true,
919 allow_guarded: false,
920 allow_unsafe: false,
921 allow_dirty: false,
922 params: HashMap::new(),
923 auto_commit: false,
924 commit_message: None,
925 backup_enabled: false,
926 backup_suffix: ".buildfix.bak".to_string(),
927 mode: RunMode::Standalone,
928 }
929 }
930
931 #[test]
932 fn report_plan_data_contains_plan_available() {
933 let plan = make_plan(
934 vec![make_op(SafetyClass::Safe, false, None)],
935 Some(SafetyCounts {
936 safe: 1,
937 guarded: 0,
938 unsafe_count: 0,
939 }),
940 );
941
942 let report = report_from_plan(&plan, tool(), &[]);
943 let data = report.data.unwrap();
944 let plan_data = &data["buildfix"]["plan"];
945
946 assert_eq!(plan_data["plan_available"], serde_json::json!(true));
947 }
948
949 #[test]
950 fn report_from_plan_passes_when_no_ops_and_no_failures() {
951 let plan = make_plan(vec![], None);
952 let report = report_from_plan(&plan, tool(), &[]);
953 assert_eq!(report.verdict.status, ReportStatus::Pass);
954 assert_eq!(report.verdict.counts.warn, 0);
955 }
956
957 #[test]
958 fn report_plan_data_plan_available_false_when_empty() {
959 let plan = make_plan(vec![], None);
960
961 let report = report_from_plan(&plan, tool(), &[]);
962 let data = report.data.unwrap();
963 let plan_data = &data["buildfix"]["plan"];
964
965 assert_eq!(plan_data["plan_available"], serde_json::json!(false));
966 }
967
968 #[test]
969 fn report_from_plan_uses_unknown_version_when_missing() {
970 let plan = make_plan(vec![], None);
971 let mut t = tool();
972 t.version = None;
973 let report = report_from_plan(&plan, t, &[]);
974 assert_eq!(report.tool.version, "unknown");
975 }
976
977 #[test]
978 fn report_plan_data_contains_safety_counts() {
979 let sc = SafetyCounts {
980 safe: 2,
981 guarded: 1,
982 unsafe_count: 0,
983 };
984 let plan = make_plan(
985 vec![
986 make_op(SafetyClass::Safe, false, None),
987 make_op(SafetyClass::Safe, false, None),
988 make_op(SafetyClass::Guarded, false, None),
989 ],
990 Some(sc),
991 );
992
993 let report = report_from_plan(&plan, tool(), &[]);
994 let data = report.data.unwrap();
995 let plan_data = &data["buildfix"]["plan"];
996
997 let sc_data = &plan_data["safety_counts"];
998 assert_eq!(sc_data["safe"], serde_json::json!(2));
999 assert_eq!(sc_data["guarded"], serde_json::json!(1));
1000 assert_eq!(sc_data["unsafe"], serde_json::json!(0));
1001 }
1002
1003 #[test]
1004 fn report_plan_data_contains_blocked_reason_tokens_top() {
1005 let plan = make_plan(
1006 vec![
1007 make_op_with_token(
1008 SafetyClass::Safe,
1009 true,
1010 Some("denied by policy"),
1011 Some("denylist"),
1012 ),
1013 make_op_with_token(
1014 SafetyClass::Guarded,
1015 true,
1016 Some("missing params: version"),
1017 Some("missing_params"),
1018 ),
1019 ],
1020 Some(SafetyCounts {
1021 safe: 1,
1022 guarded: 1,
1023 unsafe_count: 0,
1024 }),
1025 );
1026
1027 let report = report_from_plan(&plan, tool(), &[]);
1028 let data = report.data.unwrap();
1029 let plan_data = &data["buildfix"]["plan"];
1030
1031 let tokens = plan_data["blocked_reason_tokens_top"].as_array().unwrap();
1032 assert_eq!(tokens.len(), 2);
1033 assert_eq!(tokens[0], "denylist");
1035 assert_eq!(tokens[1], "missing_params");
1036 }
1037
1038 #[test]
1039 fn report_plan_data_no_blocked_reason_tokens_when_none_blocked() {
1040 let plan = make_plan(
1041 vec![make_op(SafetyClass::Safe, false, None)],
1042 Some(SafetyCounts {
1043 safe: 1,
1044 guarded: 0,
1045 unsafe_count: 0,
1046 }),
1047 );
1048
1049 let report = report_from_plan(&plan, tool(), &[]);
1050 let data = report.data.unwrap();
1051 let plan_data = &data["buildfix"]["plan"];
1052
1053 assert!(plan_data.get("blocked_reason_tokens_top").is_none());
1054 }
1055
1056 #[test]
1057 fn report_plan_data_contains_ops_applicable_and_fix_available() {
1058 let plan = make_plan(
1059 vec![
1060 make_op(SafetyClass::Safe, false, None),
1061 make_op_with_token(SafetyClass::Safe, true, Some("denied"), Some("denylist")),
1062 ],
1063 Some(SafetyCounts {
1064 safe: 2,
1065 guarded: 0,
1066 unsafe_count: 0,
1067 }),
1068 );
1069
1070 let report = report_from_plan(&plan, tool(), &[]);
1071 let data = report.data.unwrap();
1072 let plan_data = &data["buildfix"]["plan"];
1073
1074 assert_eq!(plan_data["ops_applicable"], serde_json::json!(1));
1075 assert_eq!(plan_data["fix_available"], serde_json::json!(true));
1076 }
1077
1078 #[test]
1079 fn report_plan_data_fix_available_false_when_all_blocked() {
1080 let plan = make_plan(
1081 vec![make_op_with_token(
1082 SafetyClass::Safe,
1083 true,
1084 Some("denied"),
1085 Some("denylist"),
1086 )],
1087 Some(SafetyCounts {
1088 safe: 1,
1089 guarded: 0,
1090 unsafe_count: 0,
1091 }),
1092 );
1093
1094 let report = report_from_plan(&plan, tool(), &[]);
1095 let data = report.data.unwrap();
1096 let plan_data = &data["buildfix"]["plan"];
1097
1098 assert_eq!(plan_data["ops_applicable"], serde_json::json!(0));
1099 assert_eq!(plan_data["fix_available"], serde_json::json!(false));
1100 }
1101
1102 #[test]
1103 fn report_apply_data_contains_apply_performed() {
1104 let mut apply = BuildfixApply::new(
1105 tool(),
1106 buildfix_types::apply::ApplyRepoInfo {
1107 root: ".".into(),
1108 head_sha_before: None,
1109 head_sha_after: None,
1110 dirty_before: None,
1111 dirty_after: None,
1112 },
1113 buildfix_types::apply::PlanRef {
1114 path: "plan.json".into(),
1115 sha256: None,
1116 },
1117 );
1118 apply.summary.applied = 3;
1119
1120 let report = report_from_apply(&apply, tool());
1121 let data = report.data.unwrap();
1122 let apply_data = &data["buildfix"]["apply"];
1123
1124 assert_eq!(apply_data["apply_performed"], serde_json::json!(true));
1125 }
1126
1127 #[test]
1128 fn report_apply_data_apply_performed_false_when_zero() {
1129 let apply = BuildfixApply::new(
1130 tool(),
1131 buildfix_types::apply::ApplyRepoInfo {
1132 root: ".".into(),
1133 head_sha_before: None,
1134 head_sha_after: None,
1135 dirty_before: None,
1136 dirty_after: None,
1137 },
1138 buildfix_types::apply::PlanRef {
1139 path: "plan.json".into(),
1140 sha256: None,
1141 },
1142 );
1143
1144 let report = report_from_apply(&apply, tool());
1145 let data = report.data.unwrap();
1146 let apply_data = &data["buildfix"]["apply"];
1147
1148 assert_eq!(apply_data["apply_performed"], serde_json::json!(false));
1149 }
1150
1151 #[test]
1152 fn report_apply_data_includes_auto_commit_when_present() {
1153 let mut apply = BuildfixApply::new(
1154 tool(),
1155 buildfix_types::apply::ApplyRepoInfo {
1156 root: ".".into(),
1157 head_sha_before: None,
1158 head_sha_after: None,
1159 dirty_before: None,
1160 dirty_after: None,
1161 },
1162 buildfix_types::apply::PlanRef {
1163 path: "plan.json".into(),
1164 sha256: None,
1165 },
1166 );
1167 apply.auto_commit = Some(buildfix_types::apply::AutoCommitInfo {
1168 enabled: true,
1169 attempted: true,
1170 committed: true,
1171 commit_sha: Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string()),
1172 message: Some("msg".to_string()),
1173 skip_reason: None,
1174 });
1175
1176 let report = report_from_apply(&apply, tool());
1177 let data = report.data.unwrap();
1178 let auto_commit = &data["buildfix"]["apply"]["auto_commit"];
1179
1180 assert_eq!(auto_commit["enabled"], serde_json::json!(true));
1181 assert_eq!(auto_commit["attempted"], serde_json::json!(true));
1182 assert_eq!(auto_commit["committed"], serde_json::json!(true));
1183 assert_eq!(
1184 auto_commit["commit_sha"],
1185 serde_json::json!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
1186 );
1187 }
1188
1189 #[test]
1190 fn report_from_plan_includes_input_failures_and_warn_status() {
1191 let plan = make_plan(vec![], None);
1192 let receipts = vec![LoadedReceipt {
1193 path: Utf8PathBuf::from("artifacts/bad/report.json"),
1194 sensor_id: "bad".to_string(),
1195 receipt: Err(ReceiptLoadError::Io {
1196 message: "missing".to_string(),
1197 }),
1198 }];
1199
1200 let report = report_from_plan(&plan, tool(), &receipts);
1201 assert_eq!(report.verdict.status, ReportStatus::Warn);
1202 assert_eq!(report.findings.len(), 1);
1203 assert!(
1204 report.findings[0]
1205 .message
1206 .contains("Receipt failed to load")
1207 );
1208 assert!(
1209 report
1210 .capabilities
1211 .as_ref()
1212 .unwrap()
1213 .inputs_failed
1214 .iter()
1215 .any(|f| f.path.contains("report.json"))
1216 );
1217 }
1218
1219 #[test]
1220 fn report_from_plan_collects_check_ids_scopes_and_sorts_inputs() {
1221 let plan = make_plan(vec![], None);
1222 let receipt_with_caps = ReceiptEnvelope {
1223 schema: "sensor.report.v1".to_string(),
1224 tool: ToolInfo {
1225 name: "builddiag".to_string(),
1226 version: Some("1.0.0".to_string()),
1227 repo: None,
1228 commit: None,
1229 },
1230 run: RunInfo::default(),
1231 verdict: Verdict::default(),
1232 findings: vec![Finding {
1233 severity: Default::default(),
1234 check_id: Some("workspace.resolver_v2".to_string()),
1235 code: None,
1236 message: None,
1237 location: None,
1238 fingerprint: None,
1239 data: None,
1240 }],
1241 capabilities: Some(ReceiptCapabilities {
1242 check_ids: vec![
1243 "z.check".to_string(),
1244 "a.check".to_string(),
1245 "workspace.resolver_v2".to_string(),
1246 ],
1247 scopes: vec!["workspace".to_string(), "crate".to_string()],
1248 partial: false,
1249 reason: None,
1250 }),
1251 data: None,
1252 };
1253 let receipt_findings_only = ReceiptEnvelope {
1254 schema: "sensor.report.v1".to_string(),
1255 tool: ToolInfo {
1256 name: "depguard".to_string(),
1257 version: Some("1.0.0".to_string()),
1258 repo: None,
1259 commit: None,
1260 },
1261 run: RunInfo::default(),
1262 verdict: Verdict::default(),
1263 findings: vec![
1264 Finding {
1265 severity: Default::default(),
1266 check_id: Some("b.check".to_string()),
1267 code: None,
1268 message: None,
1269 location: None,
1270 fingerprint: None,
1271 data: None,
1272 },
1273 Finding {
1274 severity: Default::default(),
1275 check_id: Some(String::new()),
1276 code: None,
1277 message: None,
1278 location: None,
1279 fingerprint: None,
1280 data: None,
1281 },
1282 ],
1283 capabilities: None,
1284 data: None,
1285 };
1286 let receipts = vec![
1287 LoadedReceipt {
1288 path: Utf8PathBuf::from("artifacts/z/report.json"),
1289 sensor_id: "z".to_string(),
1290 receipt: Ok(receipt_findings_only),
1291 },
1292 LoadedReceipt {
1293 path: Utf8PathBuf::from("artifacts/a/report.json"),
1294 sensor_id: "a".to_string(),
1295 receipt: Ok(receipt_with_caps),
1296 },
1297 ];
1298
1299 let report = report_from_plan(&plan, tool(), &receipts);
1300 let caps = report.capabilities.expect("capabilities");
1301
1302 assert_eq!(
1303 caps.check_ids,
1304 vec![
1305 "a.check".to_string(),
1306 "b.check".to_string(),
1307 "workspace.resolver_v2".to_string(),
1308 "z.check".to_string(),
1309 ]
1310 );
1311 assert_eq!(
1312 caps.scopes,
1313 vec!["crate".to_string(), "workspace".to_string()]
1314 );
1315 assert_eq!(
1316 caps.inputs_available,
1317 vec![
1318 "artifacts/a/report.json".to_string(),
1319 "artifacts/z/report.json".to_string(),
1320 ]
1321 );
1322 }
1323
1324 #[test]
1325 fn report_from_apply_sets_status_for_failed_and_blocked() {
1326 let mut apply = BuildfixApply::new(
1327 tool(),
1328 buildfix_types::apply::ApplyRepoInfo {
1329 root: ".".into(),
1330 head_sha_before: None,
1331 head_sha_after: None,
1332 dirty_before: None,
1333 dirty_after: None,
1334 },
1335 buildfix_types::apply::PlanRef {
1336 path: "plan.json".into(),
1337 sha256: None,
1338 },
1339 );
1340
1341 apply.summary.failed = 1;
1342 let report = report_from_apply(&apply, tool());
1343 assert_eq!(report.verdict.status, ReportStatus::Fail);
1344
1345 apply.summary.failed = 0;
1346 apply.summary.blocked = 1;
1347 let report = report_from_apply(&apply, tool());
1348 assert_eq!(report.verdict.status, ReportStatus::Warn);
1349
1350 apply.summary.blocked = 0;
1351 apply.summary.applied = 1;
1352 let report = report_from_apply(&apply, tool());
1353 assert_eq!(report.verdict.status, ReportStatus::Pass);
1354
1355 apply.summary.applied = 0;
1356 let report = report_from_apply(&apply, tool());
1357 assert_eq!(report.verdict.status, ReportStatus::Warn);
1358 }
1359
1360 #[test]
1361 fn run_plan_attaches_preconditions_and_git_info() {
1362 let (_temp, root) = create_temp_repo("[workspace]\nresolver = \"1\"\n");
1363 let receipts = crate::adapters::InMemoryReceiptSource::new(vec![resolver_receipt()]);
1364
1365 let mut settings = build_plan_settings(&root);
1366 settings.git_head_precondition = true;
1367
1368 let git = StubGitPort {
1369 head: Some("deadbeef".to_string()),
1370 dirty: Some(true),
1371 };
1372
1373 let outcome = run_plan(&settings, &receipts, &git, tool()).expect("run_plan");
1374 assert_eq!(outcome.plan.ops.len(), 1);
1375
1376 assert_eq!(outcome.plan.preconditions.files.len(), 1);
1377 let pre = &outcome.plan.preconditions.files[0];
1378 assert_eq!(pre.path, "Cargo.toml");
1379
1380 let expected_sha = sha256_hex(b"[workspace]\nresolver = \"1\"\n");
1381 assert_eq!(pre.sha256, expected_sha);
1382
1383 assert_eq!(
1384 outcome.plan.preconditions.head_sha.as_deref(),
1385 Some("deadbeef")
1386 );
1387 assert_eq!(outcome.plan.preconditions.dirty, Some(true));
1388 assert_eq!(outcome.plan.repo.head_sha.as_deref(), Some("deadbeef"));
1389 assert_eq!(outcome.plan.repo.dirty, Some(true));
1390 }
1391
1392 #[test]
1393 fn run_plan_skips_file_preconditions_when_disabled() {
1394 let (_temp, root) = create_temp_repo("[workspace]\nresolver = \"1\"\n");
1395 let receipts = crate::adapters::InMemoryReceiptSource::new(vec![resolver_receipt()]);
1396
1397 let mut settings = build_plan_settings(&root);
1398 settings.require_clean_hashes = false;
1399 settings.git_head_precondition = true;
1400
1401 let git = StubGitPort {
1402 head: Some("cafebabe".to_string()),
1403 dirty: Some(false),
1404 };
1405
1406 let outcome = run_plan(&settings, &receipts, &git, tool()).expect("run_plan");
1407 assert!(outcome.plan.preconditions.files.is_empty());
1408 assert_eq!(
1409 outcome.plan.preconditions.head_sha.as_deref(),
1410 Some("cafebabe")
1411 );
1412 assert_eq!(outcome.plan.preconditions.dirty, Some(false));
1413 }
1414
1415 #[test]
1416 fn run_plan_blocks_when_patch_cap_exceeded() {
1417 let (_temp, root) = create_temp_repo("[workspace]\nresolver = \"1\"\n");
1418 let receipts = crate::adapters::InMemoryReceiptSource::new(vec![resolver_receipt()]);
1419
1420 let mut settings = build_plan_settings(&root);
1421 settings.max_patch_bytes = Some(0);
1422
1423 let git = StubGitPort::default();
1424 let outcome = run_plan(&settings, &receipts, &git, tool()).expect("run_plan");
1425
1426 assert!(outcome.plan.ops.iter().all(|o| o.blocked));
1427 assert_eq!(
1428 outcome.plan.summary.ops_blocked,
1429 outcome.plan.ops.len() as u64
1430 );
1431 assert_eq!(outcome.plan.summary.patch_bytes, Some(0));
1432 assert!(outcome.patch.is_empty());
1433 assert!(outcome.policy_block);
1434
1435 for op in &outcome.plan.ops {
1436 assert_eq!(
1437 op.blocked_reason_token.as_deref(),
1438 Some(buildfix_types::plan::blocked_tokens::MAX_PATCH_BYTES)
1439 );
1440 }
1441 }
1442
1443 #[test]
1444 fn run_plan_propagates_receipt_load_errors() {
1445 let (_temp, root) = create_temp_repo("[workspace]\nresolver = \"1\"\n");
1446 let settings = build_plan_settings(&root);
1447 let git = StubGitPort::default();
1448
1449 let err = run_plan(&settings, &FailingReceiptSource, &git, tool())
1450 .err()
1451 .expect("run_plan");
1452 match err {
1453 ToolError::Internal(e) => {
1454 assert!(e.to_string().contains("receipt load failed"));
1455 }
1456 ToolError::PolicyBlock => panic!("expected internal error"),
1457 }
1458 }
1459
1460 #[test]
1461 fn write_plan_artifacts_writes_expected_files() {
1462 let (_temp, root) = create_temp_repo("[workspace]\nresolver = \"1\"\n");
1463 let receipts = crate::adapters::InMemoryReceiptSource::new(vec![resolver_receipt()]);
1464 let settings = build_plan_settings(&root);
1465 let git = StubGitPort::default();
1466
1467 let outcome = run_plan(&settings, &receipts, &git, tool()).expect("run_plan");
1468
1469 let writer = MemWritePort::default();
1470 let out_dir = Utf8PathBuf::from("out");
1471 write_plan_artifacts(&outcome, &out_dir, &writer).expect("write artifacts");
1472
1473 let files = writer.files.lock().expect("files");
1474 assert!(files.contains_key("out/plan.json"));
1475 assert!(files.contains_key("out/plan.md"));
1476 assert!(files.contains_key("out/comment.md"));
1477 assert!(files.contains_key("out/patch.diff"));
1478 assert!(files.contains_key("out/report.json"));
1479 assert!(files.contains_key("out/extras/buildfix.report.v1.json"));
1480
1481 let extras = files
1482 .get("out/extras/buildfix.report.v1.json")
1483 .expect("extras json");
1484 let json: serde_json::Value = serde_json::from_slice(extras).expect("parse extras");
1485 assert_eq!(json["schema"], buildfix_types::schema::BUILDFIX_REPORT_V1);
1486 assert_eq!(json["artifacts"]["comment"], "comment.md");
1487 }
1488
1489 #[test]
1490 fn run_apply_blocks_on_dirty_working_tree() {
1491 let (_temp, root) = create_temp_repo("[workspace]\nresolver = \"1\"\n");
1492 let out_dir = root.join("artifacts").join("buildfix");
1493 std::fs::create_dir_all(&out_dir).expect("out dir");
1494
1495 let plan = make_plan(vec![make_op(SafetyClass::Safe, false, None)], None);
1496 let plan_wire = PlanV1::try_from(&plan).expect("wire");
1497 let plan_json = serde_json::to_string_pretty(&plan_wire).expect("plan json");
1498 std::fs::write(out_dir.join("plan.json"), plan_json).expect("write plan");
1499
1500 let mut settings = make_apply_settings(&root, &out_dir);
1501 settings.dry_run = false;
1502
1503 let git = StubGitPort {
1504 head: Some("deadbeef".to_string()),
1505 dirty: Some(true),
1506 };
1507
1508 let outcome = run_apply(&settings, &git, tool()).expect("run_apply");
1509 assert!(outcome.policy_block);
1510 assert_eq!(outcome.apply.summary.blocked, plan.ops.len() as u64);
1511 assert!(
1512 outcome
1513 .apply
1514 .results
1515 .iter()
1516 .all(|r| r.status == buildfix_types::apply::ApplyStatus::Blocked)
1517 );
1518 assert!(!outcome.apply.preconditions.verified);
1519 assert!(
1520 outcome
1521 .apply
1522 .preconditions
1523 .mismatches
1524 .iter()
1525 .any(|m| m.path == "<working_tree>")
1526 );
1527 assert!(outcome.patch.is_empty());
1528 assert!(outcome.apply.plan_ref.sha256.as_deref().unwrap_or("").len() >= 64);
1529 }
1530
1531 #[test]
1532 fn run_apply_parses_raw_plan_json_and_runs_dry_run() {
1533 let (_temp, root) = create_temp_repo("[workspace]\nresolver = \"1\"\n");
1534 let out_dir = root.join("artifacts").join("buildfix");
1535 std::fs::create_dir_all(&out_dir).expect("out dir");
1536
1537 let tool_no_version = ToolInfo {
1538 name: "buildfix".to_string(),
1539 version: None,
1540 repo: None,
1541 commit: None,
1542 };
1543 let repo = RepoInfo {
1544 root: root.to_string(),
1545 head_sha: None,
1546 dirty: None,
1547 };
1548 let mut plan = BuildfixPlan::new(tool_no_version, repo, PlanPolicy::default());
1549 plan.ops.push(make_op(SafetyClass::Safe, false, None));
1550 plan.summary = PlanSummary {
1551 ops_total: 1,
1552 ops_blocked: 0,
1553 files_touched: 1,
1554 patch_bytes: None,
1555 safety_counts: None,
1556 };
1557 let plan_json = serde_json::to_string_pretty(&plan).expect("plan json");
1558 std::fs::write(out_dir.join("plan.json"), plan_json).expect("write plan");
1559
1560 let settings = make_apply_settings(&root, &out_dir);
1561 let git = StubGitPort::default();
1562
1563 let outcome = run_apply(&settings, &git, tool()).expect("run_apply");
1564 assert_eq!(outcome.apply.results.len(), 1);
1565 assert_eq!(
1566 outcome.apply.results[0].status,
1567 buildfix_types::apply::ApplyStatus::Skipped
1568 );
1569 assert!(!outcome.patch.is_empty());
1570 assert!(!outcome.policy_block);
1571 }
1572
1573 #[test]
1574 fn run_apply_auto_commit_updates_head_and_metadata() {
1575 let (_temp, root) = create_temp_repo("[workspace]\nresolver = \"1\"\n");
1576 let out_dir = root.join("artifacts").join("buildfix");
1577 std::fs::create_dir_all(&out_dir).expect("out dir");
1578
1579 let plan = make_plan(vec![make_op(SafetyClass::Safe, false, None)], None);
1580 let plan_wire = PlanV1::try_from(&plan).expect("wire");
1581 let plan_json = serde_json::to_string_pretty(&plan_wire).expect("plan json");
1582 std::fs::write(out_dir.join("plan.json"), plan_json).expect("write plan");
1583
1584 let mut settings = make_apply_settings(&root, &out_dir);
1585 settings.dry_run = false;
1586 settings.auto_commit = true;
1587
1588 let git = CommitGitPort {
1589 head_before: Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string()),
1590 head_after: Some("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string()),
1591 dirty_before: Some(false),
1592 dirty_after: Some(false),
1593 commit_sha: Some("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string()),
1594 commit_calls: Mutex::new(0),
1595 };
1596
1597 let outcome = run_apply(&settings, &git, tool()).expect("run_apply");
1598 assert!(!outcome.policy_block);
1599 assert_eq!(outcome.apply.summary.applied, 1);
1600 assert_eq!(
1601 outcome.apply.repo.head_sha_after.as_deref(),
1602 Some("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
1603 );
1604 assert!(outcome.apply.auto_commit.is_some());
1605 let auto_commit = outcome.apply.auto_commit.as_ref().expect("auto_commit");
1606 assert!(auto_commit.enabled);
1607 assert!(auto_commit.attempted);
1608 assert!(auto_commit.committed);
1609 assert_eq!(
1610 auto_commit.commit_sha.as_deref(),
1611 Some("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
1612 );
1613 }
1614
1615 #[test]
1616 fn run_apply_auto_commit_blocks_when_tree_is_dirty() {
1617 let (_temp, root) = create_temp_repo("[workspace]\nresolver = \"1\"\n");
1618 let out_dir = root.join("artifacts").join("buildfix");
1619 std::fs::create_dir_all(&out_dir).expect("out dir");
1620
1621 let plan = make_plan(vec![make_op(SafetyClass::Safe, false, None)], None);
1622 let plan_wire = PlanV1::try_from(&plan).expect("wire");
1623 let plan_json = serde_json::to_string_pretty(&plan_wire).expect("plan json");
1624 std::fs::write(out_dir.join("plan.json"), plan_json).expect("write plan");
1625
1626 let mut settings = make_apply_settings(&root, &out_dir);
1627 settings.dry_run = false;
1628 settings.allow_dirty = true;
1629 settings.auto_commit = true;
1630
1631 let git = StubGitPort {
1632 head: Some("deadbeef".to_string()),
1633 dirty: Some(true),
1634 };
1635
1636 let outcome = run_apply(&settings, &git, tool()).expect("run_apply");
1637 assert!(outcome.policy_block);
1638 assert_eq!(outcome.apply.summary.blocked, 1);
1639 assert!(
1640 outcome
1641 .apply
1642 .results
1643 .iter()
1644 .all(|r| r.blocked_reason.as_deref()
1645 == Some("auto-commit requires clean git working tree"))
1646 );
1647 }
1648
1649 #[test]
1650 fn write_apply_artifacts_writes_expected_files() {
1651 let (_temp, root) = create_temp_repo("[workspace]\nresolver = \"1\"\n");
1652 let out_dir = root.join("artifacts").join("buildfix");
1653 std::fs::create_dir_all(&out_dir).expect("out dir");
1654
1655 let plan = make_plan(vec![make_op(SafetyClass::Safe, false, None)], None);
1656 let plan_wire = PlanV1::try_from(&plan).expect("wire");
1657 let plan_json = serde_json::to_string_pretty(&plan_wire).expect("plan json");
1658 std::fs::write(out_dir.join("plan.json"), plan_json).expect("write plan");
1659
1660 let settings = make_apply_settings(&root, &out_dir);
1661 let git = StubGitPort::default();
1662 let outcome = run_apply(&settings, &git, tool()).expect("run_apply");
1663
1664 let writer = MemWritePort::default();
1665 let out_dir = Utf8PathBuf::from("out");
1666 write_apply_artifacts(&outcome, &out_dir, &writer).expect("write apply artifacts");
1667
1668 let files = writer.files.lock().expect("files");
1669 assert!(files.contains_key("out/apply.json"));
1670 assert!(files.contains_key("out/apply.md"));
1671 assert!(files.contains_key("out/patch.diff"));
1672 assert!(files.contains_key("out/report.json"));
1673 assert!(files.contains_key("out/extras/buildfix.report.v1.json"));
1674
1675 let extras = files
1676 .get("out/extras/buildfix.report.v1.json")
1677 .expect("extras json");
1678 let json: serde_json::Value = serde_json::from_slice(extras).expect("parse extras");
1679 assert_eq!(json["schema"], buildfix_types::schema::BUILDFIX_REPORT_V1);
1680 }
1681}