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