Skip to main content

lean_ctx/tools/ctx_patch/
mod.rs

1//! `ctx_patch` — hash-anchored editing (epic #1008).
2//!
3//! "Edit by reference, not by reproduction": the model edits lines by their
4//! `(line, hash)` anchor (from `ctx_read(mode="anchored")`) instead of quoting
5//! the old text byte-for-byte. Each anchor is verified against the *current*
6//! file; on drift the edit is rejected with fresh anchors — except a
7//! single-line anchor (`set_line`/`insert_after`) whose content moved intact
8//! to exactly one other line (e.g. an earlier, separate edit shifted it):
9//! that's resolved automatically rather than failing (#812). Multiple edits
10//! in one call are **batch-atomic** — all validated against the same
11//! preimage and applied all-or-nothing, bottom-up.
12//!
13//! Reuses the exact `ctx_edit` I/O boundary (`crate::tools::edit_io`):
14//! TOCTOU preimage guard, permission-preserving atomic write, read-only-roots
15//! deny, symlink rejection. `ctx_edit` (str_replace) stays as the fallback.
16
17mod anchors;
18mod apply;
19mod metering;
20mod output;
21mod symbol;
22#[cfg(test)]
23mod tests;
24
25pub use anchors::AnchorOp;
26pub(crate) use symbol::{build_refactor_args, is_replace_symbol};
27
28use std::path::{Path, PathBuf};
29
30use crate::core::cache::SessionCache;
31use crate::core::tokens::count_tokens;
32use crate::tools::ctx_edit::{CacheEffect, apply_cache_effect, build_diff_evidence};
33use crate::tools::edit_io::{
34    default_backup_path, ensure_preimage_still_matches, read_preimage,
35    write_atomic_bytes_with_permissions,
36};
37
38/// Parameters for an anchored patch: the target file and one or more anchored
39/// edit ops, plus optional guards/evidence (mirrors `EditParams` where it makes
40/// sense so the registered wrapper stays uniform).
41pub struct PatchParams {
42    pub path: String,
43    pub ops: Vec<AnchorOp>,
44    /// Optional whole-file preimage guard (BLAKE3 hex, as printed by ctx_edit's
45    /// `postimage:` line). When set, the edit fails if the file's hash differs.
46    pub expected_md5: Option<String>,
47    pub backup: bool,
48    pub backup_path: Option<String>,
49    pub evidence: bool,
50    pub diff_max_lines: usize,
51    pub allow_lossy_utf8: bool,
52    /// Post-edit tree-sitter gate (#1008): reject a write that turns a cleanly
53    /// parsing file into a broken one. Default `true`; set `false` to override
54    /// (e.g. intentionally writing an incomplete snippet).
55    pub validate_syntax: bool,
56}
57
58/// Parse the raw tool arguments into [`AnchorOp`]s (single op or `ops[]`).
59pub fn parse_ops(
60    args: &serde_json::Map<String, serde_json::Value>,
61) -> Result<Vec<AnchorOp>, String> {
62    anchors::parse_ops(args)
63}
64
65/// Apply an anchored patch and the resulting cache effect in one shot (tests and
66/// in-process callers that hold the cache exclusively).
67pub fn handle(cache: &mut SessionCache, params: &PatchParams) -> String {
68    let last_mode = cache
69        .get(&params.path)
70        .map(|e| e.last_mode.clone())
71        .unwrap_or_default();
72    let (text, effect) = run_io(params, &last_mode);
73    record_outcome(params, &last_mode, &text, &effect);
74    apply_cache_effect(cache, &params.path, effect);
75    text
76}
77
78/// Quality loop (#494/#1008): a clean anchored edit is a success signal for the
79/// read mode that produced the anchors; a stale-anchor `CONFLICT` is a failure
80/// signal (the view the model edited against had drifted) that arms a one-shot
81/// escalation of the next auto read to `anchored` — fresh line anchors to retry
82/// by reference. Structural errors say nothing about the read mode and are
83/// skipped.
84pub fn record_outcome(params: &PatchParams, last_mode: &str, text: &str, effect: &CacheEffect) {
85    let success = matches!(effect, CacheEffect::Invalidate);
86    let conflict = matches!(effect, CacheEffect::None) && text.starts_with("CONFLICT:");
87    if success || conflict {
88        crate::core::edit_quality::record_anchored_edit_outcome(&params.path, last_mode, success);
89    }
90}
91
92/// Perform the anchored patch on disk **without** touching the cache; returns
93/// the [`CacheEffect`] for the caller to apply. `last_mode` is currently only
94/// used by [`record_outcome`]; pass `""` when unknown.
95pub fn run_io(params: &PatchParams, _last_mode: &str) -> (String, CacheEffect) {
96    let file_path = &params.path;
97    let path = Path::new(file_path);
98    let cap = crate::core::limits::max_read_bytes();
99
100    // `create` short-circuits the anchored pipeline: no preimage exists to
101    // anchor against, so it must be the only op and the file must be new.
102    if let Some(content) = single_create_op(&params.ops) {
103        return match content {
104            Ok(text) => handle_create(params, path, text),
105            Err(e) => (e, CacheEffect::None),
106        };
107    }
108
109    let pre = match read_preimage(path, cap, params.allow_lossy_utf8) {
110        Ok(p) => p,
111        Err(e) => {
112            if !path.exists() {
113                let hint = crate::tools::edit_recovery::moved_or_deleted_hint(path);
114                return (format!("{e}{hint}"), CacheEffect::None);
115            }
116            return (e, CacheEffect::None);
117        }
118    };
119
120    if let Some(expected) = params.expected_md5.as_deref()
121        && expected != pre.fp.md5
122    {
123        return (
124            format!(
125                "ERROR: preimage mismatch for {file_path}: expected_md5={expected}, actual_md5={}",
126                pre.fp.md5
127            ),
128            CacheEffect::None,
129        );
130    }
131
132    if params.ops.is_empty() {
133        return (
134            "ERROR: no edits provided (pass an op or ops:[…])".to_string(),
135            CacheEffect::None,
136        );
137    }
138
139    // BOM parity with `ctx_read` (GH #683 follow-up): the read side strips a
140    // UTF-8 BOM before hashing line 1, so anchors must be validated against
141    // the BOM-less body — otherwise every line-1 edit of a BOM file conflicts
142    // forever. The BOM itself is preserved on write (prepended below); it is
143    // an encoding artifact of the file, not of the edit.
144    let (bom, body) = match pre.text.strip_prefix('\u{feff}') {
145        Some(rest) => ("\u{feff}", rest),
146        None => ("", pre.text.as_str()),
147    };
148    let (lines, sep, trailing) = apply::split_lines(body);
149
150    let edits = match apply::resolve_ops(&lines, &params.ops) {
151        Ok(e) => e,
152        Err(apply::ResolveError::Conflict(misses)) => {
153            // Edit-efficiency channel (#1008): a stale-anchor CONFLICT is one
154            // extra self-heal round-trip — count it, never print it (#498).
155            crate::core::edit_metering::record_anchored_conflict();
156            return (
157                output::render_conflict(file_path, &lines, &misses),
158                CacheEffect::None,
159            );
160        }
161        Err(apply::ResolveError::Invalid(msg)) => {
162            return (format!("ERROR: {msg}"), CacheEffect::None);
163        }
164    };
165
166    // Preimage math for the edit-efficiency channel — must run before the
167    // splice consumes the old span text.
168    let avoided_tokens = metering::avoided_output_tokens(&lines, &params.ops);
169
170    let n_edits = edits.len();
171    let lines_before = lines.len();
172    let new_lines = apply::apply_edits(lines.clone(), edits);
173    let new_content = format!("{bom}{}", apply::join_lines(&new_lines, sep, trailing));
174
175    if new_content == pre.text {
176        return (
177            "ERROR: edits produced no change to the file".to_string(),
178            CacheEffect::None,
179        );
180    }
181
182    let ext = Path::new(file_path)
183        .extension()
184        .and_then(|e| e.to_str())
185        .unwrap_or("");
186
187    // Post-edit syntax gate (#1008): block a clean → broken regression before any
188    // write. Pure (no I/O), so it runs before the TOCTOU re-read.
189    if params.validate_syntax
190        && let Some(reason) = crate::core::syntax_validate::gate_edit(ext, &pre.text, &new_content)
191    {
192        return (reason, CacheEffect::None);
193    }
194
195    // Code-health gate: warn on (or block) cognitive-complexity drift before write.
196    let health_notice = match crate::core::code_health::gate::evaluate(&pre.text, &new_content, ext)
197    {
198        crate::core::code_health::gate::GateOutcome::Block(reason) => {
199            return (
200                format!("ERROR: code-health gate: {reason}"),
201                CacheEffect::None,
202            );
203        }
204        crate::core::code_health::gate::GateOutcome::Allow(notice) => notice,
205    };
206
207    // TOCTOU guard: confirm the file did not change between read and write.
208    // #960: a point-in-time check, not a held lock — see
209    // ensure_preimage_still_matches' doc for the residual window between
210    // this check and the write below.
211    if let Err(e) = ensure_preimage_still_matches(path, &pre.fp, cap) {
212        return (e, CacheEffect::None);
213    }
214
215    let backup_path = match make_backup(params, path, &pre.bytes, &pre.permissions) {
216        Ok(bp) => bp,
217        Err(e) => return (e, CacheEffect::None),
218    };
219
220    if let Err(e) =
221        write_atomic_bytes_with_permissions(path, new_content.as_bytes(), Some(&pre.permissions))
222    {
223        return (e, CacheEffect::None);
224    }
225
226    if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
227        bt.record_edit(file_path);
228    }
229
230    // Edit-efficiency channel (#1008): the span the model did NOT re-emit.
231    crate::core::edit_metering::record_anchored_success(n_edits as u64, avoided_tokens);
232
233    let mut out = render_success(
234        params,
235        &pre.text,
236        &new_content,
237        pre.fp.size,
238        pre.fp.mtime_ms,
239        &pre.fp.md5,
240        lines_before,
241        new_lines.len(),
242        n_edits,
243        backup_path,
244    );
245    if let Some(notice) = health_notice {
246        out.push_str("\n\n");
247        out.push_str(&notice);
248    }
249    (out, CacheEffect::Invalidate)
250}
251
252/// If the ops contain a `create`, return its content — or an error when it is
253/// mixed with anchored ops (a batch validates against one *existing* preimage,
254/// which a new file by definition does not have).
255fn single_create_op(ops: &[AnchorOp]) -> Option<Result<&str, String>> {
256    let create = ops.iter().find_map(|op| match op {
257        AnchorOp::Create { new_text } => Some(new_text.as_str()),
258        _ => None,
259    })?;
260    if ops.len() > 1 {
261        return Some(Err(
262            "ERROR: create cannot be batched with anchored ops — a new file has no \
263             preimage to anchor against; send create as a single op"
264                .to_string(),
265        ));
266    }
267    Some(Ok(create))
268}
269
270/// `op=create`: write a NEW file (strict — an existing file is an error, unlike
271/// `ctx_edit create=true` which overwrites). Reuses the PathJail +
272/// atomic-write boundary of the anchored path.
273fn handle_create(params: &PatchParams, path: &Path, content: &str) -> (String, CacheEffect) {
274    if path.exists() {
275        return (
276            format!(
277                "ERROR: {} already exists — create is for new files only. \
278                 Use anchored ops (ctx_read mode=\"anchored\" → set_line/replace_lines) to modify it.",
279                params.path
280            ),
281            CacheEffect::None,
282        );
283    }
284
285    // Deny before create_dir_all can materialise a directory inside a
286    // read-only root (mirrors ctx_edit's create guard, #475).
287    if let Err(e) = crate::core::pathjail::enforce_writable(path) {
288        return (format!("ERROR: {e}"), CacheEffect::None);
289    }
290
291    if let Some(parent) = path.parent()
292        && !parent.exists()
293        && let Err(e) = std::fs::create_dir_all(parent)
294    {
295        return (
296            format!("ERROR: cannot create directory {}: {e}", parent.display()),
297            CacheEffect::None,
298        );
299    }
300
301    if let Err(e) = write_atomic_bytes_with_permissions(path, content.as_bytes(), None) {
302        return (e, CacheEffect::None);
303    }
304
305    if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
306        bt.record_edit(&params.path);
307    }
308
309    let lines = content.lines().count();
310    let tokens = count_tokens(content);
311    let short = output::short_name(&params.path);
312    let post_md5 = crate::core::hasher::hash_hex(content.as_bytes());
313    let mut out = format!(
314        "✓ created {short}: {lines} lines, {tokens} tok\npostimage: bytes={}, md5={post_md5}",
315        content.len()
316    );
317    if params.evidence {
318        let diff = build_diff_evidence("", content, &short, params.diff_max_lines);
319        out.push_str("\n\nevidence (diff, redacted, bounded):\n```diff\n");
320        out.push_str(&diff);
321        out.push_str("\n```");
322    }
323    (out, CacheEffect::Invalidate)
324}
325
326/// Write a pre-edit backup when requested; returns the backup path (if any).
327fn make_backup(
328    params: &PatchParams,
329    path: &Path,
330    bytes: &[u8],
331    permissions: &std::fs::Permissions,
332) -> Result<Option<String>, String> {
333    if !params.backup {
334        return Ok(None);
335    }
336    let bp = params
337        .backup_path
338        .as_deref()
339        .map(PathBuf::from)
340        .or_else(|| default_backup_path(path))
341        .ok_or_else(|| format!("ERROR: cannot compute backup path for {}", path.display()))?;
342    write_atomic_bytes_with_permissions(&bp, bytes, Some(permissions))
343        .map_err(|e| format!("ERROR: cannot create backup {}: {e}", bp.display()))?;
344    Ok(Some(bp.to_string_lossy().to_string()))
345}
346
347#[allow(clippy::too_many_arguments)]
348fn render_success(
349    params: &PatchParams,
350    old_content: &str,
351    new_content: &str,
352    pre_size: u64,
353    pre_mtime_ms: u64,
354    pre_md5: &str,
355    lines_before: usize,
356    lines_after: usize,
357    n_edits: usize,
358    backup_path: Option<String>,
359) -> String {
360    let short = output::short_name(&params.path);
361    let line_delta = lines_after as i64 - lines_before as i64;
362    let delta_str = if line_delta >= 0 {
363        format!("+{line_delta}")
364    } else {
365        format!("{line_delta}")
366    };
367    let old_tokens = count_tokens(old_content);
368    let new_tokens = count_tokens(new_content);
369
370    let post_mtime_ms = std::fs::metadata(&params.path)
371        .ok()
372        .and_then(|m| m.modified().ok())
373        .map_or(0, crate::tools::edit_io::system_time_to_millis);
374    let post_md5 = crate::core::hasher::hash_hex(new_content.as_bytes());
375
376    let edit_word = if n_edits == 1 { "edit" } else { "edits" };
377    let mut out = format!(
378        "✓ {short}: {n_edits} anchored {edit_word}, {delta_str} lines ({old_tokens}→{new_tokens} tok)\n\
379preimage: bytes={pre_size}, mtime_ms={pre_mtime_ms}, md5={pre_md5}\n\
380postimage: bytes={}, mtime_ms={post_mtime_ms}, md5={post_md5}",
381        new_content.len()
382    );
383    if let Some(bp) = backup_path {
384        out.push_str(&format!("\nbackup: {bp}"));
385    }
386    if params.evidence {
387        let diff = build_diff_evidence(old_content, new_content, &short, params.diff_max_lines);
388        out.push_str("\n\nevidence (diff, redacted, bounded):\n```diff\n");
389        out.push_str(&diff);
390        out.push_str("\n```");
391        let balance = brace_balance(new_content);
392        if balance == 0 {
393            out.push_str("\nbrace-balance: ok (matched)");
394        } else {
395            out.push_str(&format!(
396                "\n⚠ brace-balance: {} unmatched '{{' — verify file integrity",
397                balance.abs()
398            ));
399        }
400    }
401    out
402}
403
404/// Counts unmatched `{` vs `}` in the full post-edit content. Returns the
405/// difference: positive = excess `{`, negative = excess `}`, 0 = balanced.
406fn brace_balance(content: &str) -> i64 {
407    content.chars().fold(0i64, |acc, c| match c {
408        '{' => acc + 1,
409        '}' => acc - 1,
410        _ => acc,
411    })
412}