Skip to main content

lean_ctx/tools/ctx_edit/
implementation.rs

1use std::path::{Path, PathBuf};
2
3use crate::core::cache::SessionCache;
4use crate::core::tokens::count_tokens;
5// Shared TOCTOU-safe read→verify→atomic-write primitives (epic #1008): the same
6// audited boundary `ctx_patch` (anchored editing) builds on, so a fix protects
7// both tools. `verify_expected_preimage` stays here — it is `ctx_edit`-specific
8// (keyed on `EditParams`).
9use crate::tools::edit_io::{
10    FileFingerprint, FilePreimage, default_backup_path, ensure_preimage_still_matches,
11    read_preimage, system_time_to_millis, write_atomic_bytes_with_permissions,
12};
13
14/// Parameters for a file edit operation: path, old/new strings, and flags.
15pub struct EditParams {
16    pub path: String,
17    pub old_string: String,
18    pub new_string: String,
19    pub replace_all: bool,
20    pub create: bool,
21    /// Optional preimage guards. If provided, ctx_edit fails if the current file preimage differs.
22    pub expected_md5: Option<String>,
23    pub expected_size: Option<u64>,
24    pub expected_mtime_ms: Option<u64>,
25    /// Optional backup before writing.
26    pub backup: bool,
27    pub backup_path: Option<String>,
28    /// Emit bounded diff evidence (redacted) by default.
29    pub evidence: bool,
30    pub diff_max_lines: usize,
31    /// Reject invalid UTF-8 by default; allow lossy reads only when explicitly enabled.
32    pub allow_lossy_utf8: bool,
33}
34
35struct ReplaceArgs<'a> {
36    content: &'a str,
37    old_str: &'a str,
38    new_str: &'a str,
39    occurrences: usize,
40    replace_all: bool,
41    old_tokens: usize,
42    new_tokens: usize,
43}
44
45fn verify_expected_preimage(pre: &FilePreimage, params: &EditParams) -> Result<(), String> {
46    if let Some(expected) = params.expected_size
47        && expected != pre.fp.size
48    {
49        return Err(format!(
50            "ERROR: preimage mismatch for {}: expected_size={}, actual_size={}",
51            params.path, expected, pre.fp.size
52        ));
53    }
54    if let Some(expected) = params.expected_mtime_ms
55        && expected != pre.fp.mtime_ms
56    {
57        return Err(format!(
58            "ERROR: preimage mismatch for {}: expected_mtime_ms={}, actual_mtime_ms={}",
59            params.path, expected, pre.fp.mtime_ms
60        ));
61    }
62    if let Some(expected) = params.expected_md5.as_deref()
63        && expected != pre.fp.md5
64    {
65        return Err(format!(
66            "ERROR: preimage mismatch for {}: expected_md5={}, actual_md5={}",
67            params.path, expected, pre.fp.md5
68        ));
69    }
70    Ok(())
71}
72
73/// Bounded, secret-redacted unified diff for edit evidence. `pub(crate)` so the
74/// anchored editor (`ctx_patch`) reuses the identical evidence format (#1008).
75pub(crate) fn build_diff_evidence(old: &str, new: &str, label: &str, max_lines: usize) -> String {
76    let diff = similar::TextDiff::from_lines(old, new)
77        .unified_diff()
78        .context_radius(3)
79        .header(label, label)
80        .to_string();
81    // Single source of truth for secret masking — `core::redaction` carries the
82    // GH #430 non-secret-literal guard (type annotations, `undefined`, …) and
83    // does not leak the value of generic long secrets like this duplicate once
84    // did. Keeping a second regex set here only invites drift.
85    let diff = crate::core::redaction::redact_text(&diff);
86
87    let mut out = String::new();
88    for (i, line) in diff.lines().enumerate() {
89        if i >= max_lines {
90            out.push_str(&format!("\n... diff truncated (max_lines={max_lines})"));
91            break;
92        }
93        out.push_str(line);
94        out.push('\n');
95    }
96    out.trim_end_matches('\n').to_string()
97}
98
99/// A cache mutation that an edit needs *after* its disk I/O completes.
100///
101/// Decoupling the cache mutation from the I/O lets the MCP layer perform the
102/// (slow) file read/replace/write while holding only a cheap per-file lock, then
103/// touch the shared cache for a sub-millisecond instant — instead of holding the
104/// global cache write-lock across all disk I/O (the root cause of issue #320).
105pub enum CacheEffect {
106    /// No cache change required (e.g. the edit failed before writing).
107    None,
108    /// The file on disk changed; drop the stale cache entry.
109    Invalidate,
110    /// Auto-escalation re-read full content that should be stored and marked
111    /// as fully delivered.
112    StoreFull(String),
113}
114
115/// Performs a string replacement edit on a file with CRLF/LF and whitespace
116/// tolerance. Thin wrapper that runs the I/O and applies the resulting cache
117/// effect to `cache` in one shot (used by tests and any in-process caller that
118/// already holds the cache exclusively).
119pub fn handle(cache: &mut SessionCache, params: &EditParams) -> String {
120    let last_mode = cache
121        .get(&params.path)
122        .map(|e| e.last_mode.clone())
123        .unwrap_or_default();
124    let (text, effect) = run_io(params, &last_mode);
125    record_outcome(params, &last_mode, &text, &effect);
126    apply_cache_effect(cache, &params.path, effect);
127    text
128}
129
130/// Quality loop (#494): classify the edit result and feed it into
131/// `crate::core::edit_quality`. Only two outcomes carry a compression
132/// signal: a clean replacement (success) and an `old_string` miss
133/// (failure — the body the agent quoted wasn't what's on disk). Parameter
134/// mistakes (empty/identical strings, preimage mismatch, missing file) and
135/// already-applied edits say nothing about the read mode and are skipped.
136pub fn record_outcome(params: &EditParams, last_mode: &str, text: &str, effect: &CacheEffect) {
137    if params.create {
138        return;
139    }
140    let success = matches!(effect, CacheEffect::Invalidate);
141    let not_found_failure = matches!(effect, CacheEffect::StoreFull(_))
142        || (matches!(effect, CacheEffect::None)
143            && text.starts_with("ERROR: old_string not found")
144            && !text.contains("already"));
145    if success || not_found_failure {
146        crate::core::edit_quality::record_edit_outcome(&params.path, last_mode, success);
147    }
148    // Edit-efficiency channel (#1008): the str_replace baseline — output tokens
149    // actually paid reproducing `old_string`, and blind-retry round-trips.
150    // Separate from the read-gain ledger, never printed in tool output (#498).
151    if success {
152        crate::core::edit_metering::record_str_replace_success(
153            count_tokens(&params.old_string) as u64
154        );
155    } else if not_found_failure {
156        crate::core::edit_metering::record_str_replace_miss();
157    }
158}
159
160/// Applies a deferred [`CacheEffect`] to the session cache.
161pub fn apply_cache_effect(cache: &mut SessionCache, path: &str, effect: CacheEffect) {
162    match effect {
163        CacheEffect::None => {}
164        CacheEffect::Invalidate => {
165            cache.invalidate(path);
166        }
167        CacheEffect::StoreFull(content) => {
168            cache.store(path, &content);
169            cache.mark_full_delivered(path);
170        }
171    }
172}
173
174/// Performs the full edit on disk **without** touching the session cache, and
175/// reports back the [`CacheEffect`] the caller should apply afterwards.
176///
177/// `last_mode` is the cache's recorded read mode for the path (used only to
178/// decide whether to auto-escalate on a not-found match); pass `""` when unknown.
179pub fn run_io(params: &EditParams, last_mode: &str) -> (String, CacheEffect) {
180    let file_path = &params.path;
181
182    if params.create {
183        return handle_create(file_path, &params.new_string, params);
184    }
185
186    let cap = crate::core::limits::max_read_bytes();
187    let path = Path::new(file_path);
188    let pre = match read_preimage(path, cap, params.allow_lossy_utf8) {
189        Ok(p) => p,
190        Err(e) => {
191            // File missing? Tell the agent whether it moved or the path is
192            // wrong, instead of a bare "cannot open" (#331 point 3).
193            if !path.exists() {
194                let hint = crate::tools::edit_recovery::moved_or_deleted_hint(path);
195                return (format!("{e}{hint}"), CacheEffect::None);
196            }
197            return (e, CacheEffect::None);
198        }
199    };
200    if let Err(e) = verify_expected_preimage(&pre, params) {
201        return (e, CacheEffect::None);
202    }
203    let content = &pre.text;
204
205    if params.old_string.is_empty() {
206        return (
207            "ERROR: old_string must not be empty (use create=true to create a new file)".into(),
208            CacheEffect::None,
209        );
210    }
211
212    if params.old_string == params.new_string {
213        return (
214            "ERROR: old_string and new_string are identical — nothing to change.".into(),
215            CacheEffect::None,
216        );
217    }
218
219    let uses_crlf = pre.uses_crlf;
220    let old_str = &params.old_string;
221    let new_str = &params.new_string;
222
223    let occurrences = content.matches(old_str).count();
224
225    if occurrences > 0 {
226        let args = ReplaceArgs {
227            content,
228            old_str,
229            new_str,
230            occurrences,
231            replace_all: params.replace_all,
232            old_tokens: count_tokens(&params.old_string),
233            new_tokens: count_tokens(&params.new_string),
234        };
235        return do_replace(path, &pre, params, cap, &args);
236    }
237
238    if uses_crlf && !old_str.contains('\r') {
239        let old_crlf = old_str.replace('\n', "\r\n");
240        let occ = content.matches(&old_crlf).count();
241        if occ > 0 {
242            let new_crlf = new_str.replace('\n', "\r\n");
243            let args = ReplaceArgs {
244                content,
245                old_str: &old_crlf,
246                new_str: &new_crlf,
247                occurrences: occ,
248                replace_all: params.replace_all,
249                old_tokens: count_tokens(&params.old_string),
250                new_tokens: count_tokens(&params.new_string),
251            };
252            return do_replace(path, &pre, params, cap, &args);
253        }
254    } else if !uses_crlf && old_str.contains("\r\n") {
255        let old_lf = old_str.replace("\r\n", "\n");
256        let occ = content.matches(&old_lf).count();
257        if occ > 0 {
258            let new_lf = new_str.replace("\r\n", "\n");
259            let args = ReplaceArgs {
260                content,
261                old_str: &old_lf,
262                new_str: &new_lf,
263                occurrences: occ,
264                replace_all: params.replace_all,
265                old_tokens: count_tokens(&params.old_string),
266                new_tokens: count_tokens(&params.new_string),
267            };
268            return do_replace(path, &pre, params, cap, &args);
269        }
270    }
271
272    let normalized_content = trim_trailing_per_line(content);
273    let normalized_old = trim_trailing_per_line(old_str);
274    if !normalized_old.is_empty() && normalized_content.contains(&normalized_old) {
275        let line_sep = if uses_crlf { "\r\n" } else { "\n" };
276        let adapted_new = adapt_new_string_to_line_sep(new_str, line_sep);
277        let adapted_old = find_original_span(content, &normalized_old);
278        if let Some(original_match) = adapted_old {
279            let occ = content.matches(&original_match).count();
280            let args = ReplaceArgs {
281                content,
282                old_str: &original_match,
283                new_str: &adapted_new,
284                occurrences: occ,
285                replace_all: params.replace_all,
286                old_tokens: count_tokens(&params.old_string),
287                new_tokens: count_tokens(&params.new_string),
288            };
289            return do_replace(path, &pre, params, cap, &args);
290        }
291    }
292
293    if content.contains(new_str) {
294        return (
295            format!(
296                "ERROR: old_string not found in {file_path}, but new_string already exists in the file. \
297                 The edit was likely already applied (by a previous tool call or another agent)."
298            ),
299            CacheEffect::None,
300        );
301    }
302
303    let preview = if old_str.len() > 80 {
304        format!("{}...", &old_str[..old_str.floor_char_boundary(77)])
305    } else {
306        old_str.clone()
307    };
308    let hint = if uses_crlf {
309        " (file uses CRLF line endings)"
310    } else {
311        ""
312    };
313
314    let closest_hint = find_closest_line_hint(content, old_str);
315    let cross_file = crate::tools::edit_recovery::cross_file_hint(path, old_str);
316
317    let (escalation, effect) = auto_escalate_reread(last_mode, file_path);
318
319    (
320        format!(
321            "ERROR: old_string not found in {file_path}{hint}. \
322             Make sure it matches exactly (including whitespace/indentation).\n\
323             Searched for: {preview}{closest_hint}{cross_file}{escalation}"
324        ),
325        effect,
326    )
327}
328
329/// Finds the closest matching line in the file content to help the agent
330/// understand what went wrong. Returns a hint string or empty if no useful match.
331fn find_closest_line_hint(content: &str, old_str: &str) -> String {
332    let first_line = old_str.lines().next().unwrap_or("").trim();
333    if first_line.len() < 4 {
334        return String::new();
335    }
336
337    let mut best_line: Option<(usize, &str)> = None;
338
339    for (i, line) in content.lines().enumerate() {
340        if line.contains(first_line) {
341            best_line = Some((i + 1, line));
342            break;
343        }
344    }
345
346    // Try matching with significant identifiers from old_string's first line
347    if best_line.is_none() {
348        let keyword = first_line
349            .split(|c: char| !c.is_alphanumeric() && c != '_')
350            .find(|w| w.len() >= 4);
351
352        if let Some(keyword) = keyword {
353            for (i, line) in content.lines().enumerate() {
354                if line.contains(keyword) {
355                    best_line = Some((i + 1, line));
356                    break;
357                }
358            }
359        }
360    }
361
362    match best_line {
363        Some((line_num, line_content)) => {
364            let trimmed = line_content.trim();
365            let preview = if trimmed.len() > 100 {
366                format!("{}...", &trimmed[..trimmed.floor_char_boundary(97)])
367            } else {
368                trimmed.to_string()
369            };
370            format!(
371                "\nClosest match at line {line_num}: `{preview}`\n\
372                 Hint: check indentation/whitespace differences."
373            )
374        }
375        None => String::new(),
376    }
377}
378
379/// Auto-escalation: when old_string is not found and the file was previously read
380/// in a compressed mode, re-read in full and return the content so the agent
381/// can immediately retry with the correct old_string. Returns the text to append
382/// plus the [`CacheEffect`] the caller should apply (store full content).
383fn auto_escalate_reread(last_mode: &str, path: &str) -> (String, CacheEffect) {
384    if last_mode.is_empty() || last_mode == "full" {
385        return (String::new(), CacheEffect::None);
386    }
387
388    let Ok(fresh_content) = std::fs::read_to_string(path) else {
389        return (String::new(), CacheEffect::None);
390    };
391
392    let line_count = fresh_content.lines().count();
393    const MAX_LINES: usize = 300;
394
395    let content_preview = if line_count <= MAX_LINES {
396        fresh_content.clone()
397    } else {
398        let lines: Vec<&str> = fresh_content.lines().collect();
399        let head = &lines[..MAX_LINES / 2];
400        let tail = &lines[line_count - MAX_LINES / 2..];
401        let omitted = line_count - MAX_LINES;
402        format!(
403            "{}\n[... {omitted} lines omitted ...]\n{}",
404            head.join("\n"),
405            tail.join("\n")
406        )
407    };
408
409    (
410        format!(
411            "\n\n[auto-escalation] Last read used mode=\"{last_mode}\". \
412             Full content ({line_count}L) below — retry edit with exact text from here:\n\n{content_preview}"
413        ),
414        CacheEffect::StoreFull(fresh_content),
415    )
416}
417
418fn do_replace(
419    path: &Path,
420    pre: &FilePreimage,
421    params: &EditParams,
422    cap: usize,
423    args: &ReplaceArgs<'_>,
424) -> (String, CacheEffect) {
425    if args.occurrences > 1 && !args.replace_all {
426        return (
427            format!(
428                "ERROR: old_string found {} times in {}. \
429                 Use replace_all=true to replace all, or provide more context to make old_string unique.",
430                args.occurrences,
431                path.display()
432            ),
433            CacheEffect::None,
434        );
435    }
436
437    let new_content = if args.replace_all {
438        args.content.replace(args.old_str, args.new_str)
439    } else {
440        args.content.replacen(args.old_str, args.new_str, 1)
441    };
442
443    // Code-health gate: warn on (or block) cognitive-complexity drift before write.
444    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
445    let health_notice =
446        match crate::core::code_health::gate::evaluate(args.content, &new_content, ext) {
447            crate::core::code_health::gate::GateOutcome::Block(reason) => {
448                return (
449                    format!("ERROR: code-health gate: {reason}"),
450                    CacheEffect::None,
451                );
452            }
453            crate::core::code_health::gate::GateOutcome::Allow(notice) => notice,
454        };
455
456    // #960: a point-in-time check, not a held lock — see
457    // ensure_preimage_still_matches' doc for the residual window between
458    // this check and the write below.
459    if let Err(e) = ensure_preimage_still_matches(path, &pre.fp, cap) {
460        return (e, CacheEffect::None);
461    }
462
463    let backup_path = if params.backup {
464        let bp = params
465            .backup_path
466            .as_deref()
467            .map(PathBuf::from)
468            .or_else(|| default_backup_path(path));
469        let Some(bp) = bp else {
470            return (
471                format!("ERROR: cannot compute backup path for {}", path.display()),
472                CacheEffect::None,
473            );
474        };
475        if let Err(e) = write_atomic_bytes_with_permissions(&bp, &pre.bytes, Some(&pre.permissions))
476        {
477            return (
478                format!("ERROR: cannot create backup {}: {e}", bp.display()),
479                CacheEffect::None,
480            );
481        }
482        Some(bp.to_string_lossy().to_string())
483    } else {
484        None
485    };
486
487    if let Err(e) =
488        write_atomic_bytes_with_permissions(path, new_content.as_bytes(), Some(&pre.permissions))
489    {
490        return (e, CacheEffect::None);
491    }
492
493    if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
494        bt.record_edit(&params.path);
495    }
496
497    let old_lines = args.content.lines().count();
498    let new_lines = new_content.lines().count();
499    let line_delta = new_lines as i64 - old_lines as i64;
500    let delta_str = if line_delta > 0 {
501        format!("+{line_delta}")
502    } else {
503        format!("{line_delta}")
504    };
505
506    let old_tokens = args.old_tokens;
507    let new_tokens = args.new_tokens;
508
509    let replaced_str = if args.replace_all && args.occurrences > 1 {
510        format!("{} replacements", args.occurrences)
511    } else {
512        "1 replacement".into()
513    };
514
515    let short = path.file_name().map_or_else(
516        || path.to_string_lossy().to_string(),
517        |f| f.to_string_lossy().to_string(),
518    );
519
520    let post_mtime_ms = std::fs::metadata(path)
521        .ok()
522        .and_then(|m| m.modified().ok())
523        .map_or(0, system_time_to_millis);
524    let post_fp = FileFingerprint {
525        size: new_content.len() as u64,
526        mtime_ms: post_mtime_ms,
527        md5: crate::core::hasher::hash_hex(new_content.as_bytes()),
528    };
529
530    let mut out = format!(
531        "✓ {short}: {replaced_str}, {delta_str} lines ({old_tokens}→{new_tokens} tok)\n\
532preimage: bytes={}, mtime_ms={}, md5={}\n\
533postimage: bytes={}, mtime_ms={}, md5={}",
534        pre.fp.size, pre.fp.mtime_ms, pre.fp.md5, post_fp.size, post_fp.mtime_ms, post_fp.md5
535    );
536    if let Some(bp) = backup_path {
537        out.push_str(&format!("\nbackup: {bp}"));
538    }
539    if params.evidence {
540        let diff = build_diff_evidence(args.content, &new_content, &short, params.diff_max_lines);
541        out.push_str("\n\nevidence (diff, redacted, bounded):\n```diff\n");
542        out.push_str(&diff);
543        out.push_str("\n```");
544    }
545    if let Some(notice) = health_notice {
546        out.push_str("\n\n");
547        out.push_str(&notice);
548    }
549    (out, CacheEffect::Invalidate)
550}
551
552fn handle_create(file_path: &str, content: &str, params: &EditParams) -> (String, CacheEffect) {
553    let path = Path::new(file_path);
554    let cap = crate::core::limits::max_read_bytes();
555
556    // Deny before the standalone create_dir_all below can materialise a
557    // directory inside a read-only root (#475). The atomic writer guards the
558    // file write too, but this stops an empty-dir side effect first.
559    if let Err(e) = crate::core::pathjail::enforce_writable(path) {
560        return (format!("ERROR: {e}"), CacheEffect::None);
561    }
562
563    let mut preimage: Option<FilePreimage> = None;
564    if path.exists() {
565        let pre = match read_preimage(path, cap, params.allow_lossy_utf8) {
566            Ok(p) => p,
567            Err(e) => return (e, CacheEffect::None),
568        };
569        if let Err(e) = verify_expected_preimage(&pre, params) {
570            return (e, CacheEffect::None);
571        }
572        // #960: a point-in-time check, not a held lock — see
573        // ensure_preimage_still_matches' doc for the residual window between
574        // this check and the write below.
575        if let Err(e) = ensure_preimage_still_matches(path, &pre.fp, cap) {
576            return (e, CacheEffect::None);
577        }
578        preimage = Some(pre);
579    }
580
581    if let Some(parent) = path.parent()
582        && !parent.exists()
583        && let Err(e) = std::fs::create_dir_all(parent)
584    {
585        return (
586            format!("ERROR: cannot create directory {}: {e}", parent.display()),
587            CacheEffect::None,
588        );
589    }
590
591    let backup_path = if params.backup {
592        if let Some(pre) = &preimage {
593            let bp = params
594                .backup_path
595                .as_deref()
596                .map(PathBuf::from)
597                .or_else(|| default_backup_path(path));
598            let Some(bp) = bp else {
599                return (
600                    format!("ERROR: cannot compute backup path for {}", path.display()),
601                    CacheEffect::None,
602                );
603            };
604            if let Err(e) =
605                write_atomic_bytes_with_permissions(&bp, &pre.bytes, Some(&pre.permissions))
606            {
607                return (
608                    format!("ERROR: cannot create backup {}: {e}", bp.display()),
609                    CacheEffect::None,
610                );
611            }
612            Some(bp.to_string_lossy().to_string())
613        } else {
614            None
615        }
616    } else {
617        None
618    };
619
620    let perms = preimage.as_ref().map(|p| &p.permissions);
621    if let Err(e) = write_atomic_bytes_with_permissions(path, content.as_bytes(), perms) {
622        return (e, CacheEffect::None);
623    }
624
625    let lines = content.lines().count();
626    let tokens = count_tokens(content);
627    let short = path.file_name().map_or_else(
628        || path.to_string_lossy().to_string(),
629        |f| f.to_string_lossy().to_string(),
630    );
631
632    let mut out = format!("✓ created {short}: {lines} lines, {tokens} tok");
633    if let Some(bp) = backup_path {
634        out.push_str(&format!("\nbackup: {bp}"));
635    }
636    (out, CacheEffect::Invalidate)
637}
638
639fn trim_trailing_per_line(s: &str) -> String {
640    s.lines().map(str::trim_end).collect::<Vec<_>>().join("\n")
641}
642
643fn adapt_new_string_to_line_sep(s: &str, sep: &str) -> String {
644    let normalized = s.replace("\r\n", "\n");
645    if sep == "\r\n" {
646        normalized.replace('\n', "\r\n")
647    } else {
648        normalized
649    }
650}
651
652/// Find the original (un-trimmed) span in `content` that matches `normalized_needle`
653/// after trailing-whitespace trimming per line.
654fn find_original_span(content: &str, normalized_needle: &str) -> Option<String> {
655    let needle_lines: Vec<&str> = normalized_needle.lines().collect();
656    if needle_lines.is_empty() {
657        return None;
658    }
659
660    let content_lines: Vec<&str> = content.lines().collect();
661
662    'outer: for start in 0..content_lines.len() {
663        if start + needle_lines.len() > content_lines.len() {
664            break;
665        }
666        for (i, nl) in needle_lines.iter().enumerate() {
667            if content_lines[start + i].trim_end() != *nl {
668                continue 'outer;
669            }
670        }
671        let sep = if content.contains("\r\n") {
672            "\r\n"
673        } else {
674            "\n"
675        };
676        return Some(content_lines[start..start + needle_lines.len()].join(sep));
677    }
678    None
679}