coding-tools 0.3.0

Declarative, agent-friendly CLI tools behind one 'ct' command: search, view, verifiable edits, and framed command tests.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Jonathan Shook

//! `ct-edit` — declarative, verifiable text edits.
//!
//! A find/replace that *asserts its own effect*: it targets files with the same
//! predicates as `ct-search`, computes every replacement first, classifies the
//! total against an `--expect`ation into a `SUCCESS`/`ERROR` verdict, and only
//! writes when the verdict holds (never under `--dry-run`). `--find`/`--replace`
//! accept `file:PATH` / `text:VALUE` payloads; a multi-line find matches as a
//! line-anchored literal block. `--script` runs a `.ctb` batch of edits under
//! the prepare/confirm/write standard: the whole script is simulated in memory
//! and judged first, and no file changes unless every edit passes. Reachable
//! directly or as `ct edit`. The canonical reference is `docs/explain/ct-edit.md`
//! — the text this tool emits for `--explain md`; `docs/explain/ct-edit.json` is
//! the MCP tool-use definition emitted for `--explain json`. Both are embedded
//! below.

use std::path::PathBuf;
use std::process::ExitCode;

use clap::Parser;
use coding_tools::edit::Site;
use coding_tools::editscript::{self, EditOutcome, FileBuf, Op};
use coding_tools::explain::Format;
use coding_tools::pulse::{self, HeartbeatOpts, PulseState, Watchdog};
use coding_tools::verdict::{Expect, Verdict};
use coding_tools::walk::{self, EntryType};
use coding_tools::{blockdoc, pattern, payload};
use serde_json::json;

/// Agent documentation, embedded from the canonical `docs/explain` payloads.
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. --find/--replace accept file:PATH / text:VALUE payloads; \
                  a multi-line find matches as a literal block. --script runs a .ctb batch \
                  atomically: everything is verified in memory before anything is written. \
                  See `ct-edit --explain` for agent-oriented documentation."
)]
struct Cli {
    /// Search root (relative or absolute); a file edits just that file, a directory is descended.
    #[arg(long, default_value = ".")]
    base: PathBuf,

    /// Limit to files whose name matches; '|'-separated alternatives, each substring->glob->regex promoted and anchored.
    #[arg(long)]
    name: Option<String>,

    /// Include dot-entries (names starting with '.'); default skips them.
    #[arg(long)]
    hidden: bool,

    /// Follow symlinks while traversing.
    #[arg(long)]
    follow: bool,

    /// Pattern to find (substring->glob->regex promoted); matched per line. Accepts file:PATH / text:VALUE; a multi-line payload matches as a line-anchored literal block. Required unless --script is given.
    #[arg(long, conflicts_with = "script")]
    find: Option<String>,

    /// Replacement text. With a regex --find, $1/${name} expand; otherwise literal. Accepts file:PATH / text:VALUE; for a block --find, an empty payload deletes the matched lines. Required unless --script is given.
    #[arg(long, conflicts_with = "script")]
    replace: Option<String>,

    /// Pin how --find is interpreted (promotion off): literal, glob, or regex.
    #[arg(long, value_enum, conflicts_with = "script")]
    mode: Option<pattern::Mode>,

    /// Run a .ctb edit script: a batch of find/replace blocks verified in full before any write (see --explain).
    #[arg(long, value_name = "PATH")]
    script: Option<PathBuf>,

    /// Fence string opening script directive lines (for payloads that contain the default at line start).
    #[arg(long, default_value = blockdoc::DEFAULT_FENCE, requires = "script")]
    fence: String,

    /// Script edits match pristine content instead of cascading; overlapping edits become a usage error.
    #[arg(long, requires = "script")]
    no_cascade: bool,

    /// Verdict expectation over the total replacement count: any|none|N|=N|+N|-N (default: any). In scripts, per-edit expect= defaults to =1.
    #[arg(long, conflicts_with = "script")]
    expect: Option<String>,

    /// Show what would change and the verdict, but write nothing.
    #[arg(long)]
    dry_run: bool,

    /// Suppress the per-site diff; print only the summary line.
    #[arg(long)]
    quiet: bool,

    /// Emit a structured JSON result instead of text.
    #[arg(long)]
    json: bool,

    /// Abort with exit 2 if the scan exceeds SECS seconds (fractional allowed). Never interrupts the write phase: once a SUCCESS verdict starts writing, every write completes.
    #[arg(long, value_name = "SECS")]
    timeout: Option<f64>,

    #[command(flatten)]
    heartbeat: HeartbeatOpts,

    /// Print agent usage docs (md or json) and exit.
    #[arg(long, value_enum, num_args = 0..=1, default_missing_value = "md")]
    explain: Option<Format>,
}

/// Build the file selector shared by both forms.
fn selector(cli: &Cli) -> Result<walk::Selector, String> {
    let names = match &cli.name {
        Some(spec) => Some(
            pattern::compile_name_set_with(spec, cli.mode)
                .map_err(|e| format!("invalid --name pattern: {e}"))?,
        ),
        None => None,
    };
    Ok(walk::Selector {
        base: cli.base.clone(),
        names,
        types: vec![EntryType::F],
        size: None,
        hidden: cli.hidden,
        follow: cli.follow,
    })
}

/// Read every selected UTF-8 file into memory. Files that do not read as
/// UTF-8 text (e.g. binaries) are left out, hence untouched.
fn load_files(sel: &walk::Selector) -> Result<Vec<FileBuf>, String> {
    let mut files = Vec::new();
    for entry in sel.walk() {
        let entry = entry?;
        if !entry.file_type().is_file() {
            continue;
        }
        if let Ok(content) = std::fs::read_to_string(entry.path()) {
            files.push(FileBuf {
                path: entry.path().display().to_string(),
                content,
            });
        }
    }
    Ok(files)
}

/// Confirm every changed file can be written before any write begins — the
/// pre-flight half of the prepare/confirm/write standard.
fn preflight(paths: &[&str]) -> Result<(), String> {
    for p in paths {
        let meta = std::fs::metadata(p)
            .map_err(|e| format!("write pre-flight: {p}: {e}; nothing written"))?;
        if meta.permissions().readonly() {
            return Err(format!(
                "write pre-flight: {p} is not writable; nothing written"
            ));
        }
    }
    Ok(())
}

/// Write every changed buffer. The watchdog is disarmed first: a write phase,
/// once begun, always completes.
fn write_changed(
    watchdog: &Option<Watchdog>,
    files: &[FileBuf],
    changed: &[bool],
) -> Result<(), String> {
    if let Some(w) = watchdog {
        w.disarm();
    }
    for (f, ch) in files.iter().zip(changed) {
        if *ch {
            std::fs::write(&f.path, &f.content).map_err(|e| format!("writing {}: {e}", f.path))?;
        }
    }
    Ok(())
}

/// Print sites as per-line diff rows, multi-line aware; `tag` prefixes each
/// row (empty for the argv form, `[i/N] ` for scripts).
fn print_sites(tag: &str, sites: &[Site]) {
    for s in sites {
        for l in s.before.lines() {
            println!("{tag}{}:{}:- {}", s.path, s.line, l);
        }
        for l in s.after.lines() {
            println!("{tag}{}:{}:+ {}", s.path, s.line, l);
        }
    }
}

fn site_json(s: &Site) -> serde_json::Value {
    json!({ "path": s.path, "line": s.line, "before": s.before, "after": s.after })
}

/// Compile the argv `--find`/`--replace` pair into an [`Op`], resolving the
/// payload schemes. A `file:`-sourced find defaults to literal; a multi-line
/// find is a literal block.
fn compile_argv_op(cli: &Cli) -> Result<Op, String> {
    let (Some(find_raw), Some(replace_raw)) = (cli.find.as_deref(), cli.replace.as_deref())
    else {
        return Err("missing --find/--replace (or run a batch with --script)".to_string());
    };
    let find = payload::resolve(find_raw)?;
    let replace = payload::resolve(replace_raw)?;
    let find_lines = payload::to_lines(&find.text);
    match find_lines.len() {
        0 => Err("empty --find payload".to_string()),
        1 => {
            let effective = cli
                .mode
                .or(find.from_file.then_some(pattern::Mode::Literal));
            let single = find_lines.into_iter().next().unwrap();
            let re = pattern::compile_with(&single, effective)
                .map_err(|e| format!("invalid --find pattern: {e}"))?;
            let literal = !matches!(
                pattern::classify_with(&single, effective),
                pattern::PatternKind::Regex
            );
            let text = replace.text.as_str();
            Ok(Op::Line {
                re,
                literal,
                replace: text.strip_suffix('\n').unwrap_or(text).to_string(),
            })
        }
        _ => {
            if matches!(
                cli.mode,
                Some(pattern::Mode::Glob) | Some(pattern::Mode::Regex)
            ) {
                return Err(
                    "a multi-line --find matches as a literal block; --mode glob/regex is reserved"
                        .to_string(),
                );
            }
            Ok(Op::Block {
                find: find_lines,
                replace: payload::to_lines(&replace.text),
            })
        }
    }
}

/// The single-edit argv form: one op over the selection, verdict over the
/// total count, write only on SUCCESS.
fn run_single(cli: &Cli, watchdog: &Option<Watchdog>) -> Result<ExitCode, String> {
    let op = compile_argv_op(cli)?;
    let expect = match &cli.expect {
        Some(s) => Expect::parse(s).map_err(|e| format!("invalid --expect: {e}"))?,
        None => Expect::default(),
    };

    let mut files = load_files(&selector(cli)?)?;
    let mut replacements = 0usize;
    let mut sites: Vec<Site> = Vec::new();
    let mut changed = vec![false; files.len()];
    let mut miss: Option<(String, coding_tools::block::NearestMiss)> = None;

    for (i, f) in files.iter_mut().enumerate() {
        let (new_content, hits, file_sites) = op.apply(&f.path, &f.content);
        replacements += hits;
        if hits > 0 && new_content != f.content {
            f.content = new_content;
            changed[i] = true;
            sites.extend(file_sites);
        } else if hits == 0
            && let Op::Block { find, .. } = &op
        {
            let lines: Vec<&str> = f.content.lines().collect();
            if let Some(m) = coding_tools::block::nearest_miss(&lines, find)
                && miss
                    .as_ref()
                    .is_none_or(|(_, b)| m.first_diverging_line > b.first_diverging_line)
            {
                miss = Some((f.path.clone(), m));
            }
        }
    }

    if replacements == 0
        && !cli.json
        && let Some((path, m)) = &miss
    {
        eprintln!(
            "ct-edit: nearest miss: {path}:{}: block diverges at its line {}",
            m.line, m.first_diverging_line
        );
        eprintln!("ct-edit:   expected: {}", m.expected);
        eprintln!("ct-edit:   found:    {}", m.found);
    }

    let verdict = expect.eval(replacements as u64);
    let applied = verdict == Verdict::Success && !cli.dry_run;
    if applied {
        let to_write: Vec<&str> = files
            .iter()
            .zip(&changed)
            .filter(|(_, ch)| **ch)
            .map(|(f, _)| f.path.as_str())
            .collect();
        preflight(&to_write)?;
        write_changed(watchdog, &files, &changed)?;
    }

    let files_changed = changed.iter().filter(|c| **c).count();
    if cli.json {
        let mut obj = json!({
            "tool": "ct-edit",
            "verdict": verdict.label(),
            "dry_run": cli.dry_run,
            "applied": applied,
            "replacements": replacements,
            "files_changed": files_changed,
            "sites": sites.iter().map(site_json).collect::<Vec<_>>(),
        });
        if let Some((path, m)) = &miss
            && replacements == 0
        {
            obj["nearest_miss"] = miss_json(path, m);
        }
        println!("{obj}");
    } else {
        if !cli.quiet {
            print_sites("", &sites);
        }
        println!(
            "{} replacement(s) in {} file(s) -> {} ({})",
            replacements,
            files_changed,
            verdict.label(),
            status_label(applied, cli.dry_run),
        );
    }

    Ok(verdict.exit_code())
}

fn status_label(applied: bool, dry_run: bool) -> &'static str {
    if applied {
        "applied"
    } else if dry_run {
        "dry-run, not written"
    } else {
        "verdict ERROR, not written"
    }
}

fn miss_json(path: &str, m: &coding_tools::block::NearestMiss) -> serde_json::Value {
    json!({
        "path": path,
        "line": m.line,
        "first_diverging_line": m.first_diverging_line,
        "expected": m.expected,
        "found": m.found,
    })
}

/// The `--script` form: parse the `.ctb` document, simulate the whole batch
/// in memory, and write only when every edit passed — atomic by design, with
/// no flag that makes a partial write possible.
fn run_script(cli: &Cli, watchdog: &Option<Watchdog>) -> Result<ExitCode, String> {
    let script_path = cli.script.as_ref().unwrap();
    let src = std::fs::read_to_string(script_path)
        .map_err(|e| format!("reading script {}: {e}", script_path.display()))?;
    let items = blockdoc::parse(&src, &cli.fence, &["edit"])
        .map_err(|e| format!("{}: {e}", script_path.display()))?;
    if items.is_empty() {
        return Err(format!(
            "{}: script contains no edits",
            script_path.display()
        ));
    }
    let specs = items
        .iter()
        .enumerate()
        .map(|(i, it)| editscript::compile_item(it, i + 1))
        .collect::<Result<Vec<_>, _>>()
        .map_err(|e| format!("{}: {e}", script_path.display()))?;

    let mut files = load_files(&selector(cli)?)?;
    let pristine: Vec<String> = files.iter().map(|f| f.content.clone()).collect();

    // Phase 1: the whole batch, simulated and judged in memory.
    let outcomes = if cli.no_cascade {
        editscript::run_no_cascade(&specs, &mut files)?
    } else {
        editscript::run_cascade(&specs, &mut files)?
    };
    let batch_ok = outcomes.iter().all(|o| o.verdict == Verdict::Success);
    let changed: Vec<bool> = files
        .iter()
        .zip(&pristine)
        .map(|(f, p)| f.content != *p)
        .collect();
    let replacements: usize = outcomes.iter().map(|o| o.replacements).sum();
    let files_changed = changed.iter().filter(|c| **c).count();

    // Phase 2: confirm writability, then write — only when every edit passed.
    let applied = batch_ok && !cli.dry_run;
    if applied {
        let to_write: Vec<&str> = files
            .iter()
            .zip(&changed)
            .filter(|(_, ch)| **ch)
            .map(|(f, _)| f.path.as_str())
            .collect();
        preflight(&to_write)?;
        write_changed(watchdog, &files, &changed)?;
    }

    let verdict = if batch_ok {
        Verdict::Success
    } else {
        Verdict::Error
    };
    let total = specs.len();

    if cli.json {
        let edits: Vec<_> = outcomes.iter().map(outcome_json).collect();
        let obj = json!({
            "tool": "ct-edit",
            "script": script_path.display().to_string(),
            "verdict": verdict.label(),
            "cascade": !cli.no_cascade,
            "dry_run": cli.dry_run,
            "applied": applied,
            "replacements": replacements,
            "files_changed": files_changed,
            "edits": edits,
        });
        println!("{obj}");
    } else {
        if !cli.quiet {
            for o in &outcomes {
                print_sites(&format!("[{}/{total}] ", o.ordinal), &o.sites);
            }
            for o in &outcomes {
                println!(
                    "edit {}/{total}: expect {}, mode {} -> {} ({} replacement(s))",
                    o.ordinal,
                    o.expect,
                    o.mode,
                    o.verdict.label(),
                    o.replacements,
                );
                if let Some((path, m)) = &o.miss {
                    println!(
                        "  nearest miss: {path}:{}: block diverges at its line {}",
                        m.line, m.first_diverging_line
                    );
                    println!("    expected: {}", m.expected);
                    println!("    found:    {}", m.found);
                }
            }
        }
        println!(
            "{} replacement(s) in {} file(s) over {} edit(s) -> {} ({})",
            replacements,
            files_changed,
            total,
            verdict.label(),
            status_label(applied, cli.dry_run),
        );
    }

    Ok(verdict.exit_code())
}

fn outcome_json(o: &EditOutcome) -> serde_json::Value {
    let mut obj = json!({
        "ordinal": o.ordinal,
        "expect": o.expect,
        "mode": o.mode,
        "replacements": o.replacements,
        "verdict": o.verdict.label(),
        "sites": o.sites.iter().map(site_json).collect::<Vec<_>>(),
    });
    if let Some((path, m)) = &o.miss {
        obj["nearest_miss"] = miss_json(path, m);
    }
    obj
}

fn run(cli: Cli) -> Result<ExitCode, String> {
    let watchdog = pulse::watchdog("ct-edit", cli.timeout)?;
    let _pulse = cli.heartbeat.start("ct-edit", PulseState::new())?;
    if cli.script.is_some() {
        run_script(&cli, &watchdog)
    } else {
        run_single(&cli, &watchdog)
    }
}

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)
        }
    }
}