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#[async_trait]
16pub trait EditOperations: Send + Sync {
17 async fn read_file(&self, absolute_path: &Path) -> anyhow::Result<String>;
19 async fn write_file(&self, absolute_path: &Path, content: &str) -> anyhow::Result<()>;
21 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 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
106fn 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
117fn 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
139fn normalize_for_fuzzy_match(text: &str) -> String {
149 let nfkc = text.nfkc().collect::<String>();
151
152 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 if nfkc.ends_with('\n') {
162 intermediate.push('\n');
163 }
164
165 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
187fn 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
236pub 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 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 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#[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#[derive(Debug, Clone, Copy)]
302struct LineSpan {
303 start: usize,
304 end: usize,
305}
306
307fn 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
322fn 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
338fn 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
365fn 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
381fn apply_replacements_preserving_unchanged_lines(
387 original_content: &str,
388 base_content: &str,
389 replacements: &[(usize, usize, &str)], ) -> 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 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 struct Group {
405 start_line: usize,
406 end_line: usize,
407 replacements: Vec<(usize, usize, String)>, }
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 result.push_str(&original_lines[original_line_index..group.start_line].concat());
433
434 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 result.push_str(&original_lines[original_line_index..].concat());
450
451 result
452}
453
454fn replace_tabs(text: &str) -> String {
458 text.replace('\t', " ")
459}
460
461fn 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 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 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 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 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 let total_ctx = ctx_buffer.len();
533
534 if has_leading_change && has_trailing_change {
535 if total_ctx <= CONTEXT_LINES * 2 {
536 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 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 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 old_line_num += ctx_buffer.len();
599 new_line_num += ctx_buffer.len();
600 }
601 } else {
602 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 for &line in &removed {
616 output.push(format!("-{} {}", pad(old_line_num), replace_tabs(line)));
617 old_line_num += 1;
618 }
619 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
630fn 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
658fn apply_edits_and_compute_diff(
664 normalized: &str,
665 edits: &[Edit],
666 path_str: &str,
667) -> Result<(String, String, String), String> {
668 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 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 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 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 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 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
809fn 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 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 ops.access(&abs_path).await?;
927
928 if cancel.is_cancelled() {
929 anyhow::bail!("Operation cancelled");
930 }
931
932 let raw_content = ops.read_file(&abs_path).await?;
934
935 if cancel.is_cancelled() {
936 anyhow::bail!("Operation cancelled");
937 }
938
939 let (bom, content) = strip_bom(&raw_content);
941
942 let original_ending = detect_line_ending(content);
944 let normalized = normalize_to_lf(content);
945
946 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 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 let first_changed_line = extract_first_changed_line(&diff);
966 let patch = generate_unified_patch(&path_str, &_base_content, &new_content);
967
968 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#[derive(Debug, Clone)]
1000struct EditPreview {
1001 diff: String,
1002 error: Option<String>,
1003}
1004
1005#[derive(Clone)]
1009struct EditRenderer {
1010 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 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 }
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 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 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 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 let mut preview_lock = self.preview.lock().unwrap();
1114 if preview_lock.is_some() {
1115 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 *preview_lock = Some(EditPreview {
1128 diff: String::new(),
1129 error: Some("pending".to_string()),
1130 });
1131 drop(preview_lock);
1132
1133 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 if let Some(ref tx) = invalidate_tx {
1154 let _ = tx.send(());
1155 }
1156 });
1157
1158 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 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 if ctx.is_error {
1193 if !_content.is_empty() {
1195 let msg = _content;
1196 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
1212fn 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 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
1239fn 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 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 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 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 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#[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 #[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 #[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 #[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 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 #[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 #[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 #[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 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 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 #[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 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 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 #[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 #[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 #[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 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 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 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}