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    // TOCTOU guard: confirm the file did not change between read and write.
228    // #960: a point-in-time check, not a held lock — see
229    // ensure_preimage_still_matches' doc for the residual window between
230    // this check and the write below.
231    if let Err(e) = ensure_preimage_still_matches(path, &pre.fp, cap) {
232        return (e, CacheEffect::None);
233    }
234
235    let backup_path = match make_backup(params, path, &pre.bytes, &pre.permissions) {
236        Ok(bp) => bp,
237        Err(e) => return (e, CacheEffect::None),
238    };
239
240    if let Err(e) =
241        write_atomic_bytes_with_permissions(path, new_content.as_bytes(), Some(&pre.permissions))
242    {
243        return (e, CacheEffect::None);
244    }
245
246    lean_ctx::core::edit_metering::record_loc_change(Some(file_path), lines_added, lines_removed);
247
248    if let Ok(mut bt) = lean_ctx::core::bounce_tracker::global().lock() {
249        bt.record_edit(file_path);
250    }
251
252    // Edit-efficiency channel (#1008): the span the model did NOT re-emit.
253    lean_ctx::core::edit_metering::record_anchored_success(n_edits as u64, avoided_tokens);
254
255    let mut out = render_success(
256        params,
257        &pre,
258        &new_content,
259        new_lines.len(),
260        net_change,
261        n_edits,
262        backup_path,
263    );
264    if let Some(notice) = health_notice {
265        out.push_str("\n\n");
266        out.push_str(&notice);
267    }
268    (out, CacheEffect::Invalidate)
269}
270
271/// If the ops contain a `create`, return its content — or an error when it is
272/// mixed with anchored ops (a batch validates against one *existing* preimage,
273/// which a new file by definition does not have).
274fn single_create_op(ops: &[AnchorOp]) -> Option<Result<&str, String>> {
275    let create = ops.iter().find_map(|op| match op {
276        AnchorOp::Create { new_text } => Some(new_text.as_str()),
277        _ => None,
278    })?;
279    if ops.len() > 1 {
280        return Some(Err(
281            "ERROR: create cannot be batched with anchored ops — a new file has no \
282             preimage to anchor against; send create as a single op"
283                .to_string(),
284        ));
285    }
286    Some(Ok(create))
287}
288
289/// `op=create`: write a NEW file (strict — an existing file is an error, unlike
290/// `ctx_edit create=true` which overwrites). The one exception is an existing
291/// empty regular file (not a symlink): it has no lines to anchor and no text
292/// for `replace_unique`, and its preimage (0 bytes) is fully known, so create
293/// fills it and keeps its permissions (#2223). Reuses the PathJail +
294/// atomic-write boundary of the anchored path.
295fn handle_create(params: &PatchParams, path: &Path, content: &str) -> (String, CacheEffect) {
296    let empty_file = std::fs::symlink_metadata(path)
297        .ok()
298        .filter(|m| m.file_type().is_file() && m.len() == 0);
299    if empty_file.is_none() && path.exists() {
300        return (
301            format!(
302                "ERROR: {} already exists — create is for new or empty files only. \
303                 Use anchored ops (ctx_read mode=\"anchored\" → set_line/replace_lines) to modify it.",
304                params.path
305            ),
306            CacheEffect::None,
307        );
308    }
309
310    // Deny before create_dir_all can materialise a directory inside a
311    // read-only root (mirrors ctx_edit's create guard, #475).
312    if let Err(e) = lean_ctx::core::pathjail::enforce_writable(path) {
313        return (format!("ERROR: {e}"), CacheEffect::None);
314    }
315
316    if let Some(parent) = path.parent()
317        && !parent.exists()
318        && let Err(e) = std::fs::create_dir_all(parent)
319    {
320        return (
321            format!("ERROR: cannot create directory {}: {e}", parent.display()),
322            CacheEffect::None,
323        );
324    }
325
326    let permissions = empty_file.map(|m| m.permissions());
327    if let Err(e) =
328        write_atomic_bytes_with_permissions(path, content.as_bytes(), permissions.as_ref())
329    {
330        return (e, CacheEffect::None);
331    }
332
333    let lines_added = u64::try_from(content.lines().count()).unwrap_or(u64::MAX);
334    lean_ctx::core::edit_metering::record_loc_change(Some(&params.path), lines_added, 0);
335
336    if let Ok(mut bt) = lean_ctx::core::bounce_tracker::global().lock() {
337        bt.record_edit(&params.path);
338    }
339
340    let lines = content.lines().count();
341    let short = output::short_name(&params.path);
342    let mut out = format!("created {short}: {lines} lines");
343    if params.evidence {
344        out.push_str(&format!(
345            "\npostimage: bytes={}, blake3={}",
346            content.len(),
347            lean_ctx::core::hasher::hash_hex(content.as_bytes())
348        ));
349        let diff = build_diff_evidence("", content, &short, params.diff_max_lines);
350        out.push_str("\n\nevidence (diff, redacted, bounded):\n```diff\n");
351        out.push_str(&diff);
352        out.push_str("\n```");
353    } else if params.diff_artifact
354        && let Some(trailer) = crate::tools::ctx_edit::diff_artifact_trailer(
355            "",
356            content,
357            &short,
358            params.diff_max_lines,
359        )
360    {
361        out.push('\n');
362        out.push_str(&trailer);
363    }
364    (out, CacheEffect::Invalidate)
365}
366
367/// Write a pre-edit backup when requested; returns the backup path (if any).
368fn make_backup(
369    params: &PatchParams,
370    path: &Path,
371    bytes: &[u8],
372    permissions: &std::fs::Permissions,
373) -> Result<Option<String>, String> {
374    if !params.backup {
375        return Ok(None);
376    }
377    let bp = params
378        .backup_path
379        .as_deref()
380        .map(PathBuf::from)
381        .or_else(|| default_backup_path(path))
382        .ok_or_else(|| format!("ERROR: cannot compute backup path for {}", path.display()))?;
383    write_atomic_bytes_with_permissions(&bp, bytes, Some(permissions))
384        .map_err(|e| format!("ERROR: cannot create backup {}: {e}", bp.display()))?;
385    Ok(Some(bp.to_string_lossy().to_string()))
386}
387
388/// The success receipt: one line (`patched <file>: N lines (+a -b)[, k edits]`)
389/// by default. With `evidence` the pre/postimage digests, the per-op ledger of
390/// a batch and the bounded diff follow.
391fn render_success(
392    params: &PatchParams,
393    pre: &crate::tools::edit_io::FilePreimage,
394    new_content: &str,
395    lines_after: usize,
396    (lines_added, lines_removed): (u64, u64),
397    n_edits: usize,
398    backup_path: Option<String>,
399) -> String {
400    let short = output::short_name(&params.path);
401    let detail = (n_edits > 1).then(|| format!("{n_edits} edits"));
402    let mut out = crate::tools::ctx_edit::edit_summary_line(
403        &short,
404        lines_after,
405        lines_added,
406        lines_removed,
407        detail.as_deref(),
408    );
409    if let Some(bp) = backup_path {
410        out.push_str(&format!("\nbackup: {bp}"));
411    }
412    if params.evidence {
413        let post_mtime_ms = std::fs::metadata(&params.path)
414            .ok()
415            .and_then(|m| m.modified().ok())
416            .map_or(0, crate::tools::edit_io::system_time_to_millis);
417        out.push_str(&format!(
418            "\npreimage: bytes={}, mtime_ms={}, blake3={}\n\
419postimage: bytes={}, mtime_ms={post_mtime_ms}, blake3={}",
420            pre.fp.size,
421            pre.fp.mtime_ms,
422            pre.fp.blake3,
423            new_content.len(),
424            lean_ctx::core::hasher::hash_hex(new_content.as_bytes())
425        ));
426        if params.ops.len() > 1 {
427            out.push('\n');
428            out.push_str(&render_op_ledger(&params.ops));
429        }
430        let old_content = pre.text.as_str();
431        let diff = build_diff_evidence(old_content, new_content, &short, params.diff_max_lines);
432        out.push_str("\n\nevidence (diff, redacted, bounded):\n```diff\n");
433        out.push_str(&diff);
434        out.push_str("\n```");
435        let balance = brace_balance(new_content);
436        if balance == 0 {
437            out.push_str("\nbrace-balance: ok (matched)");
438        } else {
439            out.push_str(&format!(
440                "\n⚠ brace-balance: {} unmatched '{{' — verify file integrity",
441                balance.abs()
442            ));
443        }
444    } else if params.diff_artifact
445        && let Some(trailer) = crate::tools::ctx_edit::diff_artifact_trailer(
446            &pre.text,
447            new_content,
448            &short,
449            params.diff_max_lines,
450        )
451    {
452        out.push('\n');
453        out.push_str(&trailer);
454    }
455    out
456}
457
458/// Per-op ledger for a batch receipt (#1767): a count line plus one line per
459/// op, in the order the caller sent them.
460///
461/// The receipt used to state only a total — `10 anchored edits` — so a caller
462/// who needed to know *which* ops landed where had to read the evidence diff.
463/// That diff is the bounded, droppable part of the receipt and the first thing
464/// a turn-budget truncation removes (the default budget is 4096 tokens and a
465/// ten-op diff overruns it), which left the caller running `grep` against the
466/// file to find out what had happened. The ledger costs a few tokens per op,
467/// sits ahead of the diff, and therefore survives any cut that keeps the
468/// header at all.
469///
470/// `applied` is always `n/n`: a batch is atomic, so a receipt exists only when
471/// every op landed. Stating it is the point — the reader should not have to
472/// infer completeness from the absence of an error.
473fn render_op_ledger(ops: &[AnchorOp]) -> String {
474    let mut out = format!("ops: {n}/{n} applied", n = ops.len());
475    for (index, op) in ops.iter().enumerate() {
476        let (kind, span, produced) = match op {
477            AnchorOp::SetLine { line, new_text, .. } => {
478                ("set_line", line.to_string(), produced_lines(new_text))
479            }
480            AnchorOp::ReplaceLines {
481                start_line,
482                end_line,
483                new_text,
484                ..
485            } => (
486                "replace_lines",
487                format!("{start_line}-{end_line}"),
488                produced_lines(new_text),
489            ),
490            AnchorOp::InsertAfter { line, new_text, .. } => {
491                ("insert_after", line.to_string(), produced_lines(new_text))
492            }
493            AnchorOp::Delete {
494                start_line,
495                end_line,
496                ..
497            } => ("delete", format!("{start_line}-{end_line}"), 0),
498            AnchorOp::Create { new_text } => ("create", "-".to_string(), produced_lines(new_text)),
499        };
500        let line_word = if produced == 1 { "line" } else { "lines" };
501        out.push_str(&format!(
502            "\n  {}. {kind} {span} → {produced} {line_word}",
503            index + 1
504        ));
505    }
506    out
507}
508
509/// Lines a `new_text` payload contributes. Empty text deletes (the readseek
510/// convention the op variants document), which is zero lines — not one.
511fn produced_lines(new_text: &str) -> usize {
512    if new_text.is_empty() {
513        0
514    } else {
515        new_text.lines().count()
516    }
517}
518
519/// Counts unmatched `{` vs `}` in the full post-edit content. Returns the
520/// difference: positive = excess `{`, negative = excess `}`, 0 = balanced.
521fn brace_balance(content: &str) -> i64 {
522    content.chars().fold(0i64, |acc, c| match c {
523        '{' => acc + 1,
524        '}' => acc - 1,
525        _ => acc,
526    })
527}