1use crate::filesystem::validate_path;
2use crate::prelude::*;
3use std::path::PathBuf;
4use strsim::normalized_levenshtein;
5
6#[derive(Debug, Deserialize, JsonSchema)]
8pub struct EditBlockInput {
9 pub file_path: PathBuf,
11
12 pub old_string: String,
14
15 pub new_string: String,
17
18 #[serde(default = "default_replacements")]
20 pub expected_replacements: usize,
21
22 #[serde(default = "default_fuzzy")]
24 pub enable_fuzzy: bool,
25
26 #[serde(default = "default_threshold")]
28 pub fuzzy_threshold: f32,
29}
30
31fn default_replacements() -> usize {
32 1
33}
34
35fn default_fuzzy() -> bool {
36 true
37}
38
39fn default_threshold() -> f32 {
40 0.7
41}
42
43#[derive(Debug)]
45struct FuzzyMatch {
46 start: usize,
47 end: usize,
48 similarity: f64,
49 matched_text: String,
50}
51
52pub struct EditBlockTool {
54 base_path: PathBuf,
55}
56
57impl Default for EditBlockTool {
58 fn default() -> Self {
59 Self::new()
60 }
61}
62
63impl EditBlockTool {
64 pub fn new() -> Self {
66 Self {
67 base_path: std::env::current_dir().expect("Failed to get current working directory"),
68 }
69 }
70
71 pub fn with_base_path(base_path: PathBuf) -> Self {
73 Self { base_path }
74 }
75
76 fn find_fuzzy_match(text: &str, pattern: &str, threshold: f32) -> Option<FuzzyMatch> {
78 let pattern_len = pattern.len();
79 if pattern_len == 0 || pattern_len > text.len() {
80 return None;
81 }
82
83 let mut best_match: Option<FuzzyMatch> = None;
84 let mut best_similarity = threshold as f64;
85
86 for start in 0..=(text.len() - pattern_len) {
88 let end = (start + pattern_len).min(text.len());
89 let window = &text[start..end];
90
91 let similarity = normalized_levenshtein(pattern, window);
92
93 if similarity > best_similarity {
94 best_similarity = similarity;
95 best_match = Some(FuzzyMatch {
96 start,
97 end,
98 similarity,
99 matched_text: window.to_string(),
100 });
101 }
102 }
103
104 for window_size in [
106 pattern_len.saturating_sub(pattern_len / 10),
107 pattern_len + pattern_len / 10,
108 ] {
109 if window_size == 0 || window_size > text.len() {
110 continue;
111 }
112
113 for start in 0..=(text.len() - window_size) {
114 let end = (start + window_size).min(text.len());
115 let window = &text[start..end];
116
117 let similarity = normalized_levenshtein(pattern, window);
118
119 if similarity > best_similarity {
120 best_similarity = similarity;
121 best_match = Some(FuzzyMatch {
122 start,
123 end,
124 similarity,
125 matched_text: window.to_string(),
126 });
127 }
128 }
129 }
130
131 best_match
132 }
133
134 fn detect_line_ending(content: &str) -> &str {
136 if content.contains("\r\n") {
137 "\r\n"
138 } else {
139 "\n"
140 }
141 }
142}
143
144impl Tool for EditBlockTool {
145 type Input = EditBlockInput;
146
147 fn name(&self) -> &str {
148 "edit_block"
149 }
150
151 fn description(&self) -> &str {
152 "Edit a file by replacing text. Supports exact matching with fallback to fuzzy matching. Preserves file line endings."
153 }
154
155 async fn execute(&self, input: Self::Input) -> std::result::Result<ToolResult, ToolError> {
156 let path = validate_path(&self.base_path, &input.file_path)
157 .map_err(|e| ToolError::from(e.to_string()))?;
158
159 let content = tokio::fs::read_to_string(&path)
161 .await
162 .map_err(|e| ToolError::from(format!("Failed to read file: {}", e)))?;
163
164 let line_ending = Self::detect_line_ending(&content);
165
166 let replacement_count = content.matches(&input.old_string).count();
168
169 let (new_content, actual_replacements, method) = if replacement_count > 0 {
170 let new_content = content.replace(&input.old_string, &input.new_string);
172 (new_content, replacement_count, "exact".to_string())
173 } else if input.enable_fuzzy {
174 match Self::find_fuzzy_match(&content, &input.old_string, input.fuzzy_threshold) {
176 Some(fuzzy_match) => {
177 let new_content = format!(
178 "{}{}{}",
179 &content[..fuzzy_match.start],
180 &input.new_string,
181 &content[fuzzy_match.end..]
182 );
183
184 let info = format!(
185 "fuzzy (similarity: {:.1}%)\nMatched text:\n{}",
186 fuzzy_match.similarity * 100.0,
187 fuzzy_match.matched_text
188 );
189
190 (new_content, 1, info)
191 }
192 None => {
193 return Err(format!(
194 "No match found for the specified text (tried exact and fuzzy matching with threshold {:.1}%)",
195 input.fuzzy_threshold * 100.0
196 ).into());
197 }
198 }
199 } else {
200 return Err("No exact match found and fuzzy matching is disabled".into());
201 };
202
203 if actual_replacements != input.expected_replacements {
205 return Err(format!(
206 "Expected {} replacement(s) but found {}",
207 input.expected_replacements, actual_replacements
208 )
209 .into());
210 }
211
212 let final_content = if line_ending == "\r\n" {
215 let normalized = new_content.replace("\r\n", "\n");
217 normalized.replace('\n', "\r\n")
219 } else {
220 new_content
221 };
222
223 tokio::fs::write(&path, final_content.as_bytes())
225 .await
226 .map_err(|e| ToolError::from(format!("Failed to write file: {}", e)))?;
227
228 let old_lines = input.old_string.lines().count();
230 let new_lines = input.new_string.lines().count();
231 let line_diff = new_lines as i64 - old_lines as i64;
232
233 let line_change = if line_diff > 0 {
234 format!("(\x1b[32m+{} lines\x1b[0m)", line_diff)
235 } else if line_diff < 0 {
236 format!("(\x1b[31m{} lines\x1b[0m)", line_diff)
237 } else {
238 "(no change in line count)".to_string()
239 };
240
241 let content = format!(
242 "Successfully edited {} using {} matching\n{} replacement(s) {}",
243 input.file_path.display(),
244 method,
245 actual_replacements,
246 line_change
247 );
248
249 Ok(content.into())
250 }
251
252 fn format_input_plain(&self, params: &serde_json::Value) -> String {
253 let file_path = params
254 .get("file_path")
255 .and_then(|v| v.as_str())
256 .unwrap_or("?");
257 let old_string = params
258 .get("old_string")
259 .and_then(|v| v.as_str())
260 .unwrap_or("");
261 let new_string = params
262 .get("new_string")
263 .and_then(|v| v.as_str())
264 .unwrap_or("");
265
266 let mut output = format!("edit_block: {}\n", file_path);
267 output.push_str("--- old\n");
268 for line in old_string.lines() {
269 output.push_str(&format!("- {}\n", line));
270 }
271 output.push_str("+++ new\n");
272 for line in new_string.lines() {
273 output.push_str(&format!("+ {}\n", line));
274 }
275 output
276 }
277
278 fn format_input_ansi(&self, params: &serde_json::Value) -> String {
279 let file_path = params
280 .get("file_path")
281 .and_then(|v| v.as_str())
282 .unwrap_or("?");
283 let old_string = params
284 .get("old_string")
285 .and_then(|v| v.as_str())
286 .unwrap_or("");
287 let new_string = params
288 .get("new_string")
289 .and_then(|v| v.as_str())
290 .unwrap_or("");
291
292 let mut output = format!("\x1b[1medit_block:\x1b[0m {}\n", file_path);
293 output.push_str("\x1b[31m--- old\x1b[0m\n");
294 for line in old_string.lines() {
295 output.push_str(&format!("\x1b[31m- {}\x1b[0m\n", line));
296 }
297 output.push_str("\x1b[32m+++ new\x1b[0m\n");
298 for line in new_string.lines() {
299 output.push_str(&format!("\x1b[32m+ {}\x1b[0m\n", line));
300 }
301 output
302 }
303
304 fn format_input_markdown(&self, params: &serde_json::Value) -> String {
305 let file_path = params
306 .get("file_path")
307 .and_then(|v| v.as_str())
308 .unwrap_or("?");
309 let old_string = params
310 .get("old_string")
311 .and_then(|v| v.as_str())
312 .unwrap_or("");
313 let new_string = params
314 .get("new_string")
315 .and_then(|v| v.as_str())
316 .unwrap_or("");
317
318 let mut output = format!("**edit_block:** `{}`\n\n```diff\n", file_path);
319 for line in old_string.lines() {
320 output.push_str(&format!("- {}\n", line));
321 }
322 for line in new_string.lines() {
323 output.push_str(&format!("+ {}\n", line));
324 }
325 output.push_str("```\n");
326 output
327 }
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333 use std::fs;
334 use tempfile::TempDir;
335
336 #[test]
339 fn test_tool_metadata() {
340 let tool: EditBlockTool = Default::default();
341 assert_eq!(tool.name(), "edit_block");
342 assert!(!tool.description().is_empty());
343
344 let tool2 = EditBlockTool::new();
345 assert_eq!(tool2.name(), "edit_block");
346 }
347
348 #[test]
349 fn test_format_methods() {
350 let tool = EditBlockTool::new();
351 let params =
352 serde_json::json!({"file_path": "test.txt", "old_string": "old", "new_string": "new"});
353
354 assert!(!tool.format_input_plain(¶ms).is_empty());
355 assert!(!tool.format_input_ansi(¶ms).is_empty());
356 assert!(!tool.format_input_markdown(¶ms).is_empty());
357
358 let result = ToolResult::from("Edited file");
359 assert!(!tool.format_output_plain(&result).is_empty());
360 assert!(!tool.format_output_ansi(&result).is_empty());
361 assert!(!tool.format_output_markdown(&result).is_empty());
362 }
363
364 #[test]
365 fn test_default_values() {
366 let input: EditBlockInput = serde_json::from_value(serde_json::json!({
368 "file_path": "test.txt",
369 "old_string": "old",
370 "new_string": "new"
371 }))
372 .unwrap();
373
374 assert_eq!(input.expected_replacements, 1);
375 assert!(input.enable_fuzzy);
376 assert!((input.fuzzy_threshold - 0.7).abs() < 0.001);
377 }
378
379 #[tokio::test]
380 async fn test_edit_block_exact() {
381 let temp_dir = TempDir::new().unwrap();
382 let file_path = temp_dir.path().join("test.txt");
383 fs::write(&file_path, "Hello, World!\nThis is a test.").unwrap();
384
385 let tool = EditBlockTool::with_base_path(temp_dir.path().to_path_buf());
386 let input = EditBlockInput {
387 file_path: PathBuf::from("test.txt"),
388 old_string: "World".to_string(),
389 new_string: "Rust".to_string(),
390 expected_replacements: 1,
391 enable_fuzzy: false,
392 fuzzy_threshold: 0.7,
393 };
394
395 let result = tool.execute(input).await.unwrap();
396 assert!(result.as_text().contains("exact matching"));
397
398 let content = fs::read_to_string(&file_path).unwrap();
399 assert_eq!(content, "Hello, Rust!\nThis is a test.");
400 }
401
402 #[tokio::test]
403 async fn test_edit_block_fuzzy() {
404 let temp_dir = TempDir::new().unwrap();
405 let file_path = temp_dir.path().join("test.txt");
406 fs::write(&file_path, "Hello, World!\nThis is a test.").unwrap();
407
408 let tool = EditBlockTool::with_base_path(temp_dir.path().to_path_buf());
409 let input = EditBlockInput {
410 file_path: PathBuf::from("test.txt"),
411 old_string: "Wrld".to_string(), new_string: "Rust".to_string(),
413 expected_replacements: 1,
414 enable_fuzzy: true,
415 fuzzy_threshold: 0.7,
416 };
417
418 let result = tool.execute(input).await.unwrap();
419 assert!(result.as_text().contains("fuzzy"));
420 }
421
422 #[tokio::test]
423 async fn test_edit_block_preserves_line_endings() {
424 let temp_dir = TempDir::new().unwrap();
425 let file_path = temp_dir.path().join("test.txt");
426 fs::write(&file_path, "Line1\r\nLine2\r\n").unwrap();
427
428 let tool = EditBlockTool::with_base_path(temp_dir.path().to_path_buf());
429 let input = EditBlockInput {
430 file_path: PathBuf::from("test.txt"),
431 old_string: "Line1".to_string(),
432 new_string: "First".to_string(),
433 expected_replacements: 1,
434 enable_fuzzy: false,
435 fuzzy_threshold: 0.7,
436 };
437
438 tool.execute(input).await.unwrap();
439
440 let content = fs::read_to_string(&file_path).unwrap();
441 assert!(content.contains("\r\n"));
442 }
443
444 #[tokio::test]
447 async fn test_edit_block_lf_only() {
448 let temp_dir = TempDir::new().unwrap();
449 let file_path = temp_dir.path().join("lf.txt");
450
451 let original = "Line 1\nLine 2\nLine 3\n";
452 fs::write(&file_path, original).unwrap();
453
454 let tool = EditBlockTool::with_base_path(temp_dir.path().to_path_buf());
455 let input = EditBlockInput {
456 file_path: PathBuf::from("lf.txt"),
457 old_string: "Line 2".to_string(),
458 new_string: "Modified Line 2".to_string(),
459 expected_replacements: 1,
460 enable_fuzzy: false,
461 fuzzy_threshold: 0.7,
462 };
463
464 tool.execute(input).await.unwrap();
465
466 let bytes = fs::read(&file_path).unwrap();
467 let content = String::from_utf8(bytes).unwrap();
468 assert!(content.contains("Modified Line 2"));
469 assert!(content.contains("\n"));
470 assert!(!content.contains("\r\n"));
471 }
472
473 #[tokio::test]
474 async fn test_edit_block_crlf_only() {
475 let temp_dir = TempDir::new().unwrap();
476 let file_path = temp_dir.path().join("crlf.txt");
477
478 let original = "Line 1\r\nLine 2\r\nLine 3\r\n";
479 fs::write(&file_path, original).unwrap();
480
481 let tool = EditBlockTool::with_base_path(temp_dir.path().to_path_buf());
482 let input = EditBlockInput {
483 file_path: PathBuf::from("crlf.txt"),
484 old_string: "Line 2".to_string(),
485 new_string: "Modified Line 2".to_string(),
486 expected_replacements: 1,
487 enable_fuzzy: false,
488 fuzzy_threshold: 0.7,
489 };
490
491 tool.execute(input).await.unwrap();
492
493 let bytes = fs::read(&file_path).unwrap();
494 let content = String::from_utf8(bytes).unwrap();
495 assert!(content.contains("Modified Line 2"));
496 assert!(content.contains("\r\n"));
497 let crlf_count = content.matches("\r\n").count();
499 assert!(crlf_count >= 2); }
501
502 #[tokio::test]
503 async fn test_edit_block_mixed_line_endings() {
504 let temp_dir = TempDir::new().unwrap();
505 let file_path = temp_dir.path().join("mixed.txt");
506
507 let original = "Line 1\nLine 2\r\nLine 3\rLine 4";
508 fs::write(&file_path, original).unwrap();
509
510 let tool = EditBlockTool::with_base_path(temp_dir.path().to_path_buf());
511 let input = EditBlockInput {
512 file_path: PathBuf::from("mixed.txt"),
513 old_string: "Line 2".to_string(),
514 new_string: "Modified Line 2".to_string(),
515 expected_replacements: 1,
516 enable_fuzzy: false,
517 fuzzy_threshold: 0.7,
518 };
519
520 tool.execute(input).await.unwrap();
521
522 let bytes = fs::read(&file_path).unwrap();
523 let content = String::from_utf8(bytes).unwrap();
524 assert!(content.contains("Modified Line 2"));
525 assert!(content.contains("\n") || content.contains("\r"));
527 }
528
529 #[tokio::test]
530 async fn test_edit_block_empty_file() {
531 let temp_dir = TempDir::new().unwrap();
532 let file_path = temp_dir.path().join("empty.txt");
533 fs::write(&file_path, "").unwrap();
534
535 let tool = EditBlockTool::with_base_path(temp_dir.path().to_path_buf());
536 let input = EditBlockInput {
537 file_path: PathBuf::from("empty.txt"),
538 old_string: "nonexistent".to_string(),
539 new_string: "something".to_string(),
540 expected_replacements: 1,
541 enable_fuzzy: false,
542 fuzzy_threshold: 0.7,
543 };
544
545 let result = tool.execute(input).await;
546 assert!(result.is_err() || result.unwrap().as_text().contains("not found"));
548 }
549
550 #[tokio::test]
551 async fn test_edit_block_utf8_content() {
552 let temp_dir = TempDir::new().unwrap();
553 let file_path = temp_dir.path().join("utf8.txt");
554
555 let original = "Hello 世界\nÜmläüts äöü\n🎵 Music\n";
556 fs::write(&file_path, original).unwrap();
557
558 let tool = EditBlockTool::with_base_path(temp_dir.path().to_path_buf());
559 let input = EditBlockInput {
560 file_path: PathBuf::from("utf8.txt"),
561 old_string: "Ümläüts äöü".to_string(),
562 new_string: "Modified äöü".to_string(),
563 expected_replacements: 1,
564 enable_fuzzy: false,
565 fuzzy_threshold: 0.7,
566 };
567
568 tool.execute(input).await.unwrap();
569
570 let content = fs::read_to_string(&file_path).unwrap();
571 assert!(content.contains("Modified äöü"));
572 assert!(content.contains("世界"));
573 assert!(content.contains("🎵"));
574 }
575
576 #[tokio::test]
577 async fn test_edit_block_crlf_replacement_with_crlf_in_new_string() {
578 let temp_dir = TempDir::new().unwrap();
581 let file_path = temp_dir.path().join("crlf_replace.txt");
582
583 let original = "Line 1\r\nLine 2\r\nLine 3\r\n";
585 fs::write(&file_path, original).unwrap();
586
587 let tool = EditBlockTool::with_base_path(temp_dir.path().to_path_buf());
588 let input = EditBlockInput {
589 file_path: PathBuf::from("crlf_replace.txt"),
590 old_string: "Line 2".to_string(),
591 new_string: "New Line 2\r\nExtra Line".to_string(),
593 expected_replacements: 1,
594 enable_fuzzy: false,
595 fuzzy_threshold: 0.7,
596 };
597
598 tool.execute(input).await.unwrap();
599
600 let bytes = fs::read(&file_path).unwrap();
601 let content = String::from_utf8(bytes).unwrap();
602
603 assert!(
605 !content.contains("\r\r\n"),
606 "Bug: CRLF was doubled to \\r\\r\\n! Content bytes: {:?}",
607 content.as_bytes()
608 );
609
610 assert!(content.contains("New Line 2\r\nExtra Line"));
612 }
613
614 #[tokio::test]
615 async fn test_edit_block_multiple_occurrences() {
616 let temp_dir = TempDir::new().unwrap();
617 let file_path = temp_dir.path().join("multi.txt");
618
619 let original = "Item A\nItem A\nItem B\nItem A\n";
620 fs::write(&file_path, original).unwrap();
621
622 let tool = EditBlockTool::with_base_path(temp_dir.path().to_path_buf());
623
624 let input = EditBlockInput {
626 file_path: PathBuf::from("multi.txt"),
627 old_string: "Item A".to_string(),
628 new_string: "Item X".to_string(),
629 expected_replacements: 3, enable_fuzzy: false,
631 fuzzy_threshold: 0.7,
632 };
633
634 tool.execute(input).await.unwrap();
635
636 let content = fs::read_to_string(&file_path).unwrap();
637 let x_count = content.matches("Item X").count();
639 let a_count = content.matches("Item A").count();
640 assert_eq!(x_count, 3);
641 assert_eq!(a_count, 0);
642 }
643
644 #[test]
647 fn test_fuzzy_match_empty_pattern() {
648 let result = EditBlockTool::find_fuzzy_match("some text", "", 0.5);
649 assert!(result.is_none(), "Empty pattern should return None");
650 }
651
652 #[test]
653 fn test_fuzzy_match_pattern_longer_than_text() {
654 let result =
655 EditBlockTool::find_fuzzy_match("short", "this pattern is much longer than text", 0.5);
656 assert!(
657 result.is_none(),
658 "Pattern longer than text should return None"
659 );
660 }
661
662 #[test]
663 fn test_fuzzy_match_exact_match() {
664 let result = EditBlockTool::find_fuzzy_match("hello world", "world", 0.5);
665 assert!(result.is_some());
666 let m = result.unwrap();
667 assert_eq!(m.matched_text, "world");
668 assert!(
669 (m.similarity - 1.0).abs() < 0.001,
670 "Exact match should have similarity 1.0"
671 );
672 }
673
674 #[test]
675 fn test_fuzzy_match_finds_similar() {
676 let result = EditBlockTool::find_fuzzy_match("hello world goodbye", "wrld", 0.5);
678 assert!(result.is_some());
679 let m = result.unwrap();
680 assert!(m.similarity > 0.5);
681 }
682
683 #[test]
684 fn test_fuzzy_match_below_threshold() {
685 let result = EditBlockTool::find_fuzzy_match("hello world", "xyz", 0.99);
687 assert!(result.is_none(), "Nothing should match with high threshold");
688 }
689
690 #[test]
691 fn test_fuzzy_match_variable_window_skip_large() {
692 let result = EditBlockTool::find_fuzzy_match("abcdefghij", "abcdefghij", 0.5);
695 assert!(result.is_some()); }
697
698 #[test]
699 fn test_fuzzy_match_smaller_window() {
700 let result = EditBlockTool::find_fuzzy_match("xxxABCDEFGHIxxx", "ABCDEFGHIJ", 0.5);
704 assert!(result.is_some());
705 }
707
708 #[test]
709 fn test_fuzzy_match_continue_branch() {
710 let long_pattern = "a".repeat(100);
713 let text = "a".repeat(105); let result = EditBlockTool::find_fuzzy_match(&text, &long_pattern, 0.5);
716 assert!(result.is_some()); }
719}