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    // #960: a point-in-time check, not a held lock — see
607    // ensure_preimage_still_matches' doc for the residual window between
608    // this check and the write below.
609    if let Err(e) = ensure_preimage_still_matches(path, &pre.fp, cap) {
610        return (e, CacheEffect::None);
611    }
612
613    let backup_path = if params.backup {
614        let bp = params
615            .backup_path
616            .as_deref()
617            .map(PathBuf::from)
618            .or_else(|| default_backup_path(path));
619        let Some(bp) = bp else {
620            return (
621                format!("ERROR: cannot compute backup path for {}", path.display()),
622                CacheEffect::None,
623            );
624        };
625        if let Err(e) = write_atomic_bytes_with_permissions(&bp, &pre.bytes, Some(&pre.permissions))
626        {
627            return (
628                format!("ERROR: cannot create backup {}: {e}", bp.display()),
629                CacheEffect::None,
630            );
631        }
632        Some(bp.to_string_lossy().to_string())
633    } else {
634        None
635    };
636
637    if let Err(e) =
638        write_atomic_bytes_with_permissions(path, new_content.as_bytes(), Some(&pre.permissions))
639    {
640        return (e, CacheEffect::None);
641    }
642    // The pair as the caller sent it: a repeat sends the same pair.
643    super::applied_edits::record(path, &params.old_string, &params.new_string);
644
645    let replacements = if args.replace_all {
646        args.occurrences
647    } else {
648        1
649    };
650    let replacements = u64::try_from(replacements).unwrap_or(u64::MAX);
651    let lines_added = u64::try_from(args.new_str.lines().count())
652        .unwrap_or(u64::MAX)
653        .saturating_mul(replacements);
654    let lines_removed = u64::try_from(args.old_str.lines().count())
655        .unwrap_or(u64::MAX)
656        .saturating_mul(replacements);
657    lean_ctx::core::edit_metering::record_loc_change(
658        Some(&params.path),
659        lines_added,
660        lines_removed,
661    );
662
663    if let Ok(mut bt) = lean_ctx::core::bounce_tracker::global().lock() {
664        bt.record_edit(&params.path);
665    }
666
667    let new_lines = new_content.lines().count();
668    // The receipt counts the lines that actually changed, not the spans the
669    // caller sent: an anchor repeated in new_text is unchanged text, and a
670    // receipt of "+80 -6" for a pure 74-line insertion sent agents to
671    // `git diff` to see what had been deleted.
672    let (net_added, net_removed) = net_line_change(args.old_str.lines(), args.new_str.lines());
673    let detail = (args.replace_all && args.occurrences > 1)
674        .then(|| format!("{} replacements", args.occurrences));
675
676    let short = path.file_name().map_or_else(
677        || path.to_string_lossy().to_string(),
678        |f| f.to_string_lossy().to_string(),
679    );
680
681    let mut out = edit_summary_line(
682        &short,
683        new_lines,
684        net_added.saturating_mul(replacements),
685        net_removed.saturating_mul(replacements),
686        detail.as_deref(),
687    );
688    if let Some(bp) = backup_path {
689        out.push_str(&format!("\nbackup: {bp}"));
690    }
691    if params.evidence {
692        let post_mtime_ms = std::fs::metadata(path)
693            .ok()
694            .and_then(|m| m.modified().ok())
695            .map_or(0, system_time_to_millis);
696        let post_fp = FileFingerprint {
697            size: new_content.len() as u64,
698            mtime_ms: post_mtime_ms,
699            blake3: lean_ctx::core::hasher::hash_hex(new_content.as_bytes()),
700        };
701        out.push_str(&format!(
702            "\npreimage: bytes={}, mtime_ms={}, blake3={}\n\
703postimage: bytes={}, mtime_ms={}, blake3={}",
704            pre.fp.size,
705            pre.fp.mtime_ms,
706            pre.fp.blake3,
707            post_fp.size,
708            post_fp.mtime_ms,
709            post_fp.blake3
710        ));
711        let diff = build_diff_evidence(args.content, &new_content, &short, params.diff_max_lines);
712        out.push_str("\n\nevidence (diff, redacted, bounded):\n```diff\n");
713        out.push_str(&diff);
714        out.push_str("\n```");
715    } else if params.diff_artifact
716        && let Some(trailer) =
717            diff_artifact_trailer(args.content, &new_content, &short, params.diff_max_lines)
718    {
719        out.push('\n');
720        out.push_str(&trailer);
721    }
722    if let Some(notice) = health_notice {
723        out.push_str("\n\n");
724        out.push_str(&notice);
725    }
726    (out, CacheEffect::Invalidate)
727}
728
729/// The edit's diff for a host renderer (the Claude Code mod draws it), kept
730/// out of the model's view: the bounded, redacted diff goes to the
731/// content-addressed tool archive (private files, the archive's retention;
732/// like every archive entry it is also found by archive search and
733/// `ctx_expand`) and the result names it as `[diff:<16-hex id>]`, about 13 tokens,
734/// the mod reading exactly that file. `None` when the archive may not hold it
735/// (archive off, policy, minimal escape hatch).
736pub(crate) fn diff_artifact_trailer(
737    old: &str,
738    new: &str,
739    short: &str,
740    max_lines: usize,
741) -> Option<String> {
742    let id = diff_artifact_id(old, new, short, max_lines)?;
743    Some(format!("[diff:{id}]"))
744}
745
746/// Archives the bounded, redacted diff of an edit; its archive id.
747pub(crate) fn diff_artifact_id(
748    old: &str,
749    new: &str,
750    short: &str,
751    max_lines: usize,
752) -> Option<String> {
753    if lean_ctx::core::config::Config::minimal_escape_hatch() {
754        return None;
755    }
756    let diff = build_diff_evidence(old, new, short, max_lines);
757    lean_ctx::core::archive::store("ctx_patch", &format!("diff {short}"), &diff, None)
758}
759
760/// Lines added and removed between `old` and `new` once the lines both share
761/// at the start and at the end are set aside — the change a diff would show
762/// for one contiguous edit.
763pub(crate) fn net_line_change<S: AsRef<str>, T: AsRef<str>>(
764    old: impl IntoIterator<Item = S>,
765    new: impl IntoIterator<Item = T>,
766) -> (u64, u64) {
767    let old: Vec<S> = old.into_iter().collect();
768    let new: Vec<T> = new.into_iter().collect();
769    let prefix = old
770        .iter()
771        .zip(&new)
772        .take_while(|(a, b)| a.as_ref() == b.as_ref())
773        .count();
774    let suffix = old[prefix..]
775        .iter()
776        .rev()
777        .zip(new[prefix..].iter().rev())
778        .take_while(|(a, b)| a.as_ref() == b.as_ref())
779        .count();
780    let count = |n: usize| u64::try_from(n - prefix - suffix).unwrap_or(u64::MAX);
781    (count(new.len()), count(old.len()))
782}
783
784/// The one-line success receipt shared by `ctx_edit` and `ctx_patch`:
785/// `patched <file>: <lines now> lines (+added -removed)[, detail]`. The
786/// pre/postimage digests and the diff follow only when `evidence` is asked.
787pub(crate) fn edit_summary_line(
788    short: &str,
789    lines_after: usize,
790    added: u64,
791    removed: u64,
792    detail: Option<&str>,
793) -> String {
794    let mut line = format!("patched {short}: {lines_after} lines (+{added} -{removed})");
795    if let Some(detail) = detail {
796        line.push_str(", ");
797        line.push_str(detail);
798    }
799    line
800}
801
802/// Writes `content` as the whole file. With `only_if_empty` (an empty
803/// old_string), a file that has content by the time it is read is refused
804/// rather than replaced.
805fn handle_create(
806    file_path: &str,
807    content: &str,
808    params: &EditParams,
809    only_if_empty: bool,
810) -> (String, CacheEffect) {
811    let path = Path::new(file_path);
812    let cap = lean_ctx::core::limits::max_read_bytes();
813
814    // Deny before the standalone create_dir_all below can materialise a
815    // directory inside a read-only root (#475). The atomic writer guards the
816    // file write too, but this stops an empty-dir side effect first.
817    if let Err(e) = lean_ctx::core::pathjail::enforce_writable(path) {
818        return (format!("ERROR: {e}"), CacheEffect::None);
819    }
820
821    let mut preimage: Option<FilePreimage> = None;
822    if path.exists() {
823        let pre = match read_preimage(path, cap, params.allow_lossy_utf8) {
824            Ok(p) => p,
825            Err(e) => return (e, CacheEffect::None),
826        };
827        if only_if_empty && !pre.bytes.is_empty() {
828            return (EMPTY_OLD_STRING_REFUSAL.into(), CacheEffect::None);
829        }
830        if let Err(e) = verify_expected_preimage(&pre, params) {
831            return (e, CacheEffect::None);
832        }
833        // #960: a point-in-time check, not a held lock — see
834        // ensure_preimage_still_matches' doc for the residual window between
835        // this check and the write below.
836        if let Err(e) = ensure_preimage_still_matches(path, &pre.fp, cap) {
837            return (e, CacheEffect::None);
838        }
839        preimage = Some(pre);
840    }
841
842    if let Some(parent) = path.parent()
843        && !parent.exists()
844        && let Err(e) = std::fs::create_dir_all(parent)
845    {
846        return (
847            format!("ERROR: cannot create directory {}: {e}", parent.display()),
848            CacheEffect::None,
849        );
850    }
851
852    let backup_path = if params.backup {
853        if let Some(pre) = &preimage {
854            let bp = params
855                .backup_path
856                .as_deref()
857                .map(PathBuf::from)
858                .or_else(|| default_backup_path(path));
859            let Some(bp) = bp else {
860                return (
861                    format!("ERROR: cannot compute backup path for {}", path.display()),
862                    CacheEffect::None,
863                );
864            };
865            if let Err(e) =
866                write_atomic_bytes_with_permissions(&bp, &pre.bytes, Some(&pre.permissions))
867            {
868                return (
869                    format!("ERROR: cannot create backup {}: {e}", bp.display()),
870                    CacheEffect::None,
871                );
872            }
873            Some(bp.to_string_lossy().to_string())
874        } else {
875            None
876        }
877    } else {
878        None
879    };
880
881    let perms = preimage.as_ref().map(|p| &p.permissions);
882    if let Err(e) = write_atomic_bytes_with_permissions(path, content.as_bytes(), perms) {
883        return (e, CacheEffect::None);
884    }
885
886    let lines_added = u64::try_from(content.lines().count()).unwrap_or(u64::MAX);
887    let lines_removed = preimage.as_ref().map_or(0, |pre| {
888        u64::try_from(pre.text.lines().count()).unwrap_or(u64::MAX)
889    });
890    lean_ctx::core::edit_metering::record_loc_change(Some(file_path), lines_added, lines_removed);
891
892    let lines = content.lines().count();
893    let tokens = count_tokens(content);
894    let short = path.file_name().map_or_else(
895        || path.to_string_lossy().to_string(),
896        |f| f.to_string_lossy().to_string(),
897    );
898
899    let mut out = format!("✓ created {short}: {lines} lines, {tokens} tok");
900    if let Some(bp) = backup_path {
901        out.push_str(&format!("\nbackup: {bp}"));
902    }
903    (out, CacheEffect::Invalidate)
904}
905
906fn trim_trailing_per_line(s: &str) -> String {
907    s.lines().map(str::trim_end).collect::<Vec<_>>().join("\n")
908}
909
910fn adapt_new_string_to_line_sep(s: &str, sep: &str) -> String {
911    let normalized = s.replace("\r\n", "\n");
912    if sep == "\r\n" {
913        normalized.replace('\n', "\r\n")
914    } else {
915        normalized
916    }
917}
918
919/// Find the original (un-trimmed) span in `content` that matches `normalized_needle`
920/// after trailing-whitespace trimming per line.
921fn find_original_span(content: &str, normalized_needle: &str) -> Option<String> {
922    let needle_lines: Vec<&str> = normalized_needle.lines().collect();
923    if needle_lines.is_empty() {
924        return None;
925    }
926
927    let content_lines: Vec<&str> = content.lines().collect();
928
929    'outer: for start in 0..content_lines.len() {
930        if start + needle_lines.len() > content_lines.len() {
931            break;
932        }
933        for (i, nl) in needle_lines.iter().enumerate() {
934            if content_lines[start + i].trim_end() != *nl {
935                continue 'outer;
936            }
937        }
938        let sep = if content.contains("\r\n") {
939            "\r\n"
940        } else {
941            "\n"
942        };
943        return Some(content_lines[start..start + needle_lines.len()].join(sep));
944    }
945    None
946}
947
948pub fn captured_solution_decision_kinds(old_content: &str, new_content: &str) -> Vec<&'static str> {
949    let mut kinds = Vec::new();
950
951    if new_content.contains("use std::") && !old_content.contains("use std::") {
952        kinds.push("stdlib");
953    }
954
955    if new_content.contains("// lean-ctx:") && !old_content.contains("// lean-ctx:") {
956        kinds.push("debt");
957    }
958
959    let old_deps = old_content
960        .lines()
961        .filter(|line| line.contains("[dependencies]") || line.contains("= \""))
962        .count();
963    let new_deps = new_content
964        .lines()
965        .filter(|line| line.contains("[dependencies]") || line.contains("= \""))
966        .count();
967    if new_deps < old_deps {
968        kinds.push("native");
969    }
970
971    let old_lines = old_content.lines().count();
972    let new_lines = new_content.lines().count();
973    if old_lines > 5 && new_lines < old_lines / 2 {
974        kinds.push("oneline");
975    }
976
977    kinds
978}
979
980#[cfg(test)]
981mod solution_auto_capture_tests {
982    use super::captured_solution_decision_kinds;
983
984    #[test]
985    fn classifies_decisions_from_completed_edit_content() {
986        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";
987        let new = "use crate::core::Config;\nuse std::collections::HashMap;\n[dependencies]\n// lean-ctx: replace legacy dependency\nfn concise() {}\n";
988
989        assert_eq!(
990            captured_solution_decision_kinds(old, new),
991            ["stdlib", "debt", "native"]
992        );
993    }
994}