Skip to main content

lean_ctx/tools/
ctx_edit.rs

1use std::path::{Path, PathBuf};
2use std::time::{SystemTime, UNIX_EPOCH};
3
4use crate::core::cache::SessionCache;
5use crate::core::tokens::count_tokens;
6
7/// Parameters for a file edit operation: path, old/new strings, and flags.
8pub struct EditParams {
9    pub path: String,
10    pub old_string: String,
11    pub new_string: String,
12    pub replace_all: bool,
13    pub create: bool,
14    /// Optional preimage guards. If provided, ctx_edit fails if the current file preimage differs.
15    pub expected_md5: Option<String>,
16    pub expected_size: Option<u64>,
17    pub expected_mtime_ms: Option<u64>,
18    /// Optional backup before writing.
19    pub backup: bool,
20    pub backup_path: Option<String>,
21    /// Emit bounded diff evidence (redacted) by default.
22    pub evidence: bool,
23    pub diff_max_lines: usize,
24    /// Reject invalid UTF-8 by default; allow lossy reads only when explicitly enabled.
25    pub allow_lossy_utf8: bool,
26}
27
28struct ReplaceArgs<'a> {
29    content: &'a str,
30    old_str: &'a str,
31    new_str: &'a str,
32    occurrences: usize,
33    replace_all: bool,
34    old_tokens: usize,
35    new_tokens: usize,
36}
37
38#[derive(Clone, Debug, PartialEq, Eq)]
39struct FileFingerprint {
40    size: u64,
41    mtime_ms: u64,
42    md5: String,
43}
44
45#[derive(Clone, Debug)]
46struct FilePreimage {
47    fp: FileFingerprint,
48    permissions: std::fs::Permissions,
49    bytes: Vec<u8>,
50    text: String,
51    uses_crlf: bool,
52}
53
54fn system_time_to_millis(t: SystemTime) -> u64 {
55    t.duration_since(UNIX_EPOCH)
56        .map_or(0, |d| d.as_millis() as u64)
57}
58
59fn read_file_bytes_limited(
60    path: &Path,
61    cap: usize,
62) -> Result<(Vec<u8>, std::fs::Metadata), String> {
63    if let Ok(meta) = std::fs::metadata(path) {
64        if meta.len() > cap as u64 {
65            return Err(format!(
66                "ERROR: file too large ({} bytes, cap {} via LCTX_MAX_READ_BYTES): {}",
67                meta.len(),
68                cap,
69                path.display()
70            ));
71        }
72    }
73
74    let mut file = std::fs::OpenOptions::new()
75        .read(true)
76        .open(path)
77        .map_err(|e| format!("ERROR: cannot open {}: {e}", path.display()))?;
78
79    use std::io::Read;
80    let mut raw: Vec<u8> = Vec::new();
81    let mut limited = (&mut file).take((cap as u64).saturating_add(1));
82    limited
83        .read_to_end(&mut raw)
84        .map_err(|e| format!("ERROR: cannot read {}: {e}", path.display()))?;
85    if raw.len() > cap {
86        return Err(format!(
87            "ERROR: file too large (cap {} via LCTX_MAX_READ_BYTES): {}",
88            cap,
89            path.display()
90        ));
91    }
92
93    let meta = file
94        .metadata()
95        .map_err(|e| format!("ERROR: cannot stat {}: {e}", path.display()))?;
96    Ok((raw, meta))
97}
98
99fn fingerprint_from_bytes(bytes: &[u8], meta: &std::fs::Metadata) -> FileFingerprint {
100    FileFingerprint {
101        size: bytes.len() as u64,
102        mtime_ms: meta.modified().map_or(0, system_time_to_millis),
103        md5: crate::core::hasher::hash_hex(bytes),
104    }
105}
106
107fn read_preimage(path: &Path, cap: usize, allow_lossy_utf8: bool) -> Result<FilePreimage, String> {
108    let (bytes, meta) = read_file_bytes_limited(path, cap)?;
109    let permissions = meta.permissions();
110    let fp = fingerprint_from_bytes(&bytes, &meta);
111
112    let text = if allow_lossy_utf8 {
113        String::from_utf8_lossy(&bytes).into_owned()
114    } else {
115        String::from_utf8(bytes.clone()).map_err(|_| {
116            format!(
117                "ERROR: file is not valid UTF-8 (binary/encoding). Refusing to edit: {}",
118                path.display()
119            )
120        })?
121    };
122    let uses_crlf = text.contains("\r\n");
123
124    Ok(FilePreimage {
125        fp,
126        permissions,
127        bytes,
128        text,
129        uses_crlf,
130    })
131}
132
133fn verify_expected_preimage(pre: &FilePreimage, params: &EditParams) -> Result<(), String> {
134    if let Some(expected) = params.expected_size {
135        if expected != pre.fp.size {
136            return Err(format!(
137                "ERROR: preimage mismatch for {}: expected_size={}, actual_size={}",
138                params.path, expected, pre.fp.size
139            ));
140        }
141    }
142    if let Some(expected) = params.expected_mtime_ms {
143        if expected != pre.fp.mtime_ms {
144            return Err(format!(
145                "ERROR: preimage mismatch for {}: expected_mtime_ms={}, actual_mtime_ms={}",
146                params.path, expected, pre.fp.mtime_ms
147            ));
148        }
149    }
150    if let Some(expected) = params.expected_md5.as_deref() {
151        if expected != pre.fp.md5 {
152            return Err(format!(
153                "ERROR: preimage mismatch for {}: expected_md5={}, actual_md5={}",
154                params.path, expected, pre.fp.md5
155            ));
156        }
157    }
158    Ok(())
159}
160
161fn ensure_preimage_still_matches(
162    path: &Path,
163    expected: &FileFingerprint,
164    cap: usize,
165) -> Result<(), String> {
166    let (bytes, meta) = read_file_bytes_limited(path, cap)?;
167    let now = fingerprint_from_bytes(&bytes, &meta);
168    if &now != expected {
169        return Err(format!(
170            "ERROR: file changed since read (TOCTOU guard). Re-read and retry: {}\nexpected: size={}, mtime_ms={}, md5={}\nactual:   size={}, mtime_ms={}, md5={}",
171            path.display(),
172            expected.size,
173            expected.mtime_ms,
174            expected.md5,
175            now.size,
176            now.mtime_ms,
177            now.md5
178        ));
179    }
180    Ok(())
181}
182
183fn default_backup_path(path: &Path) -> Option<PathBuf> {
184    let parent = path.parent()?;
185    let filename = path.file_name()?.to_string_lossy();
186    let pid = std::process::id();
187    let nanos = SystemTime::now()
188        .duration_since(UNIX_EPOCH)
189        .map_or(0, |d| d.as_nanos());
190    Some(parent.join(format!("{filename}.lean-ctx.bak.{pid}.{nanos}")))
191}
192
193fn write_atomic_bytes_with_permissions(
194    path: &Path,
195    bytes: &[u8],
196    permissions: Option<&std::fs::Permissions>,
197) -> Result<(), String> {
198    if let Some(parent) = path.parent() {
199        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
200    }
201
202    let parent = path
203        .parent()
204        .ok_or_else(|| "invalid path (no parent directory)".to_string())?;
205    let filename = path
206        .file_name()
207        .ok_or_else(|| "invalid path (no filename)".to_string())?
208        .to_string_lossy();
209
210    let pid = std::process::id();
211    let nanos = SystemTime::now()
212        .duration_since(UNIX_EPOCH)
213        .map_or(0, |d| d.as_nanos());
214    let tmp = parent.join(format!(".{filename}.lean-ctx.tmp.{pid}.{nanos}"));
215
216    {
217        use std::io::Write;
218        let mut f = std::fs::OpenOptions::new()
219            .write(true)
220            .create_new(true)
221            .open(&tmp)
222            .map_err(|e| format!("ERROR: cannot write {}: {e}", tmp.display()))?;
223        f.write_all(bytes)
224            .map_err(|e| format!("ERROR: cannot write {}: {e}", tmp.display()))?;
225        let _ = f.flush();
226        let _ = f.sync_all();
227    }
228
229    if let Some(perms) = permissions {
230        let _ = std::fs::set_permissions(&tmp, perms.clone());
231    }
232
233    #[cfg(windows)]
234    {
235        if path.exists() {
236            let _ = std::fs::remove_file(path);
237        }
238    }
239
240    std::fs::rename(&tmp, path).map_err(|e| {
241        format!(
242            "ERROR: atomic write failed: {} (tmp: {})",
243            e,
244            tmp.to_string_lossy()
245        )
246    })?;
247
248    Ok(())
249}
250
251macro_rules! static_regex {
252    ($pattern:expr) => {{
253        static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
254        RE.get_or_init(|| {
255            regex::Regex::new($pattern).expect(concat!("BUG: invalid static regex: ", $pattern))
256        })
257    }};
258}
259
260fn redact_sensitive_diff(input: &str) -> String {
261    let patterns: Vec<(&str, &regex::Regex)> = vec![
262        (
263            "Bearer token",
264            static_regex!(r"(?i)(bearer\s+)[a-zA-Z0-9\-_\.]{8,}"),
265        ),
266        (
267            "Authorization header",
268            static_regex!(r"(?i)(authorization:\s*(?:basic|bearer|token)\s+)[^\s\r\n]+"),
269        ),
270        (
271            "API key param",
272            static_regex!(
273                r#"(?i)((?:api[_-]?key|apikey|access[_-]?key|secret[_-]?key|token|password|passwd|pwd|secret)\s*[=:]\s*)[^\s\r\n,;&"']+"#
274            ),
275        ),
276        ("AWS key", static_regex!(r"(AKIA[0-9A-Z]{12,})")),
277        (
278            "Private key block",
279            static_regex!(
280                r"(?s)(-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----).+?(-----END\s+(?:RSA\s+)?PRIVATE\s+KEY-----)"
281            ),
282        ),
283        (
284            "GitHub token",
285            static_regex!(r"(gh[pousr]_)[a-zA-Z0-9]{20,}"),
286        ),
287        (
288            "Generic long secret",
289            static_regex!(
290                r#"(?i)(?:key|token|secret|password|credential|auth)\s*[=:]\s*['"]?([a-zA-Z0-9+/=\-_]{32,})['"]?"#
291            ),
292        ),
293    ];
294
295    let mut out = input.to_string();
296    for (label, re) in &patterns {
297        out = re
298            .replace_all(&out, |caps: &regex::Captures| {
299                if let Some(prefix) = caps.get(1) {
300                    format!("{}[REDACTED:{}]", prefix.as_str(), label)
301                } else {
302                    format!("[REDACTED:{label}]")
303                }
304            })
305            .to_string();
306    }
307    out
308}
309
310fn build_diff_evidence(old: &str, new: &str, label: &str, max_lines: usize) -> String {
311    let diff = similar::TextDiff::from_lines(old, new)
312        .unified_diff()
313        .context_radius(3)
314        .header(label, label)
315        .to_string();
316    let diff = redact_sensitive_diff(&diff);
317
318    let mut out = String::new();
319    for (i, line) in diff.lines().enumerate() {
320        if i >= max_lines {
321            out.push_str(&format!("\n... diff truncated (max_lines={max_lines})"));
322            break;
323        }
324        out.push_str(line);
325        out.push('\n');
326    }
327    out.trim_end_matches('\n').to_string()
328}
329
330/// A cache mutation that an edit needs *after* its disk I/O completes.
331///
332/// Decoupling the cache mutation from the I/O lets the MCP layer perform the
333/// (slow) file read/replace/write while holding only a cheap per-file lock, then
334/// touch the shared cache for a sub-millisecond instant — instead of holding the
335/// global cache write-lock across all disk I/O (the root cause of issue #320).
336pub enum CacheEffect {
337    /// No cache change required (e.g. the edit failed before writing).
338    None,
339    /// The file on disk changed; drop the stale cache entry.
340    Invalidate,
341    /// Auto-escalation re-read full content that should be stored and marked
342    /// as fully delivered.
343    StoreFull(String),
344}
345
346/// Performs a string replacement edit on a file with CRLF/LF and whitespace
347/// tolerance. Thin wrapper that runs the I/O and applies the resulting cache
348/// effect to `cache` in one shot (used by tests and any in-process caller that
349/// already holds the cache exclusively).
350pub fn handle(cache: &mut SessionCache, params: &EditParams) -> String {
351    let last_mode = cache
352        .get(&params.path)
353        .map(|e| e.last_mode.clone())
354        .unwrap_or_default();
355    let (text, effect) = run_io(params, &last_mode);
356    apply_cache_effect(cache, &params.path, effect);
357    text
358}
359
360/// Applies a deferred [`CacheEffect`] to the session cache.
361pub fn apply_cache_effect(cache: &mut SessionCache, path: &str, effect: CacheEffect) {
362    match effect {
363        CacheEffect::None => {}
364        CacheEffect::Invalidate => {
365            cache.invalidate(path);
366        }
367        CacheEffect::StoreFull(content) => {
368            cache.store(path, &content);
369            cache.mark_full_delivered(path);
370        }
371    }
372}
373
374/// Performs the full edit on disk **without** touching the session cache, and
375/// reports back the [`CacheEffect`] the caller should apply afterwards.
376///
377/// `last_mode` is the cache's recorded read mode for the path (used only to
378/// decide whether to auto-escalate on a not-found match); pass `""` when unknown.
379pub fn run_io(params: &EditParams, last_mode: &str) -> (String, CacheEffect) {
380    let file_path = &params.path;
381
382    if params.create {
383        return handle_create(file_path, &params.new_string, params);
384    }
385
386    let cap = crate::core::limits::max_read_bytes();
387    let path = Path::new(file_path);
388    let pre = match read_preimage(path, cap, params.allow_lossy_utf8) {
389        Ok(p) => p,
390        Err(e) => {
391            // File missing? Tell the agent whether it moved or the path is
392            // wrong, instead of a bare "cannot open" (#331 point 3).
393            if !path.exists() {
394                let hint = crate::tools::edit_recovery::moved_or_deleted_hint(path);
395                return (format!("{e}{hint}"), CacheEffect::None);
396            }
397            return (e, CacheEffect::None);
398        }
399    };
400    if let Err(e) = verify_expected_preimage(&pre, params) {
401        return (e, CacheEffect::None);
402    }
403    let content = &pre.text;
404
405    if params.old_string.is_empty() {
406        return (
407            "ERROR: old_string must not be empty (use create=true to create a new file)".into(),
408            CacheEffect::None,
409        );
410    }
411
412    if params.old_string == params.new_string {
413        return (
414            "ERROR: old_string and new_string are identical — nothing to change.".into(),
415            CacheEffect::None,
416        );
417    }
418
419    let uses_crlf = pre.uses_crlf;
420    let old_str = &params.old_string;
421    let new_str = &params.new_string;
422
423    let occurrences = content.matches(old_str).count();
424
425    if occurrences > 0 {
426        let args = ReplaceArgs {
427            content,
428            old_str,
429            new_str,
430            occurrences,
431            replace_all: params.replace_all,
432            old_tokens: count_tokens(&params.old_string),
433            new_tokens: count_tokens(&params.new_string),
434        };
435        return do_replace(path, &pre, params, cap, &args);
436    }
437
438    // Direct match failed -- try CRLF/LF normalization
439    if uses_crlf && !old_str.contains('\r') {
440        let old_crlf = old_str.replace('\n', "\r\n");
441        let occ = content.matches(&old_crlf).count();
442        if occ > 0 {
443            let new_crlf = new_str.replace('\n', "\r\n");
444            let args = ReplaceArgs {
445                content,
446                old_str: &old_crlf,
447                new_str: &new_crlf,
448                occurrences: occ,
449                replace_all: params.replace_all,
450                old_tokens: count_tokens(&params.old_string),
451                new_tokens: count_tokens(&params.new_string),
452            };
453            return do_replace(path, &pre, params, cap, &args);
454        }
455    } else if !uses_crlf && old_str.contains("\r\n") {
456        let old_lf = old_str.replace("\r\n", "\n");
457        let occ = content.matches(&old_lf).count();
458        if occ > 0 {
459            let new_lf = new_str.replace("\r\n", "\n");
460            let args = ReplaceArgs {
461                content,
462                old_str: &old_lf,
463                new_str: &new_lf,
464                occurrences: occ,
465                replace_all: params.replace_all,
466                old_tokens: count_tokens(&params.old_string),
467                new_tokens: count_tokens(&params.new_string),
468            };
469            return do_replace(path, &pre, params, cap, &args);
470        }
471    }
472
473    // Still not found -- try trimmed trailing whitespace per line
474    let normalized_content = trim_trailing_per_line(content);
475    let normalized_old = trim_trailing_per_line(old_str);
476    if !normalized_old.is_empty() && normalized_content.contains(&normalized_old) {
477        let line_sep = if uses_crlf { "\r\n" } else { "\n" };
478        let adapted_new = adapt_new_string_to_line_sep(new_str, line_sep);
479        let adapted_old = find_original_span(content, &normalized_old);
480        if let Some(original_match) = adapted_old {
481            let occ = content.matches(&original_match).count();
482            let args = ReplaceArgs {
483                content,
484                old_str: &original_match,
485                new_str: &adapted_new,
486                occurrences: occ,
487                replace_all: params.replace_all,
488                old_tokens: count_tokens(&params.old_string),
489                new_tokens: count_tokens(&params.new_string),
490            };
491            return do_replace(path, &pre, params, cap, &args);
492        }
493    }
494
495    // Check if edit was already applied (new_string exists but old_string doesn't)
496    if content.contains(new_str) {
497        return (
498            format!(
499                "ERROR: old_string not found in {file_path}, but new_string already exists in the file. \
500                 The edit was likely already applied (by a previous tool call or another agent)."
501            ),
502            CacheEffect::None,
503        );
504    }
505
506    let preview = if old_str.len() > 80 {
507        format!("{}...", &old_str[..old_str.floor_char_boundary(77)])
508    } else {
509        old_str.clone()
510    };
511    let hint = if uses_crlf {
512        " (file uses CRLF line endings)"
513    } else {
514        ""
515    };
516
517    // Same-file hint: closest matching line (usually a whitespace/indent diff).
518    let closest_hint = find_closest_line_hint(content, old_str);
519    // Cross-file hint: did the agent target the wrong file? (#331 point 2)
520    let cross_file = crate::tools::edit_recovery::cross_file_hint(path, old_str);
521
522    let (escalation, effect) = auto_escalate_reread(last_mode, file_path);
523
524    (
525        format!(
526            "ERROR: old_string not found in {file_path}{hint}. \
527             Make sure it matches exactly (including whitespace/indentation).\n\
528             Searched for: {preview}{closest_hint}{cross_file}{escalation}"
529        ),
530        effect,
531    )
532}
533
534/// Finds the closest matching line in the file content to help the agent
535/// understand what went wrong. Returns a hint string or empty if no useful match.
536fn find_closest_line_hint(content: &str, old_str: &str) -> String {
537    let first_line = old_str.lines().next().unwrap_or("").trim();
538    if first_line.len() < 4 {
539        return String::new();
540    }
541
542    let mut best_line: Option<(usize, &str)> = None;
543
544    // Try exact substring match first
545    for (i, line) in content.lines().enumerate() {
546        if line.contains(first_line) {
547            best_line = Some((i + 1, line));
548            break;
549        }
550    }
551
552    // Try matching with significant identifiers from old_string's first line
553    if best_line.is_none() {
554        let keywords: Vec<&str> = first_line
555            .split(|c: char| !c.is_alphanumeric() && c != '_')
556            .filter(|w| w.len() >= 4)
557            .collect();
558
559        if let Some(keyword) = keywords.first() {
560            for (i, line) in content.lines().enumerate() {
561                if line.contains(keyword) {
562                    best_line = Some((i + 1, line));
563                    break;
564                }
565            }
566        }
567    }
568
569    match best_line {
570        Some((line_num, line_content)) => {
571            let trimmed = line_content.trim();
572            let preview = if trimmed.len() > 100 {
573                format!("{}...", &trimmed[..trimmed.floor_char_boundary(97)])
574            } else {
575                trimmed.to_string()
576            };
577            format!(
578                "\nClosest match at line {line_num}: `{preview}`\n\
579                 Hint: check indentation/whitespace differences."
580            )
581        }
582        None => String::new(),
583    }
584}
585
586/// Auto-escalation: when old_string is not found and the file was previously read
587/// in a compressed mode, re-read in full and return the content so the agent
588/// can immediately retry with the correct old_string. Returns the text to append
589/// plus the [`CacheEffect`] the caller should apply (store full content).
590fn auto_escalate_reread(last_mode: &str, path: &str) -> (String, CacheEffect) {
591    if last_mode.is_empty() || last_mode == "full" {
592        return (String::new(), CacheEffect::None);
593    }
594
595    let Ok(fresh_content) = std::fs::read_to_string(path) else {
596        return (String::new(), CacheEffect::None);
597    };
598
599    let line_count = fresh_content.lines().count();
600    const MAX_LINES: usize = 300;
601
602    let content_preview = if line_count <= MAX_LINES {
603        fresh_content.clone()
604    } else {
605        let lines: Vec<&str> = fresh_content.lines().collect();
606        let head = &lines[..MAX_LINES / 2];
607        let tail = &lines[line_count - MAX_LINES / 2..];
608        let omitted = line_count - MAX_LINES;
609        format!(
610            "{}\n[... {omitted} lines omitted ...]\n{}",
611            head.join("\n"),
612            tail.join("\n")
613        )
614    };
615
616    (
617        format!(
618            "\n\n[auto-escalation] Last read used mode=\"{last_mode}\". \
619             Full content ({line_count}L) below — retry edit with exact text from here:\n\n{content_preview}"
620        ),
621        CacheEffect::StoreFull(fresh_content),
622    )
623}
624
625fn do_replace(
626    path: &Path,
627    pre: &FilePreimage,
628    params: &EditParams,
629    cap: usize,
630    args: &ReplaceArgs<'_>,
631) -> (String, CacheEffect) {
632    if args.occurrences > 1 && !args.replace_all {
633        return (
634            format!(
635                "ERROR: old_string found {} times in {}. \
636                 Use replace_all=true to replace all, or provide more context to make old_string unique."
637                ,
638                args.occurrences,
639                path.display()
640            ),
641            CacheEffect::None,
642        );
643    }
644
645    let new_content = if args.replace_all {
646        args.content.replace(args.old_str, args.new_str)
647    } else {
648        args.content.replacen(args.old_str, args.new_str, 1)
649    };
650
651    if let Err(e) = ensure_preimage_still_matches(path, &pre.fp, cap) {
652        return (e, CacheEffect::None);
653    }
654
655    let backup_path = if params.backup {
656        let bp = params
657            .backup_path
658            .as_deref()
659            .map(PathBuf::from)
660            .or_else(|| default_backup_path(path));
661        let Some(bp) = bp else {
662            return (
663                format!("ERROR: cannot compute backup path for {}", path.display()),
664                CacheEffect::None,
665            );
666        };
667        if let Err(e) = write_atomic_bytes_with_permissions(&bp, &pre.bytes, Some(&pre.permissions))
668        {
669            return (
670                format!("ERROR: cannot create backup {}: {e}", bp.display()),
671                CacheEffect::None,
672            );
673        }
674        Some(bp.to_string_lossy().to_string())
675    } else {
676        None
677    };
678
679    if let Err(e) =
680        write_atomic_bytes_with_permissions(path, new_content.as_bytes(), Some(&pre.permissions))
681    {
682        return (e, CacheEffect::None);
683    }
684
685    if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
686        bt.record_edit(&params.path);
687    }
688
689    let old_lines = args.content.lines().count();
690    let new_lines = new_content.lines().count();
691    let line_delta = new_lines as i64 - old_lines as i64;
692    let delta_str = if line_delta > 0 {
693        format!("+{line_delta}")
694    } else {
695        format!("{line_delta}")
696    };
697
698    let old_tokens = args.old_tokens;
699    let new_tokens = args.new_tokens;
700
701    let replaced_str = if args.replace_all && args.occurrences > 1 {
702        format!("{} replacements", args.occurrences)
703    } else {
704        "1 replacement".into()
705    };
706
707    let short = path.file_name().map_or_else(
708        || path.to_string_lossy().to_string(),
709        |f| f.to_string_lossy().to_string(),
710    );
711
712    let post_mtime_ms = std::fs::metadata(path)
713        .ok()
714        .and_then(|m| m.modified().ok())
715        .map_or(0, system_time_to_millis);
716    let post_fp = FileFingerprint {
717        size: new_content.len() as u64,
718        mtime_ms: post_mtime_ms,
719        md5: crate::core::hasher::hash_hex(new_content.as_bytes()),
720    };
721
722    let mut out = format!(
723        "✓ {short}: {replaced_str}, {delta_str} lines ({old_tokens}→{new_tokens} tok)\n\
724preimage: bytes={}, mtime_ms={}, md5={}\n\
725postimage: bytes={}, mtime_ms={}, md5={}",
726        pre.fp.size, pre.fp.mtime_ms, pre.fp.md5, post_fp.size, post_fp.mtime_ms, post_fp.md5
727    );
728    if let Some(bp) = backup_path {
729        out.push_str(&format!("\nbackup: {bp}"));
730    }
731    if params.evidence {
732        let diff = build_diff_evidence(args.content, &new_content, &short, params.diff_max_lines);
733        out.push_str("\n\nevidence (diff, redacted, bounded):\n```diff\n");
734        out.push_str(&diff);
735        out.push_str("\n```");
736    }
737    (out, CacheEffect::Invalidate)
738}
739
740fn handle_create(file_path: &str, content: &str, params: &EditParams) -> (String, CacheEffect) {
741    let path = Path::new(file_path);
742    let cap = crate::core::limits::max_read_bytes();
743
744    let mut preimage: Option<FilePreimage> = None;
745    if path.exists() {
746        let pre = match read_preimage(path, cap, params.allow_lossy_utf8) {
747            Ok(p) => p,
748            Err(e) => return (e, CacheEffect::None),
749        };
750        if let Err(e) = verify_expected_preimage(&pre, params) {
751            return (e, CacheEffect::None);
752        }
753        if let Err(e) = ensure_preimage_still_matches(path, &pre.fp, cap) {
754            return (e, CacheEffect::None);
755        }
756        preimage = Some(pre);
757    }
758
759    if let Some(parent) = path.parent() {
760        if !parent.exists() {
761            if let Err(e) = std::fs::create_dir_all(parent) {
762                return (
763                    format!("ERROR: cannot create directory {}: {e}", parent.display()),
764                    CacheEffect::None,
765                );
766            }
767        }
768    }
769
770    let backup_path = if params.backup {
771        if let Some(pre) = &preimage {
772            let bp = params
773                .backup_path
774                .as_deref()
775                .map(PathBuf::from)
776                .or_else(|| default_backup_path(path));
777            let Some(bp) = bp else {
778                return (
779                    format!("ERROR: cannot compute backup path for {}", path.display()),
780                    CacheEffect::None,
781                );
782            };
783            if let Err(e) =
784                write_atomic_bytes_with_permissions(&bp, &pre.bytes, Some(&pre.permissions))
785            {
786                return (
787                    format!("ERROR: cannot create backup {}: {e}", bp.display()),
788                    CacheEffect::None,
789                );
790            }
791            Some(bp.to_string_lossy().to_string())
792        } else {
793            None
794        }
795    } else {
796        None
797    };
798
799    let perms = preimage.as_ref().map(|p| &p.permissions);
800    if let Err(e) = write_atomic_bytes_with_permissions(path, content.as_bytes(), perms) {
801        return (e, CacheEffect::None);
802    }
803
804    let lines = content.lines().count();
805    let tokens = count_tokens(content);
806    let short = path.file_name().map_or_else(
807        || path.to_string_lossy().to_string(),
808        |f| f.to_string_lossy().to_string(),
809    );
810
811    let mut out = format!("✓ created {short}: {lines} lines, {tokens} tok");
812    if let Some(bp) = backup_path {
813        out.push_str(&format!("\nbackup: {bp}"));
814    }
815    (out, CacheEffect::Invalidate)
816}
817
818fn trim_trailing_per_line(s: &str) -> String {
819    s.lines().map(str::trim_end).collect::<Vec<_>>().join("\n")
820}
821
822fn adapt_new_string_to_line_sep(s: &str, sep: &str) -> String {
823    let normalized = s.replace("\r\n", "\n");
824    if sep == "\r\n" {
825        normalized.replace('\n', "\r\n")
826    } else {
827        normalized
828    }
829}
830
831/// Find the original (un-trimmed) span in `content` that matches `normalized_needle`
832/// after trailing-whitespace trimming per line.
833fn find_original_span(content: &str, normalized_needle: &str) -> Option<String> {
834    let needle_lines: Vec<&str> = normalized_needle.lines().collect();
835    if needle_lines.is_empty() {
836        return None;
837    }
838
839    let content_lines: Vec<&str> = content.lines().collect();
840
841    'outer: for start in 0..content_lines.len() {
842        if start + needle_lines.len() > content_lines.len() {
843            break;
844        }
845        for (i, nl) in needle_lines.iter().enumerate() {
846            if content_lines[start + i].trim_end() != *nl {
847                continue 'outer;
848            }
849        }
850        let sep = if content.contains("\r\n") {
851            "\r\n"
852        } else {
853            "\n"
854        };
855        return Some(content_lines[start..start + needle_lines.len()].join(sep));
856    }
857    None
858}
859
860#[cfg(test)]
861mod tests {
862    use super::*;
863    use std::io::Write;
864    use tempfile::NamedTempFile;
865
866    fn make_temp(content: &str) -> NamedTempFile {
867        let mut f = NamedTempFile::new().unwrap();
868        f.write_all(content.as_bytes()).unwrap();
869        f
870    }
871
872    fn mk_params(path: &Path, old: &str, new: &str, replace_all: bool, create: bool) -> EditParams {
873        EditParams {
874            path: path.to_string_lossy().to_string(),
875            old_string: old.to_string(),
876            new_string: new.to_string(),
877            replace_all,
878            create,
879            expected_md5: None,
880            expected_size: None,
881            expected_mtime_ms: None,
882            backup: false,
883            backup_path: None,
884            evidence: false,
885            diff_max_lines: 200,
886            allow_lossy_utf8: false,
887        }
888    }
889
890    #[test]
891    fn replace_single_occurrence() {
892        let f = make_temp("fn hello() {\n    println!(\"hello\");\n}\n");
893        let mut cache = SessionCache::new();
894        let result = handle(
895            &mut cache,
896            &mk_params(f.path(), "hello", "world", false, false),
897        );
898        assert!(result.contains("ERROR"), "should fail: 'hello' appears 2x");
899    }
900
901    #[test]
902    fn replace_all() {
903        let f = make_temp("aaa bbb aaa\n");
904        let mut cache = SessionCache::new();
905        let result = handle(&mut cache, &mk_params(f.path(), "aaa", "ccc", true, false));
906        assert!(result.contains("2 replacements"));
907        let content = std::fs::read_to_string(f.path()).unwrap();
908        assert_eq!(content, "ccc bbb ccc\n");
909    }
910
911    #[test]
912    fn not_found_error() {
913        let f = make_temp("some content\n");
914        let mut cache = SessionCache::new();
915        let result = handle(
916            &mut cache,
917            &mk_params(f.path(), "nonexistent", "x", false, false),
918        );
919        assert!(result.contains("ERROR: old_string not found"));
920    }
921
922    #[test]
923    fn create_new_file() {
924        let dir = tempfile::tempdir().unwrap();
925        let path = dir.path().join("sub/new_file.txt");
926        let mut cache = SessionCache::new();
927        let result = handle(
928            &mut cache,
929            &mk_params(&path, "", "line1\nline2\nline3\n", false, true),
930        );
931        assert!(result.contains("created new_file.txt"));
932        assert!(result.contains("3 lines"));
933        assert!(path.exists());
934    }
935
936    #[test]
937    fn unique_match_succeeds() {
938        let f = make_temp("fn main() {\n    let x = 42;\n}\n");
939        let mut cache = SessionCache::new();
940        let result = handle(
941            &mut cache,
942            &mk_params(f.path(), "let x = 42", "let x = 99", false, false),
943        );
944        assert!(result.contains("✓"));
945        assert!(result.contains("1 replacement"));
946        let content = std::fs::read_to_string(f.path()).unwrap();
947        assert!(content.contains("let x = 99"));
948    }
949
950    #[test]
951    fn crlf_file_with_lf_search() {
952        let f = make_temp("line1\r\nline2\r\nline3\r\n");
953        let mut cache = SessionCache::new();
954        let result = handle(
955            &mut cache,
956            &mk_params(f.path(), "line1\nline2", "changed1\nchanged2", false, false),
957        );
958        assert!(result.contains("✓"), "CRLF fallback should work: {result}");
959        let content = std::fs::read_to_string(f.path()).unwrap();
960        assert!(
961            content.contains("changed1\r\nchanged2"),
962            "new_string should be adapted to CRLF: {content:?}"
963        );
964        assert!(
965            content.contains("\r\nline3\r\n"),
966            "rest of file should keep CRLF: {content:?}"
967        );
968    }
969
970    #[test]
971    fn lf_file_with_crlf_search() {
972        let f = make_temp("line1\nline2\nline3\n");
973        let mut cache = SessionCache::new();
974        let result = handle(
975            &mut cache,
976            &mk_params(f.path(), "line1\r\nline2", "a\r\nb", false, false),
977        );
978        assert!(result.contains("✓"), "LF fallback should work: {result}");
979        let content = std::fs::read_to_string(f.path()).unwrap();
980        assert!(
981            content.contains("a\nb"),
982            "new_string should be adapted to LF: {content:?}"
983        );
984    }
985
986    #[test]
987    fn trailing_whitespace_tolerance() {
988        let f = make_temp("  let x = 1;  \n  let y = 2;\n");
989        let mut cache = SessionCache::new();
990        let result = handle(
991            &mut cache,
992            &mk_params(
993                f.path(),
994                "  let x = 1;\n  let y = 2;",
995                "  let x = 10;\n  let y = 20;",
996                false,
997                false,
998            ),
999        );
1000        assert!(
1001            result.contains("✓"),
1002            "trailing whitespace tolerance should work: {result}"
1003        );
1004        let content = std::fs::read_to_string(f.path()).unwrap();
1005        assert!(content.contains("let x = 10;"));
1006        assert!(content.contains("let y = 20;"));
1007    }
1008
1009    #[test]
1010    fn crlf_with_trailing_whitespace() {
1011        let f = make_temp("  const a = 1;  \r\n  const b = 2;\r\n");
1012        let mut cache = SessionCache::new();
1013        let result = handle(
1014            &mut cache,
1015            &mk_params(
1016                f.path(),
1017                "  const a = 1;\n  const b = 2;",
1018                "  const a = 10;\n  const b = 20;",
1019                false,
1020                false,
1021            ),
1022        );
1023        assert!(
1024            result.contains("✓"),
1025            "CRLF + trailing whitespace should work: {result}"
1026        );
1027        let content = std::fs::read_to_string(f.path()).unwrap();
1028        assert!(content.contains("const a = 10;"));
1029        assert!(content.contains("const b = 20;"));
1030    }
1031
1032    #[test]
1033    fn rejects_invalid_utf8_by_default() {
1034        let mut f = NamedTempFile::new().unwrap();
1035        f.write_all(&[0xff, 0xfe, 0xfd]).unwrap();
1036        let mut cache = SessionCache::new();
1037        let result = handle(&mut cache, &mk_params(f.path(), "a", "b", false, false));
1038        assert!(
1039            result.contains("not valid UTF-8"),
1040            "expected utf8 rejection, got: {result}"
1041        );
1042    }
1043
1044    #[test]
1045    fn allows_lossy_utf8_only_when_enabled() {
1046        let mut f = NamedTempFile::new().unwrap();
1047        f.write_all(&[0xff, 0xfe, 0xfd]).unwrap();
1048        let mut cache = SessionCache::new();
1049        let mut p = mk_params(f.path(), "a", "b", false, false);
1050        p.allow_lossy_utf8 = true;
1051        let result = handle(&mut cache, &p);
1052        assert!(
1053            !result.contains("not valid UTF-8"),
1054            "lossy mode should avoid utf8 hard error, got: {result}"
1055        );
1056    }
1057
1058    #[test]
1059    fn expected_md5_mismatch_fails_without_writing() {
1060        let f = make_temp("aaa\n");
1061        let mut cache = SessionCache::new();
1062        let mut p = mk_params(f.path(), "aaa", "bbb", false, false);
1063        p.expected_md5 = Some("deadbeef".to_string());
1064        let result = handle(&mut cache, &p);
1065        assert!(
1066            result.contains("preimage mismatch"),
1067            "expected preimage mismatch, got: {result}"
1068        );
1069        let content = std::fs::read_to_string(f.path()).unwrap();
1070        assert_eq!(content, "aaa\n");
1071    }
1072
1073    #[test]
1074    fn backup_is_created_when_enabled() {
1075        let f = make_temp("aaa\n");
1076        let mut cache = SessionCache::new();
1077        let mut p = mk_params(f.path(), "aaa", "bbb", false, false);
1078        p.backup = true;
1079        let out = handle(&mut cache, &p);
1080        assert!(out.contains("backup:"), "expected backup path, got: {out}");
1081        let bp = out
1082            .lines()
1083            .find_map(|l| l.strip_prefix("backup: "))
1084            .expect("backup line");
1085        let backup_content = std::fs::read_to_string(bp).unwrap();
1086        assert_eq!(backup_content, "aaa\n");
1087        let content = std::fs::read_to_string(f.path()).unwrap();
1088        assert_eq!(content, "bbb\n");
1089    }
1090
1091    #[test]
1092    fn evidence_diff_is_emitted_when_enabled() {
1093        let f = make_temp("line1\nline2\n");
1094        let mut cache = SessionCache::new();
1095        let mut p = mk_params(f.path(), "line2", "changed2", false, false);
1096        p.evidence = true;
1097        p.diff_max_lines = 50;
1098        let out = handle(&mut cache, &p);
1099        assert!(out.contains("```diff"), "expected diff fence, got: {out}");
1100        assert!(
1101            out.contains("preimage:"),
1102            "expected preimage metadata, got: {out}"
1103        );
1104        assert!(
1105            out.contains("postimage:"),
1106            "expected postimage metadata, got: {out}"
1107        );
1108    }
1109
1110    #[test]
1111    fn detects_toctou_via_preimage_guard() {
1112        let f = make_temp("aaa\n");
1113        let cap = crate::core::limits::max_read_bytes();
1114        let pre = read_preimage(f.path(), cap, false).unwrap();
1115        std::fs::write(f.path(), "bbb\n").unwrap();
1116        let err = ensure_preimage_still_matches(f.path(), &pre.fp, cap).unwrap_err();
1117        assert!(err.contains("TOCTOU guard"), "unexpected error: {err}");
1118    }
1119
1120    /// Issue #320: run_io performs the full edit without any cache handle, so the
1121    /// MCP layer can avoid holding the global cache write-lock across disk I/O.
1122    /// A successful edit reports an Invalidate effect.
1123    #[test]
1124    fn run_io_success_reports_invalidate_effect() {
1125        let f = make_temp("fn main() {\n    let x = 42;\n}\n");
1126        let (text, effect) = run_io(
1127            &mk_params(f.path(), "let x = 42", "let x = 99", false, false),
1128            "",
1129        );
1130        assert!(text.contains("✓"), "expected success: {text}");
1131        assert!(
1132            matches!(effect, CacheEffect::Invalidate),
1133            "successful edit must invalidate the cache entry"
1134        );
1135        let content = std::fs::read_to_string(f.path()).unwrap();
1136        assert!(content.contains("let x = 99"));
1137    }
1138
1139    #[test]
1140    fn run_io_failure_reports_no_cache_effect() {
1141        let f = make_temp("some content\n");
1142        let (text, effect) = run_io(&mk_params(f.path(), "nonexistent", "x", false, false), "");
1143        assert!(text.contains("ERROR: old_string not found"));
1144        assert!(
1145            matches!(effect, CacheEffect::None),
1146            "a failed edit must not mutate the cache"
1147        );
1148    }
1149
1150    /// Issue #320: concurrent edits to *different* files must all succeed without
1151    /// serializing on any shared lock — run_io takes no cache, so there is nothing
1152    /// global to contend on.
1153    #[test]
1154    fn run_io_concurrent_edits_to_different_files_all_succeed() {
1155        use std::sync::Arc;
1156        let dir = Arc::new(tempfile::tempdir().unwrap());
1157        let n = 16;
1158        let mut paths = Vec::new();
1159        for i in 0..n {
1160            let p = dir.path().join(format!("file_{i}.txt"));
1161            std::fs::write(&p, format!("value = {i}\n")).unwrap();
1162            paths.push(p);
1163        }
1164        let barrier = Arc::new(std::sync::Barrier::new(n));
1165        let mut handles = Vec::new();
1166        for (i, p) in paths.into_iter().enumerate() {
1167            let barrier = Arc::clone(&barrier);
1168            handles.push(std::thread::spawn(move || {
1169                barrier.wait();
1170                let (text, effect) = run_io(
1171                    &mk_params(
1172                        &p,
1173                        &format!("value = {i}"),
1174                        &format!("value = {}", i + 1000),
1175                        false,
1176                        false,
1177                    ),
1178                    "",
1179                );
1180                assert!(text.contains("✓"), "edit {i} failed: {text}");
1181                assert!(matches!(effect, CacheEffect::Invalidate));
1182                (p, i)
1183            }));
1184        }
1185        for h in handles {
1186            let (p, i) = h.join().unwrap();
1187            let content = std::fs::read_to_string(&p).unwrap();
1188            assert_eq!(content, format!("value = {}\n", i + 1000));
1189        }
1190    }
1191
1192    #[test]
1193    fn run_io_escalation_reports_store_full_effect() {
1194        // A file previously read in a compressed mode ("signatures") triggers
1195        // auto-escalation when old_string is not found: the full content is
1196        // returned for re-store.
1197        let f = make_temp("line a\nline b\nline c\n");
1198        let (text, effect) = run_io(
1199            &mk_params(f.path(), "definitely-not-present", "x", false, false),
1200            "signatures",
1201        );
1202        assert!(
1203            text.contains("[auto-escalation]"),
1204            "expected escalation: {text}"
1205        );
1206        match effect {
1207            CacheEffect::StoreFull(content) => {
1208                assert!(content.contains("line a") && content.contains("line c"));
1209            }
1210            _ => panic!("escalation must report a StoreFull cache effect"),
1211        }
1212    }
1213
1214    #[test]
1215    fn apply_cache_effect_invalidate_and_store() {
1216        let f = make_temp("hello\n");
1217        let mut cache = SessionCache::new();
1218        cache.store(&f.path().to_string_lossy(), "hello\n");
1219        apply_cache_effect(
1220            &mut cache,
1221            &f.path().to_string_lossy(),
1222            CacheEffect::Invalidate,
1223        );
1224        assert!(
1225            cache.get(&f.path().to_string_lossy()).is_none(),
1226            "Invalidate must drop the entry"
1227        );
1228        apply_cache_effect(
1229            &mut cache,
1230            &f.path().to_string_lossy(),
1231            CacheEffect::StoreFull("fresh\n".to_string()),
1232        );
1233        assert!(
1234            cache.get(&f.path().to_string_lossy()).is_some(),
1235            "StoreFull must re-populate the entry"
1236        );
1237    }
1238
1239    #[test]
1240    fn identical_old_new_rejected() {
1241        let f = make_temp("fn main() {}\n");
1242        let mut cache = SessionCache::new();
1243        let result = handle(
1244            &mut cache,
1245            &mk_params(f.path(), "fn main() {}", "fn main() {}", false, false),
1246        );
1247        assert!(result.contains("identical"));
1248    }
1249
1250    #[test]
1251    fn edit_already_applied_detected() {
1252        let f = make_temp("fn updated() {}\n");
1253        let (text, effect) = run_io(
1254            &mk_params(
1255                f.path(),
1256                "fn original() {}",
1257                "fn updated() {}",
1258                false,
1259                false,
1260            ),
1261            "",
1262        );
1263        assert!(text.contains("already exists"));
1264        assert!(text.contains("already applied"));
1265        assert!(matches!(effect, CacheEffect::None));
1266    }
1267
1268    #[test]
1269    fn closest_line_hint_shown() {
1270        let f = make_temp("  fn hello() {\n    println!(\"hi\");\n  }\n");
1271        let (text, _) = run_io(
1272            &mk_params(f.path(), "fn hello(){", "fn hello_world(){", false, false),
1273            "",
1274        );
1275        assert!(text.contains("Closest match at line"));
1276    }
1277
1278    #[test]
1279    fn missing_file_suggests_relocated_path() {
1280        let dir = tempfile::tempdir().unwrap();
1281        std::fs::create_dir_all(dir.path().join(".git")).unwrap();
1282        std::fs::create_dir_all(dir.path().join("src/new")).unwrap();
1283        std::fs::write(dir.path().join("src/new/gizmo.rs"), "fn gizmo() {}\n").unwrap();
1284
1285        let (text, effect) = run_io(
1286            &mk_params(
1287                &dir.path().join("src/old/gizmo.rs"),
1288                "fn gizmo() {}",
1289                "fn gizmo2() {}",
1290                false,
1291                false,
1292            ),
1293            "",
1294        );
1295        assert!(text.contains("same-named file was found"), "got: {text}");
1296        assert!(text.contains("gizmo.rs"), "got: {text}");
1297        assert!(matches!(effect, CacheEffect::None));
1298    }
1299
1300    #[test]
1301    fn old_string_in_other_file_is_reported() {
1302        let dir = tempfile::tempdir().unwrap();
1303        std::fs::create_dir_all(dir.path().join(".git")).unwrap();
1304        let target = dir.path().join("a.rs");
1305        std::fs::write(&target, "fn unrelated_a() {}\n").unwrap();
1306        std::fs::write(dir.path().join("b.rs"), "fn the_target_symbol() {}\n").unwrap();
1307
1308        let (text, _) = run_io(
1309            &mk_params(
1310                &target,
1311                "fn the_target_symbol() {}",
1312                "fn renamed() {}",
1313                false,
1314                false,
1315            ),
1316            "",
1317        );
1318        assert!(text.contains("matching line exists in"), "got: {text}");
1319        assert!(text.contains("b.rs"), "got: {text}");
1320    }
1321}