assay-cli 3.26.0

Policy-as-code gate for MCP agent tool calls, with verifiable evidence and Linux kernel enforcement.
use std::path::{Path, PathBuf};

use assay_core::agentic::{build_suggestions, AgenticCtx, SuggestedPatch};
use assay_core::config::{load_config, path_resolver::PathResolver};
use assay_core::errors::diagnostic::{codes, Diagnostic};
use dialoguer::{theme::ColorfulTheme, Confirm};

use crate::cli::args::DoctorArgs;
use crate::cli::helpers::{infer_policy_path, normalize_severity};

use super::patching::{apply_patch_to_file, create_empty_trace, preview_patch};

#[derive(Debug, Clone)]
enum DoctorFixOp {
    Patch(SuggestedPatch),
    CreateTrace { path: PathBuf },
}

impl DoctorFixOp {
    fn id(&self) -> String {
        match self {
            DoctorFixOp::Patch(p) => p.id.clone(),
            DoctorFixOp::CreateTrace { path } => format!("create_trace:{}", path.display()),
        }
    }

    fn title(&self) -> String {
        match self {
            DoctorFixOp::Patch(p) => p.title.clone(),
            DoctorFixOp::CreateTrace { path } => {
                format!("Create missing trace file '{}'.", path.display())
            }
        }
    }
}

pub(super) async fn run_doctor_fix(
    args: &DoctorArgs,
    config_path: &Path,
    diagnostics: &[Diagnostic],
    legacy_mode: bool,
) -> anyhow::Result<i32> {
    let initial_errors = diagnostics
        .iter()
        .filter(|d| normalize_severity(&d.severity) == "error")
        .count();

    let inferred_policy = infer_policy_path(config_path);
    let (_actions, mut patches) = build_suggestions(
        diagnostics,
        &AgenticCtx {
            policy_path: inferred_policy,
            config_path: Some(config_path.to_path_buf()),
        },
    );

    patches.sort_by(|a, b| a.id.cmp(&b.id));

    let mut ops: Vec<DoctorFixOp> = patches.into_iter().map(DoctorFixOp::Patch).collect();

    if diagnostics
        .iter()
        .any(|d| d.code == codes::E_TRACE_MISS || d.code == codes::E_PATH_NOT_FOUND)
    {
        if let Some(trace_path) = trace_fix_target(args, diagnostics) {
            if !trace_path.exists() {
                ops.push(DoctorFixOp::CreateTrace { path: trace_path });
            }
        }
    }

    ops.sort_by_key(|op| op.id());

    if ops.is_empty() {
        println!("\nNo auto-fixable diagnostics found.");
        return Ok(if initial_errors == 0 { 0 } else { 1 });
    }

    println!("\nAuto-fix candidates:");
    for op in &ops {
        println!("  - {}", op.title());
    }

    let theme = ColorfulTheme::default();
    let mut applied = 0usize;
    let mut failed = 0usize;

    for op in &ops {
        let should_apply = if args.yes || args.dry_run {
            true
        } else {
            Confirm::with_theme(&theme)
                .with_prompt(format!("Apply fix '{}'?", op.title()))
                .default(false)
                .interact()
                .unwrap_or(false)
        };

        if !should_apply {
            continue;
        }

        match op {
            DoctorFixOp::Patch(patch) => {
                if args.dry_run {
                    preview_patch(patch)?;
                    applied += 1;
                    continue;
                }

                match apply_patch_to_file(patch) {
                    Ok(_) => {
                        eprintln!("Applied: {}", patch.id);
                        applied += 1;
                    }
                    Err(err) => {
                        eprintln!("Failed: {} ({})", patch.id, err);
                        failed += 1;
                    }
                }
            }
            DoctorFixOp::CreateTrace { path } => {
                if args.dry_run {
                    println!("[dry-run] would create trace file: {}", path.display());
                    applied += 1;
                    continue;
                }

                match create_empty_trace(path) {
                    Ok(_) => {
                        eprintln!("Applied: created trace file {}", path.display());
                        applied += 1;
                    }
                    Err(err) => {
                        eprintln!("Failed: could not create {} ({})", path.display(), err);
                        failed += 1;
                    }
                }
            }
        }
    }

    if failed > 0 {
        return Ok(1);
    }

    if applied == 0 {
        println!("No fixes applied.");
        return Ok(if initial_errors == 0 { 0 } else { 1 });
    }

    if args.dry_run {
        println!("\nDry run complete. {} fix(es) previewed.", applied);
        return Ok(if initial_errors == 0 { 0 } else { 1 });
    }

    let cfg = match load_config(config_path, legacy_mode, false) {
        Ok(c) => c,
        Err(err) => {
            eprintln!("Re-validation skipped: config still invalid ({})", err);
            return Ok(1);
        }
    };

    let resolver = PathResolver::new(config_path);
    let opts = assay_core::doctor::DoctorOptions {
        config_path: config_path.to_path_buf(),
        trace_file: args.trace_file.clone(),
        baseline_file: args.baseline.clone(),
        db_path: args.db.clone(),
        replay_strict: args.replay_strict,
    };

    let report = assay_core::doctor::doctor(&cfg, &opts, &resolver).await?;
    let remaining_errors = report
        .diagnostics
        .iter()
        .filter(|d| normalize_severity(&d.severity) == "error")
        .count();

    println!(
        "\nApplied {} fix(es). Remaining: {} error(s).",
        applied, remaining_errors
    );

    Ok(if remaining_errors == 0 { 0 } else { 1 })
}

fn trace_fix_target(args: &DoctorArgs, diagnostics: &[Diagnostic]) -> Option<PathBuf> {
    if let Some(p) = &args.trace_file {
        return Some(p.clone());
    }

    for d in diagnostics {
        if d.code != codes::E_TRACE_MISS && d.code != codes::E_PATH_NOT_FOUND {
            continue;
        }

        if let Some(path) = d.context.get("trace_file").and_then(|v| v.as_str()) {
            if !path.trim().is_empty() {
                return Some(PathBuf::from(path));
            }
        }

        if let Some(path) = d.context.get("path").and_then(|v| v.as_str()) {
            if !path.trim().is_empty() {
                return Some(PathBuf::from(path));
            }
        }
    }

    None
}