use std::path::PathBuf;
use std::process::ExitCode;
use clap::Parser;
use coding_tools::edit::{Site, edit_content};
use coding_tools::explain::Format;
use coding_tools::pattern::{self, PatternKind};
use coding_tools::pulse::{self, HeartbeatOpts, PulseState};
use coding_tools::verdict::{Expect, Verdict};
use coding_tools::walk::{self, EntryType};
use serde_json::json;
const EXPLAIN_MD: &str = include_str!("../../docs/explain/ct-edit.md");
const EXPLAIN_JSON: &str = include_str!("../../docs/explain/ct-edit.json");
#[derive(Parser, Debug)]
#[command(
name = "ct-edit",
version,
about = "Find/replace across selected files, gated by an --expect verdict and previewable with --dry-run.",
long_about = "ct-edit applies a find/replace to the files chosen by ct-search-style predicates \
(also reachable as `ct edit`). It computes every replacement first, classifies \
the total against --expect, and writes only when the verdict is SUCCESS and \
--dry-run is not set. See `ct-edit --explain` for agent-oriented documentation."
)]
struct Cli {
#[arg(long, default_value = ".")]
base: PathBuf,
#[arg(long)]
name: Option<String>,
#[arg(long)]
hidden: bool,
#[arg(long)]
follow: bool,
#[arg(long)]
find: String,
#[arg(long)]
replace: String,
#[arg(long)]
expect: Option<String>,
#[arg(long)]
dry_run: bool,
#[arg(long)]
quiet: bool,
#[arg(long)]
json: bool,
#[arg(long, value_name = "SECS")]
timeout: Option<f64>,
#[command(flatten)]
heartbeat: HeartbeatOpts,
#[arg(long, value_enum, num_args = 0..=1, default_missing_value = "md")]
explain: Option<Format>,
}
fn run(cli: Cli) -> Result<ExitCode, String> {
let watchdog = pulse::watchdog("ct-edit", cli.timeout)?;
let _pulse = cli.heartbeat.start("ct-edit", PulseState::new())?;
let re = pattern::compile(&cli.find).map_err(|e| format!("invalid --find pattern: {e}"))?;
let literal = !matches!(pattern::classify(&cli.find), PatternKind::Regex);
let names = match &cli.name {
Some(spec) => Some(
pattern::compile_name_set(spec).map_err(|e| format!("invalid --name pattern: {e}"))?,
),
None => None,
};
let expect = match &cli.expect {
Some(s) => Expect::parse(s).map_err(|e| format!("invalid --expect: {e}"))?,
None => Expect::default(),
};
let selector = walk::Selector {
base: cli.base.clone(),
names,
types: vec![EntryType::F],
size: None,
hidden: cli.hidden,
follow: cli.follow,
};
let mut replacements = 0usize;
let mut sites: Vec<Site> = Vec::new();
let mut changed: Vec<(PathBuf, String)> = Vec::new();
for entry in selector.walk() {
let entry = entry?;
if !entry.file_type().is_file() {
continue;
}
let content = match std::fs::read_to_string(entry.path()) {
Ok(c) => c,
Err(_) => continue,
};
let path = entry.path().display().to_string();
let (new_content, hits, file_sites) =
edit_content(&path, &content, &re, &cli.replace, literal);
replacements += hits;
if new_content != content {
changed.push((entry.path().to_path_buf(), new_content));
sites.extend(file_sites);
}
}
let verdict = expect.eval(replacements as u64);
if let Some(w) = &watchdog {
w.disarm();
}
let applied = verdict == Verdict::Success && !cli.dry_run;
if applied {
for (path, content) in &changed {
std::fs::write(path, content)
.map_err(|e| format!("writing {}: {e}", path.display()))?;
}
}
if cli.json {
let site_objs: Vec<_> = sites
.iter()
.map(
|s| json!({ "path": s.path, "line": s.line, "before": s.before, "after": s.after }),
)
.collect();
let obj = json!({
"tool": "ct-edit",
"verdict": verdict.label(),
"dry_run": cli.dry_run,
"applied": applied,
"replacements": replacements,
"files_changed": changed.len(),
"sites": site_objs,
});
println!("{obj}");
} else {
if !cli.quiet {
for s in &sites {
println!("{}:{}:- {}", s.path, s.line, s.before);
println!("{}:{}:+ {}", s.path, s.line, s.after);
}
}
let status = if applied {
"applied"
} else if cli.dry_run {
"dry-run, not written"
} else {
"verdict ERROR, not written"
};
println!(
"{} replacement(s) in {} file(s) -> {} ({status})",
replacements,
changed.len(),
verdict.label(),
);
}
Ok(verdict.exit_code())
}
fn main() -> ExitCode {
let cli = Cli::parse();
if let Some(fmt) = cli.explain {
let body = match fmt {
Format::Md => EXPLAIN_MD,
Format::Json => EXPLAIN_JSON,
};
print!("{body}");
return ExitCode::SUCCESS;
}
match run(cli) {
Ok(code) => code,
Err(msg) => {
eprintln!("ct-edit: {msg}");
ExitCode::from(2)
}
}
}