Skip to main content

recursive/tools/
edit.rs

1//! `Edit`: edit a file by replacing an exact string.
2//!
3//! This is the recommended editing tool for single-file changes. The LLM
4//! provides an `old_string` (text to find) and `new_string` (replacement),
5//! and the tool does a precise search-and-replace with a fuzzy-match
6//! fallback chain that recovers from common LLM output quirks.
7//!
8//! Fuzzy-match chain (first success wins):
9//!   1. Exact match
10//!   2. Quote normalization (curly to straight quotes) — both needle and
11//!      haystack are normalised; the match position is used to extract the
12//!      *original* bytes from the haystack so the replacement is exact.
13//!   3. Trailing whitespace strip (rstrip each line of old_string)
14//!   4. Quote normalization + trailing whitespace strip combined
15//!   5. XML-tag desanitization (model-escaped tags to real tags)
16//!
17//! When the match succeeds via quote normalization, curly-quote style is
18//! preserved in `new_string` so the edit does not silently change typography.
19//!
20//! `new_string` has trailing whitespace stripped from each line before being
21//! written (matching the normalisation applied to `old_string` in step 3/4),
22//! unless the file is a Markdown file where trailing spaces are significant.
23
24use async_trait::async_trait;
25use serde_json::{json, Value};
26use std::path::PathBuf;
27use std::sync::{Arc, Mutex};
28
29use super::{resolve_within_any, AccessTier, SharedSandboxRoots, Tool};
30use crate::acp::ToolKind;
31use crate::error::{Error, Result};
32use crate::llm::ToolSpec;
33use crate::tools::fs::{get_file_mtime, ReadFileState};
34
35/// Maximum on-disk size of a file the Edit tool will touch, in bytes. Prevents
36/// OOM from reading multi-GB files into memory. Aligned with fake-cc's
37/// `MAX_EDIT_FILE_SIZE` (1 GiB stat bytes).
38const MAX_EDIT_FILE_SIZE: u64 = 1024 * 1024 * 1024;
39
40#[derive(Debug, Clone)]
41pub struct EditTool {
42    pub root: PathBuf,
43    pub extra_roots: Vec<(PathBuf, AccessTier)>,
44    pub session_roots: Option<SharedSandboxRoots>,
45    /// When `Some`, enforces the partial-read guard: edits on files that were
46    /// never read, or only partially read, are rejected with a clear error.
47    /// `None` (default) disables the guard for backward compatibility.
48    pub read_state: Option<Arc<Mutex<ReadFileState>>>,
49}
50
51impl EditTool {
52    pub fn new(root: impl Into<PathBuf>) -> Self {
53        Self {
54            root: root.into(),
55            extra_roots: Vec::new(),
56            session_roots: None,
57            read_state: None,
58        }
59    }
60
61    pub fn with_read_state(mut self, slot: Arc<Mutex<ReadFileState>>) -> Self {
62        self.read_state = Some(slot);
63        self
64    }
65
66    /// Append additional allowed sandbox roots. See
67    /// [`crate::tools::fs::ReadFile::with_extra_roots`].
68    pub fn with_extra_roots(
69        mut self,
70        extra: impl IntoIterator<Item = (PathBuf, AccessTier)>,
71    ) -> Self {
72        self.extra_roots.extend(extra);
73        self
74    }
75
76    /// Attach the shared, session-mutable roots slot. See [`SharedSandboxRoots`].
77    pub fn with_session_roots(mut self, slot: SharedSandboxRoots) -> Self {
78        self.session_roots = Some(slot);
79        self
80    }
81
82    /// Convenience: attach the shared slot only when `Some`.
83    pub fn with_session_roots_opt(mut self, slot: Option<SharedSandboxRoots>) -> Self {
84        if let Some(s) = slot {
85            self.session_roots = Some(s);
86        }
87        self
88    }
89
90    fn all_roots(&self) -> Vec<(PathBuf, AccessTier)> {
91        let mut v: Vec<(PathBuf, AccessTier)> = Vec::with_capacity(self.extra_roots.len() + 1);
92        v.push((self.root.clone(), AccessTier::ReadWrite));
93        v.extend(self.extra_roots.iter().cloned());
94        if let Some(slot) = &self.session_roots {
95            if let Ok(roots) = slot.read() {
96                v.extend(roots.iter().cloned());
97            }
98        }
99        v
100    }
101}
102
103// ---------------------------------------------------------------------------
104// Curly-quote helpers
105// ---------------------------------------------------------------------------
106
107const LEFT_SINGLE: char = '\u{2018}';
108const RIGHT_SINGLE: char = '\u{2019}';
109const LEFT_DOUBLE: char = '\u{201c}';
110const RIGHT_DOUBLE: char = '\u{201d}';
111
112/// Replace curly quotes with their ASCII equivalents.
113fn normalize_quotes(s: &str) -> String {
114    s.replace([LEFT_SINGLE, RIGHT_SINGLE], "'")
115        .replace([LEFT_DOUBLE, RIGHT_DOUBLE], "\"")
116}
117
118fn is_opening_context(chars: &[char], i: usize) -> bool {
119    if i == 0 {
120        return true;
121    }
122    matches!(
123        chars[i - 1],
124        ' ' | '\t' | '\n' | '\r' | '(' | '[' | '{' | '\u{2014}' | '\u{2013}'
125    )
126}
127
128/// Apply curly double-quote style to ASCII double-quotes in `s`.
129fn apply_curly_double(s: &str) -> String {
130    let chars: Vec<char> = s.chars().collect();
131    let mut out = String::with_capacity(s.len());
132    for (i, &c) in chars.iter().enumerate() {
133        if c == '"' {
134            out.push(if is_opening_context(&chars, i) {
135                LEFT_DOUBLE
136            } else {
137                RIGHT_DOUBLE
138            });
139        } else {
140            out.push(c);
141        }
142    }
143    out
144}
145
146/// Apply curly single-quote style to ASCII single-quotes in `s`.
147fn apply_curly_single(s: &str) -> String {
148    let chars: Vec<char> = s.chars().collect();
149    let mut out = String::with_capacity(s.len());
150    for (i, &c) in chars.iter().enumerate() {
151        if c == '\'' {
152            // cargo-mutants::skip — `chars[i-1]` and `chars[i+1]` mutations are
153            // equivalent: apostrophe (chars[i]) is never alphabetic, so any change
154            // to the index expression yields the same result via the else branch.
155            let prev_letter = i > 0 && chars[i - 1].is_alphabetic(); // cargo-mutants::skip
156            let next_letter = i + 1 < chars.len() && chars[i + 1].is_alphabetic(); // cargo-mutants::skip
157                                                                                   // Apostrophe in a contraction uses right single curly quote.
158            if prev_letter && next_letter {
159                out.push(RIGHT_SINGLE);
160            } else if is_opening_context(&chars, i) {
161                out.push(LEFT_SINGLE);
162            } else {
163                out.push(RIGHT_SINGLE);
164            }
165        } else {
166            out.push(c);
167        }
168    }
169    out
170}
171
172/// When `old_string` matched via quote normalization (curly quotes in file,
173/// straight quotes from model), apply the same curly-quote style to `new_string`
174/// so the edit preserves the file typography.
175fn preserve_quote_style(old_string: &str, actual_old: &str, new_string: &str) -> String {
176    if old_string == actual_old {
177        return new_string.to_string();
178    }
179    let has_double = actual_old.contains(LEFT_DOUBLE) || actual_old.contains(RIGHT_DOUBLE);
180    let has_single = actual_old.contains(LEFT_SINGLE) || actual_old.contains(RIGHT_SINGLE);
181    if !has_double && !has_single {
182        return new_string.to_string();
183    }
184    let mut result = new_string.to_string();
185    if has_double {
186        result = apply_curly_double(&result);
187    }
188    if has_single {
189        result = apply_curly_single(&result);
190    }
191    result
192}
193
194// ---------------------------------------------------------------------------
195// Trailing-whitespace normalization
196// ---------------------------------------------------------------------------
197
198/// Strip trailing whitespace from every line while preserving newlines.
199fn strip_trailing_whitespace(s: &str) -> String {
200    let trailing_newline = s.ends_with('\n');
201    let stripped: Vec<&str> = s.lines().map(|l| l.trim_end()).collect();
202    let out = stripped.join("\n");
203    if trailing_newline {
204        out + "\n"
205    } else {
206        out
207    }
208}
209
210// ---------------------------------------------------------------------------
211// Desanitization
212// ---------------------------------------------------------------------------
213
214/// Apply XML-tag desanitization: models sometimes emit placeholder forms
215/// because the training pipeline has escaped the real tag names.
216fn desanitize(s: &str) -> String {
217    let mut out = s.to_string();
218    let pairs: &[(&str, &str)] = &[
219        ("<fnr>", "<function_results>"),
220        ("</fnr>", "</function_results>"),
221        ("<n>", "<name>"),
222        ("</n>", "</name>"),
223        ("<o>", "<output>"),
224        ("</o>", "</output>"),
225        ("<e>", "<error>"),
226        ("</e>", "</error>"),
227        ("<s>", "<system>"),
228        ("</s>", "</system>"),
229        ("<r>", "<result>"),
230        ("</r>", "</result>"),
231        ("< META_START >", "<META_START>"),
232        ("< META_END >", "</META_END>"),
233        ("< EOT >", "<EOT>"),
234        ("< META >", "<META>"),
235        ("< SOS >", "<SOS>"),
236        (
237            "
238
239H:", "
240
241Human:",
242        ),
243        (
244            "
245
246A:",
247            "
248
249Assistant:",
250        ),
251    ];
252    for &(from, to) in pairs {
253        if out.contains(from) {
254            out = out.replace(from, to);
255        }
256    }
257    out
258}
259
260// ---------------------------------------------------------------------------
261// Match chain
262// ---------------------------------------------------------------------------
263
264/// Try to find `needle` in `haystack` using the fuzzy-match chain.
265///
266/// Returns the *actual* substring from `haystack` that matched (not the
267/// normalised needle), so callers can do a byte-exact replacement.
268///
269/// For quote-normalisation steps the match is found by normalising *both*
270/// sides, locating the byte index in the normalised haystack, then slicing
271/// the *original* haystack at that position.  This mirrors the approach used
272/// by Claude Code's FileEditTool and avoids silent replace failures when the
273/// normalised needle doesn't exist verbatim in the original file.
274fn try_match(haystack: &str, needle: &str) -> Option<String> {
275    // 1. Exact
276    if haystack.contains(needle) {
277        return Some(needle.to_string());
278    }
279
280    // 2. Quote normalization — normalise both sides, find index in normalised
281    //    haystack, then extract the original bytes from haystack.
282    //
283    //    We normalise both needle and haystack to straight quotes, search in
284    //    that normalised space, then map the byte index back to the *original*
285    //    haystack to return the verbatim slice.  This handles both directions:
286    //      • needle has curly quotes, file has straight quotes
287    //      • needle has straight quotes, file has curly quotes
288    let qn_needle = normalize_quotes(needle);
289    let qn_haystack = normalize_quotes(haystack);
290    // Only enter this branch if normalisation actually changed something.
291    if qn_needle != needle || qn_haystack != haystack {
292        if let Some(idx) = qn_haystack.find(qn_needle.as_str()) {
293            // `idx` is a byte index into `qn_haystack`. Because normalize_quotes
294            // only does character-level replacements that preserve char boundaries
295            // (curly quotes are multi-byte; straight quotes are single-byte), we
296            // cannot use idx directly into `haystack`. Instead, map via char counts.
297            let actual = extract_by_char_count(haystack, &qn_haystack, idx, needle.chars().count());
298            return Some(actual);
299        }
300    }
301
302    // 3. Trailing whitespace strip
303    let tws = strip_trailing_whitespace(needle);
304    if tws != needle && haystack.contains(tws.as_str()) {
305        return Some(tws);
306    }
307
308    // 4. Quote normalization + trailing whitespace strip combined
309    let qn_tws = strip_trailing_whitespace(&qn_needle);
310    // &&→|| guard mutations are equivalent (deduplication-only: step 4 finds the same
311    // result as an earlier step when the guard is bypassed). Behaviorally tested by
312    // try_match_combined_quote_and_trailing_ws; mutations skipped via end-of-line tag.
313    if qn_tws != needle && qn_tws != qn_needle && qn_tws != tws {
314        // cargo-mutants::skip
315        let qn_tws_haystack = strip_trailing_whitespace(&qn_haystack);
316        if let Some(idx) = qn_tws_haystack.find(qn_tws.as_str()) {
317            let actual =
318                extract_by_char_count(haystack, &qn_tws_haystack, idx, needle.chars().count());
319            return Some(actual);
320        }
321    }
322
323    // 5. Desanitization
324    let ds = desanitize(needle);
325    if ds != needle && haystack.contains(ds.as_str()) {
326        return Some(ds);
327    }
328
329    None
330}
331
332/// Given a `normalised` string (derived from `original` by character-level
333/// substitutions) and a byte `idx` into `normalised`, return the substring of
334/// `original` that has the same *char count* as `char_len`.
335///
336/// This works because normalize_quotes replaces each character 1-for-1 (a
337/// curly quote is still one Unicode scalar; a straight quote is also one), so
338/// char counts are preserved even though byte counts differ.
339fn extract_by_char_count(
340    original: &str,
341    normalised: &str,
342    byte_idx: usize,
343    char_len: usize,
344) -> String {
345    // Find the char offset that byte_idx corresponds to in normalised.
346    let char_start = normalised[..byte_idx].chars().count();
347    original.chars().skip(char_start).take(char_len).collect()
348}
349
350// ---------------------------------------------------------------------------
351// Tool implementation
352// ---------------------------------------------------------------------------
353
354#[async_trait]
355impl Tool for EditTool {
356    fn spec(&self) -> ToolSpec {
357        ToolSpec {
358            name: "Edit".into(),
359            description: "Performs exact string replacements in files.\n\
360Usage:\n\
361- You must use your `Read` tool at least once in the conversation before editing. \
362This tool will error if you attempt an edit without reading the file.\n\
363- When editing text from Read tool output, ensure you preserve the exact indentation \
364(tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: \
365line number + tab. Everything after that is the actual file content to match. \
366Never include any part of the line number prefix in the old_string or new_string.\n\
367- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless \
368explicitly required.\n\
369- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.\n\
370- The edit will FAIL if `old_string` is not unique in the file. Either provide a larger \
371string with more surrounding context to make it unique or use `replace_all` to change \
372every instance of `old_string`.\n\
373- Use `replace_all` for replacing and renaming strings across the file. This parameter is \
374useful if you want to rename a variable for instance."
375                .into(),
376            parameters: json!({
377                "type": "object",
378                "properties": {
379                    "file_path": {
380                        "type": "string",
381                        "description": "The absolute path to the file to modify"
382                    },
383                    "old_string": {
384                        "type": "string",
385                        "description": "The text to replace (must be different from new_string). If empty, creates a new file with new_string as content."
386                    },
387                    "new_string": {
388                        "type": "string",
389                        "description": "The text to replace it with (must be different from old_string)"
390                    },
391                    "replace_all": {
392                        "type": "boolean",
393                        "description": "Replace all occurrences of old_string (default false)",
394                        "default": false
395                    }
396                },
397                "required": ["file_path", "old_string", "new_string"]
398            }),
399        }
400    }
401
402    fn side_effect_class(&self) -> crate::tools::ToolSideEffect {
403        crate::tools::ToolSideEffect::Mutating
404    }
405
406    fn kind(&self) -> ToolKind {
407        ToolKind::Edit
408    }
409
410    async fn execute(&self, args: Value) -> Result<String> {
411        let file_path = args["file_path"]
412            .as_str()
413            .ok_or_else(|| Error::BadToolArgs {
414                name: "Edit".into(),
415                message: "missing `file_path`".into(),
416            })?;
417        let old_string = args["old_string"]
418            .as_str()
419            .ok_or_else(|| Error::BadToolArgs {
420                name: "Edit".into(),
421                message: "missing `old_string`".into(),
422            })?;
423        let new_string = args["new_string"]
424            .as_str()
425            .ok_or_else(|| Error::BadToolArgs {
426                name: "Edit".into(),
427                message: "missing `new_string`".into(),
428            })?;
429        let replace_all = args["replace_all"].as_bool().unwrap_or(false);
430
431        let abs_path = resolve_within_any(&self.all_roots(), file_path, true)?;
432
433        // ── Read-state guard ────────────────────────────────────────────
434        // A file must have been Read at least once this session (full or
435        // partial) before it can be Edited — editing a file the model never
436        // laid eyes on is disallowed.
437        //
438        // Goal 347: a *partial* read (via start_line/end_line) is NO LONGER a
439        // rejection. Previously it forced the model to re-Read the complete
440        // file, which for large files (e.g. runtime.rs, ~3500 lines) blew up
441        // the context and trapped the agent in a partial-read → reject →
442        // full-read-too-big → partial-read loop. The Edit tool does not need
443        // the model to hold the whole file in context: it reads the current
444        // disk content itself at line 557 below and validates `old_string` is
445        // present and unique (the real safety gate). `old_string` is the
446        // model's proof it knows the exact bytes being replaced. (WriteFile,
447        // which has no `old_string` anchor and would clobber unseen content,
448        // KEEPS its partial-read reject — see fs.rs.)
449        //
450        // Goal 348: partial reads also SKIP the staleness content-check below.
451        // That check compares the cached `record.content` against the full
452        // disk content, which is only meaningful when the cache holds the
453        // whole file (a full read). For a partial read the cached content is a
454        // view of a line range; comparing it to the full file would always
455        // differ and falsely reject every edit once ReadFile caches only the
456        // requested range. The `old_string` match below already guards against
457        // external modification on the partial path, so the staleness
458        // cached-content check is redundant there.
459        //
460        // The lock is acquired briefly to extract the record, then dropped
461        // before any `.await` to keep the future `Send`.
462        if let Some(slot) = &self.read_state {
463            let staleness_check: Option<(u64, String)> = {
464                let state = slot.lock().map_err(|_| Error::Tool {
465                    name: "Edit".into(),
466                    call_id: None,
467                    message: "internal: read-state lock poisoned".into(),
468                })?;
469                match state.get(&abs_path) {
470                    None => {
471                        return Err(Error::Tool {
472                            name: "Edit".into(),
473                            call_id: None,
474                            message: format!(
475                                "File `{file_path}` has not been read yet. \
476                                 Read it first before editing."
477                            ),
478                        });
479                    }
480                    Some(record) => {
481                        // Check mtime while holding the lock; if stale, extract
482                        // the cached content for an async content-fallback check
483                        // outside the lock.
484                        let disk_mtime = get_file_mtime(&abs_path);
485                        if disk_mtime > record.timestamp && !record.is_partial {
486                            // Full read: the cache holds the whole file, so the
487                            // content-equality check below is a meaningful
488                            // "nothing changed" signal. Partial reads skip it:
489                            // their cached content is a view of a line range,
490                            // not the whole file (a future ReadFile optimisation
491                            // may cache only the slice), so comparing it against
492                            // the full disk content would always differ. The
493                            // on-disk read + `old_string` match further down is
494                            // the safety net for partial reads.
495                            Some((disk_mtime, record.content.clone()))
496                        } else {
497                            None // mtime unchanged, or partial read — no staleness concern
498                        }
499                    }
500                }
501                // MutexGuard dropped here
502            };
503
504            // Async content-fallback staleness check (lock is not held).
505            if let Some((_disk_mtime, cached_content)) = staleness_check {
506                let disk_content = tokio::fs::read_to_string(&abs_path)
507                    .await
508                    .unwrap_or_default();
509                if disk_content != cached_content {
510                    return Err(Error::Tool {
511                        name: "Edit".into(),
512                        call_id: None,
513                        message: format!(
514                            "File `{file_path}` has been modified since it was \
515                             last read. Read it again before editing."
516                        ),
517                    });
518                }
519                // Content unchanged despite mtime bump — safe to proceed.
520            }
521        }
522
523        // ── File-size guard ─────────────────────────────────────────────
524        // Refuse to edit files larger than MAX_EDIT_FILE_SIZE to avoid OOM.
525        // A missing file (new-file create path) is exempt — no metadata yet.
526        match tokio::fs::metadata(&abs_path).await {
527            Ok(meta) => {
528                let size = meta.len();
529                if size > MAX_EDIT_FILE_SIZE {
530                    return Err(Error::Tool {
531                        name: "Edit".into(),
532                        call_id: None,
533                        message: format!(
534                            "File `{file_path}` is too large to edit ({} bytes). \
535                             Maximum editable file size is {} bytes.",
536                            size, MAX_EDIT_FILE_SIZE
537                        ),
538                    });
539                }
540            }
541            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
542                // New file — size guard does not apply.
543            }
544            Err(e) => {
545                return Err(Error::Tool {
546                    name: "Edit".into(),
547                    call_id: None,
548                    message: format!("{}: {e}", abs_path.display()),
549                });
550            }
551        }
552
553        // ── Empty old_string: create new file or overwrite empty file ───
554        if old_string.is_empty() {
555            if let Some(parent) = abs_path.parent() {
556                tokio::fs::create_dir_all(parent)
557                    .await
558                    .map_err(|e| Error::Tool {
559                        name: "Edit".into(),
560                        call_id: None,
561                        message: format!("mkdir {}: {e}", parent.display()),
562                    })?;
563            }
564            tokio::fs::write(&abs_path, new_string)
565                .await
566                .map_err(|e| Error::Tool {
567                    name: "Edit".into(),
568                    call_id: None,
569                    message: format!("{}: {e}", abs_path.display()),
570                })?;
571            return Ok(format!(
572                "Created `{}` ({} bytes)",
573                file_path,
574                new_string.len()
575            ));
576        }
577
578        // ── Read file ───────────────────────────────────────────────────
579        let content = tokio::fs::read_to_string(&abs_path)
580            .await
581            .map_err(|e| Error::Tool {
582                name: "Edit".into(),
583                call_id: None,
584                message: format!("{}: {e}", abs_path.display()),
585            })?;
586
587        // ── Guard: identical strings do nothing ─────────────────────────
588        if old_string == new_string {
589            return Err(Error::Tool {
590                name: "Edit".into(),
591                call_id: None,
592                message: "No changes to make: old_string and new_string are exactly the same."
593                    .into(),
594            });
595        }
596
597        // ── Normalise new_string trailing whitespace (pre-pass) ─────────
598        // Strip trailing spaces/tabs from each line of new_string before any
599        // matching, mirroring the normalisation applied to old_string in the
600        // fuzzy-match chain.  This prevents the model from accidentally writing
601        // invisible trailing whitespace into files.  Markdown files are exempt
602        // because two trailing spaces are a hard line-break in CommonMark.
603        let new_string_normalised;
604        let new_string = if file_path.ends_with(".md") || file_path.ends_with(".mdx") {
605            new_string
606        } else {
607            new_string_normalised = strip_trailing_whitespace(new_string);
608            &new_string_normalised
609        };
610
611        // ── Find old_string via fuzzy-match chain ───────────────────────
612        let actual_old = try_match(&content, old_string).ok_or_else(|| Error::Tool {
613            name: "Edit".into(),
614            call_id: None,
615            message: format!("String to replace not found in `{file_path}`.\nString: {old_string}"),
616        })?;
617
618        // ── Count occurrences ───────────────────────────────────────────
619        let occurrence_count = content.matches(actual_old.as_str()).count();
620        if !replace_all && occurrence_count > 1 {
621            return Err(Error::Tool {
622                name: "Edit".into(),
623                call_id: None,
624                message: format!(
625                    "Found {occurrence_count} matches of the string to replace, but \
626replace_all is false. To replace all occurrences, set replace_all to true. \
627To replace only one occurrence, please provide more context to uniquely identify \
628the instance.\nString: {old_string}"
629                ),
630            });
631        }
632
633        // ── Preserve curly-quote style in new_string ────────────────────
634        let actual_new = preserve_quote_style(old_string, &actual_old, new_string);
635
636        // ── Apply replacement ───────────────────────────────────────────
637        let max_replace = if replace_all { usize::MAX } else { 1 };
638        let updated = content.replacen(actual_old.as_str(), actual_new.as_str(), max_replace);
639
640        // ── Call-time staleness re-validation ──────────────────────────
641        // The pre-read guard at the top of execute() checked staleness, but
642        // several `.await` points (metadata, read_to_string) have yielded
643        // since. Re-probe mtime here and, if the file was touched since the
644        // cached read, compare the content we just read (`content`) against
645        // the cached content. This narrows the validate→write race window,
646        // mirroring fake-cc's `call()`-level mtime re-check.
647        //
648        // Goal 348: like the pre-read guard, the content-equality comparison
649        // only applies to full reads (`!is_partial`). A partial read's cached
650        // content is a line-range view, not the whole file, so comparing it to
651        // the full disk content would always differ; the old_string match
652        // above is the safety net on that path.
653        if let Some(slot) = &self.read_state {
654            let cached: Option<(u64, bool, String)> = {
655                let state = slot.lock().map_err(|_| Error::Tool {
656                    name: "Edit".into(),
657                    call_id: None,
658                    message: "internal: read-state lock poisoned".into(),
659                })?;
660                state
661                    .get(&abs_path)
662                    .map(|r| (r.timestamp, r.is_partial, r.content.clone()))
663                // guard dropped
664            };
665            if let Some((cached_ts, is_partial, cached_content)) = cached {
666                let disk_mtime = get_file_mtime(&abs_path);
667                if disk_mtime > cached_ts && !is_partial && content != cached_content {
668                    return Err(Error::Tool {
669                        name: "Edit".into(),
670                        call_id: None,
671                        message: format!(
672                            "File `{file_path}` was modified after it was last \
673                             read. Read it again before editing."
674                        ),
675                    });
676                }
677            }
678        }
679
680        tokio::fs::write(&abs_path, &updated)
681            .await
682            .map_err(|e| Error::Tool {
683                name: "Edit".into(),
684                call_id: None,
685                message: format!("{}: {e}", abs_path.display()),
686            })?;
687
688        // ── Post-edit cache update ───────────────────────────────────
689        // Update the read-state so subsequent edits to the same file
690        // don't require a redundant Read.
691        if let Some(slot) = &self.read_state {
692            if let Ok(mut state) = slot.lock() {
693                let new_mtime = get_file_mtime(&abs_path);
694                state.record(abs_path, false, updated.clone(), new_mtime);
695            }
696        }
697
698        let replaced_count = if replace_all {
699            occurrence_count.to_string()
700        } else {
701            "1".to_string()
702        };
703
704        if replace_all {
705            Ok(format!(
706                "The file {file_path} has been updated. All occurrences were successfully replaced."
707            ))
708        } else {
709            Ok(format!(
710                "The file {file_path} has been updated successfully. {replaced_count} occurrence replaced."
711            ))
712        }
713    }
714}
715
716// ---------------------------------------------------------------------------
717// Tests
718// ---------------------------------------------------------------------------
719
720#[cfg(test)]
721mod tests {
722    use super::*;
723    use tempfile::TempDir;
724
725    // ── normalize_quotes ─────────────────────────────────────────────────
726
727    #[test]
728    fn quote_normalization_replaces_curly_quotes() {
729        let input = "Here\u{2019}s a \u{201c}quoted\u{201d} string with \u{2018}single\u{2019}.";
730        let output = normalize_quotes(input);
731        assert!(!output.contains('\u{2018}'));
732        assert!(!output.contains('\u{2019}'));
733        assert!(!output.contains('\u{201c}'));
734        assert!(!output.contains('\u{201d}'));
735        assert!(output.contains('\'')); // single quote
736        assert!(output.contains('"')); // double quote
737    }
738
739    // ── strip_trailing_whitespace ────────────────────────────────────────
740
741    #[test]
742    fn strip_trailing_whitespace_per_line() {
743        let input = "hello   \nworld\t  \n  spaced  \n";
744        let output = strip_trailing_whitespace(input);
745        assert_eq!(output, "hello\nworld\n  spaced\n");
746    }
747
748    // ── preserve_quote_style ─────────────────────────────────────────────
749
750    #[test]
751    fn preserve_quote_style_noop_when_exact_match() {
752        let result = preserve_quote_style("hello", "hello", "world");
753        assert_eq!(result, "world");
754    }
755
756    #[test]
757    fn preserve_quote_style_applies_curly_double_when_needed() {
758        // actual_old contains curly double quotes (file uses them)
759        let actual_old = "\u{201c}quoted\u{201d}";
760        let result = preserve_quote_style("\"quoted\"", actual_old, "\"replaced\"");
761        // new_string should have curly quotes applied
762        assert!(result.contains('\u{201c}') || result.contains('\u{201d}'));
763    }
764
765    // ── desanitize ────────────────────────────────────────────────────────
766
767    #[test]
768    fn desanitize_function_results_tag() {
769        let input = "before <fnr> after";
770        let output = desanitize(input);
771        assert!(output.contains("<function_results>"), "got: {output}");
772    }
773
774    #[test]
775    fn desanitize_noop_when_no_match() {
776        let input = "nothing to desanitize here";
777        let output = desanitize(input);
778        assert_eq!(output, input);
779    }
780
781    // ── try_match ─────────────────────────────────────────────────────────
782
783    #[test]
784    fn try_match_exact() {
785        assert_eq!(
786            try_match("hello world", "hello world"),
787            Some("hello world".to_string())
788        );
789    }
790
791    #[test]
792    fn try_match_quote_normalization() {
793        // File uses straight quote; needle has curly quote — should match and
794        // return the *original* substring from the haystack (straight quote).
795        let haystack = "Here's a string";
796        let needle = "Here\u{2019}s a string";
797        assert_eq!(
798            try_match(haystack, needle),
799            Some("Here's a string".to_string())
800        );
801    }
802
803    #[test]
804    fn try_match_quote_normalization_file_has_curly_returns_original() {
805        // File uses curly quotes; needle has straight quotes — the returned
806        // value must be the original curly-quote slice, not the normalised needle.
807        let haystack = "say \u{201c}hello\u{201d} world";
808        let needle = "say \"hello\" world";
809        let result = try_match(haystack, needle);
810        assert!(result.is_some(), "should match via quote normalisation");
811        let matched = result.unwrap();
812        // The returned string must be the original file slice (curly quotes).
813        assert!(
814            matched.contains('\u{201c}'),
815            "returned slice should preserve original curly quote, got: {matched:?}"
816        );
817        // And it must actually exist in the haystack (so replace will work).
818        assert!(
819            haystack.contains(&matched),
820            "returned slice must be present verbatim in haystack, got: {matched:?}"
821        );
822    }
823
824    #[test]
825    fn try_match_quote_norm_combined_with_tws() {
826        // Quote normalization + trailing whitespace strip combined (step 4).
827        let haystack = "fn foo() {\n    bar\n}\n";
828        // Needle has curly brace lookalike AND trailing whitespace — exercise
829        // the combined path.  (Using a trailing space here rather than curly
830        // quotes so the test stays simple and deterministic.)
831        let needle = "fn foo() {\n    bar   \n}\n";
832        let result = try_match(haystack, needle);
833        assert!(result.is_some(), "should match via tws strip");
834        let matched = result.unwrap();
835        assert!(
836            haystack.contains(&matched),
837            "returned slice must be verbatim in haystack"
838        );
839    }
840
841    #[test]
842    fn try_match_trailing_whitespace() {
843        let haystack = "fn foo() {\n    bar\n}\n";
844        let needle = "fn foo() {   \n    bar   \n}\n";
845        assert_eq!(
846            try_match(haystack, needle),
847            Some("fn foo() {\n    bar\n}\n".to_string())
848        );
849    }
850
851    #[test]
852    fn try_match_desanitization() {
853        let haystack = "result: <function_results>data</function_results>";
854        let needle = "result: <fnr>data</fnr>";
855        assert!(try_match(haystack, needle).is_some());
856    }
857
858    #[test]
859    fn try_match_not_found() {
860        assert_eq!(try_match("hello world", "goodbye"), None);
861    }
862
863    // ── is_opening_context ────────────────────────────────────────────────
864    // Kills: replace is_opening_context -> bool with true  (113:5)
865    //        replace is_opening_context -> bool with false (113:5)
866
867    /// Position 0 must always be an opening context.
868    /// Kills `replace -> bool with false` because at i=0 the guard is unconditional.
869    #[test]
870    fn is_opening_context_position_zero_is_opening() {
871        let chars: Vec<char> = "hello".chars().collect();
872        assert!(is_opening_context(&chars, 0), "i=0 must be opening context");
873    }
874
875    /// After whitespace or punctuation must be opening.
876    /// Kills `replace -> bool with false`.
877    #[test]
878    fn is_opening_context_after_space_is_opening() {
879        let chars: Vec<char> = " word".chars().collect();
880        assert!(
881            is_opening_context(&chars, 1),
882            "after space must be opening context"
883        );
884    }
885
886    /// After a letter must NOT be opening.
887    /// Kills `replace -> bool with true` (would say "word'" is opening).
888    #[test]
889    fn is_opening_context_after_letter_is_not_opening() {
890        let chars: Vec<char> = "word'end".chars().collect();
891        assert!(
892            !is_opening_context(&chars, 4),
893            "after letter must NOT be opening context"
894        );
895    }
896
897    // ── apply_curly_double ────────────────────────────────────────────────
898    // Kills: replace == with != in apply_curly_double (127:14)
899
900    /// `"hello"` must yield left-then-right double curly quotes.
901    /// Kills `replace == with !=`: with mutation, non-`"` chars get replaced instead.
902    #[test]
903    fn apply_curly_double_wraps_correctly() {
904        let result = apply_curly_double("\"hello\"");
905        assert_eq!(
906            result, "\u{201c}hello\u{201d}",
907            "opening \" → LEFT_DOUBLE, closing \" → RIGHT_DOUBLE"
908        );
909    }
910
911    /// Text without `"` must be returned unchanged.
912    /// Kills `replace == with !=`: every letter would become a curly quote.
913    #[test]
914    fn apply_curly_double_no_quotes_unchanged() {
915        let result = apply_curly_double("no quotes here");
916        assert_eq!(result, "no quotes here");
917    }
918
919    // ── apply_curly_single ────────────────────────────────────────────────
920    // Covers mutations at lines 142-149.
921
922    /// Apostrophe in a contraction → RIGHT_SINGLE (curly apostrophe).
923    /// Kills: replace apply_curly_single -> String with String::new() (142:5)
924    ///        replace && with || (149:28) — `||` changes any prev OR next letter to contraction
925    ///        replace && with || (146:37) — prev_letter guard short-circuit
926    ///        replace && with || (147:51) — next_letter guard short-circuit
927    #[test]
928    fn apply_curly_single_contraction_becomes_right_curly() {
929        // "it's": apostrophe at position 2, prev='t' (letter), next='s' (letter) → RIGHT_SINGLE
930        let result = apply_curly_single("it's");
931        assert_eq!(result, "it\u{2019}s");
932    }
933
934    /// Opening apostrophe at position 0 → LEFT_SINGLE; no panic from prev-char access.
935    /// Kills: replace > with >= (146:33) — `i>=0` is always true → chars[-1] panics
936    ///        replace > with == (146:33) — `i==0` only checks exact zero, then checks chars[-1]
937    ///        replace && with || (146:37) — `0 || chars[-1].is_alphabetic()` panics at i=0
938    #[test]
939    fn apply_curly_single_apostrophe_at_start_is_left_curly() {
940        let result = apply_curly_single("'world");
941        // position 0: prev doesn't exist → opening → LEFT_SINGLE
942        assert!(
943            result.starts_with('\u{2018}'),
944            "apostrophe at position 0 must be left curly; got: {result:?}"
945        );
946    }
947
948    /// Apostrophe at the very end of the string → RIGHT_SINGLE; no panic from next-char access.
949    /// Kills: replace < with <= (147:37) — `i+1 <= len` → chars[len] panics at last position
950    ///        replace + with - (147:33) — `i - 1 < chars.len()` → underflow at i=0
951    ///        replace + with * (147:33) — `i * 1 < chars.len()` → `i`, same index, subtly wrong
952    ///        replace + with - (147:62) — `chars[i - 1]` at i=last uses wrong char
953    ///        replace + with * (147:62) — same wrong-index issue
954    #[test]
955    fn apply_curly_single_apostrophe_at_end_is_right_curly() {
956        // "hello'": position 5, prev='o' (letter), no next → not contraction → not opening → RIGHT_SINGLE
957        let result = apply_curly_single("hello'");
958        assert!(
959            result.ends_with('\u{2019}'),
960            "trailing apostrophe must be right curly; got: {result:?}"
961        );
962    }
963
964    /// Text without apostrophe passes through unchanged.
965    /// Kills: replace == with != (145:14) — every non-apostrophe char gets replaced with curly.
966    #[test]
967    fn apply_curly_single_no_apostrophe_unchanged() {
968        let result = apply_curly_single("hello world");
969        assert_eq!(result, "hello world");
970    }
971
972    /// After a space (opening context), apostrophe is LEFT_SINGLE even when the next char is a letter.
973    /// Kills: replace - with + (146:48) — `chars[i+1]` used as prev; space context loses its opening meaning.
974    #[test]
975    fn apply_curly_single_after_space_is_opening() {
976        // " 'hello": position 1, prev=' ' (space, opening), next='h' (letter)
977        // → prev_letter=false (space is not alphabetic) → not contraction
978        // → is_opening_context(1) = true (space before) → LEFT_SINGLE
979        // With 146:48 mutation: prev_letter checks chars[i+1]='h' → true
980        // → contraction check: prev=true(mutated), next=true → RIGHT_SINGLE (WRONG)
981        let result = apply_curly_single(" 'hello");
982        let chars: Vec<char> = result.chars().collect();
983        assert_eq!(
984            chars[1], '\u{2018}',
985            "apostrophe after space must be left curly (opening); got: {result:?}"
986        );
987    }
988
989    // ── preserve_quote_style ─────────────────────────────────────────────
990    // Kills mutations at 170:55, 171:55, 172:23.
991
992    /// actual_old with only a LEFT_DOUBLE (no RIGHT_DOUBLE) must still apply curly style.
993    /// Kills: replace || with && (170:55) — requires BOTH left AND right double to set has_double.
994    #[test]
995    fn preserve_quote_style_left_double_only_applies_curly() {
996        // actual_old has only LEFT_DOUBLE, new_string has straight quotes
997        let actual_old = "\u{201c}open quote only";
998        let result = preserve_quote_style("\"open quote only", actual_old, "\"replaced\"");
999        assert!(
1000            result.contains('\u{201c}') || result.contains('\u{201d}'),
1001            "only LEFT_DOUBLE in actual_old must still trigger curly-double application; got: {result:?}"
1002        );
1003    }
1004
1005    /// actual_old with only a LEFT_SINGLE must still apply curly single style.
1006    /// Kills: replace || with && (171:55) — requires BOTH left AND right single to set has_single.
1007    #[test]
1008    fn preserve_quote_style_left_single_only_applies_curly() {
1009        let actual_old = "\u{2018}open single only";
1010        let result = preserve_quote_style("'open single only", actual_old, "'replaced'");
1011        assert!(
1012            result.contains('\u{2018}') || result.contains('\u{2019}'),
1013            "only LEFT_SINGLE in actual_old must still trigger curly-single application; got: {result:?}"
1014        );
1015    }
1016
1017    /// actual_old with only curly singles (no doubles): must apply singles but NOT doubles.
1018    /// Kills: delete ! at 172:23 — removes the `!` before `has_single`, so the guard
1019    /// `if !has_double && !has_single` becomes `if !has_double && has_single`, causing
1020    /// early return (no style applied) when only singles are present.
1021    #[test]
1022    fn preserve_quote_style_single_quotes_only_applied() {
1023        let actual_old = "\u{2018}hello\u{2019}";
1024        let result = preserve_quote_style("'hello'", actual_old, "'world'");
1025        assert!(
1026            result.contains('\u{2018}') || result.contains('\u{2019}'),
1027            "actual_old with only single curly quotes must apply single curly style; got: {result:?}"
1028        );
1029    }
1030
1031    // ── try_match step 3 negative (kills 295:22 && with ||) ──────────────
1032
1033    /// Step 3 must NOT match when the stripped needle is not in the haystack.
1034    /// Kills: replace && with || (295:22) — would return Some(stripped) even when haystack
1035    /// does not contain the stripped needle (since `tws != needle` is already true).
1036    #[test]
1037    fn try_match_trailing_whitespace_not_in_haystack_returns_none() {
1038        // needle has trailing whitespace but stripped form ("foo") is not in haystack
1039        assert_eq!(
1040            try_match("bar baz qux", "foo   "),
1041            None,
1042            "stripped needle not in haystack must return None"
1043        );
1044    }
1045
1046    // ── try_match step 4 negative (kills 301:* mutations) ─────────────────
1047
1048    /// Step 4 (combined quote-norm + tws) must NOT fire when qn_tws == needle.
1049    /// Kills: replace != with == (301:15) — enters step 4 only when qn_tws == needle.
1050    #[test]
1051    fn try_match_combined_step_does_not_fire_when_no_change() {
1052        // needle with no quotes and no trailing whitespace: all normalizations are no-ops
1053        // qn_tws == needle → step 4 guard must be false → no match via step 4
1054        assert_eq!(try_match("hello world", "not found"), None);
1055    }
1056
1057    // ── try_match step 4 positive (exercises combined quote-norm + tws) ───
1058
1059    /// Exercises step 4 of `try_match` (combined quote-normalisation +
1060    /// trailing-whitespace strip).
1061    ///
1062    /// `needle` has both curly quotes **and** trailing spaces; `haystack` has
1063    /// straight quotes with no trailing spaces.
1064    ///
1065    /// * Step 1 (exact): fails — different quotes, different trailing WS.
1066    /// * Step 2 (quote-only): fails — `qn_needle` still has trailing spaces.
1067    /// * Step 3 (tws-only): fails — stripped needle keeps curly quotes.
1068    /// * Step 4 (combined): succeeds — `qn_tws` matches in `qn_tws_haystack`.
1069    ///
1070    /// Provides regression coverage for the guard at the step-4 `if` (which is
1071    /// skipped for mutation testing because its `&&→||` variants are
1072    /// near-equivalent deduplication guards).
1073    #[test]
1074    fn try_match_combined_quote_and_trailing_ws() {
1075        // Curly right-single quote + trailing spaces in the needle.
1076        let needle = "He said \u{2019}hello\u{2019}   ";
1077        let haystack = "He said 'hello'";
1078
1079        let result = try_match(haystack, needle);
1080        assert!(
1081            result.is_some(),
1082            "step 4 must match: needle={needle:?} in haystack={haystack:?}"
1083        );
1084        let matched = result.unwrap();
1085        assert!(
1086            haystack.contains(&matched),
1087            "returned slice must be verbatim in haystack, got {matched:?}"
1088        );
1089    }
1090
1091    // ── try_match step 5 negative (kills 312:21 && with ||) ──────────────
1092
1093    /// Step 5 must NOT match when the desanitized needle is not in the haystack.
1094    /// Kills: replace && with || (312:21) — would return Some(ds) even when haystack
1095    /// does not contain it (since `ds != needle` is already true for a desanitizable needle).
1096    #[test]
1097    fn try_match_desanitized_needle_not_in_haystack_returns_none() {
1098        // needle has desanitizable tag but the desanitized form is not in the haystack
1099        assert_eq!(
1100            try_match("some other content", "<fnr>data</fnr>"),
1101            None,
1102            "desanitized form not in haystack must return None"
1103        );
1104    }
1105
1106    // ── Tool (end-to-end) ─────────────────────────────────────────────────
1107
1108    #[tokio::test]
1109    async fn exact_match_replaces_once() {
1110        let tmp = TempDir::new().unwrap();
1111        std::fs::write(
1112            tmp.path().join("src.txt"),
1113            "fn old_func() {}\nfn mid() {}\nfn other_func() {}\n",
1114        )
1115        .unwrap();
1116
1117        let tool = EditTool::new(tmp.path());
1118        let result = tool
1119            .execute(serde_json::json!({
1120                "file_path": "src.txt",
1121                "old_string": "fn old_func() {}",
1122                "new_string": "fn new() {}",
1123            }))
1124            .await
1125            .unwrap();
1126
1127        assert!(result.contains("updated successfully"));
1128        let content = std::fs::read_to_string(tmp.path().join("src.txt")).unwrap();
1129        assert_eq!(content, "fn new() {}\nfn mid() {}\nfn other_func() {}\n");
1130    }
1131
1132    #[tokio::test]
1133    async fn fails_when_not_found() {
1134        let tmp = TempDir::new().unwrap();
1135        std::fs::write(tmp.path().join("src.txt"), "hello world\n").unwrap();
1136
1137        let err = EditTool::new(tmp.path())
1138            .execute(serde_json::json!({
1139                "file_path": "src.txt",
1140                "old_string": "goodbye",
1141                "new_string": "replaced"
1142            }))
1143            .await
1144            .unwrap_err();
1145
1146        let msg = format!("{err}");
1147        assert!(msg.contains("not found"), "expected not found, got: {msg}");
1148    }
1149
1150    #[tokio::test]
1151    async fn fails_when_ambiguous() {
1152        let tmp = TempDir::new().unwrap();
1153        std::fs::write(tmp.path().join("src.txt"), "foo\nfoo\nfoo\n").unwrap();
1154
1155        let err = EditTool::new(tmp.path())
1156            .execute(serde_json::json!({
1157                "file_path": "src.txt",
1158                "old_string": "foo",
1159                "new_string": "bar",
1160            }))
1161            .await
1162            .unwrap_err();
1163
1164        let msg = format!("{err}");
1165        assert!(msg.contains("Found 3 matches"), "got: {msg}");
1166    }
1167
1168    #[tokio::test]
1169    async fn replace_all_replaces_all() {
1170        let tmp = TempDir::new().unwrap();
1171        std::fs::write(tmp.path().join("src.txt"), "foo\nfoo\nfoo\n").unwrap();
1172
1173        let result = EditTool::new(tmp.path())
1174            .execute(serde_json::json!({
1175                "file_path": "src.txt",
1176                "old_string": "foo",
1177                "new_string": "bar",
1178                "replace_all": true
1179            }))
1180            .await
1181            .unwrap();
1182
1183        assert!(result.contains("All occurrences"));
1184        let content = std::fs::read_to_string(tmp.path().join("src.txt")).unwrap();
1185        assert_eq!(content, "bar\nbar\nbar\n");
1186    }
1187
1188    #[tokio::test]
1189    async fn empty_old_string_creates_file() {
1190        let tmp = TempDir::new().unwrap();
1191
1192        let result = EditTool::new(tmp.path())
1193            .execute(serde_json::json!({
1194                "file_path": "new_file.txt",
1195                "old_string": "",
1196                "new_string": "brand new content"
1197            }))
1198            .await
1199            .unwrap();
1200
1201        assert!(result.contains("Created"));
1202        let content = std::fs::read_to_string(tmp.path().join("new_file.txt")).unwrap();
1203        assert_eq!(content, "brand new content");
1204    }
1205
1206    #[tokio::test]
1207    async fn empty_old_creates_nested_file() {
1208        let tmp = TempDir::new().unwrap();
1209
1210        EditTool::new(tmp.path())
1211            .execute(serde_json::json!({
1212                "file_path": "a/b/c/new_file.txt",
1213                "old_string": "",
1214                "new_string": "deep"
1215            }))
1216            .await
1217            .unwrap();
1218
1219        let content = std::fs::read_to_string(tmp.path().join("a/b/c/new_file.txt")).unwrap();
1220        assert_eq!(content, "deep");
1221    }
1222
1223    #[tokio::test]
1224    async fn identical_strings_errors() {
1225        let tmp = TempDir::new().unwrap();
1226        std::fs::write(tmp.path().join("src.txt"), "hello\n").unwrap();
1227
1228        let err = EditTool::new(tmp.path())
1229            .execute(serde_json::json!({
1230                "file_path": "src.txt",
1231                "old_string": "hello",
1232                "new_string": "hello"
1233            }))
1234            .await
1235            .unwrap_err();
1236
1237        let msg = format!("{err}");
1238        assert!(msg.contains("same"), "got: {msg}");
1239    }
1240
1241    #[tokio::test]
1242    async fn sandboxed_path_rejected() {
1243        let tmp = TempDir::new().unwrap();
1244        let err = EditTool::new(tmp.path())
1245            .execute(serde_json::json!({
1246                "file_path": "../outside.txt",
1247                "old_string": "x",
1248                "new_string": "y"
1249            }))
1250            .await
1251            .unwrap_err();
1252        let msg = format!("{err}");
1253        assert!(
1254            msg.contains("escapes") || msg.contains("BadToolArgs"),
1255            "got: {msg}"
1256        );
1257    }
1258
1259    #[tokio::test]
1260    async fn quote_normalization_matches_in_file() {
1261        let tmp = TempDir::new().unwrap();
1262        std::fs::write(tmp.path().join("src.txt"), "let msg = \"it's done\";\n").unwrap();
1263
1264        let result = EditTool::new(tmp.path())
1265            .execute(serde_json::json!({
1266                "file_path": "src.txt",
1267                "old_string": "let msg = \"it\u{2019}s done\";",
1268                "new_string": "let msg = \"it's replaced\";"
1269            }))
1270            .await
1271            .unwrap();
1272
1273        assert!(result.contains("updated successfully"));
1274    }
1275
1276    #[tokio::test]
1277    async fn trailing_whitespace_strip_matches() {
1278        let tmp = TempDir::new().unwrap();
1279        std::fs::write(tmp.path().join("src.txt"), "fn foo() {\n    bar\n}\n").unwrap();
1280
1281        let result = EditTool::new(tmp.path())
1282            .execute(serde_json::json!({
1283                "file_path": "src.txt",
1284                "old_string": "fn foo() {   \n    bar   \n}\n",
1285                "new_string": "fn foo() {\n    baz\n}\n"
1286            }))
1287            .await
1288            .unwrap();
1289
1290        assert!(result.contains("updated successfully"));
1291        let content = std::fs::read_to_string(tmp.path().join("src.txt")).unwrap();
1292        assert_eq!(content, "fn foo() {\n    baz\n}\n");
1293    }
1294
1295    #[tokio::test]
1296    async fn new_string_trailing_whitespace_stripped() {
1297        let tmp = TempDir::new().unwrap();
1298        std::fs::write(tmp.path().join("src.txt"), "fn foo() {\n    bar\n}\n").unwrap();
1299
1300        EditTool::new(tmp.path())
1301            .execute(serde_json::json!({
1302                "file_path": "src.txt",
1303                "old_string": "fn foo() {\n    bar\n}\n",
1304                "new_string": "fn foo() {\n    baz   \n}\n"  // trailing spaces on middle line
1305            }))
1306            .await
1307            .unwrap();
1308
1309        let content = std::fs::read_to_string(tmp.path().join("src.txt")).unwrap();
1310        // Trailing spaces on the middle line must be stripped.
1311        assert_eq!(content, "fn foo() {\n    baz\n}\n");
1312    }
1313
1314    #[tokio::test]
1315    async fn new_string_trailing_whitespace_preserved_in_markdown() {
1316        let tmp = TempDir::new().unwrap();
1317        std::fs::write(tmp.path().join("doc.md"), "# Hello\nold line  \n").unwrap();
1318
1319        EditTool::new(tmp.path())
1320            .execute(serde_json::json!({
1321                "file_path": "doc.md",
1322                "old_string": "old line  \n",
1323                "new_string": "new line  \n"  // trailing spaces are hard line-break in Markdown
1324            }))
1325            .await
1326            .unwrap();
1327
1328        let content = std::fs::read_to_string(tmp.path().join("doc.md")).unwrap();
1329        // Trailing spaces must be preserved for Markdown files.
1330        assert_eq!(content, "# Hello\nnew line  \n");
1331    }
1332
1333    #[tokio::test]
1334    async fn replace_all_with_multiple_occurrences() {
1335        let tmp = TempDir::new().unwrap();
1336        std::fs::write(tmp.path().join("src.txt"), "aaa bbb aaa\n").unwrap();
1337
1338        let result = EditTool::new(tmp.path())
1339            .execute(serde_json::json!({
1340                "file_path": "src.txt",
1341                "old_string": "aaa",
1342                "new_string": "ccc",
1343                "replace_all": true
1344            }))
1345            .await
1346            .unwrap();
1347
1348        assert!(result.contains("All occurrences"));
1349        let content = std::fs::read_to_string(tmp.path().join("src.txt")).unwrap();
1350        assert_eq!(content, "ccc bbb ccc\n");
1351    }
1352
1353    // ── Partial-read guard tests ──────────────────────────────────────────────
1354
1355    fn make_slot() -> std::sync::Arc<std::sync::Mutex<crate::tools::fs::ReadFileState>> {
1356        std::sync::Arc::new(std::sync::Mutex::new(crate::tools::fs::ReadFileState::new()))
1357    }
1358
1359    #[tokio::test]
1360    async fn edit_rejected_when_file_never_read() {
1361        let tmp = TempDir::new().unwrap();
1362        std::fs::write(tmp.path().join("src.txt"), "hello world\n").unwrap();
1363        let slot = make_slot();
1364        let err = EditTool::new(tmp.path())
1365            .with_read_state(slot)
1366            .execute(serde_json::json!({
1367                "file_path": "src.txt",
1368                "old_string": "hello",
1369                "new_string": "goodbye"
1370            }))
1371            .await
1372            .unwrap_err();
1373        let msg = format!("{err}");
1374        assert!(
1375            msg.contains("not been read"),
1376            "expected 'not been read', got: {msg}"
1377        );
1378    }
1379
1380    #[tokio::test]
1381    async fn edit_allowed_after_partial_read() {
1382        // Goal 347: a partial read no longer blocks an Edit. Previously this
1383        // asserted a "partially read" rejection; now the Edit falls through to
1384        // the on-disk read + old_string match and succeeds.
1385        let tmp = TempDir::new().unwrap();
1386        let path = tmp.path().join("src.txt");
1387        std::fs::write(&path, "hello world\n").unwrap();
1388        let slot = make_slot();
1389        // Simulate a partial read record.
1390        slot.lock().unwrap().record(
1391            path.clone(),
1392            true,
1393            std::fs::read_to_string(&path).unwrap(),
1394            get_file_mtime(&path),
1395        );
1396        let result = EditTool::new(tmp.path())
1397            .with_read_state(slot)
1398            .execute(serde_json::json!({
1399                "file_path": "src.txt",
1400                "old_string": "hello",
1401                "new_string": "goodbye"
1402            }))
1403            .await
1404            .unwrap();
1405        assert!(
1406            result.contains("updated successfully"),
1407            "partial read + valid old_string must now succeed, got: {result}"
1408        );
1409        let content = std::fs::read_to_string(&path).unwrap();
1410        assert_eq!(content, "goodbye world\n");
1411    }
1412
1413    #[tokio::test]
1414    async fn edit_allowed_after_full_read() {
1415        let tmp = TempDir::new().unwrap();
1416        let path = tmp.path().join("src.txt");
1417        std::fs::write(&path, "hello world\n").unwrap();
1418        let slot = make_slot();
1419        // Simulate a full read record.
1420        slot.lock().unwrap().record(
1421            path.clone(),
1422            false,
1423            std::fs::read_to_string(&path).unwrap(),
1424            get_file_mtime(&path),
1425        );
1426        let result = EditTool::new(tmp.path())
1427            .with_read_state(slot)
1428            .execute(serde_json::json!({
1429                "file_path": "src.txt",
1430                "old_string": "hello",
1431                "new_string": "goodbye"
1432            }))
1433            .await
1434            .unwrap();
1435        assert!(result.contains("updated successfully"));
1436    }
1437
1438    #[tokio::test]
1439    async fn edit_allowed_when_no_read_state() {
1440        // EditTool::new() without with_read_state — guard disabled,
1441        // backward-compatible behavior.
1442        let tmp = TempDir::new().unwrap();
1443        std::fs::write(tmp.path().join("src.txt"), "hello world\n").unwrap();
1444        let result = EditTool::new(tmp.path())
1445            .execute(serde_json::json!({
1446                "file_path": "src.txt",
1447                "old_string": "hello",
1448                "new_string": "goodbye"
1449            }))
1450            .await
1451            .unwrap();
1452        assert!(result.contains("updated successfully"));
1453    }
1454
1455    // ── Staleness, call-time re-validation, size limit, post-edit cache ──────
1456
1457    /// Bump a file's mtime to 1 hour in the future so `disk_mtime > cached_ts`
1458    /// fires deterministically regardless of FS timestamp resolution.
1459    fn bump_mtime_future(path: &std::path::Path) {
1460        use std::time::{Duration, SystemTime};
1461        let future = SystemTime::now() + Duration::from_secs(3600);
1462        let f = std::fs::OpenOptions::new()
1463            .write(true)
1464            .open(path)
1465            .expect("open for set_modified");
1466        f.set_modified(future).expect("set_modified");
1467    }
1468
1469    #[tokio::test]
1470    async fn edit_rejected_when_file_modified_since_read() {
1471        let tmp = TempDir::new().unwrap();
1472        let path = tmp.path().join("src.txt");
1473        std::fs::write(&path, "hello world\n").unwrap();
1474        let slot = make_slot();
1475        // Full read.
1476        slot.lock().unwrap().record(
1477            path.clone(),
1478            false,
1479            std::fs::read_to_string(&path).unwrap(),
1480            get_file_mtime(&path),
1481        );
1482        // Externally modify content + bump mtime to the future.
1483        std::fs::write(&path, "externally changed\n").unwrap();
1484        bump_mtime_future(&path);
1485        let err = EditTool::new(tmp.path())
1486            .with_read_state(slot)
1487            .execute(serde_json::json!({
1488                "file_path": "src.txt",
1489                "old_string": "hello",
1490                "new_string": "goodbye"
1491            }))
1492            .await
1493            .unwrap_err();
1494        let msg = format!("{err}");
1495        assert!(
1496            msg.contains("modified") && msg.contains("Read it again"),
1497            "expected staleness rejection, got: {msg}"
1498        );
1499    }
1500
1501    #[tokio::test]
1502    async fn edit_allowed_when_mtime_bumped_but_content_unchanged() {
1503        // Content-fallback: mtime bumped but content identical to cached →
1504        // edit proceeds (Windows cloud-sync / antivirus false positive).
1505        let tmp = TempDir::new().unwrap();
1506        let path = tmp.path().join("src.txt");
1507        std::fs::write(&path, "hello world\n").unwrap();
1508        let slot = make_slot();
1509        slot.lock().unwrap().record(
1510            path.clone(),
1511            false,
1512            std::fs::read_to_string(&path).unwrap(),
1513            get_file_mtime(&path),
1514        );
1515        // Bump mtime without changing content.
1516        bump_mtime_future(&path);
1517        let result = EditTool::new(tmp.path())
1518            .with_read_state(slot)
1519            .execute(serde_json::json!({
1520                "file_path": "src.txt",
1521                "old_string": "hello",
1522                "new_string": "goodbye"
1523            }))
1524            .await
1525            .expect("unchanged content with bumped mtime must be allowed");
1526        assert!(result.contains("updated successfully"));
1527    }
1528
1529    #[tokio::test]
1530    async fn edit_post_update_cache_allows_consecutive_edit() {
1531        // After a successful edit, the cache is refreshed so a second edit
1532        // to the same file does not require a redundant Read.
1533        let tmp = TempDir::new().unwrap();
1534        let path = tmp.path().join("src.txt");
1535        std::fs::write(&path, "hello world\n").unwrap();
1536        let slot = make_slot();
1537        slot.lock().unwrap().record(
1538            path.clone(),
1539            false,
1540            std::fs::read_to_string(&path).unwrap(),
1541            get_file_mtime(&path),
1542        );
1543        let tool = EditTool::new(tmp.path()).with_read_state(slot);
1544        tool.execute(serde_json::json!({
1545            "file_path": "src.txt",
1546            "old_string": "hello",
1547            "new_string": "goodbye"
1548        }))
1549        .await
1550        .unwrap();
1551        // No re-read between edits — must succeed via post-edit cache update.
1552        let result = tool
1553            .execute(serde_json::json!({
1554                "file_path": "src.txt",
1555                "old_string": "goodbye",
1556                "new_string": "hello"
1557            }))
1558            .await
1559            .expect("consecutive edit must succeed without re-read");
1560        assert!(result.contains("updated successfully"));
1561        assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello world\n");
1562    }
1563
1564    #[tokio::test]
1565    async fn edit_rejected_for_oversized_file() {
1566        // A file whose logical size exceeds MAX_EDIT_FILE_SIZE is refused
1567        // before reading. We materialise a sparse file (set_len) so the
1568        // directory entry reports >1 GiB without allocating disk.
1569        let tmp = TempDir::new().unwrap();
1570        let path = tmp.path().join("big.txt");
1571        let f = std::fs::File::create(&path).unwrap();
1572        f.set_len(MAX_EDIT_FILE_SIZE + 1).unwrap();
1573        drop(f);
1574        let slot = make_slot();
1575        slot.lock()
1576            .unwrap()
1577            .record(path.clone(), false, String::new(), get_file_mtime(&path));
1578        let err = EditTool::new(tmp.path())
1579            .with_read_state(slot)
1580            .execute(serde_json::json!({
1581                "file_path": "big.txt",
1582                "old_string": "x",
1583                "new_string": "y"
1584            }))
1585            .await
1586            .unwrap_err();
1587        let msg = format!("{err}");
1588        assert!(
1589            msg.contains("too large to edit"),
1590            "expected 'too large to edit', got: {msg}"
1591        );
1592    }
1593
1594    // ── Goal 347: partial read no longer blocks Edit ─────────────────────
1595    //
1596    // These share a read_state slot between ReadFile and EditTool so a ranged
1597    // Read (is_partial=true) is recorded, then Edit is attempted. Before goal
1598    // 347 the partial-read arm hard-rejected; now it falls through to the
1599    // on-disk read + old_string match.
1600
1601    use crate::tools::ReadFile;
1602    use std::sync::{Arc, Mutex};
1603
1604    /// Read a file with a line range (→ is_partial=true), then Edit a snippet
1605    /// that exists and is unique → must succeed. Headline regression test:
1606    //  would have failed before goal 347 (hard reject on partial read).
1607    #[tokio::test]
1608    async fn edit_succeeds_after_partial_read_when_old_string_found() {
1609        let tmp = TempDir::new().unwrap();
1610        let path = tmp.path().join("e.txt");
1611        std::fs::write(&path, "line1\nline2 target\nline3\n").unwrap();
1612        let slot = Arc::new(Mutex::new(ReadFileState::new()));
1613        // Partial read via line range.
1614        ReadFile::new(tmp.path())
1615            .with_read_state(slot.clone())
1616            .execute(serde_json::json!({"path": "e.txt", "start_line": 1, "end_line": 2}))
1617            .await
1618            .unwrap();
1619        // Edit a snippet that is present and unique on disk.
1620        let result = EditTool::new(tmp.path())
1621            .with_read_state(slot)
1622            .execute(serde_json::json!({
1623                "file_path": "e.txt",
1624                "old_string": "line2 target",
1625                "new_string": "line2 edited",
1626            }))
1627            .await
1628            .unwrap();
1629        assert!(result.contains("updated successfully"), "got: {result}");
1630        let content = std::fs::read_to_string(&path).unwrap();
1631        assert_eq!(content, "line1\nline2 edited\nline3\n");
1632    }
1633
1634    /// Partial read, then Edit with an old_string NOT in the file → still
1635    /// rejected with "not found". Proves the old_string validation is intact;
1636    /// only the read guard was relaxed.
1637    #[tokio::test]
1638    async fn edit_after_partial_read_still_rejects_missing_old_string() {
1639        let tmp = TempDir::new().unwrap();
1640        std::fs::write(tmp.path().join("e.txt"), "alpha\nbeta\n").unwrap();
1641        let slot = Arc::new(Mutex::new(ReadFileState::new()));
1642        ReadFile::new(tmp.path())
1643            .with_read_state(slot.clone())
1644            .execute(serde_json::json!({"path": "e.txt", "start_line": 1, "end_line": 1}))
1645            .await
1646            .unwrap();
1647        let err = EditTool::new(tmp.path())
1648            .with_read_state(slot)
1649            .execute(serde_json::json!({
1650                "file_path": "e.txt",
1651                "old_string": "not-in-file",
1652                "new_string": "x",
1653            }))
1654            .await
1655            .unwrap_err();
1656        let msg = format!("{err}");
1657        assert!(msg.contains("not found"), "expected not found, got: {msg}");
1658    }
1659
1660    /// Partial read, file with duplicate lines, Edit without replace_all →
1661    /// still rejected as ambiguous. Proves the uniqueness guard is intact.
1662    #[tokio::test]
1663    async fn edit_after_partial_read_still_rejects_ambiguous_old_string() {
1664        let tmp = TempDir::new().unwrap();
1665        std::fs::write(tmp.path().join("e.txt"), "dup\ndup\ndup\n").unwrap();
1666        let slot = Arc::new(Mutex::new(ReadFileState::new()));
1667        ReadFile::new(tmp.path())
1668            .with_read_state(slot.clone())
1669            .execute(serde_json::json!({"path": "e.txt", "start_line": 1, "end_line": 2}))
1670            .await
1671            .unwrap();
1672        let err = EditTool::new(tmp.path())
1673            .with_read_state(slot)
1674            .execute(serde_json::json!({
1675                "file_path": "e.txt",
1676                "old_string": "dup",
1677                "new_string": "uniq",
1678            }))
1679            .await
1680            .unwrap_err();
1681        let msg = format!("{err}");
1682        assert!(
1683            msg.contains("matches") || msg.contains("replace_all"),
1684            "expected ambiguity rejection, got: {msg}"
1685        );
1686    }
1687
1688    /// No Read at all → still rejected with "has not been read yet".
1689    /// Proves the None arm (never-read invariant) is unchanged.
1690    #[tokio::test]
1691    async fn edit_still_rejects_never_read_file() {
1692        let tmp = TempDir::new().unwrap();
1693        std::fs::write(tmp.path().join("e.txt"), "hello\n").unwrap();
1694        let slot = Arc::new(Mutex::new(ReadFileState::new()));
1695        // NOTE: no ReadFile call — record is absent.
1696        let err = EditTool::new(tmp.path())
1697            .with_read_state(slot)
1698            .execute(serde_json::json!({
1699                "file_path": "e.txt",
1700                "old_string": "hello",
1701                "new_string": "world",
1702            }))
1703            .await
1704            .unwrap_err();
1705        let msg = format!("{err}");
1706        assert!(
1707            msg.contains("has not been read yet"),
1708            "expected never-read rejection, got: {msg}"
1709        );
1710    }
1711
1712    // ── Goal 348: partial reads skip the staleness content-check ─────────
1713    //
1714    // Before this goal the staleness arm compared `record.content` (the cached
1715    // file content) against the full disk content. That was only correct
1716    // because ReadFile cached the WHOLE file even on a ranged read; if a
1717    // future goal makes ReadFile cache only the requested slice, the equality
1718    // check would compare full-file vs slice → always different → every
1719    // partial-read edit falsely rejected. Partial reads now fall straight
1720    // through to the on-disk read + old_string match, which is the real
1721    // safety net. Full reads keep the content-equality staleness check.
1722
1723    /// Partial read + mtime advanced, but the disk content STILL contains the
1724    /// old_string → edit succeeds. Headline test: proves partial reads no
1725    /// longer hit the cached-content equality check. The cached content is a
1726    /// line-range slice (as a future range-only ReadFile cache would hold),
1727    /// which under the old coupling would never equal the full disk content.
1728    #[tokio::test]
1729    async fn edit_partial_read_skips_staleness_cache_check_when_mtime_advanced() {
1730        let tmp = TempDir::new().unwrap();
1731        let path = tmp.path().join("src.txt");
1732        std::fs::write(&path, "line1\nline2 target\nline3\n").unwrap();
1733        let slot = make_slot();
1734        // Record a *partial* read whose cached content is only the line-range
1735        // slice (simulating a future range-only ReadFile cache), timestamped
1736        // BEFORE the external write below.
1737        slot.lock().unwrap().record(
1738            path.clone(),
1739            true,
1740            "line1\nline2 target\n".to_string(),
1741            get_file_mtime(&path),
1742        );
1743        // Externally modify content (old_string still present) + bump mtime.
1744        std::fs::write(&path, "line1\nline2 target\nline3 modified\n").unwrap();
1745        bump_mtime_future(&path);
1746        let result = EditTool::new(tmp.path())
1747            .with_read_state(slot)
1748            .execute(serde_json::json!({
1749                "file_path": "src.txt",
1750                "old_string": "line2 target",
1751                "new_string": "line2 edited",
1752            }))
1753            .await
1754            .unwrap_or_else(|e| {
1755                panic!("partial read with mtime advanced must NOT be rejected as stale, got: {e}")
1756            });
1757        assert!(result.contains("updated successfully"), "got: {result}");
1758        let content = std::fs::read_to_string(&path).unwrap();
1759        assert_eq!(content, "line1\nline2 edited\nline3 modified\n");
1760    }
1761
1762    /// Full read + mtime advanced + content changed so old_string is gone →
1763    /// Edit rejected with the staleness error (NOT "not found"). The staleness
1764    /// check runs first (before the old_string match), and full reads keep the
1765    /// cached-content equality check exactly as before Goal 348.
1766    #[tokio::test]
1767    async fn edit_full_read_still_runs_staleness_check_when_mtime_advanced() {
1768        let tmp = TempDir::new().unwrap();
1769        let path = tmp.path().join("src.txt");
1770        std::fs::write(&path, "hello world\n").unwrap();
1771        let slot = make_slot();
1772        // Full read.
1773        slot.lock().unwrap().record(
1774            path.clone(),
1775            false,
1776            std::fs::read_to_string(&path).unwrap(),
1777            get_file_mtime(&path),
1778        );
1779        // Externally modify content (old_string GONE) + bump mtime.
1780        std::fs::write(&path, "externally changed\n").unwrap();
1781        bump_mtime_future(&path);
1782        let err = EditTool::new(tmp.path())
1783            .with_read_state(slot)
1784            .execute(serde_json::json!({
1785                "file_path": "src.txt",
1786                "old_string": "hello",
1787                "new_string": "goodbye",
1788            }))
1789            .await
1790            .unwrap_err();
1791        let msg = format!("{err}");
1792        assert!(
1793            msg.contains("modified") && msg.contains("Read it again"),
1794            "expected full-read staleness rejection, got: {msg}"
1795        );
1796    }
1797
1798    /// Partial read + mtime advanced + content changed so old_string is GONE
1799    /// → Edit rejected with "not found" (NOT a blanket "modified since read").
1800    /// Proves the old_string match is the safety net on the partial path when
1801    /// the file really changed externally.
1802    #[tokio::test]
1803    async fn edit_partial_read_still_rejects_when_old_string_gone_after_external_change() {
1804        let tmp = TempDir::new().unwrap();
1805        let path = tmp.path().join("src.txt");
1806        std::fs::write(&path, "hello world\n").unwrap();
1807        let slot = make_slot();
1808        // Partial read, timestamped before the external write below.
1809        slot.lock().unwrap().record(
1810            path.clone(),
1811            true,
1812            "hello world\n".to_string(),
1813            get_file_mtime(&path),
1814        );
1815        // Externally modify content (old_string GONE) + bump mtime.
1816        std::fs::write(&path, "externally changed\n").unwrap();
1817        bump_mtime_future(&path);
1818        let err = EditTool::new(tmp.path())
1819            .with_read_state(slot)
1820            .execute(serde_json::json!({
1821                "file_path": "src.txt",
1822                "old_string": "hello",
1823                "new_string": "goodbye",
1824            }))
1825            .await
1826            .unwrap_err();
1827        let msg = format!("{err}");
1828        assert!(
1829            msg.contains("not found"),
1830            "expected 'not found' rejection from old_string match, got: {msg}"
1831        );
1832    }
1833}