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