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. Multiple edits in
7//! one call are **batch-atomic** — all validated against the same preimage and
8//! applied all-or-nothing, bottom-up.
9//!
10//! Reuses the exact `ctx_edit` I/O boundary (`crate::tools::edit_io`):
11//! TOCTOU preimage guard, permission-preserving atomic write, read-only-roots
12//! deny, symlink rejection. `ctx_edit` (str_replace) stays as the fallback.
13
14mod anchors;
15mod apply;
16mod output;
17mod symbol;
18#[cfg(test)]
19mod tests;
20
21pub use anchors::AnchorOp;
22pub(crate) use symbol::{build_refactor_args, is_replace_symbol};
23
24use std::path::{Path, PathBuf};
25
26use crate::core::cache::SessionCache;
27use crate::core::tokens::count_tokens;
28use crate::tools::ctx_edit::{CacheEffect, apply_cache_effect, build_diff_evidence};
29use crate::tools::edit_io::{
30    default_backup_path, ensure_preimage_still_matches, read_preimage,
31    write_atomic_bytes_with_permissions,
32};
33
34/// Parameters for an anchored patch: the target file and one or more anchored
35/// edit ops, plus optional guards/evidence (mirrors `EditParams` where it makes
36/// sense so the registered wrapper stays uniform).
37pub struct PatchParams {
38    pub path: String,
39    pub ops: Vec<AnchorOp>,
40    /// Optional whole-file preimage guard (BLAKE3 hex, as printed by ctx_edit's
41    /// `postimage:` line). When set, the edit fails if the file's hash differs.
42    pub expected_md5: Option<String>,
43    pub backup: bool,
44    pub backup_path: Option<String>,
45    pub evidence: bool,
46    pub diff_max_lines: usize,
47    pub allow_lossy_utf8: bool,
48    /// Post-edit tree-sitter gate (#1008): reject a write that turns a cleanly
49    /// parsing file into a broken one. Default `true`; set `false` to override
50    /// (e.g. intentionally writing an incomplete snippet).
51    pub validate_syntax: bool,
52}
53
54/// Parse the raw tool arguments into [`AnchorOp`]s (single op or `ops[]`).
55pub fn parse_ops(
56    args: &serde_json::Map<String, serde_json::Value>,
57) -> Result<Vec<AnchorOp>, String> {
58    anchors::parse_ops(args)
59}
60
61/// Apply an anchored patch and the resulting cache effect in one shot (tests and
62/// in-process callers that hold the cache exclusively).
63pub fn handle(cache: &mut SessionCache, params: &PatchParams) -> String {
64    let last_mode = cache
65        .get(&params.path)
66        .map(|e| e.last_mode.clone())
67        .unwrap_or_default();
68    let (text, effect) = run_io(params, &last_mode);
69    record_outcome(params, &last_mode, &text, &effect);
70    apply_cache_effect(cache, &params.path, effect);
71    text
72}
73
74/// Quality loop (#494/#1008): a clean anchored edit is a success signal for the
75/// read mode that produced the anchors; a stale-anchor `CONFLICT` is a failure
76/// signal (the view the model edited against had drifted) that arms a one-shot
77/// escalation of the next auto read to `anchored` — fresh line anchors to retry
78/// by reference. Structural errors say nothing about the read mode and are
79/// skipped.
80pub fn record_outcome(params: &PatchParams, last_mode: &str, text: &str, effect: &CacheEffect) {
81    let success = matches!(effect, CacheEffect::Invalidate);
82    let conflict = matches!(effect, CacheEffect::None) && text.starts_with("CONFLICT:");
83    if success || conflict {
84        crate::core::edit_quality::record_anchored_edit_outcome(&params.path, last_mode, success);
85    }
86}
87
88/// Perform the anchored patch on disk **without** touching the cache; returns
89/// the [`CacheEffect`] for the caller to apply. `last_mode` is currently only
90/// used by [`record_outcome`]; pass `""` when unknown.
91pub fn run_io(params: &PatchParams, _last_mode: &str) -> (String, CacheEffect) {
92    let file_path = &params.path;
93    let path = Path::new(file_path);
94    let cap = crate::core::limits::max_read_bytes();
95
96    let pre = match read_preimage(path, cap, params.allow_lossy_utf8) {
97        Ok(p) => p,
98        Err(e) => {
99            if !path.exists() {
100                let hint = crate::tools::edit_recovery::moved_or_deleted_hint(path);
101                return (format!("{e}{hint}"), CacheEffect::None);
102            }
103            return (e, CacheEffect::None);
104        }
105    };
106
107    if let Some(expected) = params.expected_md5.as_deref()
108        && expected != pre.fp.md5
109    {
110        return (
111            format!(
112                "ERROR: preimage mismatch for {file_path}: expected_md5={expected}, actual_md5={}",
113                pre.fp.md5
114            ),
115            CacheEffect::None,
116        );
117    }
118
119    if params.ops.is_empty() {
120        return (
121            "ERROR: no edits provided (pass an op or ops:[…])".to_string(),
122            CacheEffect::None,
123        );
124    }
125
126    let (lines, sep, trailing) = apply::split_lines(&pre.text);
127
128    let edits = match apply::resolve_ops(&lines, &params.ops) {
129        Ok(e) => e,
130        Err(apply::ResolveError::Conflict(misses)) => {
131            return (
132                output::render_conflict(file_path, &lines, &misses),
133                CacheEffect::None,
134            );
135        }
136        Err(apply::ResolveError::Invalid(msg)) => {
137            return (format!("ERROR: {msg}"), CacheEffect::None);
138        }
139    };
140
141    let n_edits = edits.len();
142    let lines_before = lines.len();
143    let new_lines = apply::apply_edits(lines.clone(), edits);
144    let new_content = apply::join_lines(&new_lines, sep, trailing);
145
146    if new_content == pre.text {
147        return (
148            "ERROR: edits produced no change to the file".to_string(),
149            CacheEffect::None,
150        );
151    }
152
153    let ext = Path::new(file_path)
154        .extension()
155        .and_then(|e| e.to_str())
156        .unwrap_or("");
157
158    // Post-edit syntax gate (#1008): block a clean → broken regression before any
159    // write. Pure (no I/O), so it runs before the TOCTOU re-read.
160    if params.validate_syntax
161        && let Some(reason) = crate::core::syntax_validate::gate_edit(ext, &pre.text, &new_content)
162    {
163        return (reason, CacheEffect::None);
164    }
165
166    // Code-health gate: warn on (or block) cognitive-complexity drift before write.
167    let health_notice = match crate::core::code_health::gate::evaluate(&pre.text, &new_content, ext)
168    {
169        crate::core::code_health::gate::GateOutcome::Block(reason) => {
170            return (
171                format!("ERROR: code-health gate: {reason}"),
172                CacheEffect::None,
173            );
174        }
175        crate::core::code_health::gate::GateOutcome::Allow(notice) => notice,
176    };
177
178    // TOCTOU guard: confirm the file did not change between read and write.
179    if let Err(e) = ensure_preimage_still_matches(path, &pre.fp, cap) {
180        return (e, CacheEffect::None);
181    }
182
183    let backup_path = match make_backup(params, path, &pre.bytes, &pre.permissions) {
184        Ok(bp) => bp,
185        Err(e) => return (e, CacheEffect::None),
186    };
187
188    if let Err(e) =
189        write_atomic_bytes_with_permissions(path, new_content.as_bytes(), Some(&pre.permissions))
190    {
191        return (e, CacheEffect::None);
192    }
193
194    if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
195        bt.record_edit(file_path);
196    }
197
198    let mut out = render_success(
199        params,
200        &pre.text,
201        &new_content,
202        pre.fp.size,
203        pre.fp.mtime_ms,
204        &pre.fp.md5,
205        lines_before,
206        new_lines.len(),
207        n_edits,
208        backup_path,
209    );
210    if let Some(notice) = health_notice {
211        out.push_str("\n\n");
212        out.push_str(&notice);
213    }
214    (out, CacheEffect::Invalidate)
215}
216
217/// Write a pre-edit backup when requested; returns the backup path (if any).
218fn make_backup(
219    params: &PatchParams,
220    path: &Path,
221    bytes: &[u8],
222    permissions: &std::fs::Permissions,
223) -> Result<Option<String>, String> {
224    if !params.backup {
225        return Ok(None);
226    }
227    let bp = params
228        .backup_path
229        .as_deref()
230        .map(PathBuf::from)
231        .or_else(|| default_backup_path(path))
232        .ok_or_else(|| format!("ERROR: cannot compute backup path for {}", path.display()))?;
233    write_atomic_bytes_with_permissions(&bp, bytes, Some(permissions))
234        .map_err(|e| format!("ERROR: cannot create backup {}: {e}", bp.display()))?;
235    Ok(Some(bp.to_string_lossy().to_string()))
236}
237
238#[allow(clippy::too_many_arguments)]
239fn render_success(
240    params: &PatchParams,
241    old_content: &str,
242    new_content: &str,
243    pre_size: u64,
244    pre_mtime_ms: u64,
245    pre_md5: &str,
246    lines_before: usize,
247    lines_after: usize,
248    n_edits: usize,
249    backup_path: Option<String>,
250) -> String {
251    let short = output::short_name(&params.path);
252    let line_delta = lines_after as i64 - lines_before as i64;
253    let delta_str = if line_delta >= 0 {
254        format!("+{line_delta}")
255    } else {
256        format!("{line_delta}")
257    };
258    let old_tokens = count_tokens(old_content);
259    let new_tokens = count_tokens(new_content);
260
261    let post_mtime_ms = std::fs::metadata(&params.path)
262        .ok()
263        .and_then(|m| m.modified().ok())
264        .map_or(0, crate::tools::edit_io::system_time_to_millis);
265    let post_md5 = crate::core::hasher::hash_hex(new_content.as_bytes());
266
267    let edit_word = if n_edits == 1 { "edit" } else { "edits" };
268    let mut out = format!(
269        "✓ {short}: {n_edits} anchored {edit_word}, {delta_str} lines ({old_tokens}→{new_tokens} tok)\n\
270preimage: bytes={pre_size}, mtime_ms={pre_mtime_ms}, md5={pre_md5}\n\
271postimage: bytes={}, mtime_ms={post_mtime_ms}, md5={post_md5}",
272        new_content.len()
273    );
274    if let Some(bp) = backup_path {
275        out.push_str(&format!("\nbackup: {bp}"));
276    }
277    if params.evidence {
278        let diff = build_diff_evidence(old_content, new_content, &short, params.diff_max_lines);
279        out.push_str("\n\nevidence (diff, redacted, bounded):\n```diff\n");
280        out.push_str(&diff);
281        out.push_str("\n```");
282    }
283    out
284}