Skip to main content

rab/builtin/
edit.rs

1use crate::agent::extension::{Extension, ToolDefinition};
2use crate::agent::extension::{ToolRenderContext, ToolRenderer};
3use crate::tui::Theme;
4use crate::tui::ThemeKey;
5use async_trait::async_trait;
6use std::borrow::Cow;
7use std::path::{Path, PathBuf};
8use std::sync::{Arc, Mutex};
9use unicode_normalization::UnicodeNormalization;
10
11// ── EditOperations (pluggable) ─────────────────────────────────────
12
13/// Pluggable operations for the edit tool (matching pi's EditOperations).
14/// Override these to delegate file editing to remote systems (for example SSH).
15#[async_trait]
16pub trait EditOperations: Send + Sync {
17    /// Read file contents as a String.
18    async fn read_file(&self, absolute_path: &Path) -> anyhow::Result<String>;
19    /// Write content to a file.
20    async fn write_file(&self, absolute_path: &Path, content: &str) -> anyhow::Result<()>;
21    /// Check if file is readable and writable (throw if not).
22    async fn access(&self, absolute_path: &Path) -> anyhow::Result<()>;
23}
24
25struct DefaultEditOperations;
26
27#[async_trait]
28impl EditOperations for DefaultEditOperations {
29    async fn read_file(&self, absolute_path: &Path) -> anyhow::Result<String> {
30        Ok(std::fs::read_to_string(absolute_path)?)
31    }
32
33    async fn write_file(&self, absolute_path: &Path, content: &str) -> anyhow::Result<()> {
34        Ok(std::fs::write(absolute_path, content)?)
35    }
36
37    async fn access(&self, absolute_path: &Path) -> anyhow::Result<()> {
38        if !absolute_path.exists() {
39            anyhow::bail!("File not found: {}", absolute_path.display());
40        }
41        if !absolute_path.is_file() {
42            anyhow::bail!("Not a file: {}", absolute_path.display());
43        }
44        Ok(())
45    }
46}
47
48pub struct EditExtension {
49    cwd: PathBuf,
50    operations: Arc<dyn EditOperations>,
51}
52
53impl EditExtension {
54    pub fn new(cwd: PathBuf) -> Self {
55        Self {
56            cwd,
57            operations: Arc::new(DefaultEditOperations),
58        }
59    }
60
61    /// Set custom edit operations (e.g. for SSH targets).
62    pub fn with_operations(mut self, operations: Arc<dyn EditOperations>) -> Self {
63        self.operations = operations;
64        self
65    }
66}
67
68impl Extension for EditExtension {
69    fn name(&self) -> Cow<'static, str> {
70        "edit".into()
71    }
72
73    fn as_any(&self) -> &dyn std::any::Any {
74        self
75    }
76
77    fn tools(&self) -> Vec<ToolDefinition> {
78        vec![ToolDefinition {
79            tool: Box::new(EditTool {
80                cwd: self.cwd.clone(),
81                operations: self.operations.clone(),
82            }),
83            snippet: "Make precise file edits with exact text replacement, including multiple disjoint edits in one call",
84            guidelines: &[
85                "Use edit for precise changes (edits[].oldText must match exactly)",
86                "When changing multiple separate locations in one file, use one edit call with multiple entries in edits[] instead of multiple edit calls",
87                "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.",
88                "Keep edits[].oldText as small as possible while still being unique in the file. Do not pad with large unchanged regions.",
89            ],
90            prepare_arguments: Some(prepare_edit_args),
91            before_tool_call: None,
92            after_tool_call: None,
93            renderer: Some(std::sync::Arc::new(EditRenderer::new())),
94        }]
95    }
96}
97
98struct EditTool {
99    cwd: PathBuf,
100    operations: Arc<dyn EditOperations>,
101}
102
103#[derive(serde::Deserialize, Clone)]
104#[serde(rename_all = "camelCase")]
105struct Edit {
106    old_text: String,
107    new_text: String,
108}
109
110// ── BOM handling ──────────────────────────────────────────────────
111
112/// Strip UTF-8 BOM if present. Returns (bom, content_without_bom).
113fn strip_bom(content: &str) -> (&str, &str) {
114    if content.starts_with('\u{FEFF}') {
115        ("\u{FEFF}", &content['\u{FEFF}'.len_utf8()..])
116    } else {
117        ("", content)
118    }
119}
120
121// ── Line ending handling ─────────────────────────────────────────
122
123fn detect_line_ending(content: &str) -> &'static str {
124    if content.contains("\r\n") {
125        "\r\n"
126    } else {
127        "\n"
128    }
129}
130
131fn normalize_to_lf(content: &str) -> String {
132    content.replace("\r\n", "\n")
133}
134
135fn restore_line_endings(content: &str, ending: &str) -> String {
136    if ending == "\r\n" {
137        content.replace('\n', "\r\n")
138    } else {
139        content.to_string()
140    }
141}
142
143// ── Fuzzy matching ───────────────────────────────────────────────
144
145/// Normalize text for fuzzy matching (pi-compatible).
146/// Applies progressive transformations:
147/// - NFKC normalization (handles composed/decomposed Unicode)
148/// - Strip trailing whitespace from each line
149/// - Normalize Unicode smart quotes → ASCII quotes
150/// - Normalize Unicode dashes/hyphens → ASCII hyphen
151/// - Normalize special Unicode spaces → regular space
152fn normalize_for_fuzzy_match(text: &str) -> String {
153    // First: NFKC normalization (pi calls .normalize("NFKC"))
154    let nfkc = text.nfkc().collect::<String>();
155
156    // Second: strip trailing whitespace per line
157    let mut intermediate = String::with_capacity(nfkc.len());
158    for line in nfkc.lines() {
159        if !intermediate.is_empty() {
160            intermediate.push('\n');
161        }
162        intermediate.push_str(line.trim_end());
163    }
164    // Handle trailing newline: lines() strips final newline, re-add if present
165    if nfkc.ends_with('\n') {
166        intermediate.push('\n');
167    }
168
169    // Third: normalize Unicode characters to ASCII equivalents
170    let mut result = String::with_capacity(intermediate.len());
171    for ch in intermediate.chars() {
172        match ch {
173            '\u{2018}' | '\u{2019}' | '\u{201A}' | '\u{201B}' => result.push('\''),
174            '\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}' => result.push('"'),
175            '\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}' | '\u{2015}'
176            | '\u{2212}' => {
177                result.push('-');
178            }
179            '\u{00A0}' | '\u{2002}' | '\u{2003}' | '\u{2004}' | '\u{2005}' | '\u{2006}'
180            | '\u{2007}' | '\u{2008}' | '\u{2009}' | '\u{200A}' | '\u{202F}' | '\u{205F}'
181            | '\u{3000}' => {
182                result.push(' ');
183            }
184            other => result.push(other),
185        }
186    }
187
188    result
189}
190
191// ── Input normalization ──────────────────────────────────────────
192
193/// Normalize tool arguments: handle `edits` as JSON string, legacy `oldText`/`newText`.
194fn prepare_edit_arguments(args: &serde_json::Value) -> Result<(String, Vec<Edit>), String> {
195    let path = args["path"]
196        .as_str()
197        .ok_or_else(|| "Missing 'path' argument".to_string())?;
198
199    let edits = if let Some(edits_val) = args.get("edits") {
200        if let Some(s) = edits_val.as_str() {
201            serde_json::from_str::<Vec<Edit>>(s)
202                .map_err(|e| format!("Invalid edits JSON string: {}", e))?
203        } else {
204            serde_json::from_value::<Vec<Edit>>(edits_val.clone())
205                .map_err(|e| format!("Invalid edits array: {}", e))?
206        }
207    } else if let (Some(old), Some(new)) = (args.get("oldText"), args.get("newText")) {
208        let old_text = old
209            .as_str()
210            .ok_or_else(|| "Invalid 'oldText' argument: expected string".to_string())?;
211        let new_text = new
212            .as_str()
213            .ok_or_else(|| "Invalid 'newText' argument: expected string".to_string())?;
214        vec![Edit {
215            old_text: old_text.to_string(),
216            new_text: new_text.to_string(),
217        }]
218    } else if let (Some(old), Some(new)) = (args.get("old_text"), args.get("new_text")) {
219        let old_text = old
220            .as_str()
221            .ok_or_else(|| "Invalid 'old_text' argument: expected string".to_string())?;
222        let new_text = new
223            .as_str()
224            .ok_or_else(|| "Invalid 'new_text' argument: expected string".to_string())?;
225        vec![Edit {
226            old_text: old_text.to_string(),
227            new_text: new_text.to_string(),
228        }]
229    } else {
230        return Err("Missing 'edits' array (or 'oldText'/'newText' or 'old_text'/'new_text' for legacy format)".to_string());
231    };
232
233    if edits.is_empty() {
234        return Err("At least one edit is required".to_string());
235    }
236
237    Ok((path.to_string(), edits))
238}
239
240/// Normalize tool arguments before execution.
241/// Returns restructured JSON matching execute()'s expected format, or the
242/// original args on error (execute() will produce its own error message).
243pub fn prepare_edit_args(mut args: serde_json::Value) -> Result<serde_json::Value, String> {
244    let (path_str, edits) = prepare_edit_arguments(&args)?;
245
246    // Build the edits array in pi's camelCase format
247    let edits_array: Vec<serde_json::Value> = edits
248        .iter()
249        .map(|e| {
250            serde_json::json!({
251                "oldText": e.old_text,
252                "newText": e.new_text
253            })
254        })
255        .collect();
256
257    // Preserve all other fields (pi-compatible spread), only removing
258    // the legacy fields that were merged into edits.
259    if let Some(obj) = args.as_object_mut() {
260        obj.remove("oldText");
261        obj.remove("newText");
262        obj.remove("old_text");
263        obj.remove("new_text");
264        obj.insert("path".to_string(), serde_json::Value::String(path_str));
265        obj.insert("edits".to_string(), serde_json::Value::Array(edits_array));
266    }
267
268    Ok(args)
269}
270
271/// Normalize tool arguments before execution (test-only).
272#[allow(dead_code)]
273fn prepare_edit_tool_args(mut args: serde_json::Value) -> serde_json::Value {
274    let (path_str, edits) = match prepare_edit_arguments(&args) {
275        Ok(result) => result,
276        Err(_) => return args,
277    };
278
279    let edits_array: Vec<serde_json::Value> = edits
280        .iter()
281        .map(|e| {
282            serde_json::json!({
283                "oldText": e.old_text,
284                "newText": e.new_text
285            })
286        })
287        .collect();
288
289    if let Some(obj) = args.as_object_mut() {
290        obj.remove("oldText");
291        obj.remove("newText");
292        obj.remove("old_text");
293        obj.remove("new_text");
294        obj.insert("path".to_string(), serde_json::Value::String(path_str));
295        obj.insert("edits".to_string(), serde_json::Value::Array(edits_array));
296    }
297
298    args
299}
300
301// ── Line-span tracking for fuzzy mapping ────────────────────────
302
303/// A line span tracking the byte offsets of a line in the content.
304/// Matches pi's `LineSpan` struct.
305#[derive(Debug, Clone, Copy)]
306struct LineSpan {
307    start: usize,
308    end: usize,
309}
310
311/// Split content into lines, preserving each line's ending.
312/// Returns Vec<&str> where each element includes its line ending if present.
313fn split_lines_with_endings(content: &str) -> Vec<&str> {
314    let mut result = Vec::new();
315    let mut remaining = content;
316    while let Some(pos) = remaining.find('\n') {
317        result.push(&remaining[..=pos]);
318        remaining = &remaining[pos + 1..];
319    }
320    if !remaining.is_empty() {
321        result.push(remaining);
322    }
323    result
324}
325
326/// Get line spans for the content.
327fn get_line_spans(content: &str) -> Vec<LineSpan> {
328    let mut offset = 0;
329    split_lines_with_endings(content)
330        .iter()
331        .map(|line| {
332            let span = LineSpan {
333                start: offset,
334                end: offset + line.len(),
335            };
336            offset = span.end;
337            span
338        })
339        .collect()
340}
341
342/// Get the line range that a replacement touches.
343fn get_replacement_line_range(
344    lines: &[LineSpan],
345    match_index: usize,
346    match_length: usize,
347) -> (usize, usize) {
348    let replacement_end = match_index + match_length;
349
350    let mut start_line = 0;
351    for (i, line) in lines.iter().enumerate() {
352        if match_index >= line.start && match_index < line.end {
353            start_line = i;
354            break;
355        }
356    }
357
358    let mut end_line = start_line;
359    while end_line < lines.len() && lines[end_line].end < replacement_end {
360        end_line += 1;
361    }
362    if end_line >= lines.len() {
363        end_line = lines.len() - 1;
364    }
365
366    (start_line, end_line + 1)
367}
368
369/// Apply replacements to content (applied in reverse order to keep offsets stable).
370/// Each replacement is (matchIndex, matchLength, newText).
371fn apply_replacements(
372    content: &str,
373    replacements: &[(usize, usize, &str)],
374    offset: usize,
375) -> String {
376    let mut result = content.to_string();
377    for (start, length, new_text) in replacements.iter().rev() {
378        let adj_start = start - offset;
379        let adj_end = adj_start + length;
380        result.replace_range(adj_start..adj_end, new_text);
381    }
382    result
383}
384
385/// Map changes made in fuzzy-normalized space back to the original (LF-normalized)
386/// content, preserving the original bytes of unchanged lines (pi-compatible).
387///
388/// Uses line-span tracking and groups overlapping replacements, matching pi's
389/// `applyReplacementsPreservingUnchangedLines`.
390fn apply_replacements_preserving_unchanged_lines(
391    original_content: &str,
392    base_content: &str,
393    replacements: &[(usize, usize, &str)], // (matchIndex, matchLength, newText) sorted by matchIndex
394) -> String {
395    let original_lines = split_lines_with_endings(original_content);
396    let base_lines = get_line_spans(base_content);
397
398    if original_lines.len() != base_lines.len() {
399        // Line count mismatch — fall back to simple application
400        let mut result = base_content.to_string();
401        for (start, end, new_text) in replacements.iter().rev() {
402            result.replace_range(*start..*end, new_text);
403        }
404        return result;
405    }
406
407    // Build groups of overlapping replacements
408    struct Group {
409        start_line: usize,
410        end_line: usize,
411        replacements: Vec<(usize, usize, String)>, // (matchIndex, matchLength, newText)
412    }
413
414    let mut groups: Vec<Group> = Vec::new();
415    for &(start, end, new_text) in replacements {
416        let (sl, el) = get_replacement_line_range(&base_lines, start, end);
417        if let Some(last) = groups.last_mut()
418            && sl < last.end_line
419        {
420            last.end_line = last.end_line.max(el);
421            last.replacements.push((start, end, new_text.to_string()));
422            continue;
423        }
424        groups.push(Group {
425            start_line: sl,
426            end_line: el,
427            replacements: vec![(start, end, new_text.to_string())],
428        });
429    }
430
431    let mut original_line_index = 0;
432    let mut result = String::new();
433
434    for group in &groups {
435        // Copy unchanged original lines
436        result.push_str(&original_lines[original_line_index..group.start_line].concat());
437
438        // Apply replacements to the base content slice for this group
439        let group_start_offset = base_lines[group.start_line].start;
440        let group_end_offset = base_lines[group.end_line - 1].end;
441        let group_slice = &base_content[group_start_offset..group_end_offset];
442        let adjusted_replacements: Vec<(usize, usize, &str)> = group
443            .replacements
444            .iter()
445            .map(|(s, e, t)| (*s - group_start_offset, *e, t.as_str()))
446            .collect();
447        result.push_str(&apply_replacements(group_slice, &adjusted_replacements, 0));
448
449        original_line_index = group.end_line;
450    }
451
452    // Copy remaining original lines
453    result.push_str(&original_lines[original_line_index..].concat());
454
455    result
456}
457
458// ── Diff computation ─────────────────────────────────────────────
459
460/// Replace tabs with 3 spaces for consistent rendering.
461fn replace_tabs(text: &str) -> String {
462    text.replace('\t', "   ")
463}
464
465/// Compute a display-oriented diff string with line numbers and context.
466/// Produces pi-compatible format:
467/// `+{lineNum} {content}` / `-{lineNum} {content}` / ` {lineNum} {content}` / `  ...`
468/// With line numbers padded to the width of the max line number.
469fn compute_diff(original: &str, modified: &str, _path: &str) -> String {
470    let orig_lines: Vec<&str> = original.lines().collect();
471    let mod_lines: Vec<&str> = modified.lines().collect();
472
473    let max_line_num = orig_lines.len().max(mod_lines.len());
474    let line_num_width = max_line_num.to_string().len();
475
476    let mut output: Vec<String> = Vec::new();
477
478    // Use LCS to find the diff
479    let n = orig_lines.len();
480    let m = mod_lines.len();
481    let mut dp = vec![vec![0usize; m + 1]; n + 1];
482    for i in 1..=n {
483        for j in 1..=m {
484            if orig_lines[i - 1] == mod_lines[j - 1] {
485                dp[i][j] = dp[i - 1][j - 1] + 1;
486            } else {
487                dp[i][j] = dp[i - 1][j].max(dp[i][j - 1]);
488            }
489        }
490    }
491
492    // Backtrack to build sequence of changes
493    let mut changes: Vec<(char, &str)> = Vec::new();
494    let mut i = n;
495    let mut j = m;
496    while i > 0 || j > 0 {
497        if i > 0 && j > 0 && orig_lines[i - 1] == mod_lines[j - 1] {
498            changes.push((' ', orig_lines[i - 1]));
499            i -= 1;
500            j -= 1;
501        } else if j > 0 && (i == 0 || dp[i][j - 1] >= dp[i - 1][j]) {
502            changes.push(('+', mod_lines[j - 1]));
503            j -= 1;
504        } else {
505            changes.push(('-', orig_lines[i - 1]));
506            i -= 1;
507        }
508    }
509    changes.reverse();
510
511    // Group into hunks with context boundaries
512    const CONTEXT_LINES: usize = 4;
513    let mut old_line_num: usize = 1;
514    let mut new_line_num: usize = 1;
515
516    let pad = |num: usize| -> String { format!("{:width$}", num, width = line_num_width) };
517
518    let mut k = 0;
519    while k < changes.len() {
520        let (tag, _text) = changes[k];
521
522        if tag == ' ' {
523            // Context line
524            let mut ctx_buffer: Vec<&str> = Vec::new();
525            let ctx_start = k;
526            while k < changes.len() && changes[k].0 == ' ' {
527                ctx_buffer.push(changes[k].1);
528                k += 1;
529            }
530            let ctx_end = k;
531            let has_leading_change = ctx_start > 0 && changes[ctx_start - 1].0 != ' ';
532            let has_trailing_change = ctx_end < changes.len() - 1;
533
534            if has_leading_change || has_trailing_change {
535                // Show context around changes (pi-style)
536                let total_ctx = ctx_buffer.len();
537
538                if has_leading_change && has_trailing_change {
539                    if total_ctx <= CONTEXT_LINES * 2 {
540                        // Show all
541                        for &line in &ctx_buffer {
542                            output.push(format!(" {} {}", pad(old_line_num), replace_tabs(line)));
543                            old_line_num += 1;
544                            new_line_num += 1;
545                        }
546                    } else {
547                        let leading = &ctx_buffer[..CONTEXT_LINES];
548                        let trailing = &ctx_buffer[total_ctx - CONTEXT_LINES..];
549                        let skipped = total_ctx - leading.len() - trailing.len();
550
551                        for &line in leading {
552                            output.push(format!(" {} {}", pad(old_line_num), replace_tabs(line)));
553                            old_line_num += 1;
554                            new_line_num += 1;
555                        }
556
557                        output.push(format!(" {} ...", " ".repeat(line_num_width)));
558                        old_line_num += skipped;
559                        new_line_num += skipped;
560
561                        for &line in trailing {
562                            output.push(format!(" {} {}", pad(old_line_num), replace_tabs(line)));
563                            old_line_num += 1;
564                            new_line_num += 1;
565                        }
566                    }
567                } else if has_leading_change {
568                    // Context after a change (change before context): show CONTEXT_LINES leading
569                    let shown = ctx_buffer.len().min(CONTEXT_LINES);
570                    let skipped = ctx_buffer.len() - shown;
571
572                    for &line in &ctx_buffer[..shown] {
573                        output.push(format!(" {} {}", pad(old_line_num), replace_tabs(line)));
574                        old_line_num += 1;
575                        new_line_num += 1;
576                    }
577
578                    if skipped > 0 {
579                        output.push(format!(" {} ...", " ".repeat(line_num_width)));
580                        old_line_num += skipped;
581                        new_line_num += skipped;
582                    }
583                } else if has_trailing_change {
584                    // Context before a change (change after context): show CONTEXT_LINES trailing
585                    let shown = ctx_buffer.len().min(CONTEXT_LINES);
586                    let skipped = ctx_buffer.len() - shown;
587
588                    if skipped > 0 {
589                        output.push(format!(" {} ...", " ".repeat(line_num_width)));
590                        old_line_num += skipped;
591                        new_line_num += skipped;
592                    }
593
594                    for &line in &ctx_buffer[ctx_buffer.len() - shown..] {
595                        output.push(format!(" {} {}", pad(old_line_num), replace_tabs(line)));
596                        old_line_num += 1;
597                        new_line_num += 1;
598                    }
599                }
600            } else {
601                // No surrounding changes - skip entirely
602                old_line_num += ctx_buffer.len();
603                new_line_num += ctx_buffer.len();
604            }
605        } else {
606            // Change (removed or added)
607            let mut removed: Vec<&str> = Vec::new();
608            while k < changes.len() && changes[k].0 == '-' {
609                removed.push(changes[k].1);
610                k += 1;
611            }
612            let mut added: Vec<&str> = Vec::new();
613            while k < changes.len() && changes[k].0 == '+' {
614                added.push(changes[k].1);
615                k += 1;
616            }
617
618            // Show all removed lines first
619            for &line in &removed {
620                output.push(format!("-{} {}", pad(old_line_num), replace_tabs(line)));
621                old_line_num += 1;
622            }
623            // Then all added lines
624            for &line in &added {
625                output.push(format!("+{} {}", pad(new_line_num), replace_tabs(line)));
626                new_line_num += 1;
627            }
628        }
629    }
630
631    output.join("\n")
632}
633
634/// Parse path and edits from args without validation errors — returns None if
635/// arguments are not yet complete (for preview computation).
636fn parse_path_edits(args: &serde_json::Value) -> Option<(String, Vec<Edit>)> {
637    let path = args.get("path").and_then(|v| v.as_str())?;
638    let edits: Vec<Edit> = if let Some(edits_val) = args.get("edits") {
639        if let Some(s) = edits_val.as_str() {
640            serde_json::from_str(s).ok()?
641        } else {
642            serde_json::from_value(edits_val.clone()).ok()?
643        }
644    } else if let (Some(old), Some(new)) = (args.get("oldText"), args.get("newText")) {
645        let old_text = old.as_str()?;
646        let new_text = new.as_str()?;
647        vec![Edit {
648            old_text: old_text.to_string(),
649            new_text: new_text.to_string(),
650        }]
651    } else {
652        return None;
653    };
654
655    if edits.is_empty() {
656        return None;
657    }
658
659    Some((path.to_string(), edits))
660}
661
662/// Apply edits to normalized content and return (normalized, base_content, new_content, diff).
663/// This is the core edit logic extracted for reuse by both execute and preview.
664///
665/// Returns Ok((normalized, base_content, new_content, diff_string)) on success,
666/// or Err(error_message) if edits can't be applied.
667fn apply_edits_and_compute_diff(
668    normalized: &str,
669    edits: &[Edit],
670    path_str: &str,
671) -> Result<(String, String, String), String> {
672    // Determine if fuzzy matching is needed
673    let mut needs_fuzzy = false;
674    for edit in edits {
675        let old_lf = normalize_to_lf(&edit.old_text);
676        if !normalized.contains(&old_lf) {
677            needs_fuzzy = true;
678            break;
679        }
680    }
681
682    // Build work content: exact or fuzzy-normalized
683    let fuzzy_owned;
684    let (work_content, is_fuzzy_space) = if needs_fuzzy {
685        fuzzy_owned = normalize_for_fuzzy_match(normalized);
686        (fuzzy_owned.as_str(), true)
687    } else {
688        (normalized, false)
689    };
690
691    let mut matched_indices: Vec<(usize, usize)> = Vec::new();
692
693    for (i, edit) in edits.iter().enumerate() {
694        if edit.old_text.is_empty() {
695            return if edits.len() == 1 {
696                Err(format!("oldText must not be empty in {}.", path_str))
697            } else {
698                Err(format!(
699                    "edits[{}].oldText must not be empty in {}.",
700                    i, path_str
701                ))
702            };
703        }
704
705        let search_text = if is_fuzzy_space {
706            normalize_for_fuzzy_match(&normalize_to_lf(&edit.old_text))
707        } else {
708            normalize_to_lf(&edit.old_text)
709        };
710        let count = work_content.matches(&search_text).count();
711
712        if count == 0 {
713            return if edits.len() == 1 {
714                Err(format!(
715                    "Could not find the exact text in {}. \
716                     The old text must match exactly including all whitespace and newlines.",
717                    path_str
718                ))
719            } else {
720                Err(format!(
721                    "Could not find edits[{}] in {}. \
722                     The oldText must match exactly including all whitespace and newlines.",
723                    i, path_str
724                ))
725            };
726        }
727
728        if count > 1 {
729            return if edits.len() == 1 {
730                Err(format!(
731                    "Found {} occurrences of the text in {}. \
732                     The text must be unique. Please provide more context to make it unique.",
733                    count, path_str
734                ))
735            } else {
736                Err(format!(
737                    "Found {} occurrences of edits[{}] in {}. \
738                     Each oldText must be unique. Please provide more context to make it unique.",
739                    count, i, path_str
740                ))
741            };
742        }
743
744        let pos = work_content.find(&search_text).unwrap();
745        matched_indices.push((pos, pos + search_text.len()));
746    }
747
748    // Check for overlapping edits
749    for (idx_i, &(pos_i, end_i)) in matched_indices.iter().enumerate() {
750        for (idx_j, &(pos_j, end_j)) in matched_indices.iter().enumerate().skip(idx_i + 1) {
751            if pos_i < end_j && pos_j < end_i {
752                return Err(format!(
753                    "edits[{}] and edits[{}] overlap in {}. Merge them into one edit or target disjoint regions.",
754                    idx_i, idx_j, path_str
755                ));
756            }
757        }
758    }
759
760    // Apply edits (sorted left-to-right)
761    let mut sorted: Vec<(usize, usize, &Edit)> = matched_indices
762        .into_iter()
763        .zip(edits.iter())
764        .map(|((start, end), edit)| (start, end, edit))
765        .collect();
766    sorted.sort_by_key(|(pos, _, _)| *pos);
767
768    let (base_content, new_content) = if is_fuzzy_space {
769        // Build replacement tuples for the preserving function
770        let mapped_refs: Vec<(usize, usize, &str)> = sorted
771            .iter()
772            .map(|(start, end, edit)| (*start, *end - *start, &edit.new_text[..]))
773            .collect();
774
775        let new_content =
776            apply_replacements_preserving_unchanged_lines(normalized, work_content, &mapped_refs);
777
778        (normalized.to_string(), new_content)
779    } else {
780        let mut modified = String::new();
781        let mut cursor = 0usize;
782        for (start, end, edit) in &sorted {
783            modified.push_str(&normalized[cursor..*start]);
784            modified.push_str(&normalize_to_lf(&edit.new_text));
785            cursor = *end;
786        }
787        modified.push_str(&normalized[cursor..]);
788        (normalized.to_string(), modified)
789    };
790
791    // No-change detection
792    if base_content == new_content {
793        return if edits.len() == 1 {
794            Err(format!(
795                "No changes made to {}. The replacement produced identical content. \
796                 This might indicate an issue with special characters or the text not \
797                 existing as expected.",
798                path_str
799            ))
800        } else {
801            Err(format!(
802                "No changes made to {}. The replacements produced identical content.",
803                path_str
804            ))
805        };
806    }
807
808    let diff = compute_diff(&base_content, &new_content, path_str);
809
810    Ok((base_content, new_content, diff))
811}
812
813/// Read a file and compute what the diff would look like if edits were applied.
814/// This is used for the preview rendering (matching pi's computeEditsDiff).
815fn compute_edits_diff(
816    path_str: &str,
817    edits: &[Edit],
818    cwd: &std::path::Path,
819) -> Result<String, String> {
820    let abs_path = {
821        let p = std::path::Path::new(path_str);
822        if p.is_absolute() {
823            p.to_path_buf()
824        } else {
825            cwd.join(p)
826        }
827    };
828
829    let raw_content =
830        std::fs::read_to_string(&abs_path).map_err(|e| format!("Could not read file: {}", e))?;
831
832    let (_bom, content) = strip_bom(&raw_content);
833    let normalized = normalize_to_lf(content);
834
835    let (_, _, diff) = apply_edits_and_compute_diff(&normalized, edits, path_str)?;
836
837    Ok(diff)
838}
839
840#[async_trait::async_trait]
841impl yoagent::types::AgentTool for EditTool {
842    fn name(&self) -> &str {
843        "edit"
844    }
845    fn label(&self) -> &str {
846        "edit"
847    }
848    fn description(&self) -> &str {
849        "Edit a single file using exact text replacement. Every edits[].oldText must match a \
850         unique, non-overlapping region of the original file. If two changes affect the same \
851         block or nearby lines, merge them into one edit instead of emitting overlapping edits. \
852         Do not include large unchanged regions just to connect distant changes."
853    }
854    fn parameters_schema(&self) -> serde_json::Value {
855        serde_json::json!({
856            "type": "object",
857            "required": ["path", "edits"],
858            "additionalProperties": false,
859            "properties": {
860                "path": {
861                    "type": "string",
862                    "description": "Path to the file to edit"
863                },
864                "edits": {
865                    "type": "array",
866                    "items": {
867                        "type": "object",
868                        "required": ["oldText", "newText"],
869                        "additionalProperties": false,
870                        "properties": {
871                            "oldText": {
872                                "type": "string",
873                                "description": "Text to search for"
874                            },
875                            "newText": {
876                                "type": "string",
877                                "description": "Text to replace with"
878                            }
879                        }
880                    }
881                }
882            }
883        })
884    }
885    async fn execute(
886        &self,
887        params: serde_json::Value,
888        ctx: yoagent::types::ToolContext,
889    ) -> std::result::Result<yoagent::types::ToolResult, yoagent::types::ToolError> {
890        let path_str = params["path"]
891            .as_str()
892            .ok_or_else(|| {
893                yoagent::types::ToolError::InvalidArgs("Missing 'path' argument".into())
894            })?
895            .to_string();
896        let edits: Vec<Edit> = serde_json::from_value(params["edits"].clone())
897            .map_err(|e| yoagent::types::ToolError::InvalidArgs(format!("Invalid edits: {}", e)))?;
898
899        if ctx.cancel.is_cancelled() {
900            return Err(yoagent::types::ToolError::Cancelled);
901        }
902
903        let cwd = self.cwd.clone();
904        let cancel = ctx.cancel.clone();
905        let ops = self.operations.clone();
906        let path_for_queue = path_str.clone();
907        let cwd_for_closure = cwd.clone();
908        let edits_for_closure = edits.clone();
909
910        // Wrap the entire read-edit-write in a per-file mutation queue so
911        // concurrent edits to the same file are serialized (pi-style).
912        let output = crate::builtin::file_mutation_queue::with_file_mutation_queue(
913            &path_for_queue,
914            &cwd,
915            || async move {
916                let abs_path = {
917                    let p = std::path::Path::new(&path_str);
918                    if p.is_absolute() {
919                        p.to_path_buf()
920                    } else {
921                        cwd_for_closure.join(p)
922                    }
923                };
924
925                if cancel.is_cancelled() {
926                    anyhow::bail!("Operation cancelled");
927                }
928
929                // Check file accessibility using operations
930                ops.access(&abs_path).await?;
931
932                if cancel.is_cancelled() {
933                    anyhow::bail!("Operation cancelled");
934                }
935
936                // Read file using operations
937                let raw_content = ops.read_file(&abs_path).await?;
938
939                if cancel.is_cancelled() {
940                    anyhow::bail!("Operation cancelled");
941                }
942
943                // ── 1. BOM handling ──
944                let (bom, content) = strip_bom(&raw_content);
945
946                // ── 2. Line ending handling ──
947                let original_ending = detect_line_ending(content);
948                let normalized = normalize_to_lf(content);
949
950                // ── 3-8. Apply edits and compute diff ──
951                let (_base_content, new_content, diff) =
952                    apply_edits_and_compute_diff(&normalized, &edits_for_closure, &path_str)
953                        .map_err(|e| anyhow::anyhow!("{}", e))?;
954
955                if cancel.is_cancelled() {
956                    anyhow::bail!("Operation cancelled");
957                }
958
959                // ── 9. Write back with original line endings and BOM ──
960                let final_content =
961                    bom.to_string() + &restore_line_endings(&new_content, original_ending);
962                ops.write_file(&abs_path, &final_content).await?;
963
964                if cancel.is_cancelled() {
965                    anyhow::bail!("Operation cancelled");
966                }
967
968                // ── 10. Compute firstChangedLine and patch ──
969                let first_changed_line = extract_first_changed_line(&diff);
970                let patch = generate_unified_patch(&path_str, &_base_content, &new_content);
971
972                // ── 11. Return result ──
973                let noun = if edits.len() == 1 { "block" } else { "blocks" };
974                let msg = format!(
975                    "Successfully replaced {} {} in {}.",
976                    edits.len(),
977                    noun,
978                    path_str
979                );
980                let details = serde_json::json!({
981                    "diff": diff.trim_end(),
982                    "path": path_str,
983                    "patch": patch,
984                    "firstChangedLine": first_changed_line,
985                });
986                Ok::<_, anyhow::Error>((msg, details))
987            },
988        )
989        .await
990        .map_err(|e| yoagent::types::ToolError::Failed(e.to_string()))?;
991
992        let (msg, details) = output;
993        Ok(yoagent::types::ToolResult {
994            content: vec![yoagent::types::Content::Text { text: msg }],
995            details,
996        })
997    }
998}
999
1000// ── Edit tool renderer (stateful, with preview) ─────────────────
1001
1002/// Cached preview of what the edit will look like.
1003#[derive(Debug, Clone)]
1004struct EditPreview {
1005    diff: String,
1006    error: Option<String>,
1007}
1008
1009/// Tool renderer for the `edit` tool.
1010/// Uses `renderShell: "self"` - renders its own framing without colored box.
1011/// Shows a preview of what will change in the call header (matching pi behavior).
1012#[derive(Clone)]
1013struct EditRenderer {
1014    /// Cached diff preview, computed from file system during render_call.
1015    /// Protected by Mutex for interior mutability in a Sync trait impl.
1016    preview: std::sync::Arc<Mutex<Option<EditPreview>>>,
1017}
1018
1019impl EditRenderer {
1020    fn new() -> Self {
1021        Self {
1022            preview: std::sync::Arc::new(Mutex::new(None)),
1023        }
1024    }
1025}
1026
1027impl ToolRenderer for EditRenderer {
1028    fn render_self(&self) -> bool {
1029        true
1030    }
1031
1032    fn render_bg_key(&self) -> Option<&'static str> {
1033        // The edit tool handles its own bg in render_call now.
1034        // This hint is no longer used by the execution component.
1035        None
1036    }
1037
1038    fn render_call(
1039        &self,
1040        args: &serde_json::Value,
1041        width: usize,
1042        theme: &dyn Theme,
1043        ctx: &ToolRenderContext,
1044    ) -> Vec<String> {
1045        let path = args
1046            .get("file_path")
1047            .or_else(|| args.get("path"))
1048            .and_then(|v| v.as_str())
1049            .unwrap_or("");
1050        let short = if let Ok(home) = std::env::var("HOME") {
1051            path.replacen(&home, "~", 1)
1052        } else {
1053            path.to_string()
1054        };
1055        let path_disp = if short.is_empty() {
1056            String::new()
1057        } else {
1058            theme.fg_key(ThemeKey::Accent, &short)
1059        };
1060
1061        let header = format!(
1062            "{} {}",
1063            theme.fg_key(ThemeKey::ToolTitle, &theme.bold("edit")),
1064            path_disp
1065        );
1066
1067        let mut content_lines: Vec<String> = vec![header];
1068
1069        // Decide what diff to show:
1070        // 1. If execution completed and details are available, use actual diff from details
1071        // 2. Otherwise, if args are complete and we have a cached preview, show that
1072        let actual_diff = ctx
1073            .details
1074            .as_ref()
1075            .and_then(|d| d.get("diff"))
1076            .and_then(|v| v.as_str())
1077            .map(|s| s.to_string());
1078
1079        let diff_to_show = if let Some(ref d) = actual_diff {
1080            Some(d.clone())
1081        } else if ctx.args_complete {
1082            // After execution: use cached preview if available, otherwise show nothing.
1083            // Don't spawn async preview computation — the tool already ran.
1084            self.preview.lock().ok().and_then(|p| {
1085                p.as_ref().map(|preview| {
1086                    if let Some(ref err) = preview.error {
1087                        format!("error: {}", err)
1088                    } else {
1089                        preview.diff.clone()
1090                    }
1091                })
1092            })
1093        } else {
1094            // Before execution: try to show or compute cached preview
1095            let cached = self.preview.lock().ok().and_then(|p| p.clone());
1096
1097            if let Some(preview) = cached {
1098                if let Some(ref err) = preview.error {
1099                    Some(format!("error: {}", err))
1100                } else {
1101                    Some(preview.diff.clone())
1102                }
1103            } else if let Some((path_str, edits)) = parse_path_edits(args) {
1104                let mut preview_lock = self.preview.lock().unwrap();
1105                if preview_lock.is_some() {
1106                    drop(preview_lock);
1107                    let cached = self.preview.lock().ok().and_then(|p| p.clone());
1108                    cached.map(|preview| {
1109                        if let Some(ref err) = preview.error {
1110                            format!("error: {}", err)
1111                        } else {
1112                            preview.diff.clone()
1113                        }
1114                    })
1115                } else {
1116                    *preview_lock = Some(EditPreview {
1117                        diff: String::new(),
1118                        error: Some("pending".to_string()),
1119                    });
1120                    drop(preview_lock);
1121
1122                    let preview_arc = self.preview.clone();
1123                    let path_owned = path_str.clone();
1124                    let edits_owned = edits.clone();
1125                    let cwd_owned = ctx.cwd.clone();
1126                    let invalidate_tx = ctx.invalidate.clone();
1127                    tokio::spawn(async move {
1128                        let result = compute_edits_diff(
1129                            &path_owned,
1130                            &edits_owned,
1131                            std::path::Path::new(&cwd_owned),
1132                        );
1133                        let (diff, error) = match result {
1134                            Ok(d) => (d, None),
1135                            Err(e) => (String::new(), Some(e)),
1136                        };
1137                        if let Ok(mut p) = preview_arc.lock() {
1138                            *p = Some(EditPreview { diff, error });
1139                        }
1140                        if let Some(ref tx) = invalidate_tx {
1141                            let _ = tx.send(());
1142                        }
1143                    });
1144
1145                    None
1146                }
1147            } else {
1148                None
1149            }
1150        };
1151
1152        if let Some(ref diff) = diff_to_show {
1153            if let Some(err_msg) = diff.strip_prefix("error: ") {
1154                // Show error in error color (matching pi's theme.fg("error", ...))
1155                content_lines.push(String::new());
1156                content_lines.push(theme.fg_key(ThemeKey::Error, err_msg));
1157            } else if !diff.is_empty() {
1158                content_lines.push(String::new());
1159                let rendered_lines = crate::tui::components::diff::render_diff(diff, theme);
1160                content_lines.extend(rendered_lines);
1161            }
1162        }
1163
1164        // ── Pad and apply background to match pi's Box(1,1) handling ──
1165        // Pi's edit tool returns a Box with bgFn based on preview/error state.
1166        // Since rab returns Vec<String>, we pad each line to width and wrap in bg.
1167        let bg_key = if let Ok(p) = self.preview.lock()
1168            && let Some(ref preview) = *p
1169            && preview.error.as_deref() != Some("pending")
1170        {
1171            if preview.error.is_some() {
1172                "toolErrorBg"
1173            } else {
1174                "toolSuccessBg"
1175            }
1176        } else if ctx.is_error {
1177            // Execution error (not preview) — matches pi's fallback bgFn
1178            "toolErrorBg"
1179        } else if ctx.args_complete && !ctx.is_partial {
1180            "toolSuccessBg"
1181        } else {
1182            "toolPendingBg"
1183        };
1184
1185        // In pi, the edit tool uses Box(1,1) which means:
1186        // - padding_x=1 on each side (contentWidth = width - 2)
1187        // - left_pad = " "
1188        // - applyBg pads to full width
1189        // We replicate this here.
1190        let left_pad = " ";
1191        let content_width = width.saturating_sub(2).max(1);
1192
1193        let mut result: Vec<String> = Vec::new();
1194        for line in content_lines {
1195            // Pad each content line to content_width
1196            let vw = crate::tui::util::visible_width(&line);
1197            let padded_line = if vw < content_width {
1198                format!("{}{}", line, " ".repeat(content_width - vw))
1199            } else {
1200                line.to_string()
1201            };
1202            // Add left padding and pad to full width
1203            let with_pad = format!("{}{}", left_pad, padded_line);
1204            let vw2 = crate::tui::util::visible_width(&with_pad);
1205            let fully_padded = if vw2 < width {
1206                format!("{}{}", with_pad, " ".repeat(width - vw2))
1207            } else {
1208                with_pad
1209            };
1210            result.push(theme.bg(bg_key, &fully_padded));
1211        }
1212
1213        result
1214    }
1215
1216    fn render_result(
1217        &self,
1218        content: &str,
1219        width: usize,
1220        theme: &dyn Theme,
1221        ctx: &ToolRenderContext,
1222    ) -> Vec<String> {
1223        // Result is already shown in the call header (via render_call using ctx.details).
1224        // If there's an error message not already shown, return it.
1225        if ctx.is_error && !content.is_empty() {
1226            let msg = content;
1227            // Check if this error is already shown as preview
1228            let preview_err = self
1229                .preview
1230                .lock()
1231                .ok()
1232                .and_then(|p| p.as_ref().and_then(|preview| preview.error.clone()));
1233            if preview_err.as_deref() != Some(msg) {
1234                // Pi: renderResult wraps error in Container(Spacer(1) + Text(output, 1, 0)).
1235                // Text(1,0) adds 1-space padding on each side.
1236                // We return just the error line with 1-space indent (ToolExecComponent adds the spacer).
1237                let indent = " ";
1238                let error_line = format!("{}{}", indent, theme.fg_key(ThemeKey::Error, msg));
1239                // Pad to full width for consistent display
1240                let vw = crate::tui::util::visible_width(&error_line);
1241                if vw < width {
1242                    let padded = format!("{}{}", error_line, " ".repeat(width - vw));
1243                    return vec![padded];
1244                }
1245                return vec![error_line];
1246            }
1247        }
1248
1249        Vec::new()
1250    }
1251}
1252
1253// ── Diff utility functions ───────────────────────────────────────
1254
1255/// Extract the first changed line number from a diff string.
1256/// Scans for the first `+` or `-` prefixed line with a line number.
1257fn extract_first_changed_line(diff: &str) -> Option<usize> {
1258    for line in diff.lines() {
1259        let bytes = line.as_bytes();
1260        if bytes.is_empty() {
1261            continue;
1262        }
1263        let prefix = bytes[0] as char;
1264        if prefix != '+' && prefix != '-' {
1265            continue;
1266        }
1267        // Parse the line number from the rest
1268        let rest = &line[1..];
1269        let num_str: String = rest
1270            .chars()
1271            .take_while(|c| c.is_whitespace() || c.is_ascii_digit())
1272            .collect();
1273        if let Ok(num) = num_str.trim().parse::<usize>() {
1274            return Some(num);
1275        }
1276    }
1277    None
1278}
1279
1280/// Generate a unified diff patch string from original and modified content.
1281/// Uses basic hunk structure matching pi's `generateUnifiedPatch`.
1282fn generate_unified_patch(path: &str, original: &str, modified: &str) -> String {
1283    let orig_lines: Vec<&str> = original.lines().collect();
1284    let mod_lines: Vec<&str> = modified.lines().collect();
1285
1286    let n = orig_lines.len();
1287    let m = mod_lines.len();
1288    let mut dp = vec![vec![0usize; m + 1]; n + 1];
1289    for i in 1..=n {
1290        for j in 1..=m {
1291            if orig_lines[i - 1] == mod_lines[j - 1] {
1292                dp[i][j] = dp[i - 1][j - 1] + 1;
1293            } else {
1294                dp[i][j] = dp[i - 1][j].max(dp[i][j - 1]);
1295            }
1296        }
1297    }
1298
1299    // Backtrack to build sequence of changes
1300    let mut changes: Vec<(char, &str)> = Vec::new();
1301    let mut i = n;
1302    let mut j = m;
1303    while i > 0 || j > 0 {
1304        if i > 0 && j > 0 && orig_lines[i - 1] == mod_lines[j - 1] {
1305            changes.push((' ', orig_lines[i - 1]));
1306            i -= 1;
1307            j -= 1;
1308        } else if j > 0 && (i == 0 || dp[i][j - 1] >= dp[i - 1][j]) {
1309            changes.push(('+', mod_lines[j - 1]));
1310            j -= 1;
1311        } else {
1312            changes.push(('-', orig_lines[i - 1]));
1313            i -= 1;
1314        }
1315    }
1316    changes.reverse();
1317
1318    // Group into hunks
1319    const CTX: usize = 3;
1320    let mut hunks: Vec<String> = Vec::new();
1321    let mut pos = 0;
1322
1323    while pos < changes.len() {
1324        while pos < changes.len() && changes[pos].0 == ' ' {
1325            pos += 1;
1326        }
1327        if pos >= changes.len() {
1328            break;
1329        }
1330
1331        let hunk_start = pos.saturating_sub(CTX);
1332        let hunk_end = (pos + 3 * CTX).min(changes.len());
1333
1334        // Compute old/new line ranges
1335        let mut old_line = 1usize;
1336        let mut new_line = 1usize;
1337        for (tag, _) in changes.iter().take(pos.saturating_sub(CTX)) {
1338            match tag {
1339                ' ' => {
1340                    old_line += 1;
1341                    new_line += 1;
1342                }
1343                '-' => old_line += 1,
1344                '+' => new_line += 1,
1345                _ => {}
1346            }
1347        }
1348
1349        let old_start = old_line;
1350        let new_start = new_line;
1351
1352        // Count hunk size
1353        let mut old_count = 0usize;
1354        let mut new_count = 0usize;
1355        for (tag, _) in changes[hunk_start..hunk_end].iter() {
1356            match tag {
1357                ' ' => {
1358                    old_count += 1;
1359                    new_count += 1;
1360                }
1361                '-' => old_count += 1,
1362                '+' => new_count += 1,
1363                _ => {}
1364            }
1365        }
1366
1367        let mut hunk = format!(
1368            "@@ -{},{} +{},{} @@\n",
1369            old_start, old_count, new_start, new_count
1370        );
1371
1372        for (tag, text) in changes[hunk_start..hunk_end].iter() {
1373            match tag {
1374                ' ' => hunk.push_str(&format!(" {}", text)),
1375                '-' => hunk.push_str(&format!("-{}", text)),
1376                '+' => hunk.push_str(&format!("+{}", text)),
1377                _ => {}
1378            }
1379            hunk.push('\n');
1380        }
1381
1382        hunks.push(hunk);
1383        pos = hunk_end;
1384    }
1385
1386    if hunks.is_empty() {
1387        return String::new();
1388    }
1389
1390    let mut patch = format!("--- a/{}\n+++ b/{}\n", path, path);
1391    for hunk in &hunks {
1392        patch.push_str(hunk);
1393    }
1394
1395    patch
1396}
1397
1398// ═══════════════════════════════════════════════════════════════════
1399// Tests
1400// ═══════════════════════════════════════════════════════════════════
1401
1402#[cfg(test)]
1403mod tests {
1404    use super::*;
1405    use yoagent::AgentTool;
1406
1407    fn tmp_dir() -> std::path::PathBuf {
1408        let d = std::env::temp_dir().join(format!("rab-edit-test-{}", uuid::Uuid::new_v4()));
1409        std::fs::create_dir_all(&d).unwrap();
1410        d
1411    }
1412
1413    fn make_tool() -> (EditTool, std::path::PathBuf) {
1414        let tmp = tmp_dir();
1415        let tool = EditTool {
1416            cwd: tmp.clone(),
1417            operations: Arc::new(DefaultEditOperations),
1418        };
1419        (tool, tmp)
1420    }
1421
1422    fn tool_ctx() -> yoagent::types::ToolContext {
1423        yoagent::types::ToolContext {
1424            tool_call_id: "id".into(),
1425            tool_name: "edit".into(),
1426            cancel: tokio_util::sync::CancellationToken::new(),
1427            on_update: None,
1428            on_progress: None,
1429        }
1430    }
1431
1432    fn yo_msg_text(content: &[yoagent::types::Content]) -> String {
1433        content
1434            .iter()
1435            .filter_map(|c| {
1436                if let yoagent::types::Content::Text { text } = c {
1437                    Some(text.as_str())
1438                } else {
1439                    None
1440                }
1441            })
1442            .collect::<Vec<_>>()
1443            .join("")
1444    }
1445
1446    async fn exec_ok(tool: &EditTool, args: serde_json::Value) -> String {
1447        let args = prepare_edit_tool_args(args);
1448        let result = tool.execute(args, tool_ctx()).await.unwrap();
1449        yo_msg_text(&result.content)
1450    }
1451
1452    async fn exec_ok_details(
1453        tool: &EditTool,
1454        args: serde_json::Value,
1455    ) -> (String, Option<serde_json::Value>) {
1456        let args = prepare_edit_tool_args(args);
1457        let result = tool.execute(args, tool_ctx()).await.unwrap();
1458        let text = yo_msg_text(&result.content);
1459        (text, Some(result.details))
1460    }
1461
1462    async fn exec_err(tool: &EditTool, args: serde_json::Value) -> String {
1463        let args = prepare_edit_tool_args(args);
1464        tool.execute(args, tool_ctx())
1465            .await
1466            .unwrap_err()
1467            .to_string()
1468    }
1469
1470    async fn is_err(tool: &EditTool, args: serde_json::Value) -> bool {
1471        let args = prepare_edit_tool_args(args);
1472        tool.execute(args, tool_ctx()).await.is_err()
1473    }
1474
1475    #[tokio::test]
1476    async fn single_edit_replaces_text() {
1477        let (tool, tmp) = make_tool();
1478        let path = tmp.join("file.txt");
1479        std::fs::write(&path, "hello world\nfoo bar\n").unwrap();
1480
1481        exec_ok(
1482            &tool,
1483            serde_json::json!({
1484                "path": path.to_str().unwrap(),
1485                "edits": [{"oldText": "foo bar", "newText": "baz qux"}]
1486            }),
1487        )
1488        .await;
1489
1490        assert_eq!(
1491            std::fs::read_to_string(&path).unwrap(),
1492            "hello world\nbaz qux\n"
1493        );
1494    }
1495
1496    #[tokio::test]
1497    async fn multiple_edits_replaces_all() {
1498        let (tool, tmp) = make_tool();
1499        let path = tmp.join("file.txt");
1500        std::fs::write(&path, "aaa\nbbb\nccc\n").unwrap();
1501
1502        exec_ok(
1503            &tool,
1504            serde_json::json!({
1505                "path": path.to_str().unwrap(),
1506                "edits": [
1507                    {"oldText": "aaa", "newText": "111"},
1508                    {"oldText": "ccc", "newText": "333"}
1509                ]
1510            }),
1511        )
1512        .await;
1513
1514        assert_eq!(std::fs::read_to_string(&path).unwrap(), "111\nbbb\n333\n");
1515    }
1516
1517    #[tokio::test]
1518    async fn non_unique_oldtext_errors() {
1519        let (tool, tmp) = make_tool();
1520        let path = tmp.join("file.txt");
1521        std::fs::write(&path, "dup\ndup\n").unwrap();
1522
1523        assert!(
1524            is_err(
1525                &tool,
1526                serde_json::json!({
1527                    "path": path.to_str().unwrap(),
1528                    "edits": [{"oldText": "dup", "newText": "x"}]
1529                }),
1530            )
1531            .await
1532        );
1533    }
1534
1535    #[tokio::test]
1536    async fn missing_oldtext_errors() {
1537        let (tool, tmp) = make_tool();
1538        let path = tmp.join("file.txt");
1539        std::fs::write(&path, "content\n").unwrap();
1540
1541        let err = exec_err(
1542            &tool,
1543            serde_json::json!({
1544                "path": path.to_str().unwrap(),
1545                "edits": [{"oldText": "not found", "newText": "x"}]
1546            }),
1547        )
1548        .await;
1549        assert!(err.contains("Could not find"));
1550    }
1551
1552    #[tokio::test]
1553    async fn overlapping_edits_error() {
1554        let (tool, tmp) = make_tool();
1555        let path = tmp.join("file.txt");
1556        std::fs::write(&path, "abcdef\n").unwrap();
1557
1558        assert!(
1559            is_err(
1560                &tool,
1561                serde_json::json!({
1562                    "path": path.to_str().unwrap(),
1563                    "edits": [
1564                        {"oldText": "abc", "newText": "1"},
1565                        {"oldText": "bcd", "newText": "2"}
1566                    ]
1567                }),
1568            )
1569            .await
1570        );
1571    }
1572
1573    #[tokio::test]
1574    async fn empty_edits_errors() {
1575        let (tool, tmp) = make_tool();
1576        let path = tmp.join("file.txt");
1577        std::fs::write(&path, "content\n").unwrap();
1578
1579        assert!(
1580            is_err(
1581                &tool,
1582                serde_json::json!({"path": path.to_str().unwrap(), "edits": []}),
1583            )
1584            .await
1585        );
1586    }
1587
1588    // ── BOM handling ─────────────────────────────────────────
1589
1590    #[tokio::test]
1591    async fn handles_bom() {
1592        let (tool, tmp) = make_tool();
1593        let path = tmp.join("bom.txt");
1594        std::fs::write(&path, "\u{FEFF}hello world\n").unwrap();
1595
1596        exec_ok(
1597            &tool,
1598            serde_json::json!({
1599                "path": path.to_str().unwrap(),
1600                "edits": [{"oldText": "hello world", "newText": "goodbye"}]
1601            }),
1602        )
1603        .await;
1604
1605        let content = std::fs::read_to_string(&path).unwrap();
1606        assert!(content.starts_with('\u{FEFF}'));
1607        assert!(content.contains("goodbye"));
1608    }
1609
1610    #[tokio::test]
1611    async fn preserves_bom_when_no_edit_at_start() {
1612        let (tool, tmp) = make_tool();
1613        let path = tmp.join("bom2.txt");
1614        std::fs::write(&path, "\u{FEFF}line1\nline2\n").unwrap();
1615
1616        exec_ok(
1617            &tool,
1618            serde_json::json!({
1619                "path": path.to_str().unwrap(),
1620                "edits": [{"oldText": "line2", "newText": "modified"}]
1621            }),
1622        )
1623        .await;
1624
1625        let content = std::fs::read_to_string(&path).unwrap();
1626        assert!(content.starts_with('\u{FEFF}'));
1627        assert!(content.contains("modified"));
1628    }
1629
1630    // ── CRLF handling ────────────────────────────────────────
1631
1632    #[tokio::test]
1633    async fn preserves_crlf() {
1634        let (tool, tmp) = make_tool();
1635        let path = tmp.join("crlf.txt");
1636        std::fs::write(&path, "hello\r\nworld\r\n").unwrap();
1637
1638        exec_ok(
1639            &tool,
1640            serde_json::json!({
1641                "path": path.to_str().unwrap(),
1642                "edits": [{"oldText": "world", "newText": "universe"}]
1643            }),
1644        )
1645        .await;
1646
1647        let content = std::fs::read_to_string(&path).unwrap();
1648        assert_eq!(content, "hello\r\nuniverse\r\n");
1649    }
1650
1651    #[tokio::test]
1652    async fn handles_mixed_line_endings() {
1653        let (tool, tmp) = make_tool();
1654        let path = tmp.join("mixed.txt");
1655        std::fs::write(&path, "line1\r\nline2\nline3\n").unwrap();
1656
1657        exec_ok(
1658            &tool,
1659            serde_json::json!({
1660                "path": path.to_str().unwrap(),
1661                "edits": [{"oldText": "line2", "newText": "modified"}]
1662            }),
1663        )
1664        .await;
1665
1666        let content = std::fs::read_to_string(&path).unwrap();
1667        assert_eq!(content, "line1\r\nmodified\r\nline3\r\n");
1668    }
1669
1670    #[tokio::test]
1671    async fn lf_only_stays_lf() {
1672        let (tool, tmp) = make_tool();
1673        let path = tmp.join("lf.txt");
1674        std::fs::write(&path, "hello\nworld\n").unwrap();
1675
1676        exec_ok(
1677            &tool,
1678            serde_json::json!({
1679                "path": path.to_str().unwrap(),
1680                "edits": [{"oldText": "world", "newText": "universe"}]
1681            }),
1682        )
1683        .await;
1684
1685        let content = std::fs::read_to_string(&path).unwrap();
1686        assert_eq!(content, "hello\nuniverse\n");
1687    }
1688
1689    // ── Fuzzy matching ───────────────────────────────────────
1690
1691    #[tokio::test]
1692    async fn fuzzy_match_trailing_whitespace() {
1693        let (tool, tmp) = make_tool();
1694        let path = tmp.join("trailing.txt");
1695        std::fs::write(&path, "hello world  \nnext line\n").unwrap();
1696
1697        exec_ok(
1698            &tool,
1699            serde_json::json!({
1700                "path": path.to_str().unwrap(),
1701                "edits": [{"oldText": "hello world", "newText": "hi there"}]
1702            }),
1703        )
1704        .await;
1705
1706        let content = std::fs::read_to_string(&path).unwrap();
1707        // Pi behavior: exact match ("hello world" is a substring of "hello world  "),
1708        // so trailing whitespace on unchanged suffix is preserved.
1709        assert_eq!(content, "hi there  \nnext line\n");
1710    }
1711
1712    #[tokio::test]
1713    async fn fuzzy_match_smart_quotes() {
1714        let (tool, tmp) = make_tool();
1715        let path = tmp.join("quotes.txt");
1716        std::fs::write(&path, "he said \u{201C}hello\u{201D}\n").unwrap();
1717
1718        exec_ok(
1719            &tool,
1720            serde_json::json!({
1721                "path": path.to_str().unwrap(),
1722                "edits": [{"oldText": "he said \"hello\"", "newText": "she said \"hi\""}]
1723            }),
1724        )
1725        .await;
1726
1727        let content = std::fs::read_to_string(&path).unwrap();
1728        assert_eq!(content, "she said \"hi\"\n");
1729    }
1730
1731    #[tokio::test]
1732    async fn fuzzy_match_dashes() {
1733        let (tool, tmp) = make_tool();
1734        let path = tmp.join("dashes.txt");
1735        std::fs::write(&path, "foo \u{2014} bar\n").unwrap();
1736
1737        exec_ok(
1738            &tool,
1739            serde_json::json!({
1740                "path": path.to_str().unwrap(),
1741                "edits": [{"oldText": "foo - bar", "newText": "baz"}]
1742            }),
1743        )
1744        .await;
1745
1746        let content = std::fs::read_to_string(&path).unwrap();
1747        assert_eq!(content, "baz\n");
1748    }
1749
1750    // ── No-change detection ──────────────────────────────────
1751
1752    #[tokio::test]
1753    async fn no_change_identical_edit_errors() {
1754        let (tool, tmp) = make_tool();
1755        let path = tmp.join("nochange.txt");
1756        std::fs::write(&path, "hello\nworld\n").unwrap();
1757
1758        let err = exec_err(
1759            &tool,
1760            serde_json::json!({
1761                "path": path.to_str().unwrap(),
1762                "edits": [{"oldText": "hello", "newText": "hello"}]
1763            }),
1764        )
1765        .await;
1766        assert!(
1767            err.contains("No changes made"),
1768            "expected no-change error but got: {}",
1769            err
1770        );
1771    }
1772
1773    // ── Input normalization ──────────────────────────────────
1774
1775    #[tokio::test]
1776    async fn legacy_oldtext_newtext() {
1777        let (tool, tmp) = make_tool();
1778        let path = tmp.join("legacy.txt");
1779        std::fs::write(&path, "hello world\n").unwrap();
1780
1781        exec_ok(
1782            &tool,
1783            serde_json::json!({
1784                "path": path.to_str().unwrap(),
1785                "oldText": "hello world",
1786                "newText": "goodbye"
1787            }),
1788        )
1789        .await;
1790
1791        assert_eq!(std::fs::read_to_string(&path).unwrap(), "goodbye\n");
1792    }
1793
1794    #[tokio::test]
1795    async fn edits_as_json_string() {
1796        let (tool, tmp) = make_tool();
1797        let path = tmp.join("jsonstr.txt");
1798        std::fs::write(&path, "aaa\nbbb\n").unwrap();
1799
1800        exec_ok(
1801            &tool,
1802            serde_json::json!({
1803                "path": path.to_str().unwrap(),
1804                "edits": r#"[{"oldText": "bbb", "newText": "xxx"}]"#
1805            }),
1806        )
1807        .await;
1808
1809        assert_eq!(std::fs::read_to_string(&path).unwrap(), "aaa\nxxx\n");
1810    }
1811
1812    // ── Structured details (diff no longer embedded in content) ──
1813
1814    #[tokio::test]
1815    async fn result_content_has_no_diff_block() {
1816        let (tool, tmp) = make_tool();
1817        let path = tmp.join("diff_test.txt");
1818        std::fs::write(&path, "aaa\nbbb\nccc\n").unwrap();
1819
1820        let (content, details) = exec_ok_details(
1821            &tool,
1822            serde_json::json!({
1823                "path": path.to_str().unwrap(),
1824                "edits": [{"oldText": "bbb", "newText": "xxx"}]
1825            }),
1826        )
1827        .await;
1828
1829        // Content should NOT contain a ```diff block anymore
1830        assert!(
1831            !content.contains("```diff"),
1832            "content should not contain diff block, got: {}",
1833            content
1834        );
1835        assert!(content.contains("Successfully replaced 1 block"));
1836
1837        // Diff should be in details
1838        let details_obj = details.expect("details should be present");
1839        let diff = details_obj
1840            .get("diff")
1841            .and_then(|v| v.as_str())
1842            .unwrap_or("");
1843        assert!(
1844            diff.contains("-2 bbb"),
1845            "diff should contain '-2 bbb' but got: {}",
1846            diff
1847        );
1848        assert!(
1849            diff.contains("+2 xxx"),
1850            "diff should contain '+2 xxx' but got: {}",
1851            diff
1852        );
1853    }
1854
1855    // ── Fuzzy matching preserves unchanged lines (using new line-span mapping) ──
1856
1857    #[tokio::test]
1858    async fn fuzzy_preserves_unchanged_line_trailing_whitespace() {
1859        let (tool, tmp) = make_tool();
1860        let path = tmp.join("fuzzy_preserve.txt");
1861        // First line has trailing spaces, second has smart quotes (forces fuzzy)
1862        std::fs::write(&path, "keep this line  \nchange \u{201C}this\u{201D}\n").unwrap();
1863
1864        exec_ok(
1865            &tool,
1866            serde_json::json!({
1867                "path": path.to_str().unwrap(),
1868                "edits": [{"oldText": "change \"this\"", "newText": "changed"}]
1869            }),
1870        )
1871        .await;
1872
1873        let content = std::fs::read_to_string(&path).unwrap();
1874        // Unchanged first line preserves trailing spaces (pi behavior)
1875        assert!(
1876            content.starts_with("keep this line  "),
1877            "expected preserved trailing spaces but got: {:?}",
1878            content
1879        );
1880        assert!(content.contains("changed\n"), "got: {:?}", content);
1881    }
1882
1883    // ── Empty oldText ────────────────────────────────────────
1884
1885    #[tokio::test]
1886    async fn empty_oldtext_errors() {
1887        let (tool, tmp) = make_tool();
1888        let path = tmp.join("empty.txt");
1889        std::fs::write(&path, "content\n").unwrap();
1890
1891        let err = exec_err(
1892            &tool,
1893            serde_json::json!({
1894                "path": path.to_str().unwrap(),
1895                "edits": [{"oldText": "", "newText": "x"}]
1896            }),
1897        )
1898        .await;
1899        assert!(err.contains("empty"));
1900    }
1901
1902    // ── Relative paths ───────────────────────────────────────
1903
1904    #[tokio::test]
1905    async fn relative_path_resolves_to_cwd() {
1906        let (tool, tmp) = make_tool();
1907        let path = tmp.join("relative.txt");
1908        std::fs::write(&path, "hello\n").unwrap();
1909
1910        exec_ok(
1911            &tool,
1912            serde_json::json!({
1913                "path": "relative.txt",
1914                "edits": [{"oldText": "hello", "newText": "hi"}]
1915            }),
1916        )
1917        .await;
1918
1919        assert_eq!(std::fs::read_to_string(&path).unwrap(), "hi\n");
1920    }
1921
1922    // ── NFKC normalization test ─────────────────────────────
1923
1924    #[tokio::test]
1925    async fn fuzzy_match_nfkc_composed_vs_decomposed() {
1926        let (tool, tmp) = make_tool();
1927        let path = tmp.join("nfkc.txt");
1928        // "café" in NFD (decomposed): cafe + combining acute accent
1929        let nfd: String = "cafe\u{0301}".chars().collect();
1930        std::fs::write(&path, format!("{} rest\n", nfd)).unwrap();
1931
1932        exec_ok(
1933            &tool,
1934            serde_json::json!({
1935                "path": path.to_str().unwrap(),
1936                "edits": [{"oldText": "café", "newText": "changed"}]
1937            }),
1938        )
1939        .await;
1940
1941        let content = std::fs::read_to_string(&path).unwrap();
1942        assert!(
1943            content.starts_with("changed"),
1944            "expected 'changed' but got: {:?}",
1945            content
1946        );
1947    }
1948}
1949
1950#[cfg(test)]
1951mod fuzzy_tests {
1952    use super::*;
1953
1954    #[test]
1955    fn test_strip_trailing_whitespace() {
1956        assert_eq!(
1957            normalize_for_fuzzy_match("hello   \nworld  "),
1958            "hello\nworld"
1959        );
1960    }
1961
1962    #[test]
1963    fn test_smart_quotes() {
1964        assert_eq!(
1965            normalize_for_fuzzy_match("\u{2018}hello\u{2019} \u{201C}world\u{201D}"),
1966            "'hello' \"world\""
1967        );
1968    }
1969
1970    #[test]
1971    fn test_dashes() {
1972        assert_eq!(normalize_for_fuzzy_match("a\u{2014}b"), "a-b");
1973        assert_eq!(normalize_for_fuzzy_match("a\u{2013}b"), "a-b");
1974    }
1975
1976    #[test]
1977    fn test_nbsp() {
1978        assert_eq!(normalize_for_fuzzy_match("a\u{00A0}b"), "a b");
1979    }
1980
1981    #[test]
1982    fn test_preserves_trailing_newline() {
1983        assert_eq!(normalize_for_fuzzy_match("hello\n"), "hello\n");
1984        assert_eq!(
1985            normalize_for_fuzzy_match("hello\nworld\n"),
1986            "hello\nworld\n"
1987        );
1988    }
1989
1990    #[test]
1991    fn test_nfkc_normalization() {
1992        // é composed (NFC) vs decomposed (NFD) + NFKC
1993        let composed = "café";
1994        let decomposed: String = "cafe\u{0301}".chars().collect();
1995        assert_eq!(
1996            normalize_for_fuzzy_match(composed),
1997            normalize_for_fuzzy_match(&decomposed),
1998            "NFKC should make composed and decomposed café match"
1999        );
2000    }
2001}
2002
2003#[cfg(test)]
2004mod diff_tests {
2005    use super::*;
2006
2007    #[test]
2008    fn test_simple_diff() {
2009        let orig = "aaa\nbbb\nccc\n";
2010        let modified = "aaa\nxxx\nccc\n";
2011        let diff = compute_diff(orig, modified, "test.txt");
2012        assert!(
2013            diff.contains("-2 bbb"),
2014            "diff should contain -2 bbb but got: {}",
2015            diff
2016        );
2017        assert!(
2018            diff.contains("+2 xxx"),
2019            "diff should contain +2 xxx but got: {}",
2020            diff
2021        );
2022    }
2023
2024    #[test]
2025    fn test_no_changes() {
2026        let text = "hello\nworld\n";
2027        let diff = compute_diff(text, text, "f.txt");
2028        assert!(diff.is_empty(), "no changes should produce empty diff");
2029    }
2030
2031    #[test]
2032    fn test_multiple_hunks() {
2033        let orig = "a\nb\nc\nd\ne\nf\ng\nh\n";
2034        let modified = "a\nX\nc\nd\ne\nY\ng\nh\n";
2035        let diff = compute_diff(orig, modified, "f.txt");
2036        assert!(
2037            diff.contains("-2 b"),
2038            "should contain -2 b but got: {}",
2039            diff
2040        );
2041        assert!(
2042            diff.contains("+2 X"),
2043            "should contain +2 X but got: {}",
2044            diff
2045        );
2046        assert!(
2047            diff.contains("-6 f"),
2048            "should contain -6 f but got: {}",
2049            diff
2050        );
2051        assert!(
2052            diff.contains("+6 Y"),
2053            "should contain +6 Y but got: {}",
2054            diff
2055        );
2056    }
2057
2058    #[test]
2059    fn test_apply_replacements_preserving_unchanged_lines() {
2060        let original = "keep this  \nchange this\nkeep that  \n";
2061        let base = "keep this\nchange this\nkeep that\n";
2062        // matchIndex 10, matchLength 11 covers "change this" (bytes 10..21 in base)
2063        let replacements = vec![(10usize, 11usize, "modified")];
2064        let result = apply_replacements_preserving_unchanged_lines(original, base, &replacements);
2065        assert_eq!(result, "keep this  \nmodified\nkeep that  \n");
2066    }
2067}