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