Skip to main content

buildfix_core/
pipeline.rs

1//! Core plan and apply pipelines, extracted from the CLI.
2//!
3//! These entry points are I/O-agnostic: all filesystem and git operations
4//! are performed through the port traits.
5
6use 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/// Error type for pipeline results.  Exit code 2 = policy block, 1 = tool error.
38#[derive(Debug, thiserror::Error)]
39pub enum ToolError {
40    #[error("policy block")]
41    PolicyBlock,
42    #[error("{0:#}")]
43    Internal(#[from] anyhow::Error),
44}
45
46/// Outcome of `run_plan`.
47pub struct PlanOutcome {
48    pub plan: BuildfixPlan,
49    pub report: BuildfixReport,
50    pub patch: String,
51    pub policy_block: bool,
52}
53
54/// Run the plan pipeline. Returns the plan, report, and patch.
55///
56/// The caller is responsible for writing artifacts to disk (via `WritePort`)
57/// or the convenience `write_plan_artifacts` helper.
58pub 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    // Attach preconditions.
91    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    // Populate repo info from git.
102    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    // Preview patch (all unblocked ops, guarded/unsafe included).
114    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    // Update patch_bytes and enforce max_patch_bytes cap.
127    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/// Write all plan artifacts to the output directory.
159#[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
184/// Outcome of `run_apply`.
185pub struct ApplyOutcome {
186    pub apply: BuildfixApply,
187    pub report: BuildfixReport,
188    pub patch: String,
189    pub policy_block: bool,
190}
191
192/// Run the apply pipeline. Returns the apply result, report, and patch.
193pub 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    // Block apply on dirty working tree unless explicitly allowed.
228    if !settings.dry_run && !settings.allow_dirty && dirty_before == Some(true) {
229        policy_block_dirty = true;
230    }
231
232    // Auto-commit is maintainer-only and requires a known-clean git tree.
233    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    // Populate plan_ref and repo info.
273    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/// Write all apply artifacts to the output directory.
351#[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        // BTreeSet sorts: "denylist" < "missing_params"
1034        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}