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