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). Reuses the PathJail +
291/// atomic-write boundary of the anchored path.
292fn handle_create(params: &PatchParams, path: &Path, content: &str) -> (String, CacheEffect) {
293    if path.exists() {
294        return (
295            format!(
296                "ERROR: {} already exists — create is for new files only. \
297                 Use anchored ops (ctx_read mode=\"anchored\" → set_line/replace_lines) to modify it.",
298                params.path
299            ),
300            CacheEffect::None,
301        );
302    }
303
304    // Deny before create_dir_all can materialise a directory inside a
305    // read-only root (mirrors ctx_edit's create guard, #475).
306    if let Err(e) = lean_ctx::core::pathjail::enforce_writable(path) {
307        return (format!("ERROR: {e}"), CacheEffect::None);
308    }
309
310    if let Some(parent) = path.parent()
311        && !parent.exists()
312        && let Err(e) = std::fs::create_dir_all(parent)
313    {
314        return (
315            format!("ERROR: cannot create directory {}: {e}", parent.display()),
316            CacheEffect::None,
317        );
318    }
319
320    if let Err(e) = write_atomic_bytes_with_permissions(path, content.as_bytes(), None) {
321        return (e, CacheEffect::None);
322    }
323
324    let lines_added = u64::try_from(content.lines().count()).unwrap_or(u64::MAX);
325    lean_ctx::core::edit_metering::record_loc_change(Some(&params.path), lines_added, 0);
326
327    if let Ok(mut bt) = lean_ctx::core::bounce_tracker::global().lock() {
328        bt.record_edit(&params.path);
329    }
330
331    let lines = content.lines().count();
332    let short = output::short_name(&params.path);
333    let mut out = format!("created {short}: {lines} lines");
334    if params.evidence {
335        out.push_str(&format!(
336            "\npostimage: bytes={}, blake3={}",
337            content.len(),
338            lean_ctx::core::hasher::hash_hex(content.as_bytes())
339        ));
340        let diff = build_diff_evidence("", content, &short, params.diff_max_lines);
341        out.push_str("\n\nevidence (diff, redacted, bounded):\n```diff\n");
342        out.push_str(&diff);
343        out.push_str("\n```");
344    } else if params.diff_artifact
345        && let Some(trailer) = crate::tools::ctx_edit::diff_artifact_trailer(
346            "",
347            content,
348            &short,
349            params.diff_max_lines,
350        )
351    {
352        out.push('\n');
353        out.push_str(&trailer);
354    }
355    (out, CacheEffect::Invalidate)
356}
357
358/// Write a pre-edit backup when requested; returns the backup path (if any).
359fn make_backup(
360    params: &PatchParams,
361    path: &Path,
362    bytes: &[u8],
363    permissions: &std::fs::Permissions,
364) -> Result<Option<String>, String> {
365    if !params.backup {
366        return Ok(None);
367    }
368    let bp = params
369        .backup_path
370        .as_deref()
371        .map(PathBuf::from)
372        .or_else(|| default_backup_path(path))
373        .ok_or_else(|| format!("ERROR: cannot compute backup path for {}", path.display()))?;
374    write_atomic_bytes_with_permissions(&bp, bytes, Some(permissions))
375        .map_err(|e| format!("ERROR: cannot create backup {}: {e}", bp.display()))?;
376    Ok(Some(bp.to_string_lossy().to_string()))
377}
378
379/// The success receipt: one line (`patched <file>: N lines (+a -b)[, k edits]`)
380/// by default. With `evidence` the pre/postimage digests, the per-op ledger of
381/// a batch and the bounded diff follow.
382fn render_success(
383    params: &PatchParams,
384    pre: &crate::tools::edit_io::FilePreimage,
385    new_content: &str,
386    lines_after: usize,
387    (lines_added, lines_removed): (u64, u64),
388    n_edits: usize,
389    backup_path: Option<String>,
390) -> String {
391    let short = output::short_name(&params.path);
392    let detail = (n_edits > 1).then(|| format!("{n_edits} edits"));
393    let mut out = crate::tools::ctx_edit::edit_summary_line(
394        &short,
395        lines_after,
396        lines_added,
397        lines_removed,
398        detail.as_deref(),
399    );
400    if let Some(bp) = backup_path {
401        out.push_str(&format!("\nbackup: {bp}"));
402    }
403    if params.evidence {
404        let post_mtime_ms = std::fs::metadata(&params.path)
405            .ok()
406            .and_then(|m| m.modified().ok())
407            .map_or(0, crate::tools::edit_io::system_time_to_millis);
408        out.push_str(&format!(
409            "\npreimage: bytes={}, mtime_ms={}, blake3={}\n\
410postimage: bytes={}, mtime_ms={post_mtime_ms}, blake3={}",
411            pre.fp.size,
412            pre.fp.mtime_ms,
413            pre.fp.blake3,
414            new_content.len(),
415            lean_ctx::core::hasher::hash_hex(new_content.as_bytes())
416        ));
417        if params.ops.len() > 1 {
418            out.push('\n');
419            out.push_str(&render_op_ledger(&params.ops));
420        }
421        let old_content = pre.text.as_str();
422        let diff = build_diff_evidence(old_content, new_content, &short, params.diff_max_lines);
423        out.push_str("\n\nevidence (diff, redacted, bounded):\n```diff\n");
424        out.push_str(&diff);
425        out.push_str("\n```");
426        let balance = brace_balance(new_content);
427        if balance == 0 {
428            out.push_str("\nbrace-balance: ok (matched)");
429        } else {
430            out.push_str(&format!(
431                "\n⚠ brace-balance: {} unmatched '{{' — verify file integrity",
432                balance.abs()
433            ));
434        }
435    } else if params.diff_artifact
436        && let Some(trailer) = crate::tools::ctx_edit::diff_artifact_trailer(
437            &pre.text,
438            new_content,
439            &short,
440            params.diff_max_lines,
441        )
442    {
443        out.push('\n');
444        out.push_str(&trailer);
445    }
446    out
447}
448
449/// Per-op ledger for a batch receipt (#1767): a count line plus one line per
450/// op, in the order the caller sent them.
451///
452/// The receipt used to state only a total — `10 anchored edits` — so a caller
453/// who needed to know *which* ops landed where had to read the evidence diff.
454/// That diff is the bounded, droppable part of the receipt and the first thing
455/// a turn-budget truncation removes (the default budget is 4096 tokens and a
456/// ten-op diff overruns it), which left the caller running `grep` against the
457/// file to find out what had happened. The ledger costs a few tokens per op,
458/// sits ahead of the diff, and therefore survives any cut that keeps the
459/// header at all.
460///
461/// `applied` is always `n/n`: a batch is atomic, so a receipt exists only when
462/// every op landed. Stating it is the point — the reader should not have to
463/// infer completeness from the absence of an error.
464fn render_op_ledger(ops: &[AnchorOp]) -> String {
465    let mut out = format!("ops: {n}/{n} applied", n = ops.len());
466    for (index, op) in ops.iter().enumerate() {
467        let (kind, span, produced) = match op {
468            AnchorOp::SetLine { line, new_text, .. } => {
469                ("set_line", line.to_string(), produced_lines(new_text))
470            }
471            AnchorOp::ReplaceLines {
472                start_line,
473                end_line,
474                new_text,
475                ..
476            } => (
477                "replace_lines",
478                format!("{start_line}-{end_line}"),
479                produced_lines(new_text),
480            ),
481            AnchorOp::InsertAfter { line, new_text, .. } => {
482                ("insert_after", line.to_string(), produced_lines(new_text))
483            }
484            AnchorOp::Delete {
485                start_line,
486                end_line,
487                ..
488            } => ("delete", format!("{start_line}-{end_line}"), 0),
489            AnchorOp::Create { new_text } => ("create", "-".to_string(), produced_lines(new_text)),
490        };
491        let line_word = if produced == 1 { "line" } else { "lines" };
492        out.push_str(&format!(
493            "\n  {}. {kind} {span} → {produced} {line_word}",
494            index + 1
495        ));
496    }
497    out
498}
499
500/// Lines a `new_text` payload contributes. Empty text deletes (the readseek
501/// convention the op variants document), which is zero lines — not one.
502fn produced_lines(new_text: &str) -> usize {
503    if new_text.is_empty() {
504        0
505    } else {
506        new_text.lines().count()
507    }
508}
509
510/// Counts unmatched `{` vs `}` in the full post-edit content. Returns the
511/// difference: positive = excess `{`, negative = excess `}`, 0 = balanced.
512fn brace_balance(content: &str) -> i64 {
513    content.chars().fold(0i64, |acc, c| match c {
514        '{' => acc + 1,
515        '}' => acc - 1,
516        _ => acc,
517    })
518}