Skip to main content

lean_ctx_server/tools/ctx_edit/
implementation.rs

1// SPDX-License-Identifier: Apache-2.0
2use std::path::{Path, PathBuf};
3
4use lean_ctx::core::cache::SessionCache;
5use lean_ctx::core::tokens::count_tokens;
6// Shared TOCTOU-safe read→verify→atomic-write primitives (epic #1008): the same
7// audited boundary `ctx_patch` (anchored editing) builds on, so a fix protects
8// both tools. `verify_expected_preimage` stays here — it is `ctx_edit`-specific
9// (keyed on `EditParams`).
10use crate::tools::edit_io::{
11    FileFingerprint, FilePreimage, default_backup_path, ensure_preimage_still_matches,
12    read_preimage, system_time_to_millis, write_atomic_bytes_with_permissions,
13};
14
15/// Parameters for a file edit operation: path, old/new strings, and flags.
16pub struct EditParams {
17    pub path: String,
18    pub old_string: String,
19    pub new_string: String,
20    pub replace_all: bool,
21    pub create: bool,
22    /// Optional preimage guards. If provided, ctx_edit fails if the current file preimage differs.
23    ///
24    /// The content guard is BLAKE3, named for what it is (#1592); the wire
25    /// parameter `expected_md5` stays accepted as an alias for existing callers.
26    pub expected_blake3: Option<String>,
27    pub expected_size: Option<u64>,
28    pub expected_mtime_ms: Option<u64>,
29    /// Optional backup before writing.
30    pub backup: bool,
31    pub backup_path: Option<String>,
32    /// Emit the digests and the bounded, redacted diff in the result.
33    pub evidence: bool,
34    /// Without `evidence`: archive the diff for a host renderer and name it
35    /// in a short `[diff:<id>]` trailer (see `diff_artifact_trailer`).
36    pub diff_artifact: bool,
37    pub diff_max_lines: usize,
38    /// Reject invalid UTF-8 by default; allow lossy reads only when explicitly enabled.
39    pub allow_lossy_utf8: bool,
40}
41
42struct ReplaceArgs<'a> {
43    content: &'a str,
44    old_str: &'a str,
45    new_str: &'a str,
46    occurrences: usize,
47    replace_all: bool,
48}
49
50fn verify_expected_preimage(pre: &FilePreimage, params: &EditParams) -> Result<(), String> {
51    if let Some(expected) = params.expected_size
52        && expected != pre.fp.size
53    {
54        return Err(format!(
55            "ERROR: preimage mismatch for {}: expected_size={}, actual_size={}",
56            params.path, expected, pre.fp.size
57        ));
58    }
59    if let Some(expected) = params.expected_mtime_ms
60        && expected != pre.fp.mtime_ms
61    {
62        return Err(format!(
63            "ERROR: preimage mismatch for {}: expected_mtime_ms={}, actual_mtime_ms={}",
64            params.path, expected, pre.fp.mtime_ms
65        ));
66    }
67    if let Some(expected) = params.expected_blake3.as_deref()
68        && expected != pre.fp.blake3
69    {
70        return Err(format!(
71            "ERROR: preimage mismatch for {}: expected_blake3={}, actual_blake3={}",
72            params.path, expected, pre.fp.blake3
73        ));
74    }
75    Ok(())
76}
77
78/// Bounded, secret-redacted unified diff for edit evidence. `pub(crate)` so the
79/// anchored editor (`ctx_patch`) reuses the identical evidence format (#1008).
80pub(crate) fn build_diff_evidence(old: &str, new: &str, label: &str, max_lines: usize) -> String {
81    let diff = similar::TextDiff::from_lines(old, new)
82        .unified_diff()
83        .context_radius(3)
84        .header(label, label)
85        .to_string();
86    // Single source of truth for secret masking — `core::redaction` carries the
87    // GH #430 non-secret-literal guard (type annotations, `undefined`, …) and
88    // does not leak the value of generic long secrets like this duplicate once
89    // did. Keeping a second regex set here only invites drift.
90    let diff = lean_ctx::core::redaction::redact_text(&diff);
91
92    let mut out = String::new();
93    for (i, line) in diff.lines().enumerate() {
94        if i >= max_lines {
95            out.push_str(&format!("\n... diff truncated (max_lines={max_lines})"));
96            break;
97        }
98        out.push_str(line);
99        out.push('\n');
100    }
101    out.trim_end_matches('\n').to_string()
102}
103
104/// A cache mutation that an edit needs *after* its disk I/O completes.
105///
106/// Decoupling the cache mutation from the I/O lets the MCP layer perform the
107/// (slow) file read/replace/write while holding only a cheap per-file lock, then
108/// touch the shared cache for a sub-millisecond instant — instead of holding the
109/// global cache write-lock across all disk I/O (the root cause of issue #320).
110pub enum CacheEffect {
111    /// No cache change required (e.g. the edit failed before writing).
112    None,
113    /// The file on disk changed; drop the stale cache entry.
114    Invalidate,
115    /// Auto-escalation re-read full content that should be stored and marked
116    /// as fully delivered.
117    StoreFull(String),
118}
119
120/// Performs a string replacement edit on a file with CRLF/LF and whitespace
121/// tolerance. Thin wrapper that runs the I/O and applies the resulting cache
122/// effect to `cache` in one shot (used by tests and any in-process caller that
123/// already holds the cache exclusively).
124pub fn handle(cache: &mut SessionCache, params: &EditParams) -> String {
125    let last_mode = cache
126        .get(&params.path)
127        .map(|e| e.last_mode.clone())
128        .unwrap_or_default();
129    let (text, effect) = run_io(params, &last_mode);
130    record_outcome(params, &last_mode, &text, &effect);
131    apply_cache_effect(cache, &params.path, effect);
132    text
133}
134
135/// Quality loop (#494): classify the edit result and feed it into
136/// `lean_ctx::core::edit_quality`. Only two outcomes carry a compression
137/// signal: a clean replacement (success) and an `old_string` miss
138/// (failure — the body the agent quoted wasn't what's on disk). Parameter
139/// mistakes (empty/identical strings, preimage mismatch, missing file) and
140/// already-applied edits say nothing about the read mode and are skipped.
141pub fn record_outcome(params: &EditParams, last_mode: &str, text: &str, effect: &CacheEffect) {
142    // A create (explicit, or by an empty old_string) quoted no old text.
143    if params.create || params.old_string.is_empty() {
144        return;
145    }
146    let success = matches!(effect, CacheEffect::Invalidate);
147    let not_found_failure = matches!(effect, CacheEffect::StoreFull(_))
148        || (matches!(effect, CacheEffect::None)
149            && text.starts_with("ERROR: old_string not found")
150            && !text.contains("already"));
151    if success {
152        if let Some(project_root) = crate::server::derive_project_root_from_cwd() {
153            lean_ctx::core::solution_auto_capture::capture_edit_decisions(
154                &project_root,
155                &params.path,
156                &params.old_string,
157                &params.new_string,
158            );
159        }
160    }
161
162    if success || not_found_failure {
163        lean_ctx::core::edit_quality::record_edit_outcome(&params.path, last_mode, success);
164    }
165    // Edit-efficiency channel (#1008): the str_replace baseline — output tokens
166    // actually paid reproducing `old_string`, and blind-retry round-trips.
167    // Separate from the read-gain ledger, never printed in tool output (#498).
168    if success {
169        lean_ctx::core::edit_metering::record_str_replace_success(
170            count_tokens(&params.old_string) as u64
171        );
172    } else if not_found_failure {
173        lean_ctx::core::edit_metering::record_str_replace_miss();
174    }
175}
176
177/// Applies a deferred [`CacheEffect`] to the session cache.
178pub fn apply_cache_effect(cache: &mut SessionCache, path: &str, effect: CacheEffect) {
179    match effect {
180        CacheEffect::None => {}
181        CacheEffect::Invalidate => {
182            cache.invalidate(path);
183        }
184        CacheEffect::StoreFull(content) => {
185            cache.store(path, &content);
186            cache.mark_full_delivered(path);
187        }
188    }
189}
190
191/// Prefix of the result for an edit whose end state already holds: nothing is
192/// written, and the call is a success, not an error.
193pub const NO_CHANGE_PREFIX: &str = "No change:";
194
195/// An empty old_string on a file with content: there is no place to put
196/// new_string, and replacing the file is what create=true says explicitly.
197const EMPTY_OLD_STRING_REFUSAL: &str = "ERROR: old_string must not be empty for a non-empty \
198     file (an empty old_string only creates a missing or empty file; use create=true to \
199     replace the whole file)";
200
201/// No file at `path` (a dangling symlink included, which the create rules
202/// then refuse), or an empty regular file.
203fn is_missing_or_empty(path: &Path) -> bool {
204    match std::fs::metadata(path) {
205        Ok(meta) => meta.is_file() && meta.len() == 0,
206        Err(error) => error.kind() == std::io::ErrorKind::NotFound,
207    }
208}
209
210/// Performs the full edit on disk **without** touching the session cache, and
211/// reports back the [`CacheEffect`] the caller should apply afterwards.
212///
213/// `last_mode` is the cache's recorded read mode for the path (used only to
214/// decide whether to auto-escalate on a not-found match); pass `""` when unknown.
215pub fn run_io(params: &EditParams, last_mode: &str) -> (String, CacheEffect) {
216    let file_path = &params.path;
217
218    if params.create {
219        return handle_create(file_path, &params.new_string, params, false);
220    }
221
222    let cap = lean_ctx::core::limits::max_read_bytes();
223    let path = Path::new(file_path);
224
225    // An empty old_string replaces nothing, so it can only mean "write
226    // new_string here". Where nothing would be lost — no file yet, or an empty
227    // one — that is a create, under exactly the rules of create=true.
228    if params.old_string.is_empty() {
229        if is_missing_or_empty(path) {
230            return handle_create(file_path, &params.new_string, params, true);
231        }
232        return (EMPTY_OLD_STRING_REFUSAL.into(), CacheEffect::None);
233    }
234    let pre = match read_preimage(path, cap, params.allow_lossy_utf8) {
235        Ok(p) => p,
236        Err(e) => {
237            // File missing? Tell the agent whether it moved or the path is
238            // wrong, instead of a bare "cannot open" (#331 point 3).
239            if !path.exists() {
240                let hint = crate::tools::edit_recovery::moved_or_deleted_hint(path);
241                return (format!("{e}{hint}"), CacheEffect::None);
242            }
243            return (e, CacheEffect::None);
244        }
245    };
246    if let Err(e) = verify_expected_preimage(&pre, params) {
247        return (e, CacheEffect::None);
248    }
249    let content = &pre.text;
250
251    if params.old_string == params.new_string {
252        // The requested end state already holds: report a no-op, not a
253        // failure the agent would retry. Text that is absent stays an error.
254        if content.contains(&params.old_string) {
255            return (
256                format!(
257                    "{NO_CHANGE_PREFIX} {file_path} already contains this text \
258                     (old_string equals new_string)."
259                ),
260                CacheEffect::None,
261            );
262        }
263        return (
264            "ERROR: old_string and new_string are identical — nothing to change.".into(),
265            CacheEffect::None,
266        );
267    }
268
269    let uses_crlf = pre.uses_crlf;
270    let old_str = &params.old_string;
271    let new_str = &params.new_string;
272
273    let occurrences = content.matches(old_str).count();
274
275    if occurrences > 0 {
276        let args = ReplaceArgs {
277            content,
278            old_str,
279            new_str,
280            occurrences,
281            replace_all: params.replace_all,
282        };
283        return do_replace(path, &pre, params, cap, &args);
284    }
285
286    if uses_crlf && !old_str.contains('\r') {
287        let old_crlf = old_str.replace('\n', "\r\n");
288        let occ = content.matches(&old_crlf).count();
289        if occ > 0 {
290            let new_crlf = new_str.replace('\n', "\r\n");
291            let args = ReplaceArgs {
292                content,
293                old_str: &old_crlf,
294                new_str: &new_crlf,
295                occurrences: occ,
296                replace_all: params.replace_all,
297            };
298            return do_replace(path, &pre, params, cap, &args);
299        }
300    } else if !uses_crlf && old_str.contains("\r\n") {
301        let old_lf = old_str.replace("\r\n", "\n");
302        let occ = content.matches(&old_lf).count();
303        if occ > 0 {
304            let new_lf = new_str.replace("\r\n", "\n");
305            let args = ReplaceArgs {
306                content,
307                old_str: &old_lf,
308                new_str: &new_lf,
309                occurrences: occ,
310                replace_all: params.replace_all,
311            };
312            return do_replace(path, &pre, params, cap, &args);
313        }
314    }
315
316    // Lossy-read guard: after a compressed or mode-reduced read, old_string
317    // was quoted from a view that differs from the file, so only an exact
318    // match against the on-disk text may be written; the approximate matchers
319    // below are skipped and the miss asks for a full re-read.
320    let lossy_read = last_read_was_lossy(last_mode);
321
322    let normalized_content = trim_trailing_per_line(content);
323    let normalized_old = trim_trailing_per_line(old_str);
324    if !lossy_read && !normalized_old.is_empty() && normalized_content.contains(&normalized_old) {
325        let line_sep = if uses_crlf { "\r\n" } else { "\n" };
326        let adapted_new = adapt_new_string_to_line_sep(new_str, line_sep);
327        let adapted_old = find_original_span(content, &normalized_old);
328        if let Some(original_match) = adapted_old {
329            let occ = content.matches(&original_match).count();
330            let args = ReplaceArgs {
331                content,
332                old_str: &original_match,
333                new_str: &adapted_new,
334                occurrences: occ,
335                replace_all: params.replace_all,
336            };
337            return do_replace(path, &pre, params, cap, &args);
338        }
339    }
340
341    // Same block at a uniformly different indentation, in exactly one place.
342    if !lossy_read && let Some(found) = super::reindent::find(content, old_str, new_str) {
343        let line_sep = if uses_crlf { "\r\n" } else { "\n" };
344        let replacement = adapt_new_string_to_line_sep(&found.replacement, line_sep);
345        let args = ReplaceArgs {
346            content,
347            old_str: &found.span,
348            new_str: &replacement,
349            occurrences: content.matches(found.span.as_str()).count(),
350            replace_all: params.replace_all,
351        };
352        let (out, effect) = do_replace(path, &pre, params, cap, &args);
353        if out.starts_with("ERROR") {
354            return (out, effect);
355        }
356        return (
357            format!(
358                "note: old_string matched with different indentation — applied at the \
359                 file's indentation.\n{out}"
360            ),
361            effect,
362        );
363    }
364
365    // old_string is absent in every form tried above. A repeat of an edit this
366    // process applied to this file is proven, not guessed: the exact pair is
367    // on record and new_string is in the file, so the end state holds.
368    let new_present = content.contains(new_str.as_str())
369        || (uses_crlf && content.contains(&new_str.replace('\n', "\r\n")));
370    if new_present && super::applied_edits::contains(path, old_str, new_str) {
371        return (
372            format!(
373                "{NO_CHANGE_PREFIX} {file_path} — this exact edit was already applied by an \
374                 earlier ctx_edit call (old_string is absent, new_string is present)."
375            ),
376            CacheEffect::None,
377        );
378    }
379
380    // new_string often overlaps unchanged context, so its presence does not
381    // prove the edit landed (#2143): report it as a fact, keep the diagnosis.
382    let present_note = if content.contains(new_str) {
383        "\nnew_string exists in the file, but that does not establish the edit was \
384         already applied. Re-read the target region."
385    } else {
386        ""
387    };
388
389    let preview = if old_str.len() > 80 {
390        format!("{}...", &old_str[..old_str.floor_char_boundary(77)])
391    } else {
392        old_str.clone()
393    };
394    let hint = if uses_crlf {
395        " (file uses CRLF line endings)"
396    } else {
397        ""
398    };
399
400    // Everything this failure quotes from the file is model-visible, so it is
401    // taken from the admitted reading, never from the original bytes the edit
402    // itself works on. A withheld source yields no quote and no cache store.
403    let admitted = crate::tools::ctx_read::read_file_lossy_admitted_for_cli(file_path).ok();
404    let closest_hint = admitted
405        .as_ref()
406        .map(|source| find_closest_line_hint(&source.text, old_str))
407        .unwrap_or_default();
408    let cross_file = crate::tools::edit_recovery::cross_file_hint(path, old_str);
409
410    let (escalation, effect) = auto_escalate_reread(last_mode, admitted);
411    let lossy_note = if lossy_read {
412        format!(
413            "\nRefused: the last read of this file used mode=\"{last_mode}\", a lossy view, \
414             and old_string does not match the full file content exactly. Re-read the file \
415             with ctx_read mode=\"full\" (or \"anchored\") and retry with the exact text."
416        )
417    } else {
418        String::new()
419    };
420
421    (
422        format!(
423            "ERROR: old_string not found in {file_path}{hint}. \
424             Make sure it matches exactly (including whitespace/indentation).\n\
425             Searched for: {preview}{present_note}{closest_hint}{cross_file}{lossy_note}{escalation}"
426        ),
427        effect,
428    )
429}
430
431/// Whether the session's last read of the file was a lossy view (a
432/// compressed summary, a density cut or a one-line reference), whose text an
433/// edit must not be matched against approximately. Verbatim modes (`full`,
434/// `full-compact`, `anchored`, `raw`, line windows, `diff`) and an unknown or
435/// empty mode are not lossy.
436fn last_read_was_lossy(last_mode: &str) -> bool {
437    use crate::tools::ctx_read::ReadMode;
438    last_mode.parse::<ReadMode>().is_ok_and(|mode| {
439        mode.is_lossy_summary() || matches!(mode, ReadMode::Density(_) | ReadMode::Reference)
440    })
441}
442
443/// Finds the closest matching line in the file content to help the agent
444/// understand what went wrong. Returns a hint string or empty if no useful match.
445fn find_closest_line_hint(content: &str, old_str: &str) -> String {
446    let first_line = old_str.lines().next().unwrap_or("").trim();
447    if first_line.len() < 4 {
448        return String::new();
449    }
450
451    let mut best_line: Option<(usize, &str)> = None;
452
453    for (i, line) in content.lines().enumerate() {
454        if line.contains(first_line) {
455            best_line = Some((i + 1, line));
456            break;
457        }
458    }
459
460    // Try matching with significant identifiers from old_string's first line
461    if best_line.is_none() {
462        let keyword = first_line
463            .split(|c: char| !c.is_alphanumeric() && c != '_')
464            .find(|w| w.len() >= 4);
465
466        if let Some(keyword) = keyword {
467            for (i, line) in content.lines().enumerate() {
468                if line.contains(keyword) {
469                    best_line = Some((i + 1, line));
470                    break;
471                }
472            }
473        }
474    }
475
476    match best_line {
477        Some((line_num, line_content)) => {
478            let trimmed = line_content.trim();
479            let preview = if trimmed.len() > 100 {
480                format!("{}...", &trimmed[..trimmed.floor_char_boundary(97)])
481            } else {
482                trimmed.to_string()
483            };
484            format!(
485                "\nClosest match at line {line_num}: `{preview}`\n\
486                 Hint: check indentation/whitespace differences."
487            )
488        }
489        None => String::new(),
490    }
491}
492
493/// Auto-escalation: when old_string is not found and the file was previously read
494/// in a compressed mode, re-read in full and return the content so the agent
495/// can immediately retry with the correct old_string. Returns the text to append
496/// plus the [`CacheEffect`] the caller should apply (store full content).
497///
498/// `admitted` is the Gateway-admitted reading of the file; `None` (withheld or
499/// unreadable) escalates nothing. Restricted text is shown as admitted but
500/// never handed to the cache (E3).
501fn auto_escalate_reread(
502    last_mode: &str,
503    admitted: Option<lean_ctx::core::context_admission::SourceAdmission>,
504) -> (String, CacheEffect) {
505    if last_mode.is_empty() || last_mode == "full" {
506        return (String::new(), CacheEffect::None);
507    }
508
509    let Some(admitted) = admitted else {
510        return (String::new(), CacheEffect::None);
511    };
512    let storable = admitted.storable;
513    let fresh_content = admitted.text;
514
515    let line_count = fresh_content.lines().count();
516    const MAX_LINES: usize = 300;
517
518    let content_preview = if line_count <= MAX_LINES {
519        fresh_content.clone()
520    } else {
521        let lines: Vec<&str> = fresh_content.lines().collect();
522        let head = &lines[..MAX_LINES / 2];
523        let tail = &lines[line_count - MAX_LINES / 2..];
524        let omitted = line_count - MAX_LINES;
525        format!(
526            "{}\n[... {omitted} lines omitted ...]\n{}",
527            head.join("\n"),
528            tail.join("\n")
529        )
530    };
531
532    (
533        format!(
534            "\n\n[auto-escalation] Last read used mode=\"{last_mode}\". \
535             Full content ({line_count}L) below — retry edit with exact text from here:\n\n{content_preview}"
536        ),
537        if storable {
538            CacheEffect::StoreFull(fresh_content)
539        } else {
540            CacheEffect::None
541        },
542    )
543}
544
545/// Occurrences named by line in an ambiguous-match error.
546const LISTED_OCCURRENCES: usize = 5;
547
548/// `lines 4, 9, 17` — the 1-based lines where the first matches of `needle`
549/// start (non-overlapping, as `str::matches` counts them), with `and N more`
550/// beyond [`LISTED_OCCURRENCES`].
551fn occurrence_lines(content: &str, needle: &str, occurrences: usize) -> String {
552    let mut lines = Vec::with_capacity(LISTED_OCCURRENCES);
553    let (mut line, mut scanned) = (1, 0);
554    for (offset, _) in content.match_indices(needle).take(LISTED_OCCURRENCES) {
555        line += content[scanned..offset].matches('\n').count();
556        scanned = offset;
557        lines.push(line.to_string());
558    }
559    let mut out = format!("lines {}", lines.join(", "));
560    if occurrences > lines.len() {
561        out.push_str(&format!(" and {} more", occurrences - lines.len()));
562    }
563    out
564}
565
566fn do_replace(
567    path: &Path,
568    pre: &FilePreimage,
569    params: &EditParams,
570    cap: usize,
571    args: &ReplaceArgs<'_>,
572) -> (String, CacheEffect) {
573    if args.occurrences > 1 && !args.replace_all {
574        return (
575            format!(
576                "ERROR: old_string found {} times in {} ({}). \
577                 Use replace_all=true to replace every occurrence, or add surrounding \
578                 lines to old_string until it matches exactly one.",
579                args.occurrences,
580                path.display(),
581                occurrence_lines(args.content, args.old_str, args.occurrences)
582            ),
583            CacheEffect::None,
584        );
585    }
586
587    let new_content = if args.replace_all {
588        args.content.replace(args.old_str, args.new_str)
589    } else {
590        args.content.replacen(args.old_str, args.new_str, 1)
591    };
592
593    // Code-health gate: warn on (or block) cognitive-complexity drift before write.
594    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
595    let health_notice =
596        match lean_ctx::core::code_health::gate::evaluate(args.content, &new_content, ext) {
597            lean_ctx::core::code_health::gate::GateOutcome::Block(reason) => {
598                return (
599                    format!("ERROR: code-health gate: {reason}"),
600                    CacheEffect::None,
601                );
602            }
603            lean_ctx::core::code_health::gate::GateOutcome::Allow(notice) => notice,
604        };
605
606    // Pre-edit checks (unit claims, decisions): hint, or refuse when strict.
607    if let Err(e) = crate::tools::claims_gate::before_write(path, Some(args.content), &new_content)
608    {
609        return (e, CacheEffect::None);
610    }
611
612    // #960: a point-in-time check, not a held lock — see
613    // ensure_preimage_still_matches' doc for the residual window between
614    // this check and the write below.
615    if let Err(e) = ensure_preimage_still_matches(path, &pre.fp, cap) {
616        return (e, CacheEffect::None);
617    }
618
619    let backup_path = if params.backup {
620        let bp = params
621            .backup_path
622            .as_deref()
623            .map(PathBuf::from)
624            .or_else(|| default_backup_path(path));
625        let Some(bp) = bp else {
626            return (
627                format!("ERROR: cannot compute backup path for {}", path.display()),
628                CacheEffect::None,
629            );
630        };
631        if let Err(e) = write_atomic_bytes_with_permissions(&bp, &pre.bytes, Some(&pre.permissions))
632        {
633            return (
634                format!("ERROR: cannot create backup {}: {e}", bp.display()),
635                CacheEffect::None,
636            );
637        }
638        Some(bp.to_string_lossy().to_string())
639    } else {
640        None
641    };
642
643    if let Err(e) =
644        write_atomic_bytes_with_permissions(path, new_content.as_bytes(), Some(&pre.permissions))
645    {
646        return (e, CacheEffect::None);
647    }
648    // The pair as the caller sent it: a repeat sends the same pair.
649    super::applied_edits::record(path, &params.old_string, &params.new_string);
650
651    let replacements = if args.replace_all {
652        args.occurrences
653    } else {
654        1
655    };
656    let replacements = u64::try_from(replacements).unwrap_or(u64::MAX);
657    let lines_added = u64::try_from(args.new_str.lines().count())
658        .unwrap_or(u64::MAX)
659        .saturating_mul(replacements);
660    let lines_removed = u64::try_from(args.old_str.lines().count())
661        .unwrap_or(u64::MAX)
662        .saturating_mul(replacements);
663    lean_ctx::core::edit_metering::record_loc_change(
664        Some(&params.path),
665        lines_added,
666        lines_removed,
667    );
668
669    if let Ok(mut bt) = lean_ctx::core::bounce_tracker::global().lock() {
670        bt.record_edit(&params.path);
671    }
672
673    let new_lines = new_content.lines().count();
674    // The receipt counts the lines that actually changed, not the spans the
675    // caller sent: an anchor repeated in new_text is unchanged text, and a
676    // receipt of "+80 -6" for a pure 74-line insertion sent agents to
677    // `git diff` to see what had been deleted.
678    let (net_added, net_removed) = net_line_change(args.old_str.lines(), args.new_str.lines());
679    let detail = (args.replace_all && args.occurrences > 1)
680        .then(|| format!("{} replacements", args.occurrences));
681
682    let short = path.file_name().map_or_else(
683        || path.to_string_lossy().to_string(),
684        |f| f.to_string_lossy().to_string(),
685    );
686
687    let mut out = edit_summary_line(
688        &short,
689        new_lines,
690        net_added.saturating_mul(replacements),
691        net_removed.saturating_mul(replacements),
692        detail.as_deref(),
693    );
694    if let Some(bp) = backup_path {
695        out.push_str(&format!("\nbackup: {bp}"));
696    }
697    if params.evidence {
698        let post_mtime_ms = std::fs::metadata(path)
699            .ok()
700            .and_then(|m| m.modified().ok())
701            .map_or(0, system_time_to_millis);
702        let post_fp = FileFingerprint {
703            size: new_content.len() as u64,
704            mtime_ms: post_mtime_ms,
705            blake3: lean_ctx::core::hasher::hash_hex(new_content.as_bytes()),
706        };
707        out.push_str(&format!(
708            "\npreimage: bytes={}, mtime_ms={}, blake3={}\n\
709postimage: bytes={}, mtime_ms={}, blake3={}",
710            pre.fp.size,
711            pre.fp.mtime_ms,
712            pre.fp.blake3,
713            post_fp.size,
714            post_fp.mtime_ms,
715            post_fp.blake3
716        ));
717        let diff = build_diff_evidence(args.content, &new_content, &short, params.diff_max_lines);
718        out.push_str("\n\nevidence (diff, redacted, bounded):\n```diff\n");
719        out.push_str(&diff);
720        out.push_str("\n```");
721    } else if params.diff_artifact
722        && let Some(trailer) =
723            diff_artifact_trailer(args.content, &new_content, &short, params.diff_max_lines)
724    {
725        out.push('\n');
726        out.push_str(&trailer);
727    }
728    if let Some(notice) = health_notice {
729        out.push_str("\n\n");
730        out.push_str(&notice);
731    }
732    (out, CacheEffect::Invalidate)
733}
734
735/// The edit's diff for a host renderer (the Claude Code mod draws it), kept
736/// out of the model's view: the bounded, redacted diff goes to the
737/// content-addressed tool archive (private files, the archive's retention;
738/// like every archive entry it is also found by archive search and
739/// `ctx_expand`) and the result names it as `[diff:<16-hex id>]`, about 13 tokens,
740/// the mod reading exactly that file. `None` when the archive may not hold it
741/// (archive off, policy, minimal escape hatch).
742pub(crate) fn diff_artifact_trailer(
743    old: &str,
744    new: &str,
745    short: &str,
746    max_lines: usize,
747) -> Option<String> {
748    let id = diff_artifact_id(old, new, short, max_lines)?;
749    Some(format!("[diff:{id}]"))
750}
751
752/// Archives the bounded, redacted diff of an edit; its archive id.
753pub(crate) fn diff_artifact_id(
754    old: &str,
755    new: &str,
756    short: &str,
757    max_lines: usize,
758) -> Option<String> {
759    if lean_ctx::core::config::Config::minimal_escape_hatch() {
760        return None;
761    }
762    let diff = build_diff_evidence(old, new, short, max_lines);
763    lean_ctx::core::archive::store("ctx_patch", &format!("diff {short}"), &diff, None)
764}
765
766/// Lines added and removed between `old` and `new` once the lines both share
767/// at the start and at the end are set aside — the change a diff would show
768/// for one contiguous edit.
769pub(crate) fn net_line_change<S: AsRef<str>, T: AsRef<str>>(
770    old: impl IntoIterator<Item = S>,
771    new: impl IntoIterator<Item = T>,
772) -> (u64, u64) {
773    let old: Vec<S> = old.into_iter().collect();
774    let new: Vec<T> = new.into_iter().collect();
775    let prefix = old
776        .iter()
777        .zip(&new)
778        .take_while(|(a, b)| a.as_ref() == b.as_ref())
779        .count();
780    let suffix = old[prefix..]
781        .iter()
782        .rev()
783        .zip(new[prefix..].iter().rev())
784        .take_while(|(a, b)| a.as_ref() == b.as_ref())
785        .count();
786    let count = |n: usize| u64::try_from(n - prefix - suffix).unwrap_or(u64::MAX);
787    (count(new.len()), count(old.len()))
788}
789
790/// The one-line success receipt shared by `ctx_edit` and `ctx_patch`:
791/// `patched <file>: <lines now> lines (+added -removed)[, detail]`. The
792/// pre/postimage digests and the diff follow only when `evidence` is asked.
793pub(crate) fn edit_summary_line(
794    short: &str,
795    lines_after: usize,
796    added: u64,
797    removed: u64,
798    detail: Option<&str>,
799) -> String {
800    let mut line = format!("patched {short}: {lines_after} lines (+{added} -{removed})");
801    if let Some(detail) = detail {
802        line.push_str(", ");
803        line.push_str(detail);
804    }
805    line
806}
807
808/// Writes `content` as the whole file. With `only_if_empty` (an empty
809/// old_string), a file that has content by the time it is read is refused
810/// rather than replaced.
811fn handle_create(
812    file_path: &str,
813    content: &str,
814    params: &EditParams,
815    only_if_empty: bool,
816) -> (String, CacheEffect) {
817    let path = Path::new(file_path);
818    let cap = lean_ctx::core::limits::max_read_bytes();
819
820    // Deny before the standalone create_dir_all below can materialise a
821    // directory inside a read-only root (#475). The atomic writer guards the
822    // file write too, but this stops an empty-dir side effect first.
823    if let Err(e) = lean_ctx::core::pathjail::enforce_writable(path) {
824        return (format!("ERROR: {e}"), CacheEffect::None);
825    }
826
827    let mut preimage: Option<FilePreimage> = None;
828    if path.exists() {
829        let pre = match read_preimage(path, cap, params.allow_lossy_utf8) {
830            Ok(p) => p,
831            Err(e) => return (e, CacheEffect::None),
832        };
833        if only_if_empty && !pre.bytes.is_empty() {
834            return (EMPTY_OLD_STRING_REFUSAL.into(), CacheEffect::None);
835        }
836        if let Err(e) = verify_expected_preimage(&pre, params) {
837            return (e, CacheEffect::None);
838        }
839        // #960: a point-in-time check, not a held lock — see
840        // ensure_preimage_still_matches' doc for the residual window between
841        // this check and the write below.
842        if let Err(e) = ensure_preimage_still_matches(path, &pre.fp, cap) {
843            return (e, CacheEffect::None);
844        }
845        preimage = Some(pre);
846    }
847
848    let before = preimage.as_ref().map(|pre| pre.text.as_str());
849    if let Err(e) = crate::tools::claims_gate::before_write(path, before, content) {
850        return (e, CacheEffect::None);
851    }
852
853    if let Some(parent) = path.parent()
854        && !parent.exists()
855        && let Err(e) = std::fs::create_dir_all(parent)
856    {
857        return (
858            format!("ERROR: cannot create directory {}: {e}", parent.display()),
859            CacheEffect::None,
860        );
861    }
862
863    let backup_path = if params.backup {
864        if let Some(pre) = &preimage {
865            let bp = params
866                .backup_path
867                .as_deref()
868                .map(PathBuf::from)
869                .or_else(|| default_backup_path(path));
870            let Some(bp) = bp else {
871                return (
872                    format!("ERROR: cannot compute backup path for {}", path.display()),
873                    CacheEffect::None,
874                );
875            };
876            if let Err(e) =
877                write_atomic_bytes_with_permissions(&bp, &pre.bytes, Some(&pre.permissions))
878            {
879                return (
880                    format!("ERROR: cannot create backup {}: {e}", bp.display()),
881                    CacheEffect::None,
882                );
883            }
884            Some(bp.to_string_lossy().to_string())
885        } else {
886            None
887        }
888    } else {
889        None
890    };
891
892    let perms = preimage.as_ref().map(|p| &p.permissions);
893    if let Err(e) = write_atomic_bytes_with_permissions(path, content.as_bytes(), perms) {
894        return (e, CacheEffect::None);
895    }
896
897    let lines_added = u64::try_from(content.lines().count()).unwrap_or(u64::MAX);
898    let lines_removed = preimage.as_ref().map_or(0, |pre| {
899        u64::try_from(pre.text.lines().count()).unwrap_or(u64::MAX)
900    });
901    lean_ctx::core::edit_metering::record_loc_change(Some(file_path), lines_added, lines_removed);
902
903    let lines = content.lines().count();
904    let tokens = count_tokens(content);
905    let short = path.file_name().map_or_else(
906        || path.to_string_lossy().to_string(),
907        |f| f.to_string_lossy().to_string(),
908    );
909
910    let mut out = format!("✓ created {short}: {lines} lines, {tokens} tok");
911    if let Some(bp) = backup_path {
912        out.push_str(&format!("\nbackup: {bp}"));
913    }
914    (out, CacheEffect::Invalidate)
915}
916
917fn trim_trailing_per_line(s: &str) -> String {
918    s.lines().map(str::trim_end).collect::<Vec<_>>().join("\n")
919}
920
921fn adapt_new_string_to_line_sep(s: &str, sep: &str) -> String {
922    let normalized = s.replace("\r\n", "\n");
923    if sep == "\r\n" {
924        normalized.replace('\n', "\r\n")
925    } else {
926        normalized
927    }
928}
929
930/// Find the original (un-trimmed) span in `content` that matches `normalized_needle`
931/// after trailing-whitespace trimming per line.
932fn find_original_span(content: &str, normalized_needle: &str) -> Option<String> {
933    let needle_lines: Vec<&str> = normalized_needle.lines().collect();
934    if needle_lines.is_empty() {
935        return None;
936    }
937
938    let content_lines: Vec<&str> = content.lines().collect();
939
940    'outer: for start in 0..content_lines.len() {
941        if start + needle_lines.len() > content_lines.len() {
942            break;
943        }
944        for (i, nl) in needle_lines.iter().enumerate() {
945            if content_lines[start + i].trim_end() != *nl {
946                continue 'outer;
947            }
948        }
949        let sep = if content.contains("\r\n") {
950            "\r\n"
951        } else {
952            "\n"
953        };
954        return Some(content_lines[start..start + needle_lines.len()].join(sep));
955    }
956    None
957}
958
959pub fn captured_solution_decision_kinds(old_content: &str, new_content: &str) -> Vec<&'static str> {
960    let mut kinds = Vec::new();
961
962    if new_content.contains("use std::") && !old_content.contains("use std::") {
963        kinds.push("stdlib");
964    }
965
966    if new_content.contains("// lean-ctx:") && !old_content.contains("// lean-ctx:") {
967        kinds.push("debt");
968    }
969
970    let old_deps = old_content
971        .lines()
972        .filter(|line| line.contains("[dependencies]") || line.contains("= \""))
973        .count();
974    let new_deps = new_content
975        .lines()
976        .filter(|line| line.contains("[dependencies]") || line.contains("= \""))
977        .count();
978    if new_deps < old_deps {
979        kinds.push("native");
980    }
981
982    let old_lines = old_content.lines().count();
983    let new_lines = new_content.lines().count();
984    if old_lines > 5 && new_lines < old_lines / 2 {
985        kinds.push("oneline");
986    }
987
988    kinds
989}
990
991#[cfg(test)]
992mod solution_auto_capture_tests {
993    use super::captured_solution_decision_kinds;
994
995    #[test]
996    fn classifies_decisions_from_completed_edit_content() {
997        let old = "use crate::core::Config;\n[dependencies]\nlegacy = \"1\"\nfn verbose() {\n    first();\n    second();\n    third();\n    fourth();\n    fifth();\n    sixth();\n}\n";
998        let new = "use crate::core::Config;\nuse std::collections::HashMap;\n[dependencies]\n// lean-ctx: replace legacy dependency\nfn concise() {}\n";
999
1000        assert_eq!(
1001            captured_solution_decision_kinds(old, new),
1002            ["stdlib", "debt", "native"]
1003        );
1004    }
1005}