Skip to main content

atomcode_core/tool/
edit.rs

1use anyhow::{Context, Result};
2use async_trait::async_trait;
3use serde::Deserialize;
4use serde_json::json;
5
6/// Deserialize a number that may arrive as a JSON string (weak models often quote integers).
7fn deserialize_lenient_usize<'de, D>(
8    deserializer: D,
9) -> std::result::Result<Option<usize>, D::Error>
10where
11    D: serde::Deserializer<'de>,
12{
13    use serde::de;
14
15    struct LenientUsize;
16
17    impl<'de> de::Visitor<'de> for LenientUsize {
18        type Value = Option<usize>;
19        fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
20            f.write_str("a usize or a string containing a usize")
21        }
22        fn visit_none<E: de::Error>(self) -> std::result::Result<Self::Value, E> {
23            Ok(None)
24        }
25        fn visit_unit<E: de::Error>(self) -> std::result::Result<Self::Value, E> {
26            Ok(None)
27        }
28        fn visit_u64<E: de::Error>(self, v: u64) -> std::result::Result<Self::Value, E> {
29            Ok(Some(v as usize))
30        }
31        fn visit_i64<E: de::Error>(self, v: i64) -> std::result::Result<Self::Value, E> {
32            if v >= 0 {
33                Ok(Some(v as usize))
34            } else {
35                Err(de::Error::custom("negative line number"))
36            }
37        }
38        fn visit_f64<E: de::Error>(self, v: f64) -> std::result::Result<Self::Value, E> {
39            Ok(Some(v as usize))
40        }
41        fn visit_str<E: de::Error>(self, v: &str) -> std::result::Result<Self::Value, E> {
42            v.trim()
43                .parse::<usize>()
44                .map(Some)
45                .map_err(de::Error::custom)
46        }
47    }
48
49    deserializer.deserialize_any(LenientUsize)
50}
51
52/// Atomic write: write to temp file then rename. Prevents corruption on crash.
53/// Retries rename once after a short delay — dev servers (Vite, webpack) may
54/// briefly lock files during hot-reload, causing transient rename failures.
55async fn atomic_write(path: &str, content: &str) -> Result<()> {
56    let temp = format!("{}.atomcode.tmp", path);
57    tokio::fs::write(&temp, content)
58        .await
59        .with_context(|| format!("Failed to write temp file {}", temp))?;
60    match tokio::fs::rename(&temp, path).await {
61        Ok(()) => Ok(()),
62        Err(_) => {
63            // Retry once after 150ms — likely a transient file lock from dev server.
64            tokio::time::sleep(std::time::Duration::from_millis(150)).await;
65            match tokio::fs::rename(&temp, path).await {
66                Ok(()) => Ok(()),
67                Err(_) => {
68                    // Final fallback: direct write (not atomic, but better than failing).
69                    let _ = tokio::fs::remove_file(&temp).await;
70                    tokio::fs::write(path, content)
71                        .await
72                        .with_context(|| format!("Failed to write {}", path))?;
73                    Ok(())
74                }
75            }
76        }
77    }
78}
79
80use super::auto_fix;
81use super::{ApprovalRequirement, Tool, ToolContext, ToolDef, ToolResult};
82
83/// Validate content in memory, write to disk, then run syntax check.
84/// Only check remaining: duplicate block detection. Structural checks
85/// (brace/HTML/Vue SFC) removed — auto-compile handles those better.
86async fn validate_write_check(
87    content: &str,
88    file_path: &str,
89    new_string: &str,
90    original_content: &str,
91    result: ToolResult,
92    ctx: &ToolContext,
93) -> Result<(ToolResult, String)> {
94    if !result.success {
95        return Ok((result, content.to_string()));
96    }
97    let validated =
98        auto_fix::validate_and_fix(content, file_path, new_string, original_content).await;
99
100    // Duplicate detection is the only remaining rejection.
101    if validated.rejected {
102        let errors = validated.warnings.join("\n");
103        return Ok((
104            ToolResult {
105                call_id: result.call_id,
106                output: format!(
107                    "EDIT REJECTED — duplicate code detected:\n{}\n\
108                     Fix your new_string and retry. The file was NOT modified.",
109                    errors
110                ),
111                success: false,
112            },
113            content.to_string(),
114        ));
115    }
116
117    atomic_write(file_path, &validated.fixed_content).await?;
118
119    // Canonicalize once for downstream tools that key by absolute path
120    // (FileStore, LSP). `file_path` here may be the model's
121    // raw-as-supplied string — e.g. on macOS that's `/var/folders/...`
122    // while FileStore stored the symlink-resolved `/private/var/...`.
123    // Without this normalization, FileStore's `invalidate(raw_path)`
124    // is a silent no-op and a stale `store_id` keeps serving pre-edit
125    // bytes to the next peek_file call.
126    let raw_path = std::path::Path::new(file_path);
127    let canon_path = std::fs::canonicalize(raw_path).unwrap_or_else(|_| raw_path.to_path_buf());
128
129    // Notify LSP that file changed (if LSP is enabled).
130    ctx.notify_lsp_file_changed(&canon_path, &validated.fixed_content)
131        .await;
132    // D3: drop any FileStore entry for this path. peek_file against the
133    // pre-edit store_id will return a "stale" hint pointing at re-read,
134    // ensuring the model never operates on a snapshot that no longer
135    // matches disk after its own edit.
136    ctx.file_store.write().await.invalidate(&canon_path);
137    // Defense-in-depth: also purge read_cache entries for this path. The
138    // mtime gate at read.rs catches most cases, but on FS with coarse
139    // mtime granularity (ext4 sec, NFS) an edit within the same tick as
140    // the prior read leaves mtime unchanged and the gate fails open.
141    // Explicit purge closes that corner case.
142    ctx.read_cache
143        .write()
144        .await
145        .retain(|(p, _, _), _| p != &canon_path);
146
147    // 4. Post-write syntax check (needs file on disk)
148    let syntax_warn = auto_fix::post_edit_syntax_check(file_path).await;
149
150    let mut all_warnings: Vec<String> = validated.warnings;
151    if !syntax_warn.is_empty() {
152        all_warnings.push(syntax_warn);
153    }
154
155    // 5. Append surrounding context after edit — gives model the current file
156    // state around the edit point so it can construct accurate old_string
157    // for the next edit without needing a separate read_file/grep call.
158    let context_snippet = build_edit_context(&validated.fixed_content, new_string);
159    let result = ToolResult {
160        output: format!("{}{}", result.output, context_snippet),
161        ..result
162    };
163
164    if all_warnings.is_empty() {
165        Ok((result, validated.fixed_content))
166    } else {
167        let combined = all_warnings.join("");
168        Ok((
169            ToolResult {
170                output: format!("{}{}", result.output, combined),
171                ..result
172            },
173            validated.fixed_content,
174        ))
175    }
176}
177
178pub struct EditFileTool;
179
180#[derive(Deserialize)]
181struct EditFileArgs {
182    file_path: String,
183    /// Text to find and replace. Required unless using line-number mode (start_line/end_line).
184    #[serde(default)]
185    old_string: Option<String>,
186    /// Not required when using `edits` array mode.
187    #[serde(default)]
188    new_string: Option<String>,
189    #[serde(default)]
190    replace_all: bool,
191    /// Scope edit to a specific function/class by name (tree-sitter).
192    #[serde(default)]
193    symbol: Option<String>,
194    /// Line-number mode: replace lines start_line..end_line with new_string.
195    /// Use line numbers from read_file output. No need to copy text precisely.
196    #[serde(default, deserialize_with = "deserialize_lenient_usize")]
197    start_line: Option<usize>,
198    #[serde(default, deserialize_with = "deserialize_lenient_usize")]
199    end_line: Option<usize>,
200    /// Multi-edit mode: apply multiple edits to different regions in one call.
201    /// Mutually exclusive with single-edit fields (old_string/new_string/start_line/end_line).
202    #[serde(default)]
203    edits: Option<Vec<SingleEdit>>,
204}
205
206#[derive(Deserialize)]
207struct SingleEdit {
208    #[serde(default, deserialize_with = "deserialize_lenient_usize")]
209    start_line: Option<usize>,
210    #[serde(default, deserialize_with = "deserialize_lenient_usize")]
211    end_line: Option<usize>,
212    #[serde(default)]
213    old_string: Option<String>,
214    new_string: String,
215}
216
217#[async_trait]
218impl Tool for EditFileTool {
219    // ── INVARIANT (2026-04-16): edit_file schema MUST expose both modes ──
220    // Line mode (start_line/end_line) and text mode (old_string) must BOTH
221    // be in the tool schema. Removing start_line/end_line forces the model
222    // into old_string-only → match failures → full redo → 14-turn sessions.
223    // History: added Phase 3, removed 5e09b86 ("confuses weak models"),
224    // restored today. If a model can't handle both, fix the description,
225    // don't delete the parameter.
226    // ────────────────────────────────────────────────────────────────────
227    fn definition(&self) -> ToolDef {
228        ToolDef {
229            name: "edit_file",
230            description: "Replace text in a file. ALWAYS prefer this over write_file for existing files.\n\
231                Two modes:\n\
232                1. Line mode: use start_line + end_line + new_string. Line numbers from read_file or grep output.\n\
233                2. Text mode: use old_string + new_string. old_string must match exactly.\n\
234                Both modes work. Use whichever is faster — if grep already showed the code, edit directly.\n\
235                For multiple changes in one file: make separate edit_file calls, one per region.".to_string(),
236            parameters: json!({
237                "type": "object",
238                "properties": {
239                    "file_path": {
240                        "type": "string",
241                        "description": "Path to the file to edit"
242                    },
243                    "old_string": {
244                        "type": "string",
245                        "description": "Text mode: exact text to find and replace. Include enough context to be unique."
246                    },
247                    "new_string": {
248                        "type": "string",
249                        "description": "Replacement text. Use empty string to delete."
250                    },
251                    "start_line": {
252                        "type": "integer",
253                        "description": "Line mode: first line to replace (1-indexed, from read_file output)"
254                    },
255                    "end_line": {
256                        "type": "integer",
257                        "description": "Line mode: last line to replace (inclusive)"
258                    },
259                    "replace_all": {
260                        "type": "boolean",
261                        "description": "Replace ALL occurrences (default: first only). Only for text mode."
262                    }
263                },
264                // Only file_path is universally required. Mode-specific
265                // requirements (text/line/edits/symbol) are enforced in
266                // validate_args() below — encoding them in `required` is
267                // a lie because edits-mode doesn't need top-level
268                // new_string, and that lie used to bounce legitimate
269                // edits-mode calls.
270                "required": ["file_path"]
271            }),
272        }
273    }
274
275    fn validate_args(&self, args: &str) -> std::result::Result<(), String> {
276        // Stage 1: structural diagnostic — list provided keys, name what's
277        // missing, give a copy-pasteable example. Replaces the raw serde
278        // "line 1 column N" error that weak models read as a positional-
279        // arg complaint.
280        super::diagnose_args(
281            "edit_file",
282            args,
283            &[&["file_path"]],
284            "edit_file({\"file_path\": \"<abs>\", \"old_string\": \"<old>\", \"new_string\": \"<new>\"}) \
285             — text mode; or use start_line+end_line+new_string for line mode",
286        )?;
287        let parsed: EditFileArgs = serde_json::from_str(args).map_err(|e| {
288            format!(
289                "edit_file: {e}. Check that file_path is a string, line numbers are integers, \
290                 and old_string/new_string are strings."
291            )
292        })?;
293        // Stage 2: semantic gate — edit_file is a multi-mode tool. Accepts
294        // (old_string + new_string) OR (start_line + end_line +
295        // new_string) OR (edits array) OR (symbol + new_string). A call
296        // with only `file_path` is a truncated/half-formed payload that
297        // would deterministically fail in execute(); reject it here so
298        // the model gets a structured retry hint without an approval
299        // round-trip.
300        let has_string_mode = parsed.old_string.is_some() || parsed.new_string.is_some();
301        let has_line_mode = parsed.start_line.is_some() || parsed.end_line.is_some();
302        let has_edits = parsed.edits.is_some();
303        let has_symbol = parsed.symbol.is_some();
304        if !has_string_mode && !has_line_mode && !has_edits && !has_symbol {
305            return Err(
306                "edit_file arguments missing edit content. Provide `old_string`+`new_string`, \
307                 `start_line`+`end_line`+`new_string`, an `edits` array, or `symbol`+`new_string`."
308                    .to_string(),
309            );
310        }
311        Ok(())
312    }
313
314    fn approval(&self, args: &str) -> ApprovalRequirement {
315        // The runner's `validate_args` gate already rejects unparseable
316        // payloads upstream — when we get here, args are valid JSON
317        // matching the schema. Sensitive-path detection still runs as
318        // a separate concern: even valid args can target a path that
319        // requires explicit user approval.
320        let parsed = match serde_json::from_str::<EditFileArgs>(args) {
321            Ok(p) => p,
322            Err(_) => {
323                // Defense in depth: if validate_args was somehow bypassed
324                // (e.g. tool invoked from a path that doesn't run the
325                // gate), fall back to the original fail-closed behaviour.
326                return ApprovalRequirement::RequireApproval(
327                    "Could not parse edit_file arguments for safety check.".to_string(),
328                );
329            }
330        };
331
332        if super::is_sensitive_input_path(&parsed.file_path) {
333            return ApprovalRequirement::RequireApproval(
334                format!("Editing sensitive system path: {}", parsed.file_path),
335            );
336        }
337
338        ApprovalRequirement::AutoApprove
339    }
340
341    fn approval_with_context(&self, args: &str, ctx: &ToolContext) -> ApprovalRequirement {
342        let parsed = match serde_json::from_str::<EditFileArgs>(args) {
343            Ok(parsed) => parsed,
344            Err(_) => return self.approval(args),
345        };
346        let working_dir = match ctx.working_dir.try_read() {
347            Ok(wd) => wd.clone(),
348            Err(_) => return self.approval(args),
349        };
350        match super::approval_for_path(
351            &parsed.file_path,
352            &working_dir,
353            super::ExternalPathAction::Write,
354        ) {
355            Ok(approval) => approval,
356            Err(_) => self.approval(args),
357        }
358    }
359
360    async fn execute(&self, args: &str, ctx: &ToolContext) -> Result<ToolResult> {
361        // Defense-in-depth: validate_args runs at the runner gate, but if
362        // it's bypassed we surface the same model-friendly diagnostic
363        // instead of bubbling a raw serde error up the call stack.
364        if let Err(msg) = super::diagnose_args(
365            "edit_file",
366            args,
367            &[&["file_path"]],
368            "edit_file({\"file_path\": \"<abs>\", \"old_string\": \"<old>\", \"new_string\": \"<new>\"})",
369        ) {
370            return Ok(ToolResult {
371                call_id: String::new(),
372                output: msg,
373                success: false,
374            });
375        }
376        let parsed: EditFileArgs = match serde_json::from_str(args) {
377            Ok(p) => p,
378            Err(e) => {
379                return Ok(ToolResult {
380                    call_id: String::new(),
381                    output: format!(
382                        "edit_file: {e}. Check that file_path is a string, line numbers are \
383                         integers, and old_string/new_string are strings."
384                    ),
385                    success: false,
386                });
387            }
388        };
389        let working_dir = ctx.working_dir.read().await.clone();
390        let file_path = match super::inspect_path_access(&parsed.file_path, &working_dir) {
391            Ok(access) => access.path,
392            Err(err) => {
393                return Ok(ToolResult {
394                    call_id: String::new(),
395                    output: err.to_string(),
396                    success: false,
397                });
398            }
399        };
400        let file_path_str = file_path.to_string_lossy().to_string();
401
402        // Backup file before any modification (file-level checkpointing).
403        ctx.file_history
404            .lock()
405            .await
406            .backup_before_write(&file_path_str)
407            .await;
408
409        let content = tokio::fs::read_to_string(&file_path)
410            .await
411            .with_context(|| format!("Failed to read {}", file_path.display()))?;
412
413        // ── MULTI-EDIT MODE — disabled ──
414        // Multi-edit 25次实测:apply 成功率高但改对率低,N个edit同时出错难回滚。
415        // Multi-edit mode: apply multiple edits in one call.
416        // Re-enabled with overlapping auto-merge + delta validation.
417        if let Some(edits) = parsed.edits {
418            if edits.is_empty() {
419                return Ok(ToolResult {
420                    call_id: String::new(),
421                    output: "Error: edits array is empty.".to_string(),
422                    success: false,
423                });
424            }
425            return self
426                .execute_multi_edit(&file_path_str, &content, edits, ctx)
427                .await;
428        }
429
430        // Single-edit mode: new_string is required
431        let new_string = match parsed.new_string {
432            Some(s) => s,
433            None => {
434                return Ok(ToolResult {
435                    call_id: String::new(),
436                    output: "Error: missing new_string.\n\
437                        To REPLACE: edit_file({file_path, old_string: \"old code\", new_string: \"new code\"})\n\
438                        To DELETE:  edit_file({file_path, old_string: \"old code\", new_string: \"\"})\n\
439                        You MUST include new_string in every edit_file call.".to_string(),
440                    success: false,
441                });
442            }
443        };
444
445        // ── LINE-NUMBER MODE ──
446        // Replace lines start_line..=end_line with new_string. No text matching needed.
447        if let (Some(mut start), Some(mut end)) = (parsed.start_line, parsed.end_line) {
448            let lines: Vec<&str> = content.lines().collect();
449            let total = lines.len();
450
451            // Auto-swap if model gave start > end
452            if end < start {
453                std::mem::swap(&mut start, &mut end);
454            }
455            if start == 0 || start > total {
456                return Ok(ToolResult {
457                    call_id: String::new(),
458                    output: format!(
459                        "Invalid line range: {}-{} (file has {} lines)",
460                        start, end, total
461                    ),
462                    success: false,
463                });
464            }
465            let mut end = end.min(total);
466
467            // Boundary overlap auto-correction (trailing): if new_string's trailing lines
468            // duplicate lines immediately after end_line, extend end to absorb them.
469            let ns_lines: Vec<&str> = new_string.lines().collect();
470            if !ns_lines.is_empty() {
471                let mut extra = 0usize;
472                for i in 0..ns_lines.len() {
473                    let ns_idx = ns_lines.len() - 1 - i;
474                    let orig_idx = end + extra; // 0-indexed line after current end
475                    if orig_idx >= total {
476                        break;
477                    }
478                    if ns_lines[ns_idx].trim() == lines[orig_idx].trim()
479                        && !ns_lines[ns_idx].trim().is_empty()
480                    {
481                        extra += 1;
482                    } else {
483                        break;
484                    }
485                }
486                if extra > 0 {
487                    end = (end + extra).min(total);
488                }
489            }
490
491            // Boundary overlap auto-correction (leading): if new_string's leading lines
492            // duplicate lines immediately before start_line, extend start upward.
493            if !ns_lines.is_empty() {
494                let mut extra = 0usize;
495                for i in 0..ns_lines.len() {
496                    if start <= 1 + extra {
497                        break;
498                    } // can't go above line 1
499                    let orig_idx = start - 2 - extra; // 0-indexed line before current start
500                    if ns_lines[i].trim() == lines[orig_idx].trim()
501                        && !ns_lines[i].trim().is_empty()
502                    {
503                        extra += 1;
504                    } else {
505                        break;
506                    }
507                }
508                if extra > 0 {
509                    start = start.saturating_sub(extra).max(1);
510                }
511            }
512
513            // Show what's being replaced
514            let old_text: String = lines[start - 1..end].join("\n");
515            let removed = end - start + 1;
516            let added = new_string.lines().count();
517
518            // Guard: warn (not block) on large single edits on template-heavy files.
519            // Previously this was a hard block, but for bug-fix scenarios (corrupted files)
520            // the model needs to do large rewrites to restore structure.
521            let ext = parsed.file_path.rsplit('.').next().unwrap_or("");
522            let _large_edit_warning =
523                if removed > 50 && matches!(ext, "vue" | "html" | "svelte" | "tsx" | "jsx") {
524                    format!(
525                    "\n⚠ Large edit ({} lines replaced). Verify HTML tag balance after this edit.",
526                    removed
527                )
528                } else {
529                    String::new()
530                };
531
532            // Reconstruct file
533            let mut new_lines: Vec<&str> = Vec::with_capacity(total);
534            new_lines.extend_from_slice(&lines[..start - 1]);
535            // new_string lines go in the middle
536            let new_content_lines: Vec<&str> = new_string.lines().collect();
537            new_lines.extend_from_slice(&new_content_lines);
538            if end < total {
539                new_lines.extend_from_slice(&lines[end..]);
540            }
541            let new_content = if content.ends_with('\n') {
542                format!("{}\n", new_lines.join("\n"))
543            } else {
544                new_lines.join("\n")
545            };
546
547            let diff = build_compact_diff(&old_text, &new_string);
548            let _new_end = start + added.saturating_sub(1);
549            // Concise output: just confirmation + short diff. No outline, no surrounding context.
550            let result = ToolResult {
551                call_id: String::new(),
552                output: format!(
553                    "Edited {} lines {}-{} (-{} +{} lines).\n{}",
554                    parsed.file_path, start, end, removed, added, diff
555                ),
556                success: true,
557            };
558            let (result, _final_content) = validate_write_check(
559                &new_content,
560                &file_path_str,
561                &new_string,
562                &content,
563                result,
564                ctx,
565            )
566            .await?;
567            return Ok(result);
568        }
569
570        // ── old_string is required for text-match and symbol modes ──
571        let old_string = match parsed.old_string {
572            Some(ref s) if !s.is_empty() => s.clone(),
573            _ => {
574                // old_string is required. Do NOT auto-append — it creates duplicate code
575                // when the model intends to replace but forgets old_string.
576                return Ok(ToolResult {
577                    call_id: String::new(),
578                    output: "Error: old_string is required for editing existing files. \
579                             Provide the exact text you want to replace, or use start_line/end_line for line-based editing.".to_string(),
580                    success: false,
581                });
582            }
583        };
584
585        // If symbol is provided, scope the edit to that symbol's body using tree-sitter.
586        // This resolves ambiguity: old_string only needs to be unique within the symbol, not the whole file.
587        if let Some(ref symbol_name) = parsed.symbol {
588            let path = file_path.as_path();
589            let mut searcher = ctx.semantic.lock().await;
590            if let Some(slice) = searcher.extract_symbol(path, symbol_name) {
591                let sym_text = &content[slice.start_byte..slice.end_byte];
592                let sym_count = sym_text.matches(&old_string).count();
593
594                if sym_count == 0 {
595                    let (hint, _) = find_closest_match_with_suggestion(sym_text, &old_string);
596                    let reread = auto_reread_content(&content, &old_string);
597                    return Ok(ToolResult {
598                        call_id: String::new(),
599                        output: format!(
600                            "Error: old_string not found in symbol '{}' (lines {}-{}).\n{}\n{}\n\
601                             [HINT: Copy the EXACT text from the returned content as your new old_string.]",
602                            symbol_name, slice.start_line, slice.end_line, hint, reread
603                        ),
604                        success: false,
605                    });
606                }
607
608                if !parsed.replace_all && sym_count > 1 {
609                    return Ok(ToolResult {
610                        call_id: String::new(),
611                        output: format!(
612                            "Error: old_string found {} times in symbol '{}'. Use replace_all=true or provide more context.",
613                            sym_count, symbol_name
614                        ),
615                        success: false,
616                    });
617                }
618
619                // Replace within the symbol, reconstruct the full file
620                let new_sym_text = if parsed.replace_all {
621                    sym_text.replace(&old_string, &new_string)
622                } else {
623                    sym_text.replacen(&old_string, &new_string, 1)
624                };
625                let new_content = format!(
626                    "{}{}{}",
627                    &content[..slice.start_byte],
628                    new_sym_text,
629                    &content[slice.end_byte..]
630                );
631
632                let diff = build_compact_diff(&old_string, &new_string);
633                let label = if parsed.replace_all {
634                    format!("replaced {} occurrences in {}", sym_count, symbol_name)
635                } else {
636                    format!(
637                        "in {} (lines {}-{})",
638                        symbol_name, slice.start_line, slice.end_line
639                    )
640                };
641                let result = ToolResult {
642                    call_id: String::new(),
643                    output: format!("Edited {} {}.\n{}", parsed.file_path, label, diff),
644                    success: true,
645                };
646                let (result, _final_content) = validate_write_check(
647                    &new_content,
648                    &file_path_str,
649                    &new_string,
650                    &content,
651                    result,
652                    ctx,
653                )
654                .await?;
655                // Invalidate AST cache for this file
656                drop(searcher); // release lock before re-acquiring
657                let mut searcher = ctx.semantic.lock().await;
658                searcher.invalidate(path);
659                return Ok(result);
660            } else {
661                // Symbol not found — list available symbols as hint
662                let hint = match searcher.list_symbols(path) {
663                    Some(syms) => {
664                        let names: Vec<&str> = syms.iter().map(|s| s.name.as_str()).collect();
665                        format!(
666                            "Symbol '{}' not found. Available: {}",
667                            symbol_name,
668                            names.join(", ")
669                        )
670                    }
671                    None => format!("Symbol '{}' not found in {}", symbol_name, parsed.file_path),
672                };
673                return Ok(ToolResult {
674                    call_id: String::new(),
675                    output: hint,
676                    success: false,
677                });
678            }
679        }
680
681        // Standard path: no symbol scoping
682        let count = content.matches(&old_string).count();
683
684        if count == 0 {
685            // Auto-fuzzy: try whitespace-normalized matching before failing.
686            // This handles the common case where the model gets indentation slightly wrong.
687            if let Some((fuzzy_result, fuzzy_count)) =
688                try_fuzzy_replace(&content, &old_string, &new_string, parsed.replace_all)
689            {
690                let diff = build_compact_diff(&old_string, &new_string);
691                let result = ToolResult {
692                    call_id: String::new(),
693                    output: format!(
694                        "Edited {} (fuzzy match, {} occurrence{}).\n{}",
695                        parsed.file_path,
696                        fuzzy_count,
697                        if fuzzy_count > 1 { "s" } else { "" },
698                        diff
699                    ),
700                    success: true,
701                };
702                let (result, _final_content) = validate_write_check(
703                    &fuzzy_result,
704                    &file_path_str,
705                    &new_string,
706                    &content,
707                    result,
708                    ctx,
709                )
710                .await?;
711                return Ok(result);
712            }
713
714            let (hint, _suggested_old) = find_closest_match_with_suggestion(&content, &old_string);
715
716            // Auto-fallback to line mode: if we found the approximate location,
717            // suggest the model use start_line/end_line instead of retrying text match.
718            let line_hint = {
719                let old_first = old_string
720                    .lines()
721                    .find(|l| !l.trim().is_empty())
722                    .map(|l| l.trim());
723                let lines: Vec<&str> = content.lines().collect();
724                old_first
725                    .and_then(|needle| {
726                        lines
727                            .iter()
728                            .position(|l| l.trim().contains(needle))
729                            .map(|center| {
730                                let old_line_count = old_string.lines().count();
731                                let start = center + 1; // 1-indexed
732                                let end = (center + old_line_count).min(lines.len());
733                                format!(
734                                    "\n[TIP: Use line mode instead — edit_file(file_path=\"{}\", \
735                                 start_line={}, end_line={}, new_string=\"...\")]",
736                                    parsed.file_path, start, end
737                                )
738                            })
739                    })
740                    .unwrap_or_default()
741            };
742
743            let reread = auto_reread_content(&content, &old_string);
744            return Ok(ToolResult {
745                call_id: String::new(),
746                output: format!(
747                    "Error: old_string not found in {}.\n{}{}\n{}\n\
748                     [HINT: old_string did not match. The file content around your target has been \
749                     returned above. Copy the EXACT text from the returned content as your new old_string.]\n\
750                     [Do not fall back to shell file modification (in-place editors, redirects, \
751                     write scripts) — re-issue edit_file with the corrected old_string so the \
752                     change is tracked and reversible via /undo.]",
753                    parsed.file_path, hint, line_hint, reread
754                ),
755                success: false,
756            });
757        }
758
759        // Safety check: warn about large deletions
760        let old_lines = old_string.lines().count();
761        let new_lines = new_string.lines().count();
762        let net_deleted = old_lines.saturating_sub(new_lines);
763        let _deletion_warning = if net_deleted > 10 {
764            format!(
765                "\nWARNING: You removed {} more lines than you added. If you only meant to ADD a skeleton/loading section, \
766                 use v-if/v-else to show it ALONGSIDE the existing content, not INSTEAD of it.",
767                net_deleted
768            )
769        } else {
770            String::new()
771        };
772
773        if parsed.replace_all {
774            // Safety check: warn about high replacement count
775            let _replace_warning = if count > 10 {
776                format!(
777                    "\nWARNING: Replaced {} occurrences. This many replacements may have changed structural \
778                     elements (tags, brackets) that should not be bulk-replaced. Verify the file structure.",
779                    count
780                )
781            } else {
782                String::new()
783            };
784
785            let new_content = content.replace(&old_string, &new_string);
786            let diff = build_compact_diff(&old_string, &new_string);
787            let result = ToolResult {
788                call_id: String::new(),
789                output: format!(
790                    "Edited {} (replaced {} occurrence{}).\n{}",
791                    parsed.file_path,
792                    count,
793                    if count > 1 { "s" } else { "" },
794                    diff,
795                ),
796                success: true,
797            };
798            let (result, _final_content) = validate_write_check(
799                &new_content,
800                &file_path_str,
801                &new_string,
802                &content,
803                result,
804                ctx,
805            )
806            .await?;
807            Ok(result)
808        } else {
809            if count > 1 {
810                // Auto-disambiguate using tree-sitter: if only ONE symbol contains the match,
811                // scope to that symbol automatically. The model doesn't need to pass symbol=.
812                let path = file_path.as_path();
813                let mut searcher = ctx.semantic.lock().await;
814                if let Some(symbols) = searcher.list_symbols(path) {
815                    // Find which symbols contain the old_string
816                    let matching_syms: Vec<&crate::semantic::Symbol> = symbols
817                        .iter()
818                        .filter(|sym| {
819                            let sym_text =
820                                &content[sym.start_byte..sym.end_byte.min(content.len())];
821                            sym_text.contains(&*old_string)
822                        })
823                        .collect();
824
825                    if matching_syms.len() == 1 {
826                        // Only one symbol contains it — auto-scope and replace
827                        let sym = matching_syms[0];
828                        let sym_text = &content[sym.start_byte..sym.end_byte.min(content.len())];
829                        let new_sym = sym_text.replacen(&*old_string, &new_string, 1);
830                        let new_content = format!(
831                            "{}{}{}",
832                            &content[..sym.start_byte],
833                            new_sym,
834                            &content[sym.end_byte.min(content.len())..]
835                        );
836                        drop(searcher);
837                        let diff = build_compact_diff(&old_string, &new_string);
838                        let result = ToolResult {
839                            call_id: String::new(),
840                            output: format!(
841                                "Edited {} in {}() (auto-scoped, {} global matches).\n{}",
842                                parsed.file_path, sym.name, count, diff
843                            ),
844                            success: true,
845                        };
846                        let (result, _final_content) = validate_write_check(
847                            &new_content,
848                            &file_path_str,
849                            &new_string,
850                            &content,
851                            result,
852                            ctx,
853                        )
854                        .await?;
855                        return Ok(result);
856                    }
857                }
858                drop(searcher);
859
860                return Ok(ToolResult {
861                    call_id: String::new(),
862                    output: format!(
863                        "Error: old_string found {} times in {}. Use replace_all=true to replace all, or provide more context to make it unique.",
864                        count, parsed.file_path
865                    ),
866                    success: false,
867                });
868            }
869
870            let new_content = content.replacen(&old_string, &new_string, 1);
871
872            let removed = old_string.lines().count();
873            let added = new_string.lines().count();
874            let diff = build_compact_diff(&old_string, &new_string);
875            let result = ToolResult {
876                call_id: String::new(),
877                output: format!(
878                    "Edited {} (-{} +{} lines).\n{}",
879                    parsed.file_path, removed, added, diff,
880                ),
881                success: true,
882            };
883            let (result, _final_content) = validate_write_check(
884                &new_content,
885                &file_path_str,
886                &new_string,
887                &content,
888                result,
889                ctx,
890            )
891            .await?;
892            Ok(result)
893        }
894    }
895}
896
897impl EditFileTool {
898    /// Execute multi-edit: apply multiple edits to different regions in one call.
899    /// Edits are resolved to line ranges, sorted back-to-front, then applied sequentially.
900    async fn execute_multi_edit(
901        &self,
902        file_path: &str,
903        content: &str,
904        edits: Vec<SingleEdit>,
905        ctx: &ToolContext,
906    ) -> Result<ToolResult> {
907        let lines: Vec<&str> = content.lines().collect();
908        let total = lines.len();
909
910        // Resolve each edit to a (start, end, new_string) tuple where start/end are 1-indexed line numbers.
911        let mut resolved: Vec<(usize, usize, String)> = Vec::with_capacity(edits.len());
912
913        for (i, edit) in edits.iter().enumerate() {
914            if let (Some(start), Some(end)) = (edit.start_line, edit.end_line) {
915                // Auto-swap if start > end
916                let (start, end) = if end < start {
917                    (end, start)
918                } else {
919                    (start, end)
920                };
921                if start == 0 || start > total {
922                    return Ok(ToolResult {
923                        call_id: String::new(),
924                        output: format!(
925                            "Error in edit #{}: invalid line range {}-{} (file has {} lines)",
926                            i + 1,
927                            start,
928                            end,
929                            total
930                        ),
931                        success: false,
932                    });
933                }
934                resolved.push((start, end.min(total), edit.new_string.clone()));
935            } else if let Some(ref old_str) = edit.old_string {
936                if old_str.is_empty() {
937                    return Ok(ToolResult {
938                        call_id: String::new(),
939                        output: format!("Error in edit #{}: old_string is empty", i + 1),
940                        success: false,
941                    });
942                }
943                // Text-match mode: find the old_string and convert to line range
944                match find_text_line_range(content, old_str) {
945                    Some((start, end)) => {
946                        resolved.push((start, end, edit.new_string.clone()));
947                    }
948                    None => {
949                        return Ok(ToolResult {
950                            call_id: String::new(),
951                            output: format!(
952                                "Error in edit #{}: old_string not found in {}.\nSearched for: {:?}",
953                                i + 1, file_path, old_str.lines().next().unwrap_or("")
954                            ),
955                            success: false,
956                        });
957                    }
958                }
959            } else {
960                return Ok(ToolResult {
961                    call_id: String::new(),
962                    output: format!(
963                        "Error in edit #{}: must specify start_line/end_line or old_string",
964                        i + 1
965                    ),
966                    success: false,
967                });
968            }
969        }
970
971        // Boundary overlap auto-correction: trailing + leading.
972        // Trailing: new_string ends duplicate lines after end_line → extend end.
973        // Leading: new_string begins duplicate lines before start_line → extend start.
974        for (start, end, new_str) in &mut resolved {
975            let new_lines: Vec<&str> = new_str.lines().collect();
976            if new_lines.is_empty() {
977                continue;
978            }
979
980            // Trailing overlap
981            let mut trail_extra = 0usize;
982            for i in 0..new_lines.len() {
983                let new_idx = new_lines.len() - 1 - i;
984                let orig_idx = *end + trail_extra;
985                if orig_idx >= total {
986                    break;
987                }
988                if new_lines[new_idx].trim() == lines[orig_idx].trim()
989                    && !new_lines[new_idx].trim().is_empty()
990                {
991                    trail_extra += 1;
992                } else {
993                    break;
994                }
995            }
996            if trail_extra > 0 {
997                *end = (*end + trail_extra).min(total);
998            }
999
1000            // Leading overlap
1001            let mut lead_extra = 0usize;
1002            for i in 0..new_lines.len() {
1003                if *start <= 1 + lead_extra {
1004                    break;
1005                }
1006                let orig_idx = *start - 2 - lead_extra;
1007                if new_lines[i].trim() == lines[orig_idx].trim() && !new_lines[i].trim().is_empty()
1008                {
1009                    lead_extra += 1;
1010                } else {
1011                    break;
1012                }
1013            }
1014            if lead_extra > 0 {
1015                *start = start.saturating_sub(lead_extra).max(1);
1016            }
1017        }
1018
1019        // Auto-merge overlapping ranges instead of rejecting.
1020        // Weak models frequently generate edits like (264-279) + (268-279)
1021        // where the second edit extends or replaces part of the first.
1022        // Merge: keep the union range, second edit's new_string wins for
1023        // the overlapping portion.
1024        resolved.sort_by_key(|(start, _, _)| *start);
1025        let mut merged: Vec<(usize, usize, String)> = Vec::new();
1026        for edit in resolved {
1027            if let Some(last) = merged.last_mut() {
1028                if edit.0 <= last.1 {
1029                    // Overlapping — merge (adjacent edits are NOT merged).
1030                    // Strategy: the later edit (by position) wins for the
1031                    // overlapping region.
1032                    if edit.1 >= last.1 {
1033                        // Second edit extends beyond first — take second's content
1034                        // for the entire merged range (matches model intent:
1035                        // it wanted to replace this whole region).
1036                        last.1 = edit.1;
1037                        last.2 = edit.2;
1038                    }
1039                    // If second is fully contained in first, first wins (skip second)
1040                    continue;
1041                }
1042            }
1043            merged.push(edit);
1044        }
1045        let mut resolved = merged;
1046
1047        // Apply edits back-to-front to preserve line numbers
1048        resolved.sort_by(|a, b| b.0.cmp(&a.0));
1049
1050        let mut result_lines: Vec<String> = lines.iter().map(|l| l.to_string()).collect();
1051        let mut summary_parts: Vec<String> = Vec::new();
1052
1053        // Note: large edit guard changed from hard block to warning.
1054        // Corrupted files need large rewrites to restore structure.
1055        let _ext = file_path.rsplit('.').next().unwrap_or("");
1056        if false { // guard disabled — auto_fix handles validation
1057        }
1058
1059        for (start, end, new_str) in &resolved {
1060            let removed = end - start + 1;
1061            let new_edit_lines: Vec<String> = new_str.lines().map(|l| l.to_string()).collect();
1062            let added = new_edit_lines.len();
1063            result_lines.splice((start - 1)..*end, new_edit_lines);
1064            summary_parts.push(format!("L{}-{} (-{} +{})", start, end, removed, added));
1065        }
1066        // Reverse so summary is top-to-bottom
1067        summary_parts.reverse();
1068
1069        let new_content = if content.ends_with('\n') {
1070            format!("{}\n", result_lines.join("\n"))
1071        } else {
1072            result_lines.join("\n")
1073        };
1074
1075        let edit_count = resolved.len();
1076        let all_new_strings: String = edits
1077            .iter()
1078            .map(|e| e.new_string.as_str())
1079            .collect::<Vec<_>>()
1080            .join("\n");
1081        let short_name = std::path::Path::new(file_path)
1082            .file_name()
1083            .map(|n| n.to_string_lossy().to_string())
1084            .unwrap_or_else(|| file_path.to_string());
1085        let result = ToolResult {
1086            call_id: String::new(),
1087            output: format!(
1088                "Multi-edit: {} edits applied to {} [{}].\n\u{2713} {} updated. Proceed to your next file.",
1089                edit_count, file_path, summary_parts.join(", "), short_name),
1090            success: true,
1091        };
1092        let (result, _final_content) =
1093            validate_write_check(&new_content, file_path, &all_new_strings, content, result, ctx)
1094                .await?;
1095        Ok(result)
1096    }
1097}
1098
1099/// Find the line range (1-indexed, inclusive) where `needle` appears in `content`.
1100/// Returns None if not found or if found multiple times.
1101fn find_text_line_range(content: &str, needle: &str) -> Option<(usize, usize)> {
1102    let needle_lines: Vec<&str> = needle.lines().collect();
1103    if needle_lines.is_empty() {
1104        return None;
1105    }
1106
1107    let content_lines: Vec<&str> = content.lines().collect();
1108    let mut matches: Vec<usize> = Vec::new();
1109
1110    // Try exact match first
1111    for i in 0..content_lines.len().saturating_sub(needle_lines.len() - 1) {
1112        if content_lines[i..i + needle_lines.len()] == needle_lines[..] {
1113            matches.push(i);
1114        }
1115    }
1116
1117    // If no exact match, try trimmed (fuzzy) match
1118    if matches.is_empty() {
1119        let needle_trimmed: Vec<&str> = needle_lines.iter().map(|l| l.trim()).collect();
1120        for i in 0..content_lines.len().saturating_sub(needle_trimmed.len() - 1) {
1121            let window: Vec<&str> = content_lines[i..i + needle_trimmed.len()]
1122                .iter()
1123                .map(|l| l.trim())
1124                .collect();
1125            if window == needle_trimmed {
1126                matches.push(i);
1127            }
1128        }
1129    }
1130
1131    if matches.len() == 1 {
1132        let start = matches[0] + 1; // 1-indexed
1133        let end = start + needle_lines.len() - 1;
1134        Some((start, end))
1135    } else {
1136        None // not found or ambiguous
1137    }
1138}
1139
1140/// Try fuzzy matching: normalize whitespace (trim each line) and try to match.
1141/// `replace_all` controls whether all matches or just a unique one should be replaced.
1142fn try_fuzzy_replace(
1143    content: &str,
1144    old_string: &str,
1145    new_string: &str,
1146    replace_all: bool,
1147) -> Option<(String, usize)> {
1148    let old_normalized: Vec<&str> = old_string.lines().map(|l| l.trim()).collect();
1149    if old_normalized.is_empty() || old_normalized.iter().all(|l| l.is_empty()) {
1150        return None;
1151    }
1152
1153    let content_lines: Vec<&str> = content.lines().collect();
1154    let has_trailing_newline = content.ends_with('\n');
1155    let mut matches: Vec<(usize, usize)> = Vec::new();
1156
1157    // Only attempt fuzzy match if old_string has substantial content (not just short fragments)
1158    let total_non_ws: usize = old_normalized.iter().map(|l| l.len()).sum();
1159    if total_non_ws < 10 {
1160        return None; // Too short for reliable fuzzy matching
1161    }
1162
1163    // Slide window — skip overlapping matches
1164    let mut i = 0;
1165    while i + old_normalized.len() <= content_lines.len() {
1166        let window: Vec<&str> = content_lines[i..i + old_normalized.len()]
1167            .iter()
1168            .map(|l| l.trim())
1169            .collect();
1170        if window == old_normalized {
1171            matches.push((i, i + old_normalized.len()));
1172            i += old_normalized.len(); // skip past this match
1173        } else {
1174            i += 1;
1175        }
1176    }
1177
1178    if matches.is_empty() {
1179        return None;
1180    }
1181
1182    // If replace_all=false, require exactly one match
1183    if !replace_all && matches.len() > 1 {
1184        return None; // caller will handle the "multiple matches" error
1185    }
1186
1187    // Compute the base indent of new_string used as the re-anchor point.
1188    //
1189    // 2026-04-23 (P1 #14c+11): changed from `.min()` to "first non-empty
1190    // line's indent".
1191    //
1192    // BUG the old `.min()` version caused (hermes session 2026-04-22_21-06):
1193    //   Model's new_string had 4 lines — `.run(...)` at 9 spaces,
1194    //   `.expect(...)` at 9 spaces, `}` at 0 spaces, `marker()` at 0 spaces.
1195    //   `.min()` picked 0 (from the outdented `}`). Then `.run(...)` got
1196    //   `relative = 9 - 0 = 9`, added to the file's 8-space prefix → output
1197    //   at 17 spaces. Next fuzzy edit on that corrupted file repeated the
1198    //   shift → 17 → 26 → accumulating indent drift.
1199    //
1200    // NEW ANCHOR semantics: the model's first non-empty line in new_string
1201    // represents the OUTER indent the model intended to match in old_string.
1202    // Relative differences (lines indented more OR less than the anchor)
1203    // are preserved as signed offsets from the file's actual indent.
1204    let new_lines: Vec<&str> = new_string.lines().collect();
1205    let new_base_indent = new_lines.iter()
1206        .find(|l| !l.trim().is_empty())
1207        .map(|l| l.len() - l.trim_start().len())
1208        .unwrap_or(0);
1209
1210    let mut result_lines: Vec<String> = content_lines.iter().map(|l| l.to_string()).collect();
1211
1212    // Process matches in reverse to preserve indices
1213    let to_replace = if replace_all {
1214        &matches[..]
1215    } else {
1216        &matches[..1]
1217    };
1218    for &(start, end) in to_replace.iter().rev() {
1219        // Detect indentation from the first matched line in the file
1220        let original_line = content_lines[start];
1221        let file_indent = original_line.len() - original_line.trim_start().len();
1222        let file_indent_str: String = original_line.chars().take(file_indent).collect();
1223
1224        // Build replacement preserving RELATIVE indentation from new_string,
1225        // supporting both deeper-than-anchor (add spaces) and outdented-from-
1226        // anchor (trim the file's indent prefix) cases.
1227        let replacement: Vec<String> = new_lines.iter().map(|l| {
1228            if l.trim().is_empty() {
1229                String::new()
1230            } else {
1231                let line_indent = l.len() - l.trim_start().len();
1232                let signed_relative = line_indent as isize - new_base_indent as isize;
1233                let total_indent = if signed_relative >= 0 {
1234                    // Same or deeper than anchor — keep file's indent prefix
1235                    // (preserves tabs/spaces mix) and extend with plain spaces.
1236                    format!("{}{}", file_indent_str, " ".repeat(signed_relative as usize))
1237                } else {
1238                    // Outdented from anchor — drop `abs(signed_relative)`
1239                    // chars from the tail of file_indent_str. Preserves the
1240                    // leading whitespace semantics up to the needed depth.
1241                    let drop = (-signed_relative) as usize;
1242                    let keep = file_indent.saturating_sub(drop);
1243                    file_indent_str.chars().take(keep).collect()
1244                };
1245                format!("{}{}", total_indent, l.trim())
1246            }
1247        }).collect();
1248
1249        result_lines.splice(start..end, replacement);
1250    }
1251
1252    let mut result = result_lines.join("\n");
1253    // Preserve trailing newline
1254    if has_trailing_newline && !result.ends_with('\n') {
1255        result.push('\n');
1256    }
1257
1258    let count = if replace_all { matches.len() } else { 1 };
1259    Some((result, count))
1260}
1261
1262/// Build a compact diff showing removed/added lines (max 8 lines total).
1263fn build_compact_diff(old: &str, new: &str) -> String {
1264    let mut diff = String::new();
1265    let old_lines: Vec<&str> = old.lines().collect();
1266    let new_lines: Vec<&str> = new.lines().collect();
1267
1268    let max_show = 4; // max lines per side
1269
1270    // Show removed lines (prefixed with -)
1271    for (i, line) in old_lines.iter().take(max_show).enumerate() {
1272        diff.push_str(&format!("- {}\n", line));
1273        if i == max_show - 1 && old_lines.len() > max_show {
1274            diff.push_str(&format!(
1275                "  ... ({} more removed)\n",
1276                old_lines.len() - max_show
1277            ));
1278        }
1279    }
1280
1281    // Show added lines (prefixed with +)
1282    for (i, line) in new_lines.iter().take(max_show).enumerate() {
1283        diff.push_str(&format!("+ {}\n", line));
1284        if i == max_show - 1 && new_lines.len() > max_show {
1285            diff.push_str(&format!(
1286                "  ... ({} more added)\n",
1287                new_lines.len() - max_show
1288            ));
1289        }
1290    }
1291
1292    diff.trim_end().to_string()
1293}
1294
1295/// Build a context snippet showing ±4 lines around the edited region.
1296/// This lets the model see the current file state after the edit,
1297/// so it can construct accurate old_string for the next edit without re-reading.
1298///
1299/// Threshold raised 20 → 100: for small/medium files the diff is already enough
1300/// context, and the snippet just duplicates content that recency-reinjection or
1301/// the next read_file will surface. Large files (> 100 lines) still get the
1302/// snippet because they're the only case where re-reading is expensive.
1303fn build_edit_context(content: &str, new_string: &str) -> String {
1304    let lines: Vec<&str> = content.lines().collect();
1305    if lines.len() <= 20 {
1306        return String::new();
1307    }
1308
1309    // Find where new_string appears in the final content using substring search.
1310    // More reliable than line-by-line matching (handles indentation changes).
1311    let new_trimmed = new_string.trim();
1312    if new_trimmed.is_empty() {
1313        return String::new();
1314    }
1315
1316    // Find the first non-empty line of new_string in the file
1317    let search_line = new_trimmed
1318        .lines()
1319        .find(|l| l.trim().len() >= 5)
1320        .unwrap_or("");
1321    if search_line.is_empty() {
1322        return String::new();
1323    }
1324
1325    let center = match lines.iter().position(|l| l.contains(search_line.trim())) {
1326        Some(idx) => idx,
1327        None => return String::new(),
1328    };
1329
1330    let ctx = 4;
1331    let new_lines_count = new_string.lines().count();
1332    let start = center.saturating_sub(ctx);
1333    let end = (center + new_lines_count + ctx).min(lines.len());
1334
1335    let mut snippet = format!("\n[File after edit, lines {}-{}:]\n", start + 1, end);
1336    for (i, line) in lines[start..end].iter().enumerate() {
1337        snippet.push_str(&format!("{:>4}| {}\n", start + i + 1, line));
1338    }
1339    snippet
1340}
1341
1342/// Auto re-read: when old_string match fails, include current file content
1343/// so the model can retry immediately without a separate read_file call.
1344///
1345/// - Files <= 200 lines: full content with line numbers.
1346/// - Files > 200 lines: 50 lines around the approximate target area.
1347fn auto_reread_content(content: &str, old_string: &str) -> String {
1348    let lines: Vec<&str> = content.lines().collect();
1349    let total = lines.len();
1350
1351    if total == 0 {
1352        return String::new();
1353    }
1354
1355    let mut out = String::new();
1356
1357    // Find approximate target area
1358    let target_line = old_string
1359        .lines()
1360        .find(|l| !l.trim().is_empty())
1361        .map(|first| first.trim());
1362
1363    let center = target_line
1364        .and_then(|needle| lines.iter().position(|l| l.trim().contains(needle)))
1365        .unwrap_or(0);
1366
1367    // Cap output to prevent context explosion when multiple edits fail in one turn.
1368    // ≤100 lines: return full file (~1.2K tok, safe even with 5 failures = 6K tok)
1369    // 101-300 lines: return ±15 lines around target (~30 lines = 400 tok)
1370    // >300 lines: return ±7 lines around target (~15 lines = 200 tok)
1371    if total <= 100 {
1372        out.push_str(&format!(
1373            "\n[Edit failed. Full file ({} lines) — copy EXACT text for old_string:]\n",
1374            total
1375        ));
1376        for (i, line) in lines.iter().enumerate() {
1377            out.push_str(&format!("{:>4}| {}\n", i + 1, line));
1378        }
1379    } else {
1380        let half = if total <= 300 { 15 } else { 7 };
1381        let start = center.saturating_sub(half);
1382        let end = (center + half + 1).min(total);
1383
1384        out.push_str(&format!(
1385            "\n[Edit failed. Lines {}-{} of {} (use EXACT text from below as old_string):]\n",
1386            start + 1,
1387            end,
1388            total
1389        ));
1390        for i in start..end {
1391            out.push_str(&format!("{:>4}| {}\n", i + 1, lines[i]));
1392        }
1393    }
1394
1395    out
1396}
1397
1398/// Find the closest match and return (hint_message, suggested_old_string).
1399/// The suggested_old_string is the exact text from the file that the model
1400/// should use — it can copy-paste this into old_string to retry immediately
1401/// without re-reading the file.
1402fn find_closest_match_with_suggestion(content: &str, old_string: &str) -> (String, Option<String>) {
1403    let old_lines: Vec<&str> = old_string.lines().collect();
1404    let content_lines: Vec<&str> = content.lines().collect();
1405
1406    if old_lines.is_empty() {
1407        return (
1408            "old_string is empty. Use read_file to re-read the file.".to_string(),
1409            None,
1410        );
1411    }
1412
1413    let old_first_trimmed = old_lines[0].trim();
1414    if old_first_trimmed.is_empty() && old_lines.len() > 1 {
1415        let hint = find_closest_match(content, old_string);
1416        return (hint, None);
1417    }
1418
1419    // Try to find where the first line matches (trimmed) in the file
1420    for (i, line) in content_lines.iter().enumerate() {
1421        if line.trim() == old_first_trimmed {
1422            // Found potential match start. Extract the same number of lines from file.
1423            let end = (i + old_lines.len()).min(content_lines.len());
1424            let actual_lines = &content_lines[i..end];
1425
1426            // Check if it's a plausible match (at least 30% of lines match trimmed)
1427            let matching = actual_lines
1428                .iter()
1429                .zip(old_lines.iter())
1430                .filter(|(a, b)| a.trim() == b.trim())
1431                .count();
1432
1433            if matching >= old_lines.len() / 3 || matching >= 2 {
1434                let suggested = actual_lines.join("\n");
1435                let hint = find_closest_match(content, old_string);
1436                return (hint, Some(suggested));
1437            }
1438        }
1439    }
1440
1441    let hint = find_closest_match(content, old_string);
1442    (hint, None)
1443}
1444
1445/// Find the closest matching region in the file to help the model fix old_string.
1446/// Three strategies: (1) whitespace-normalized multi-line match, (2) first-line match, (3) keyword search.
1447fn find_closest_match(content: &str, old_string: &str) -> String {
1448    let old_lines: Vec<&str> = old_string.lines().collect();
1449    let content_lines: Vec<&str> = content.lines().collect();
1450
1451    if old_lines.is_empty() {
1452        return "old_string is empty. Use read_file to re-read the file.".to_string();
1453    }
1454
1455    let old_first_trimmed = old_lines[0].trim();
1456    if old_first_trimmed.is_empty() && old_lines.len() > 1 {
1457        // First line is empty — try second line
1458        return find_closest_match_inner(content, &content_lines, old_lines[1].trim(), &old_lines);
1459    }
1460
1461    find_closest_match_inner(content, &content_lines, old_first_trimmed, &old_lines)
1462}
1463
1464fn find_closest_match_inner(
1465    _content: &str,
1466    content_lines: &[&str],
1467    first_line_trimmed: &str,
1468    old_lines: &[&str],
1469) -> String {
1470    if first_line_trimmed.is_empty() {
1471        return "old_string appears empty after trimming. Use read_file to re-read the file."
1472            .to_string();
1473    }
1474
1475    // Strategy 1: Find where the first line matches (trimmed) and show divergence point
1476    let mut candidates: Vec<(usize, usize)> = Vec::new(); // (line_idx, match_score)
1477
1478    for (i, line) in content_lines.iter().enumerate() {
1479        let trimmed = line.trim();
1480        // Exact trimmed match of first line
1481        if trimmed == first_line_trimmed {
1482            // Check how many subsequent lines also match (trimmed)
1483            let mut match_count = 1;
1484            for j in 1..old_lines.len() {
1485                if i + j >= content_lines.len() {
1486                    break;
1487                }
1488                if content_lines[i + j].trim() == old_lines[j].trim() {
1489                    match_count += 1;
1490                } else {
1491                    break;
1492                }
1493            }
1494            candidates.push((i, match_count));
1495        }
1496        // Substring match of first line — require both sides to carry real
1497        // signal. Without a length floor, `first_line_trimmed.contains("")`
1498        // is TRUE for every blank line in the file (trim() → "") and
1499        // `contains("}")` / `contains(")")` fire on every closing bracket,
1500        // so the "closest match" result regularly pointed at the first
1501        // blank line (session 2026-04-22 20-41: `.Run(...)` old_string →
1502        // "Closest match found near line 3" which was an empty line, with
1503        // a lines-1-16 snippet unrelated to the model's real target near
1504        // line 270). Bumping to 4 chars filters blanks and single-token
1505        // syntactic noise while still catching short identifiers like
1506        // `main`, `impl`, `pub` that the length-15 prefix check would miss.
1507        else if trimmed.len() >= 4
1508            && first_line_trimmed.len() >= 4
1509            && (trimmed.contains(first_line_trimmed) || first_line_trimmed.contains(trimmed))
1510        {
1511            candidates.push((i, 0));
1512        }
1513        // Prefix match (first 25 chars)
1514        else if trimmed.len() > 15
1515            && first_line_trimmed.len() > 15
1516            && trimmed.chars().take(25).collect::<String>()
1517                == first_line_trimmed.chars().take(25).collect::<String>()
1518        {
1519            candidates.push((i, 0));
1520        }
1521    }
1522
1523    // Sort by match_count (highest first)
1524    candidates.sort_by(|a, b| b.1.cmp(&a.1));
1525
1526    if let Some(&(best_idx, match_count)) = candidates.first() {
1527        let start = best_idx.saturating_sub(1);
1528        // Cap snippet to 20 lines max — large snippets waste context without helping
1529        let end = (best_idx + old_lines.len().min(18) + 2).min(content_lines.len());
1530
1531        let mut snippet = String::new();
1532        for i in start..end {
1533            snippet.push_str(&format!("{:>4}| {}\n", i + 1, content_lines[i]));
1534        }
1535        if best_idx + old_lines.len() + 2 > end {
1536            snippet.push_str(&format!(
1537                "     ... ({} more lines in file)\n",
1538                content_lines.len() - end
1539            ));
1540        }
1541
1542        // If some lines matched but not all, show exactly where the divergence is
1543        if match_count > 0
1544            && match_count < old_lines.len()
1545            && best_idx + match_count < content_lines.len()
1546        {
1547            let diverge_idx = match_count;
1548            let file_line = content_lines[best_idx + diverge_idx].trim();
1549            let old_line = old_lines[diverge_idx].trim();
1550
1551            // Detect indentation mismatch
1552            let file_indent =
1553                content_lines[best_idx].len() - content_lines[best_idx].trim_start().len();
1554            let old_indent = old_lines[0].len() - old_lines[0].trim_start().len();
1555
1556            let mut hint = format!(
1557                "First {} line(s) match (trimmed) but line {} diverges:\n\
1558                 YOUR old_string line {}: \"{}\"\n\
1559                 ACTUAL file line {}:     \"{}\"\n",
1560                match_count,
1561                diverge_idx + 1,
1562                diverge_idx + 1,
1563                old_line,
1564                best_idx + diverge_idx + 1,
1565                file_line,
1566            );
1567
1568            if file_indent != old_indent {
1569                hint.push_str(&format!(
1570                    "INDENTATION MISMATCH: file uses {} spaces, your old_string uses {} spaces.\n",
1571                    file_indent, old_indent,
1572                ));
1573            }
1574
1575            return format!(
1576                "Partial match at lines {}-{} ({}/{} lines match).\n{}\n{}\n\
1577                 Copy the EXACT text from above (including indentation) for old_string.",
1578                best_idx + 1,
1579                end,
1580                match_count,
1581                old_lines.len(),
1582                snippet,
1583                hint
1584            );
1585        }
1586
1587        // Indentation-only mismatch detection
1588        if match_count == 0 {
1589            let file_indent =
1590                content_lines[best_idx].len() - content_lines[best_idx].trim_start().len();
1591            let old_indent = old_lines[0].len() - old_lines[0].trim_start().len();
1592            if file_indent != old_indent && content_lines[best_idx].trim() == old_lines[0].trim() {
1593                return format!(
1594                    "INDENTATION MISMATCH at line {}. File uses {} spaces, your old_string uses {} spaces.\n\
1595                     Actual file content:\n{}\n\
1596                     Copy the EXACT text (with correct indentation) for old_string.",
1597                    best_idx + 1, file_indent, old_indent, snippet
1598                );
1599            }
1600        }
1601
1602        return format!(
1603            "Closest match found near line {}:\n{}\n\
1604             Copy the EXACT text from above for old_string (preserve indentation).",
1605            best_idx + 1,
1606            snippet
1607        );
1608    }
1609
1610    // Strategy 2: keyword-based search — find lines containing distinctive words from old_string
1611    let keywords: Vec<&str> = first_line_trimmed
1612        .split_whitespace()
1613        .filter(|w| {
1614            w.len() > 3
1615                && !matches!(
1616                    *w,
1617                    "const"
1618                        | "let"
1619                        | "var"
1620                        | "this"
1621                        | "self"
1622                        | "return"
1623                        | "from"
1624                        | "import"
1625                        | "function"
1626                )
1627        })
1628        .take(3)
1629        .collect();
1630
1631    if !keywords.is_empty() {
1632        for (i, line) in content_lines.iter().enumerate() {
1633            let lower = line.to_lowercase();
1634            if keywords.iter().all(|kw| lower.contains(&kw.to_lowercase())) {
1635                let start = i.saturating_sub(2);
1636                let end = (i + 5).min(content_lines.len());
1637                let mut snippet = String::new();
1638                for j in start..end {
1639                    snippet.push_str(&format!("{:>4}| {}\n", j + 1, content_lines[j]));
1640                }
1641                return format!(
1642                    "No exact match, but keywords [{}] found near line {}:\n{}\n\
1643                     Use read_file with offset={} limit=20 to see the exact content.",
1644                    keywords.join(", "),
1645                    i + 1,
1646                    snippet,
1647                    start + 1
1648                );
1649            }
1650        }
1651    }
1652
1653    format!(
1654        "No similar text found in the file ({} lines total). \
1655         The content may have changed. Use read_file to re-read the file.",
1656        content_lines.len()
1657    )
1658}
1659#[cfg(test)]
1660mod security_tests {
1661    use super::*;
1662    use crate::tool::{ApprovalRequirement, Tool, ToolContext};
1663    use serial_test::serial;
1664    use tempfile::TempDir;
1665
1666    #[test]
1667    fn edit_file_requires_approval_for_sensitive_paths() {
1668        let tool = EditFileTool;
1669        let args = serde_json::json!({
1670            "file_path": "/etc/hosts",
1671            "old_string": "old",
1672            "new_string": "new"
1673        })
1674        .to_string();
1675
1676        assert!(matches!(
1677            tool.approval(&args),
1678            ApprovalRequirement::RequireApproval(_)
1679        ));
1680    }
1681
1682    #[test]
1683    fn edit_file_auto_approves_regular_paths() {
1684        let tool = EditFileTool;
1685        let args = serde_json::json!({
1686            "file_path": "src/main.rs",
1687            "old_string": "old",
1688            "new_string": "new"
1689        })
1690        .to_string();
1691
1692        assert!(matches!(tool.approval(&args), ApprovalRequirement::AutoApprove));
1693    }
1694
1695    #[test]
1696    fn edit_file_requires_approval_when_args_do_not_parse() {
1697        let tool = EditFileTool;
1698        assert!(matches!(
1699            tool.approval("{not valid json"),
1700            ApprovalRequirement::RequireApproval(_)
1701        ));
1702    }
1703
1704    #[tokio::test]
1705    #[serial]
1706    async fn edit_file_writes_relative_path_against_tool_working_dir() {
1707        let workspace = TempDir::new().unwrap();
1708        let process_cwd = TempDir::new().unwrap();
1709        std::fs::create_dir_all(workspace.path().join("src")).unwrap();
1710        std::fs::create_dir_all(process_cwd.path().join("src")).unwrap();
1711        std::fs::write(workspace.path().join("src/app.rs"), "fn main() {\n    old();\n}\n")
1712            .unwrap();
1713
1714        let original_cwd = std::env::current_dir().unwrap();
1715        std::env::set_current_dir(process_cwd.path()).unwrap();
1716        let result = EditFileTool
1717            .execute(
1718                r#"{"file_path":"src/app.rs","old_string":"old();","new_string":"new();"}"#,
1719                &ToolContext::new(workspace.path().to_path_buf()),
1720            )
1721            .await;
1722        std::env::set_current_dir(original_cwd).unwrap();
1723
1724        let result = result.unwrap();
1725        assert!(result.success, "{}", result.output);
1726        assert_eq!(
1727            std::fs::read_to_string(workspace.path().join("src/app.rs")).unwrap(),
1728            "fn main() {\n    new();\n}\n"
1729        );
1730        assert!(
1731            !process_cwd.path().join("src/app.rs").exists(),
1732            "edit_file must not write relative paths against the process cwd"
1733        );
1734    }
1735}