use anyhow::{anyhow, Context};
use dialoguer::{theme::ColorfulTheme, Confirm};
use similar::TextDiff;
use assay_core::agentic::{build_suggestions, AgenticCtx, RiskLevel};
use assay_core::config::{load_config, path_resolver::PathResolver};
use assay_core::validate::{validate, ValidateOptions};
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use crate::cli::args::{FixArgs, MaxRisk};
use crate::cli::util::{decide_exit, infer_policy_path, normalize_severity};
use crate::exit_codes;
pub async fn run(args: FixArgs, legacy_mode: bool) -> anyhow::Result<i32> {
let cfg = load_config(&args.config, legacy_mode, true)
.map_err(|e| anyhow!("failed to load config {}: {}", args.config.display(), e))?;
let resolver = PathResolver::new(&args.config);
let opts = ValidateOptions {
trace_file: args.trace_file.clone(),
baseline_file: args.baseline.clone(),
replay_strict: args.replay_strict,
};
let report = validate(&cfg, &opts, &resolver).await?;
let inferred_policy = infer_policy_path(&args.config);
let (_actions, mut patches) = build_suggestions(
&report.diagnostics,
&AgenticCtx {
policy_path: inferred_policy,
config_path: Some(args.config.clone()),
},
);
if patches.is_empty() {
eprintln!("No suggested patches. Nothing to fix.");
return Ok(exit_codes::OK);
}
let only_set: BTreeSet<String> = args.only.iter().cloned().collect();
let max_risk = max_risk_to_agentic(args.max_risk);
patches.retain(|p| {
let ok_only = only_set.is_empty() || only_set.contains(&p.id);
let ok_risk = p.risk <= max_risk;
ok_only && ok_risk
});
if patches.is_empty() {
eprintln!("No patches match the provided filters (--only/--max-risk).");
return Ok(exit_codes::OK);
}
if args.list {
let mut ps = patches;
ps.sort_by(|a, b| a.id.cmp(&b.id));
for p in ps {
println!("{}\t{:?}\t{}\t{}", p.id, p.risk, p.file, p.title);
}
return Ok(exit_codes::OK);
}
let mut by_file: BTreeMap<String, Vec<assay_core::agentic::SuggestedPatch>> = BTreeMap::new();
for p in patches {
by_file.entry(p.file.clone()).or_default().push(p);
}
for (_file, ps) in by_file.iter_mut() {
ps.sort_by(|a, b| a.id.cmp(&b.id));
}
let theme = ColorfulTheme::default();
let mut applied: Vec<String> = Vec::new();
let mut failed: Vec<(String, String)> = Vec::new();
for (file, ps) in by_file {
let path = PathBuf::from(&file);
for p in ps {
let prompt = format!(
"Apply patch '{}' (id: {}, risk: {:?}) to {}?",
p.title, p.id, p.risk, file
);
let do_apply = if args.yes {
true
} else {
Confirm::with_theme(&theme)
.with_prompt(prompt)
.default(false)
.interact()
.unwrap_or(false)
};
if !do_apply {
continue;
}
if args.dry_run {
let input = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
let is_json = path
.extension()
.and_then(|s| s.to_str())
.map(|s| s.eq_ignore_ascii_case("json"))
.unwrap_or(false);
let out = assay_core::fix::apply_ops_to_text(&input, &p.ops, is_json)
.with_context(|| format!("failed to apply patch {} in memory", p.id))?;
print_unified_diff(&file, &p.id, &input, &out);
applied.push(p.id);
continue;
}
match assay_core::fix::apply_ops_to_file(&path, &p.ops) {
Ok(_) => {
eprintln!("Applied: {} -> {}", p.id, file);
applied.push(p.id);
}
Err(e) => {
eprintln!("Failed: {} -> {} ({})", p.id, file, e);
failed.push((p.id, e.to_string()));
}
}
}
}
if !failed.is_empty() {
return Ok(exit_codes::CONFIG_ERROR);
}
if applied.is_empty() {
eprintln!("No patches applied.");
return Ok(exit_codes::OK);
}
if args.dry_run {
return Ok(exit_codes::OK);
}
let cfg2 = load_config(&args.config, legacy_mode, true).map_err(|e| {
anyhow!(
"after fixes, failed to load config {}: {}",
args.config.display(),
e
)
})?;
let report2 = validate(&cfg2, &opts, &resolver).await?;
let exit2 = decide_exit(&report2.diagnostics);
let error_count = report2
.diagnostics
.iter()
.filter(|d| normalize_severity(d.severity.as_str()) == "error")
.count();
let warn_count = report2
.diagnostics
.iter()
.filter(|d| normalize_severity(d.severity.as_str()) == "warn")
.count();
eprintln!(
"Done. Applied {} patch(es). Remaining: {} error(s), {} warning(s).",
applied.len(),
error_count,
warn_count
);
Ok(exit2)
}
fn print_unified_diff(file: &str, patch_id: &str, before: &str, after: &str) {
println!("--- {} (dry-run) patch={} ---", file, patch_id);
if before == after {
println!("(no changes)");
println!("--- end ---");
return;
}
let diff = TextDiff::from_lines(before, after);
print!(
"{}",
diff.unified_diff().context_radius(3).header(file, file)
);
println!("--- end ---");
}
fn max_risk_to_agentic(r: MaxRisk) -> RiskLevel {
match r {
MaxRisk::Low => RiskLevel::Low,
MaxRisk::Medium => RiskLevel::Medium,
MaxRisk::High => RiskLevel::High,
}
}