Skip to main content

lean_ctx/tools/
ctx_edit.rs

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