Skip to main content

lean_ctx_server/tools/ctx_patch/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2//! `ctx_patch` — hash-anchored editing (epic #1008).
3//!
4//! "Edit by reference, not by reproduction": the model edits lines by their
5//! `(line, hash)` anchor (from `ctx_read(mode="anchored")`) instead of quoting
6//! the old text byte-for-byte. Each anchor is verified against the *current*
7//! file; on drift the edit is rejected with fresh anchors — except a
8//! single-line anchor (`set_line`/`insert_after`) whose content moved intact
9//! to exactly one other line (e.g. an earlier, separate edit shifted it):
10//! that's resolved automatically rather than failing (#812). Multiple edits
11//! in one call are **batch-atomic** — all validated against the same
12//! preimage and applied all-or-nothing, bottom-up.
13//!
14//! Reuses the exact `ctx_edit` I/O boundary (`crate::tools::edit_io`):
15//! TOCTOU preimage guard, permission-preserving atomic write, read-only-roots
16//! deny, symlink rejection. `ctx_edit` (str_replace) stays as the fallback.
17
18mod anchors;
19mod apply;
20mod metering;
21mod output;
22mod symbol;
23#[cfg(test)]
24mod tests;
25
26pub use anchors::AnchorOp;
27pub(crate) use symbol::{build_refactor_args, is_replace_symbol};
28
29use std::path::{Path, PathBuf};
30
31use crate::tools::ctx_edit::{CacheEffect, apply_cache_effect, build_diff_evidence};
32use crate::tools::edit_io::{
33    default_backup_path, ensure_preimage_still_matches, read_preimage,
34    write_atomic_bytes_with_permissions,
35};
36use lean_ctx::core::cache::SessionCache;
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    ///
47    /// Named for the algorithm it actually uses (#1592); the wire parameter
48    /// `expected_md5` stays accepted as an alias for existing callers.
49    pub expected_blake3: Option<String>,
50    pub backup: bool,
51    pub backup_path: Option<String>,
52    pub evidence: bool,
53    /// Without `evidence`: archive the diff for a host renderer, named by a
54    /// `[diff:<id>]` trailer (see `ctx_edit::diff_artifact_trailer`).
55    pub diff_artifact: bool,
56    pub diff_max_lines: usize,
57    pub allow_lossy_utf8: bool,
58    /// Post-edit tree-sitter gate (#1008): reject a write that turns a cleanly
59    /// parsing file into a broken one. Default `true`; set `false` to override
60    /// (e.g. intentionally writing an incomplete snippet).
61    pub validate_syntax: bool,
62}
63
64/// Parse the raw tool arguments into [`AnchorOp`]s (single op or `ops[]`).
65pub fn parse_ops(
66    args: &serde_json::Map<String, serde_json::Value>,
67) -> Result<Vec<AnchorOp>, String> {
68    anchors::parse_ops(args)
69}
70
71/// Apply an anchored patch and the resulting cache effect in one shot (tests and
72/// in-process callers that hold the cache exclusively).
73pub fn handle(cache: &mut SessionCache, params: &PatchParams) -> String {
74    let last_mode = cache
75        .get(&params.path)
76        .map(|e| e.last_mode.clone())
77        .unwrap_or_default();
78    let (text, effect) = run_io(params, &last_mode);
79    record_outcome(params, &last_mode, &text, &effect);
80    apply_cache_effect(cache, &params.path, effect);
81    text
82}
83
84/// Quality loop (#494/#1008): a clean anchored edit is a success signal for the
85/// read mode that produced the anchors; a stale-anchor `CONFLICT` is a failure
86/// signal (the view the model edited against had drifted) that arms a one-shot
87/// escalation of the next auto read to `anchored` — fresh line anchors to retry
88/// by reference. Structural errors say nothing about the read mode and are
89/// skipped.
90pub fn record_outcome(params: &PatchParams, last_mode: &str, text: &str, effect: &CacheEffect) {
91    let success = matches!(effect, CacheEffect::Invalidate);
92    let conflict = matches!(effect, CacheEffect::None) && text.starts_with("CONFLICT:");
93    if success || conflict {
94        lean_ctx::core::edit_quality::record_anchored_edit_outcome(
95            &params.path,
96            last_mode,
97            success,
98        );
99    }
100}
101
102/// Perform the anchored patch on disk **without** touching the cache; returns
103/// the [`CacheEffect`] for the caller to apply. `last_mode` is currently only
104/// used by [`record_outcome`]; pass `""` when unknown.
105pub fn run_io(params: &PatchParams, _last_mode: &str) -> (String, CacheEffect) {
106    let file_path = &params.path;
107    let path = Path::new(file_path);
108    let cap = lean_ctx::core::limits::max_read_bytes();
109
110    // `create` short-circuits the anchored pipeline: no preimage exists to
111    // anchor against, so it must be the only op and the file must be new.
112    if let Some(content) = single_create_op(&params.ops) {
113        return match content {
114            Ok(text) => handle_create(params, path, text),
115            Err(e) => (e, CacheEffect::None),
116        };
117    }
118
119    let pre = match read_preimage(path, cap, params.allow_lossy_utf8) {
120        Ok(p) => p,
121        Err(e) => {
122            if !path.exists() {
123                let hint = crate::tools::edit_recovery::moved_or_deleted_hint(path);
124                return (format!("{e}{hint}"), CacheEffect::None);
125            }
126            return (e, CacheEffect::None);
127        }
128    };
129
130    if let Some(expected) = params.expected_blake3.as_deref()
131        && expected != pre.fp.blake3
132    {
133        return (
134            format!(
135                "ERROR: preimage mismatch for {file_path}: expected_blake3={expected}, actual_blake3={}",
136                pre.fp.blake3
137            ),
138            CacheEffect::None,
139        );
140    }
141
142    if params.ops.is_empty() {
143        return (
144            "ERROR: no edits provided (pass an op or ops:[…])".to_string(),
145            CacheEffect::None,
146        );
147    }
148
149    // BOM parity with `ctx_read` (GH #683 follow-up): the read side strips a
150    // UTF-8 BOM before hashing line 1, so anchors must be validated against
151    // the BOM-less body — otherwise every line-1 edit of a BOM file conflicts
152    // forever. The BOM itself is preserved on write (prepended below); it is
153    // an encoding artifact of the file, not of the edit.
154    let (bom, body) = match pre.text.strip_prefix('\u{feff}') {
155        Some(rest) => ("\u{feff}", rest),
156        None => ("", pre.text.as_str()),
157    };
158    let (lines, sep, trailing) = apply::split_lines(body);
159
160    let edits = match apply::resolve_ops(&lines, &params.ops) {
161        Ok(e) => e,
162        Err(apply::ResolveError::Conflict(misses)) => {
163            // Edit-efficiency channel (#1008): a stale-anchor CONFLICT is one
164            // extra self-heal round-trip — count it, never print it (#498).
165            lean_ctx::core::edit_metering::record_anchored_conflict();
166            return (
167                output::render_conflict(file_path, &lines, &misses),
168                CacheEffect::None,
169            );
170        }
171        Err(apply::ResolveError::Invalid(msg)) => {
172            return (format!("ERROR: {msg}"), CacheEffect::None);
173        }
174    };
175
176    // Preimage math for the edit-efficiency channel — must run before the
177    // splice consumes the old span text.
178    let avoided_tokens = metering::avoided_output_tokens(&lines, &params.ops);
179
180    let n_edits = edits.len();
181    let net_change = edits.iter().fold((0u64, 0u64), |(added, removed), edit| {
182        let (a, r) = edit.net_change(&lines);
183        (added.saturating_add(a), removed.saturating_add(r))
184    });
185    let lines_added = edits.iter().fold(0u64, |total, edit| {
186        total.saturating_add(u64::try_from(edit.new_lines.len()).unwrap_or(u64::MAX))
187    });
188    let lines_removed = edits.iter().fold(0u64, |total, edit| {
189        total.saturating_add(u64::try_from(edit.remove_count).unwrap_or(u64::MAX))
190    });
191    let new_lines = apply::apply_edits(lines.clone(), edits);
192    let new_content = format!("{bom}{}", apply::join_lines(&new_lines, sep, trailing));
193
194    if new_content == pre.text {
195        return (
196            "ERROR: edits produced no change to the file".to_string(),
197            CacheEffect::None,
198        );
199    }
200
201    let ext = Path::new(file_path)
202        .extension()
203        .and_then(|e| e.to_str())
204        .unwrap_or("");
205
206    // Post-edit syntax gate (#1008): block a clean → broken regression before any
207    // write. Pure (no I/O), so it runs before the TOCTOU re-read.
208    if params.validate_syntax
209        && let Some(reason) =
210            lean_ctx::core::syntax_validate::gate_edit(ext, &pre.text, &new_content)
211    {
212        return (reason, CacheEffect::None);
213    }
214
215    // Code-health gate: warn on (or block) cognitive-complexity drift before write.
216    let health_notice =
217        match lean_ctx::core::code_health::gate::evaluate(&pre.text, &new_content, ext) {
218            lean_ctx::core::code_health::gate::GateOutcome::Block(reason) => {
219                return (
220                    format!("ERROR: code-health gate: {reason}"),
221                    CacheEffect::None,
222                );
223            }
224            lean_ctx::core::code_health::gate::GateOutcome::Allow(notice) => notice,
225        };
226
227    // Pre-edit checks (unit claims, decisions): hint, or refuse when strict.
228    if let Err(e) = crate::tools::claims_gate::before_write(path, Some(&pre.text), &new_content) {
229        return (e, CacheEffect::None);
230    }
231
232    // TOCTOU guard: confirm the file did not change between read and write.
233    // #960: a point-in-time check, not a held lock — see
234    // ensure_preimage_still_matches' doc for the residual window between
235    // this check and the write below.
236    if let Err(e) = ensure_preimage_still_matches(path, &pre.fp, cap) {
237        return (e, CacheEffect::None);
238    }
239
240    let backup_path = match make_backup(params, path, &pre.bytes, &pre.permissions) {
241        Ok(bp) => bp,
242        Err(e) => return (e, CacheEffect::None),
243    };
244
245    if let Err(e) =
246        write_atomic_bytes_with_permissions(path, new_content.as_bytes(), Some(&pre.permissions))
247    {
248        return (e, CacheEffect::None);
249    }
250
251    lean_ctx::core::edit_metering::record_loc_change(Some(file_path), lines_added, lines_removed);
252
253    if let Ok(mut bt) = lean_ctx::core::bounce_tracker::global().lock() {
254        bt.record_edit(file_path);
255    }
256
257    // Edit-efficiency channel (#1008): the span the model did NOT re-emit.
258    lean_ctx::core::edit_metering::record_anchored_success(n_edits as u64, avoided_tokens);
259
260    let mut out = render_success(
261        params,
262        &pre,
263        &new_content,
264        new_lines.len(),
265        net_change,
266        n_edits,
267        backup_path,
268    );
269    if let Some(notice) = health_notice {
270        out.push_str("\n\n");
271        out.push_str(&notice);
272    }
273    (out, CacheEffect::Invalidate)
274}
275
276/// If the ops contain a `create`, return its content — or an error when it is
277/// mixed with anchored ops (a batch validates against one *existing* preimage,
278/// which a new file by definition does not have).
279fn single_create_op(ops: &[AnchorOp]) -> Option<Result<&str, String>> {
280    let create = ops.iter().find_map(|op| match op {
281        AnchorOp::Create { new_text } => Some(new_text.as_str()),
282        _ => None,
283    })?;
284    if ops.len() > 1 {
285        return Some(Err(
286            "ERROR: create cannot be batched with anchored ops — a new file has no \
287             preimage to anchor against; send create as a single op"
288                .to_string(),
289        ));
290    }
291    Some(Ok(create))
292}
293
294/// `op=create`: write a NEW file (strict — an existing file is an error, unlike
295/// `ctx_edit create=true` which overwrites). The one exception is an existing
296/// empty regular file (not a symlink): it has no lines to anchor and no text
297/// for `replace_unique`, and its preimage (0 bytes) is fully known, so create
298/// fills it and keeps its permissions (#2223). Reuses the PathJail +
299/// atomic-write boundary of the anchored path.
300fn handle_create(params: &PatchParams, path: &Path, content: &str) -> (String, CacheEffect) {
301    let empty_file = std::fs::symlink_metadata(path)
302        .ok()
303        .filter(|m| m.file_type().is_file() && m.len() == 0);
304    if empty_file.is_none() && path.exists() {
305        return (
306            format!(
307                "ERROR: {} already exists — create is for new or empty files only. \
308                 Use anchored ops (ctx_read mode=\"anchored\" → set_line/replace_lines) to modify it.",
309                params.path
310            ),
311            CacheEffect::None,
312        );
313    }
314
315    // Deny before create_dir_all can materialise a directory inside a
316    // read-only root (mirrors ctx_edit's create guard, #475).
317    if let Err(e) = lean_ctx::core::pathjail::enforce_writable(path) {
318        return (format!("ERROR: {e}"), CacheEffect::None);
319    }
320
321    if let Err(e) = crate::tools::claims_gate::before_write(path, None, content) {
322        return (e, CacheEffect::None);
323    }
324
325    if let Some(parent) = path.parent()
326        && !parent.exists()
327        && let Err(e) = std::fs::create_dir_all(parent)
328    {
329        return (
330            format!("ERROR: cannot create directory {}: {e}", parent.display()),
331            CacheEffect::None,
332        );
333    }
334
335    let permissions = empty_file.map(|m| m.permissions());
336    if let Err(e) =
337        write_atomic_bytes_with_permissions(path, content.as_bytes(), permissions.as_ref())
338    {
339        return (e, CacheEffect::None);
340    }
341
342    let lines_added = u64::try_from(content.lines().count()).unwrap_or(u64::MAX);
343    lean_ctx::core::edit_metering::record_loc_change(Some(&params.path), lines_added, 0);
344
345    if let Ok(mut bt) = lean_ctx::core::bounce_tracker::global().lock() {
346        bt.record_edit(&params.path);
347    }
348
349    let lines = content.lines().count();
350    let short = output::short_name(&params.path);
351    let mut out = format!("created {short}: {lines} lines");
352    if params.evidence {
353        out.push_str(&format!(
354            "\npostimage: bytes={}, blake3={}",
355            content.len(),
356            lean_ctx::core::hasher::hash_hex(content.as_bytes())
357        ));
358        let diff = build_diff_evidence("", content, &short, params.diff_max_lines);
359        out.push_str("\n\nevidence (diff, redacted, bounded):\n```diff\n");
360        out.push_str(&diff);
361        out.push_str("\n```");
362    } else if params.diff_artifact
363        && let Some(trailer) = crate::tools::ctx_edit::diff_artifact_trailer(
364            "",
365            content,
366            &short,
367            params.diff_max_lines,
368        )
369    {
370        out.push('\n');
371        out.push_str(&trailer);
372    }
373    (out, CacheEffect::Invalidate)
374}
375
376/// Write a pre-edit backup when requested; returns the backup path (if any).
377fn make_backup(
378    params: &PatchParams,
379    path: &Path,
380    bytes: &[u8],
381    permissions: &std::fs::Permissions,
382) -> Result<Option<String>, String> {
383    if !params.backup {
384        return Ok(None);
385    }
386    let bp = params
387        .backup_path
388        .as_deref()
389        .map(PathBuf::from)
390        .or_else(|| default_backup_path(path))
391        .ok_or_else(|| format!("ERROR: cannot compute backup path for {}", path.display()))?;
392    write_atomic_bytes_with_permissions(&bp, bytes, Some(permissions))
393        .map_err(|e| format!("ERROR: cannot create backup {}: {e}", bp.display()))?;
394    Ok(Some(bp.to_string_lossy().to_string()))
395}
396
397/// The success receipt: one line (`patched <file>: N lines (+a -b)[, k edits]`)
398/// by default. With `evidence` the pre/postimage digests, the per-op ledger of
399/// a batch and the bounded diff follow.
400fn render_success(
401    params: &PatchParams,
402    pre: &crate::tools::edit_io::FilePreimage,
403    new_content: &str,
404    lines_after: usize,
405    (lines_added, lines_removed): (u64, u64),
406    n_edits: usize,
407    backup_path: Option<String>,
408) -> String {
409    let short = output::short_name(&params.path);
410    let detail = (n_edits > 1).then(|| format!("{n_edits} edits"));
411    let mut out = crate::tools::ctx_edit::edit_summary_line(
412        &short,
413        lines_after,
414        lines_added,
415        lines_removed,
416        detail.as_deref(),
417    );
418    if let Some(bp) = backup_path {
419        out.push_str(&format!("\nbackup: {bp}"));
420    }
421    if params.evidence {
422        let post_mtime_ms = std::fs::metadata(&params.path)
423            .ok()
424            .and_then(|m| m.modified().ok())
425            .map_or(0, crate::tools::edit_io::system_time_to_millis);
426        out.push_str(&format!(
427            "\npreimage: bytes={}, mtime_ms={}, blake3={}\n\
428postimage: bytes={}, mtime_ms={post_mtime_ms}, blake3={}",
429            pre.fp.size,
430            pre.fp.mtime_ms,
431            pre.fp.blake3,
432            new_content.len(),
433            lean_ctx::core::hasher::hash_hex(new_content.as_bytes())
434        ));
435        if params.ops.len() > 1 {
436            out.push('\n');
437            out.push_str(&render_op_ledger(&params.ops));
438        }
439        let old_content = pre.text.as_str();
440        let diff = build_diff_evidence(old_content, new_content, &short, params.diff_max_lines);
441        out.push_str("\n\nevidence (diff, redacted, bounded):\n```diff\n");
442        out.push_str(&diff);
443        out.push_str("\n```");
444        let balance = brace_balance(new_content);
445        if balance == 0 {
446            out.push_str("\nbrace-balance: ok (matched)");
447        } else {
448            out.push_str(&format!(
449                "\n⚠ brace-balance: {} unmatched '{{' — verify file integrity",
450                balance.abs()
451            ));
452        }
453    } else if params.diff_artifact
454        && let Some(trailer) = crate::tools::ctx_edit::diff_artifact_trailer(
455            &pre.text,
456            new_content,
457            &short,
458            params.diff_max_lines,
459        )
460    {
461        out.push('\n');
462        out.push_str(&trailer);
463    }
464    out
465}
466
467/// Per-op ledger for a batch receipt (#1767): a count line plus one line per
468/// op, in the order the caller sent them.
469///
470/// The receipt used to state only a total — `10 anchored edits` — so a caller
471/// who needed to know *which* ops landed where had to read the evidence diff.
472/// That diff is the bounded, droppable part of the receipt and the first thing
473/// a turn-budget truncation removes (the default budget is 4096 tokens and a
474/// ten-op diff overruns it), which left the caller running `grep` against the
475/// file to find out what had happened. The ledger costs a few tokens per op,
476/// sits ahead of the diff, and therefore survives any cut that keeps the
477/// header at all.
478///
479/// `applied` is always `n/n`: a batch is atomic, so a receipt exists only when
480/// every op landed. Stating it is the point — the reader should not have to
481/// infer completeness from the absence of an error.
482fn render_op_ledger(ops: &[AnchorOp]) -> String {
483    let mut out = format!("ops: {n}/{n} applied", n = ops.len());
484    for (index, op) in ops.iter().enumerate() {
485        let (kind, span, produced) = match op {
486            AnchorOp::SetLine { line, new_text, .. } => {
487                ("set_line", line.to_string(), produced_lines(new_text))
488            }
489            AnchorOp::ReplaceLines {
490                start_line,
491                end_line,
492                new_text,
493                ..
494            } => (
495                "replace_lines",
496                format!("{start_line}-{end_line}"),
497                produced_lines(new_text),
498            ),
499            AnchorOp::InsertAfter { line, new_text, .. } => {
500                ("insert_after", line.to_string(), produced_lines(new_text))
501            }
502            AnchorOp::Delete {
503                start_line,
504                end_line,
505                ..
506            } => ("delete", format!("{start_line}-{end_line}"), 0),
507            AnchorOp::Create { new_text } => ("create", "-".to_string(), produced_lines(new_text)),
508        };
509        let line_word = if produced == 1 { "line" } else { "lines" };
510        out.push_str(&format!(
511            "\n  {}. {kind} {span} → {produced} {line_word}",
512            index + 1
513        ));
514    }
515    out
516}
517
518/// Lines a `new_text` payload contributes. Empty text deletes (the readseek
519/// convention the op variants document), which is zero lines — not one.
520fn produced_lines(new_text: &str) -> usize {
521    if new_text.is_empty() {
522        0
523    } else {
524        new_text.lines().count()
525    }
526}
527
528/// Counts unmatched `{` vs `}` in the full post-edit content. Returns the
529/// difference: positive = excess `{`, negative = excess `}`, 0 = balanced.
530fn brace_balance(content: &str) -> i64 {
531    content.chars().fold(0i64, |acc, c| match c {
532        '{' => acc + 1,
533        '}' => acc - 1,
534        _ => acc,
535    })
536}