Skip to main content

rab/builtin/
edit.rs

1use crate::agent::extension::{AgentTool, Cancel, Extension, ToolOutput};
2use crate::agent::extension::{ToolRenderContext, ToolRenderer};
3use crate::tui::Theme;
4use anyhow::Context;
5use async_trait::async_trait;
6use std::borrow::Cow;
7use tokio::sync::mpsc::UnboundedSender;
8
9pub struct EditExtension {
10    cwd: std::path::PathBuf,
11}
12
13impl EditExtension {
14    pub fn new(cwd: std::path::PathBuf) -> Self {
15        Self { cwd }
16    }
17}
18
19impl Extension for EditExtension {
20    fn name(&self) -> Cow<'static, str> {
21        "edit".into()
22    }
23
24    fn tools(&self) -> Vec<Box<dyn AgentTool>> {
25        vec![Box::new(EditTool {
26            cwd: self.cwd.clone(),
27        })]
28    }
29}
30
31struct EditTool {
32    cwd: std::path::PathBuf,
33}
34
35#[derive(serde::Deserialize)]
36#[serde(rename_all = "camelCase")]
37struct Edit {
38    old_text: String,
39    new_text: String,
40}
41
42// ── BOM handling ──────────────────────────────────────────────────
43
44/// Strip UTF-8 BOM if present. Returns (bom, content_without_bom).
45fn strip_bom(content: &str) -> (&str, &str) {
46    if content.starts_with('\u{FEFF}') {
47        ("\u{FEFF}", &content['\u{FEFF}'.len_utf8()..])
48    } else {
49        ("", content)
50    }
51}
52
53// ── Line ending handling ─────────────────────────────────────────
54
55fn detect_line_ending(content: &str) -> &'static str {
56    if content.contains("\r\n") {
57        "\r\n"
58    } else {
59        "\n"
60    }
61}
62
63fn normalize_to_lf(content: &str) -> String {
64    content.replace("\r\n", "\n")
65}
66
67fn restore_line_endings(content: &str, ending: &str) -> String {
68    if ending == "\r\n" {
69        content.replace('\n', "\r\n")
70    } else {
71        content.to_string()
72    }
73}
74
75// ── Fuzzy matching ───────────────────────────────────────────────
76
77/// Normalize text for fuzzy matching:
78/// - Strip trailing whitespace from each line
79/// - Normalize Unicode smart quotes → ASCII quotes
80/// - Normalize Unicode dashes/hyphens → ASCII hyphen
81/// - Normalize special Unicode spaces → regular space
82fn normalize_for_fuzzy_match(text: &str) -> String {
83    // First pass: strip trailing whitespace per line
84    let mut intermediate = String::with_capacity(text.len());
85    for line in text.lines() {
86        if !intermediate.is_empty() {
87            intermediate.push('\n');
88        }
89        intermediate.push_str(line.trim_end());
90    }
91    // Handle trailing newline: lines() strips final newline, re-add if present
92    if text.ends_with('\n') {
93        intermediate.push('\n');
94    }
95
96    // Second pass: normalize Unicode characters to ASCII equivalents
97    let mut result = String::with_capacity(intermediate.len());
98    for ch in intermediate.chars() {
99        match ch {
100            '\u{2018}' | '\u{2019}' | '\u{201A}' | '\u{201B}' => result.push('\''),
101            '\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}' => result.push('"'),
102            '\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}' | '\u{2015}'
103            | '\u{2212}' => {
104                result.push('-');
105            }
106            '\u{00A0}' | '\u{2002}' | '\u{2003}' | '\u{2004}' | '\u{2005}' | '\u{2006}'
107            | '\u{2007}' | '\u{2008}' | '\u{2009}' | '\u{200A}' | '\u{202F}' | '\u{205F}'
108            | '\u{3000}' => {
109                result.push(' ');
110            }
111            other => result.push(other),
112        }
113    }
114
115    result
116}
117
118// ── Input normalization ──────────────────────────────────────────
119
120/// Normalize tool arguments: handle `edits` as JSON string, legacy `oldText`/`newText`.
121fn prepare_edit_arguments(args: &serde_json::Value) -> Result<(String, Vec<Edit>), String> {
122    let path = args["path"]
123        .as_str()
124        .ok_or_else(|| "Missing 'path' argument".to_string())?;
125
126    let edits = if let Some(edits_val) = args.get("edits") {
127        if let Some(s) = edits_val.as_str() {
128            // Some models send edits as a JSON string instead of an array
129            serde_json::from_str::<Vec<Edit>>(s)
130                .map_err(|e| format!("Invalid edits JSON string: {}", e))?
131        } else {
132            serde_json::from_value::<Vec<Edit>>(edits_val.clone())
133                .map_err(|e| format!("Invalid edits array: {}", e))?
134        }
135    } else if let (Some(old), Some(new)) = (args.get("oldText"), args.get("newText")) {
136        // Legacy: oldText + newText at top level
137        let old_text = old
138            .as_str()
139            .ok_or_else(|| "Invalid 'oldText' argument: expected string".to_string())?;
140        let new_text = new
141            .as_str()
142            .ok_or_else(|| "Invalid 'newText' argument: expected string".to_string())?;
143        vec![Edit {
144            old_text: old_text.to_string(),
145            new_text: new_text.to_string(),
146        }]
147    } else {
148        return Err("Missing 'edits' array (or 'oldText'/'newText' for legacy format)".to_string());
149    };
150
151    if edits.is_empty() {
152        return Err("At least one edit is required".to_string());
153    }
154
155    Ok((path.to_string(), edits))
156}
157
158// ── Diff computation ─────────────────────────────────────────────
159
160/// Compute a simple unified diff between original and modified content.
161fn compute_diff(original: &str, modified: &str, path: &str) -> String {
162    let orig_lines: Vec<&str> = original.lines().collect();
163    let mod_lines: Vec<&str> = modified.lines().collect();
164
165    let mut diff = String::new();
166    diff.push_str("--- a/");
167    diff.push_str(path);
168    diff.push('\n');
169    diff.push_str("+++ b/");
170    diff.push_str(path);
171    diff.push('\n');
172
173    let mut i = 0;
174    let mut j = 0;
175    let mut hunk: Vec<(char, &str)> = Vec::new();
176    let mut hunk_start_orig = 0;
177    let mut hunk_start_mod = 0;
178
179    while i < orig_lines.len() || j < mod_lines.len() {
180        let same = i < orig_lines.len() && j < mod_lines.len() && orig_lines[i] == mod_lines[j];
181
182        if same {
183            if !hunk.is_empty() && hunk.len() >= 3 {
184                // Emit context line within hunk
185                hunk.push((' ', orig_lines[i]));
186            } else {
187                // Flush current hunk
188                if !hunk.is_empty() {
189                    flush_hunk(&mut diff, &mut hunk, hunk_start_orig, hunk_start_mod);
190                }
191                hunk_start_orig = i + 1;
192                hunk_start_mod = j + 1;
193            }
194            i += 1;
195            j += 1;
196        } else {
197            if hunk.is_empty() {
198                hunk_start_orig = i;
199                hunk_start_mod = j;
200            }
201            if i < orig_lines.len() {
202                hunk.push(('-', orig_lines[i]));
203                i += 1;
204            }
205            if j < mod_lines.len() {
206                hunk.push(('+', mod_lines[j]));
207                j += 1;
208            }
209        }
210    }
211
212    if !hunk.is_empty() {
213        flush_hunk(&mut diff, &mut hunk, hunk_start_orig, hunk_start_mod);
214    }
215
216    diff
217}
218
219fn flush_hunk(
220    diff: &mut String,
221    hunk: &mut Vec<(char, &str)>,
222    orig_start: usize,
223    mod_start: usize,
224) {
225    let orig_count = hunk.iter().filter(|(c, _)| *c == '-' || *c == ' ').count();
226    let mod_count = hunk.iter().filter(|(c, _)| *c == '+' || *c == ' ').count();
227    use std::fmt::Write;
228    let _ = writeln!(
229        diff,
230        "@@ -{},{} +{},{} @@",
231        orig_start + 1,
232        orig_count,
233        mod_start + 1,
234        mod_count
235    );
236    for (c, line) in hunk.drain(..) {
237        let _ = writeln!(diff, "{}{}", c, line);
238    }
239}
240
241// ── AgentTool implementation ─────────────────────────────────────
242
243#[async_trait]
244impl AgentTool for EditTool {
245    fn name(&self) -> &str {
246        "edit"
247    }
248
249    fn description(&self) -> &str {
250        "Edit a single file using exact text replacement. Every edits[].oldText must match a \
251         unique, non-overlapping region of the original file. If two changes affect the same \
252         block or nearby lines, merge them into one edit instead of emitting overlapping edits. \
253         Do not include large unchanged regions just to connect distant changes."
254    }
255
256    fn parameters(&self) -> serde_json::Value {
257        serde_json::json!({
258            "type": "object",
259            "required": ["path", "edits"],
260            "properties": {
261                "path": {
262                    "type": "string",
263                    "description": "Path to the file to edit (relative or absolute)"
264                },
265                "edits": {
266                    "type": "array",
267                    "description": "One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.",
268                    "items": {
269                        "type": "object",
270                        "required": ["oldText", "newText"],
271                        "properties": {
272                            "oldText": {
273                                "type": "string",
274                                "description": "Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call."
275                            },
276                            "newText": {
277                                "type": "string",
278                                "description": "Replacement text for this targeted edit."
279                            }
280                        }
281                    }
282                }
283            }
284        })
285    }
286
287    fn prompt_guidelines(&self) -> Vec<String> {
288        vec![
289            "Use edit for precise changes (edits[].oldText must match exactly)".into(),
290            "When changing multiple separate locations in one file, use one edit call with multiple entries in edits[] instead of multiple edit calls".into(),
291            "Each edits[].oldText is matched against the original file, not after earlier edits are applied. Do not emit overlapping or nested edits. Merge nearby changes into one edit.".into(),
292            "Keep edits[].oldText as small as possible while still being unique in the file. Do not pad with large unchanged regions.".into(),
293        ]
294    }
295
296    fn label(&self) -> &str {
297        "Make precise file edits with exact text replacement, including multiple disjoint edits in one call"
298    }
299
300    fn renderer(&self) -> Option<Box<dyn ToolRenderer>> {
301        Some(Box::new(EditRenderer))
302    }
303
304    async fn execute(
305        &self,
306        tool_call_id: String,
307        args: serde_json::Value,
308        cancel: Cancel,
309        _on_update: Option<UnboundedSender<ToolOutput>>,
310    ) -> anyhow::Result<ToolOutput> {
311        let _ = tool_call_id;
312        let (path_str, edits) =
313            prepare_edit_arguments(&args).map_err(|e| anyhow::anyhow!("{}", e))?;
314
315        cancel.check()?;
316
317        let cwd = self.cwd.clone();
318        let path_for_queue = path_str.clone();
319        let cwd_for_closure = cwd.clone();
320
321        // Wrap the entire read-edit-write in a per-file mutation queue so
322        // concurrent edits to the same file are serialized (pi-style).
323        let output = crate::builtin::file_mutation_queue::with_file_mutation_queue(
324            &path_for_queue,
325            &cwd,
326            || async move {
327                let abs_path = {
328                    let p = std::path::Path::new(&path_str);
329                    if p.is_absolute() {
330                        p.to_path_buf()
331                    } else {
332                        cwd_for_closure.join(p)
333                    }
334                };
335
336                // Read file
337                let raw_content = std::fs::read_to_string(&abs_path)
338                    .with_context(|| format!("Failed to read {}", abs_path.display()))?;
339
340                // ── 1. BOM handling ──
341                let (bom, content) = strip_bom(&raw_content);
342
343                // ── 2. Line ending handling ──
344                let original_ending = detect_line_ending(content);
345                let normalized = normalize_to_lf(content);
346
347                // ── 3. Work in fuzzy-normalized space ──
348                let work_content = normalize_for_fuzzy_match(&normalized);
349
350                // ── 4. Validate and find each edit ──
351                let mut matched_indices: Vec<(usize, usize)> = Vec::new();
352
353                for (i, edit) in edits.iter().enumerate() {
354                    if edit.old_text.is_empty() {
355                        return if edits.len() == 1 {
356                            Err(anyhow::anyhow!("oldText must not be empty in {}.", path_str))
357                        } else {
358                            Err(anyhow::anyhow!(
359                                "edits[{}].oldText must not be empty in {}.",
360                                i,
361                                path_str
362                            ))
363                        };
364                    }
365
366                    let fuzzy_old = normalize_for_fuzzy_match(&edit.old_text);
367                    let count = work_content.matches(&fuzzy_old).count();
368
369                    if count == 0 {
370                        return if edits.len() == 1 {
371                            Err(anyhow::anyhow!(
372                                "Could not find the exact text in {}. \
373                                 The old text must match exactly including all whitespace and newlines.",
374                                path_str
375                            ))
376                        } else {
377                            Err(anyhow::anyhow!(
378                                "Could not find edits[{}] in {}. \
379                                 The oldText must match exactly including all whitespace and newlines.",
380                                i,
381                                path_str
382                            ))
383                        };
384                    }
385
386                    if count > 1 {
387                        return if edits.len() == 1 {
388                            Err(anyhow::anyhow!(
389                                "Found {} occurrences of the text in {}. \
390                                 The text must be unique. Please provide more context to make it unique.",
391                                count,
392                                path_str
393                            ))
394                        } else {
395                            Err(anyhow::anyhow!(
396                                "Found {} occurrences of edits[{}] in {}. \
397                                 Each oldText must be unique. Please provide more context to make it unique.",
398                                count,
399                                i,
400                                path_str
401                            ))
402                        };
403                    }
404
405                    let pos = work_content.find(&fuzzy_old).unwrap();
406                    matched_indices.push((pos, pos + fuzzy_old.len()));
407                }
408
409                // ── 5. Check for overlapping edits ──
410                for (idx_i, &(pos_i, end_i)) in matched_indices.iter().enumerate() {
411                    for (idx_j, &(pos_j, end_j)) in matched_indices.iter().enumerate().skip(idx_i + 1) {
412                        if pos_i < end_j && pos_j < end_i {
413                            return Err(anyhow::anyhow!(
414                                "edits[{}] and edits[{}] overlap. Merge them into one edit.",
415                                idx_i,
416                                idx_j
417                            ));
418                        }
419                    }
420                }
421
422                // ── 6. Apply edits (sorted left-to-right) ──
423                let mut sorted: Vec<(usize, usize, &Edit)> = matched_indices
424                    .into_iter()
425                    .zip(edits.iter())
426                    .map(|((start, end), edit)| (start, end, edit))
427                    .collect();
428                sorted.sort_by_key(|(pos, _, _)| *pos);
429
430                let mut modified = String::new();
431                let mut cursor = 0;
432                for (start, end, edit) in &sorted {
433                    modified.push_str(&work_content[cursor..*start]);
434                    modified.push_str(&edit.new_text);
435                    cursor = *end;
436                }
437                modified.push_str(&work_content[cursor..]);
438
439                // ── 7. Compute diff ──
440                let diff = compute_diff(&normalized, &modified, &path_str);
441
442                // ── 8. Write back with original line endings and BOM ──
443                let final_content =
444                    bom.to_string() + &restore_line_endings(&modified, original_ending);
445                std::fs::write(&abs_path, &final_content)
446                    .with_context(|| format!("Failed to write {}", abs_path.display()))?;
447
448                // ── 9. Return result ──
449                let noun = if edits.len() == 1 { "block" } else { "blocks" };
450                Ok(format!(
451                    "Successfully replaced {} {} in {}.\n```diff\n{}```",
452                    edits.len(),
453                    noun,
454                    path_str,
455                    diff.trim_end()
456                ))
457            },
458        )
459        .await?;
460
461        Ok(ToolOutput::ok(output))
462    }
463}
464
465/// Tool renderer for the `edit` tool.
466/// Uses `renderShell: "self"` — renders its own framing without colored box.
467/// Shows a preview of what will change in the call header.
468struct EditRenderer;
469
470impl ToolRenderer for EditRenderer {
471    fn render_self(&self) -> bool {
472        true
473    }
474
475    fn render_call(
476        &self,
477        args: &serde_json::Value,
478        width: usize,
479        theme: &dyn Theme,
480        ctx: &ToolRenderContext,
481    ) -> Vec<String> {
482        let path = args
483            .get("file_path")
484            .or_else(|| args.get("path"))
485            .and_then(|v| v.as_str())
486            .unwrap_or("");
487        let short = if let Ok(home) = std::env::var("HOME") {
488            path.replacen(&home, "~", 1)
489        } else {
490            path.to_string()
491        };
492        let path_disp = if short.is_empty() {
493            String::new()
494        } else {
495            theme.fg("accent", &short)
496        };
497
498        let mut lines = vec![format!(
499            "{} {}",
500            theme.fg("toolTitle", &theme.bold("edit")),
501            path_disp
502        )];
503
504        // Show edit preview when collapsed (compact summary of changes)
505        if !ctx.expanded
506            && let Some(edits) = args.get("edits")
507        {
508            let edits_arr = if let Some(arr) = edits.as_array() {
509                arr.as_slice()
510            } else {
511                static EMPTY: [serde_json::Value; 0] = [];
512                &EMPTY // Can't parse here, skip preview
513            };
514
515            for edit in edits_arr.iter().take(3) {
516                if let (Some(old), new) = (edit.get("oldText"), edit.get("newText"))
517                    && let (Some(old_str), Some(new_str)) =
518                        (old.as_str(), new.and_then(|v| v.as_str()))
519                {
520                    let preview = format_edit_preview(old_str, new_str, width, theme);
521                    lines.extend(preview);
522                }
523            }
524
525            if edits_arr.len() > 3 {
526                lines.push(theme.fg(
527                    "muted",
528                    &format!("... and {} more edits", edits_arr.len() - 3),
529                ));
530            }
531        }
532
533        lines
534    }
535
536    fn render_result(
537        &self,
538        content: &str,
539        _width: usize,
540        theme: &dyn Theme,
541        _ctx: &ToolRenderContext,
542    ) -> Vec<String> {
543        // Extract diff from ```diff ... ``` block in the result
544        if let Some(start) = content.find("```diff\n") {
545            let after = &content[start + 8..];
546            if let Some(end) = after.find("```") {
547                let diff_text = &after[..end];
548                let has_diff = diff_text
549                    .lines()
550                    .any(|l| l.starts_with('-') || l.starts_with('+') || l.starts_with(' '));
551                if has_diff {
552                    let rendered = crate::tui::components::diff::render_diff(diff_text);
553                    return rendered;
554                }
555            }
556        }
557        // Fallback: show content as-is
558        if content.is_empty() {
559            return vec![];
560        }
561        vec![theme.fg("toolOutput", content)]
562    }
563}
564
565/// Format a compact preview of a single edit operation.
566/// Shows first N chars of oldText → first N chars of newText as separate lines.
567fn format_edit_preview(old: &str, new: &str, _width: usize, theme: &dyn Theme) -> Vec<String> {
568    let max_preview = 30;
569    let old_first_line = old.lines().next().unwrap_or("");
570    let new_first_line = new.lines().next().unwrap_or("");
571
572    let old_preview = truncate_simple(old_first_line, max_preview);
573    let new_preview = truncate_simple(new_first_line, max_preview);
574
575    let old_styled = theme.fg("toolDiffRemoved", &format!("-{}", old_preview));
576    let new_styled = theme.fg("toolDiffAdded", &format!("+{}", new_preview));
577    vec![format!("  {}", old_styled), format!("  {}", new_styled)]
578}
579
580/// Truncate a string to max_chars, adding "..." if truncated.
581fn truncate_simple(s: &str, max_chars: usize) -> String {
582    if s.len() <= max_chars {
583        s.to_string()
584    } else if max_chars > 3 {
585        format!("{}...", &s[..max_chars - 3])
586    } else {
587        s[..max_chars].to_string()
588    }
589}
590
591#[cfg(test)]
592mod tests {
593    use super::*;
594    use crate::agent::extension::Cancel;
595
596    fn tmp_dir() -> std::path::PathBuf {
597        let d = std::env::temp_dir().join(format!("rab-edit-test-{}", uuid::Uuid::new_v4()));
598        std::fs::create_dir_all(&d).unwrap();
599        d
600    }
601
602    fn make_tool() -> (EditTool, std::path::PathBuf) {
603        let tmp = tmp_dir();
604        let tool = EditTool { cwd: tmp.clone() };
605        (tool, tmp)
606    }
607
608    async fn exec_ok(tool: &EditTool, args: serde_json::Value) -> String {
609        tool.execute("id".into(), args, Cancel::new(), None)
610            .await
611            .unwrap()
612            .content
613    }
614
615    async fn exec_err(tool: &EditTool, args: serde_json::Value) -> String {
616        tool.execute("id".into(), args, Cancel::new(), None)
617            .await
618            .unwrap_err()
619            .to_string()
620    }
621
622    async fn is_err(tool: &EditTool, args: serde_json::Value) -> bool {
623        tool.execute("id".into(), args, Cancel::new(), None)
624            .await
625            .is_err()
626    }
627
628    #[tokio::test]
629    async fn single_edit_replaces_text() {
630        let (tool, tmp) = make_tool();
631        let path = tmp.join("file.txt");
632        std::fs::write(&path, "hello world\nfoo bar\n").unwrap();
633
634        exec_ok(
635            &tool,
636            serde_json::json!({
637                "path": path.to_str().unwrap(),
638                "edits": [{"oldText": "foo bar", "newText": "baz qux"}]
639            }),
640        )
641        .await;
642
643        assert_eq!(
644            std::fs::read_to_string(&path).unwrap(),
645            "hello world\nbaz qux\n"
646        );
647    }
648
649    #[tokio::test]
650    async fn multiple_edits_replaces_all() {
651        let (tool, tmp) = make_tool();
652        let path = tmp.join("file.txt");
653        std::fs::write(&path, "aaa\nbbb\nccc\n").unwrap();
654
655        exec_ok(
656            &tool,
657            serde_json::json!({
658                "path": path.to_str().unwrap(),
659                "edits": [
660                    {"oldText": "aaa", "newText": "111"},
661                    {"oldText": "ccc", "newText": "333"}
662                ]
663            }),
664        )
665        .await;
666
667        assert_eq!(std::fs::read_to_string(&path).unwrap(), "111\nbbb\n333\n");
668    }
669
670    #[tokio::test]
671    async fn non_unique_oldtext_errors() {
672        let (tool, tmp) = make_tool();
673        let path = tmp.join("file.txt");
674        std::fs::write(&path, "dup\ndup\n").unwrap();
675
676        assert!(
677            is_err(
678                &tool,
679                serde_json::json!({
680                    "path": path.to_str().unwrap(),
681                    "edits": [{"oldText": "dup", "newText": "x"}]
682                }),
683            )
684            .await
685        );
686    }
687
688    #[tokio::test]
689    async fn missing_oldtext_errors() {
690        let (tool, tmp) = make_tool();
691        let path = tmp.join("file.txt");
692        std::fs::write(&path, "content\n").unwrap();
693
694        let err = exec_err(
695            &tool,
696            serde_json::json!({
697                "path": path.to_str().unwrap(),
698                "edits": [{"oldText": "not found", "newText": "x"}]
699            }),
700        )
701        .await;
702        assert!(err.contains("Could not find"));
703    }
704
705    #[tokio::test]
706    async fn overlapping_edits_error() {
707        let (tool, tmp) = make_tool();
708        let path = tmp.join("file.txt");
709        std::fs::write(&path, "abcdef\n").unwrap();
710
711        assert!(
712            is_err(
713                &tool,
714                serde_json::json!({
715                    "path": path.to_str().unwrap(),
716                    "edits": [
717                        {"oldText": "abc", "newText": "1"},
718                        {"oldText": "bcd", "newText": "2"}
719                    ]
720                }),
721            )
722            .await
723        );
724    }
725
726    #[tokio::test]
727    async fn empty_edits_errors() {
728        let (tool, tmp) = make_tool();
729        let path = tmp.join("file.txt");
730        std::fs::write(&path, "content\n").unwrap();
731
732        assert!(
733            is_err(
734                &tool,
735                serde_json::json!({"path": path.to_str().unwrap(), "edits": []}),
736            )
737            .await
738        );
739    }
740
741    // ── BOM handling ─────────────────────────────────────────
742
743    #[tokio::test]
744    async fn handles_bom() {
745        let (tool, tmp) = make_tool();
746        let path = tmp.join("bom.txt");
747        std::fs::write(&path, "\u{FEFF}hello world\n").unwrap();
748
749        exec_ok(
750            &tool,
751            serde_json::json!({
752                "path": path.to_str().unwrap(),
753                "edits": [{"oldText": "hello world", "newText": "goodbye"}]
754            }),
755        )
756        .await;
757
758        let content = std::fs::read_to_string(&path).unwrap();
759        assert!(content.starts_with('\u{FEFF}'));
760        assert!(content.contains("goodbye"));
761    }
762
763    #[tokio::test]
764    async fn preserves_bom_when_no_edit_at_start() {
765        let (tool, tmp) = make_tool();
766        let path = tmp.join("bom2.txt");
767        std::fs::write(&path, "\u{FEFF}line1\nline2\n").unwrap();
768
769        exec_ok(
770            &tool,
771            serde_json::json!({
772                "path": path.to_str().unwrap(),
773                "edits": [{"oldText": "line2", "newText": "modified"}]
774            }),
775        )
776        .await;
777
778        let content = std::fs::read_to_string(&path).unwrap();
779        assert!(content.starts_with('\u{FEFF}'));
780        assert!(content.contains("modified"));
781    }
782
783    // ── CRLF handling ────────────────────────────────────────
784
785    #[tokio::test]
786    async fn preserves_crlf() {
787        let (tool, tmp) = make_tool();
788        let path = tmp.join("crlf.txt");
789        std::fs::write(&path, "hello\r\nworld\r\n").unwrap();
790
791        exec_ok(
792            &tool,
793            serde_json::json!({
794                "path": path.to_str().unwrap(),
795                "edits": [{"oldText": "world", "newText": "universe"}]
796            }),
797        )
798        .await;
799
800        let content = std::fs::read_to_string(&path).unwrap();
801        assert_eq!(content, "hello\r\nuniverse\r\n");
802    }
803
804    #[tokio::test]
805    async fn handles_mixed_line_endings() {
806        let (tool, tmp) = make_tool();
807        let path = tmp.join("mixed.txt");
808        std::fs::write(&path, "line1\r\nline2\nline3\n").unwrap();
809
810        exec_ok(
811            &tool,
812            serde_json::json!({
813                "path": path.to_str().unwrap(),
814                "edits": [{"oldText": "line2", "newText": "modified"}]
815            }),
816        )
817        .await;
818
819        let content = std::fs::read_to_string(&path).unwrap();
820        assert_eq!(content, "line1\r\nmodified\r\nline3\r\n");
821    }
822
823    #[tokio::test]
824    async fn lf_only_stays_lf() {
825        let (tool, tmp) = make_tool();
826        let path = tmp.join("lf.txt");
827        std::fs::write(&path, "hello\nworld\n").unwrap();
828
829        exec_ok(
830            &tool,
831            serde_json::json!({
832                "path": path.to_str().unwrap(),
833                "edits": [{"oldText": "world", "newText": "universe"}]
834            }),
835        )
836        .await;
837
838        let content = std::fs::read_to_string(&path).unwrap();
839        assert_eq!(content, "hello\nuniverse\n");
840    }
841
842    // ── Fuzzy matching ───────────────────────────────────────
843
844    #[tokio::test]
845    async fn fuzzy_match_trailing_whitespace() {
846        let (tool, tmp) = make_tool();
847        let path = tmp.join("trailing.txt");
848        std::fs::write(&path, "hello world  \nnext line\n").unwrap();
849
850        exec_ok(
851            &tool,
852            serde_json::json!({
853                "path": path.to_str().unwrap(),
854                "edits": [{"oldText": "hello world", "newText": "hi there"}]
855            }),
856        )
857        .await;
858
859        let content = std::fs::read_to_string(&path).unwrap();
860        assert_eq!(content, "hi there\nnext line\n");
861    }
862
863    #[tokio::test]
864    async fn fuzzy_match_smart_quotes() {
865        let (tool, tmp) = make_tool();
866        let path = tmp.join("quotes.txt");
867        std::fs::write(&path, "he said \u{201C}hello\u{201D}\n").unwrap();
868
869        exec_ok(
870            &tool,
871            serde_json::json!({
872                "path": path.to_str().unwrap(),
873                "edits": [{"oldText": "he said \"hello\"", "newText": "she said \"hi\""}]
874            }),
875        )
876        .await;
877
878        let content = std::fs::read_to_string(&path).unwrap();
879        assert_eq!(content, "she said \"hi\"\n");
880    }
881
882    #[tokio::test]
883    async fn fuzzy_match_dashes() {
884        let (tool, tmp) = make_tool();
885        let path = tmp.join("dashes.txt");
886        std::fs::write(&path, "foo \u{2014} bar\n").unwrap();
887
888        exec_ok(
889            &tool,
890            serde_json::json!({
891                "path": path.to_str().unwrap(),
892                "edits": [{"oldText": "foo - bar", "newText": "baz"}]
893            }),
894        )
895        .await;
896
897        let content = std::fs::read_to_string(&path).unwrap();
898        assert_eq!(content, "baz\n");
899    }
900
901    // ── Input normalization ──────────────────────────────────
902
903    #[tokio::test]
904    async fn legacy_oldtext_newtext() {
905        let (tool, tmp) = make_tool();
906        let path = tmp.join("legacy.txt");
907        std::fs::write(&path, "hello world\n").unwrap();
908
909        exec_ok(
910            &tool,
911            serde_json::json!({
912                "path": path.to_str().unwrap(),
913                "oldText": "hello world",
914                "newText": "goodbye"
915            }),
916        )
917        .await;
918
919        assert_eq!(std::fs::read_to_string(&path).unwrap(), "goodbye\n");
920    }
921
922    #[tokio::test]
923    async fn edits_as_json_string() {
924        let (tool, tmp) = make_tool();
925        let path = tmp.join("jsonstr.txt");
926        std::fs::write(&path, "aaa\nbbb\n").unwrap();
927
928        exec_ok(
929            &tool,
930            serde_json::json!({
931                "path": path.to_str().unwrap(),
932                "edits": r#"[{"oldText": "bbb", "newText": "xxx"}]"#
933            }),
934        )
935        .await;
936
937        assert_eq!(std::fs::read_to_string(&path).unwrap(), "aaa\nxxx\n");
938    }
939
940    // ── Diff output ──────────────────────────────────────────
941
942    #[tokio::test]
943    async fn result_contains_diff() {
944        let (tool, tmp) = make_tool();
945        let path = tmp.join("diff_test.txt");
946        std::fs::write(&path, "aaa\nbbb\nccc\n").unwrap();
947
948        let result = exec_ok(
949            &tool,
950            serde_json::json!({
951                "path": path.to_str().unwrap(),
952                "edits": [{"oldText": "bbb", "newText": "xxx"}]
953            }),
954        )
955        .await;
956
957        assert!(result.contains("```diff"));
958        assert!(result.contains("-bbb"));
959        assert!(result.contains("+xxx"));
960        assert!(result.contains("Successfully replaced 1 block"));
961    }
962
963    // ── Empty oldText ────────────────────────────────────────
964
965    #[tokio::test]
966    async fn empty_oldtext_errors() {
967        let (tool, tmp) = make_tool();
968        let path = tmp.join("empty.txt");
969        std::fs::write(&path, "content\n").unwrap();
970
971        let err = exec_err(
972            &tool,
973            serde_json::json!({
974                "path": path.to_str().unwrap(),
975                "edits": [{"oldText": "", "newText": "x"}]
976            }),
977        )
978        .await;
979        assert!(err.contains("empty"));
980    }
981
982    // ── Relative paths ───────────────────────────────────────
983
984    #[tokio::test]
985    async fn relative_path_resolves_to_cwd() {
986        let (tool, tmp) = make_tool();
987        let path = tmp.join("relative.txt");
988        std::fs::write(&path, "hello\n").unwrap();
989
990        exec_ok(
991            &tool,
992            serde_json::json!({
993                "path": "relative.txt",
994                "edits": [{"oldText": "hello", "newText": "hi"}]
995            }),
996        )
997        .await;
998
999        assert_eq!(std::fs::read_to_string(&path).unwrap(), "hi\n");
1000    }
1001}
1002
1003#[cfg(test)]
1004mod fuzzy_tests {
1005    use super::*;
1006
1007    #[test]
1008    fn test_strip_trailing_whitespace() {
1009        assert_eq!(
1010            normalize_for_fuzzy_match("hello   \nworld  "),
1011            "hello\nworld"
1012        );
1013    }
1014
1015    #[test]
1016    fn test_smart_quotes() {
1017        assert_eq!(
1018            normalize_for_fuzzy_match("\u{2018}hello\u{2019} \u{201C}world\u{201D}"),
1019            "'hello' \"world\""
1020        );
1021    }
1022
1023    #[test]
1024    fn test_dashes() {
1025        assert_eq!(normalize_for_fuzzy_match("a\u{2014}b"), "a-b");
1026        assert_eq!(normalize_for_fuzzy_match("a\u{2013}b"), "a-b");
1027    }
1028
1029    #[test]
1030    fn test_nbsp() {
1031        assert_eq!(normalize_for_fuzzy_match("a\u{00A0}b"), "a b");
1032    }
1033
1034    #[test]
1035    fn test_preserves_trailing_newline() {
1036        assert_eq!(normalize_for_fuzzy_match("hello\n"), "hello\n");
1037        assert_eq!(
1038            normalize_for_fuzzy_match("hello\nworld\n"),
1039            "hello\nworld\n"
1040        );
1041    }
1042}
1043
1044#[cfg(test)]
1045mod diff_tests {
1046    use super::*;
1047
1048    #[test]
1049    fn test_simple_diff() {
1050        let orig = "aaa\nbbb\nccc\n";
1051        let modified = "aaa\nxxx\nccc\n";
1052        let diff = compute_diff(orig, modified, "test.txt");
1053        assert!(diff.contains("--- a/test.txt"));
1054        assert!(diff.contains("+++ b/test.txt"));
1055        assert!(diff.contains("-bbb"));
1056        assert!(diff.contains("+xxx"));
1057    }
1058
1059    #[test]
1060    fn test_no_changes() {
1061        let text = "hello\nworld\n";
1062        let diff = compute_diff(text, text, "f.txt");
1063        assert!(diff.contains("--- a/f.txt"));
1064        assert!(diff.contains("+++ b/f.txt"));
1065        assert!(!diff.contains("@@"));
1066    }
1067
1068    #[test]
1069    fn test_multiple_hunks() {
1070        let orig = "a\nb\nc\nd\ne\nf\ng\nh\n";
1071        let modified = "a\nX\nc\nd\ne\nY\ng\nh\n";
1072        let diff = compute_diff(orig, modified, "f.txt");
1073        assert!(diff.contains("-b"));
1074        assert!(diff.contains("+X"));
1075        assert!(diff.contains("-f"));
1076        assert!(diff.contains("+Y"));
1077    }
1078}