1use std::collections::HashMap;
8use std::fs;
9use std::future::Future;
10use std::path::{Path, PathBuf};
11use std::pin::Pin;
12use std::sync::Arc;
13
14use strsim::normalized_levenshtein;
15
16use super::types::{
17 DisplayConfig, DisplayResult, Executable, ResultContentType, ToolContext, ToolType,
18};
19use crate::permissions::{GrantTarget, PermissionLevel, PermissionRegistry, PermissionRequest};
20
21pub const EDIT_FILE_TOOL_NAME: &str = "edit_file";
23
24pub const EDIT_FILE_TOOL_DESCRIPTION: &str = r#"Performs string replacement in a file with optional fuzzy matching.
26
27Usage:
28- The file_path parameter must be an absolute path, not a relative path
29- The old_string must be found in the file (or fuzzy matched if enabled)
30- The new_string will replace the old_string
31- By default, only the first occurrence is replaced
32- Use replace_all to replace all occurrences
33- Fuzzy matching helps handle whitespace differences and minor variations
34
35Returns:
36- Success message with number of replacements made
37- Error if old_string is not found in the file
38- Error if file doesn't exist or cannot be read"#;
39
40pub const EDIT_FILE_TOOL_SCHEMA: &str = r#"{
42 "type": "object",
43 "properties": {
44 "file_path": {
45 "type": "string",
46 "description": "The absolute path to the file to edit"
47 },
48 "old_string": {
49 "type": "string",
50 "description": "The string to find and replace"
51 },
52 "new_string": {
53 "type": "string",
54 "description": "The string to replace with"
55 },
56 "replace_all": {
57 "type": "boolean",
58 "description": "Whether to replace all occurrences or just the first. Defaults to false."
59 },
60 "fuzzy_match": {
61 "type": "boolean",
62 "description": "Enable fuzzy matching for whitespace-insensitive matching. Defaults to false."
63 },
64 "fuzzy_threshold": {
65 "type": "number",
66 "description": "Similarity threshold for fuzzy matching (0.0 to 1.0). Defaults to 0.7."
67 }
68 },
69 "required": ["file_path", "old_string", "new_string"]
70}"#;
71
72#[derive(Debug, Clone)]
74pub struct FuzzyConfig {
75 pub threshold: f64,
77 pub normalize_whitespace: bool,
79}
80
81impl Default for FuzzyConfig {
82 fn default() -> Self {
83 Self {
84 threshold: 0.7,
85 normalize_whitespace: true,
86 }
87 }
88}
89
90#[derive(Debug, Clone, Copy, PartialEq)]
92pub enum MatchType {
93 Exact,
95 WhitespaceInsensitive,
97 Fuzzy,
99}
100
101pub struct EditFileTool {
103 permission_registry: Arc<PermissionRegistry>,
105}
106
107impl EditFileTool {
108 pub fn new(permission_registry: Arc<PermissionRegistry>) -> Self {
110 Self {
111 permission_registry,
112 }
113 }
114
115 fn build_permission_request(
117 tool_use_id: &str,
118 file_path: &str,
119 old_string: &str,
120 ) -> PermissionRequest {
121 let path = file_path;
122 let truncated_old = truncate_string(old_string, 30);
123 let reason = format!("Replace '{}' in file", truncated_old);
124
125 PermissionRequest::new(
126 tool_use_id,
127 GrantTarget::path(path, false),
128 PermissionLevel::Write,
129 format!("Edit file: {}", path),
130 )
131 .with_reason(reason)
132 .with_tool(EDIT_FILE_TOOL_NAME)
133 }
134
135 fn normalize_whitespace(s: &str) -> String {
137 s.split_whitespace().collect::<Vec<_>>().join(" ")
138 }
139
140 fn find_match(
143 content: &str,
144 search: &str,
145 config: &FuzzyConfig,
146 ) -> Option<(usize, usize, f64, MatchType)> {
147 if let Some(start) = content.find(search) {
149 return Some((start, start + search.len(), 1.0, MatchType::Exact));
150 }
151
152 if let Some(pos) = Self::find_normalized_position(content, search) {
154 return Some((pos.0, pos.1, 0.95, MatchType::WhitespaceInsensitive));
155 }
156
157 Self::find_fuzzy_match_sliding_window(content, search, config)
159 .map(|(start, end, sim)| (start, end, sim, MatchType::Fuzzy))
160 }
161
162 fn find_normalized_position(content: &str, search: &str) -> Option<(usize, usize)> {
164 let search_lines: Vec<&str> = search.lines().collect();
165 let content_lines: Vec<&str> = content.lines().collect();
166
167 if search_lines.is_empty() {
168 return None;
169 }
170
171 let first_search_normalized = Self::normalize_whitespace(search_lines[0]);
173
174 for (i, content_line) in content_lines.iter().enumerate() {
175 let content_normalized = Self::normalize_whitespace(content_line);
176
177 if content_normalized == first_search_normalized {
178 let mut all_match = true;
180 for (j, search_line) in search_lines.iter().enumerate().skip(1) {
181 if i + j >= content_lines.len() {
182 all_match = false;
183 break;
184 }
185 let cn = Self::normalize_whitespace(content_lines[i + j]);
186 let sn = Self::normalize_whitespace(search_line);
187 if cn != sn {
188 all_match = false;
189 break;
190 }
191 }
192
193 if all_match {
194 let start_byte: usize = content_lines[..i]
196 .iter()
197 .map(|l| l.len() + 1) .sum();
199 let end_line = i + search_lines.len();
200 let matched_text = content_lines[i..end_line].join("\n");
201 let end_byte = start_byte + matched_text.len();
202
203 return Some((start_byte, end_byte));
204 }
205 }
206 }
207
208 None
209 }
210
211 fn find_fuzzy_match_sliding_window(
213 content: &str,
214 search: &str,
215 config: &FuzzyConfig,
216 ) -> Option<(usize, usize, f64)> {
217 let search_lines: Vec<&str> = search.lines().collect();
218 let content_lines: Vec<&str> = content.lines().collect();
219 let search_line_count = search_lines.len();
220
221 if search_line_count == 0 || content_lines.len() < search_line_count {
222 return None;
223 }
224
225 let mut best_match: Option<(usize, usize, f64)> = None;
226
227 for window_start in 0..=(content_lines.len() - search_line_count) {
229 let window_end = window_start + search_line_count;
230 let window: Vec<&str> = content_lines[window_start..window_end].to_vec();
231
232 let window_text = if config.normalize_whitespace {
234 Self::normalize_whitespace(&window.join("\n"))
235 } else {
236 window.join("\n")
237 };
238
239 let search_text = if config.normalize_whitespace {
240 Self::normalize_whitespace(search)
241 } else {
242 search.to_string()
243 };
244
245 let similarity = normalized_levenshtein(&search_text, &window_text);
246
247 if similarity >= config.threshold
248 && (best_match.is_none() || similarity > best_match.unwrap().2)
249 {
250 let start_byte: usize = content_lines[..window_start]
252 .iter()
253 .map(|l| l.len() + 1)
254 .sum();
255
256 let matched_text = content_lines[window_start..window_end].join("\n");
257 let end_byte = start_byte + matched_text.len();
258
259 best_match = Some((start_byte, end_byte, similarity));
260 }
261 }
262
263 best_match
264 }
265
266 fn find_all_exact_matches(content: &str, search: &str) -> Vec<(usize, usize)> {
268 let mut matches = Vec::new();
269 let mut start = 0;
270
271 while let Some(pos) = content[start..].find(search) {
272 let actual_start = start + pos;
273 matches.push((actual_start, actual_start + search.len()));
274 start = actual_start + search.len();
275 }
276
277 matches
278 }
279}
280
281impl Executable for EditFileTool {
282 fn name(&self) -> &str {
283 EDIT_FILE_TOOL_NAME
284 }
285
286 fn description(&self) -> &str {
287 EDIT_FILE_TOOL_DESCRIPTION
288 }
289
290 fn input_schema(&self) -> &str {
291 EDIT_FILE_TOOL_SCHEMA
292 }
293
294 fn tool_type(&self) -> ToolType {
295 ToolType::TextEdit
296 }
297
298 fn execute(
299 &self,
300 context: ToolContext,
301 input: HashMap<String, serde_json::Value>,
302 ) -> Pin<Box<dyn Future<Output = Result<String, String>> + Send>> {
303 let permission_registry = self.permission_registry.clone();
304
305 Box::pin(async move {
306 let file_path = input
308 .get("file_path")
309 .and_then(|v| v.as_str())
310 .ok_or_else(|| "Missing required 'file_path' parameter".to_string())?;
311
312 let old_string = input
313 .get("old_string")
314 .and_then(|v| v.as_str())
315 .ok_or_else(|| "Missing required 'old_string' parameter".to_string())?;
316
317 let new_string = input
318 .get("new_string")
319 .and_then(|v| v.as_str())
320 .ok_or_else(|| "Missing required 'new_string' parameter".to_string())?;
321
322 let replace_all = input
324 .get("replace_all")
325 .and_then(|v| v.as_bool())
326 .unwrap_or(false);
327
328 let fuzzy_match = input
329 .get("fuzzy_match")
330 .and_then(|v| v.as_bool())
331 .unwrap_or(false);
332
333 let fuzzy_threshold = input
334 .get("fuzzy_threshold")
335 .and_then(|v| v.as_f64())
336 .unwrap_or(0.7)
337 .clamp(0.0, 1.0);
338
339 let path = PathBuf::from(file_path);
340
341 if !path.is_absolute() {
343 return Err(format!(
344 "file_path must be an absolute path, got: {}",
345 file_path
346 ));
347 }
348
349 if !path.exists() {
351 return Err(format!("File does not exist: {}", file_path));
352 }
353
354 if old_string == new_string {
356 return Err("old_string and new_string are identical".to_string());
357 }
358
359 if !context.permissions_pre_approved {
361 let permission_request =
362 Self::build_permission_request(&context.tool_use_id, file_path, old_string);
363 let response_rx = permission_registry
364 .request_permission(
365 context.session_id,
366 permission_request,
367 context.turn_id.clone(),
368 )
369 .await
370 .map_err(|e| format!("Failed to request permission: {}", e))?;
371
372 let response = response_rx
373 .await
374 .map_err(|_| "Permission request was cancelled".to_string())?;
375
376 if !response.granted {
377 let reason = response
378 .message
379 .unwrap_or_else(|| "Permission denied by user".to_string());
380 return Err(format!(
381 "Permission denied to edit '{}': {}",
382 file_path, reason
383 ));
384 }
385 }
386
387 let content = fs::read_to_string(&path)
389 .map_err(|e| format!("Failed to read file '{}': {}", file_path, e))?;
390
391 let (new_content, replacement_count, match_info) = if fuzzy_match {
393 let config = FuzzyConfig {
394 threshold: fuzzy_threshold,
395 normalize_whitespace: true,
396 };
397
398 if let Some((start, end, similarity, match_type)) =
399 Self::find_match(&content, old_string, &config)
400 {
401 let mut new_content = String::with_capacity(content.len());
402 new_content.push_str(&content[..start]);
403 new_content.push_str(new_string);
404 new_content.push_str(&content[end..]);
405
406 let match_info = format!(
407 " (match type: {:?}, similarity: {:.1}%)",
408 match_type,
409 similarity * 100.0
410 );
411 (new_content, 1, match_info)
412 } else {
413 return Err(format!(
414 "No match found for '{}' with threshold {:.0}%",
415 truncate_string(old_string, 50),
416 fuzzy_threshold * 100.0
417 ));
418 }
419 } else if replace_all {
420 let matches = Self::find_all_exact_matches(&content, old_string);
422 if matches.is_empty() {
423 return Err(format!(
424 "String not found in file: '{}'",
425 truncate_string(old_string, 50)
426 ));
427 }
428 let new_content = content.replace(old_string, new_string);
429 (new_content, matches.len(), String::new())
430 } else {
431 if let Some(start) = content.find(old_string) {
433 let end = start + old_string.len();
434 let mut new_content = String::with_capacity(content.len());
435 new_content.push_str(&content[..start]);
436 new_content.push_str(new_string);
437 new_content.push_str(&content[end..]);
438 (new_content, 1, String::new())
439 } else {
440 return Err(format!(
441 "String not found in file: '{}'",
442 truncate_string(old_string, 50)
443 ));
444 }
445 };
446
447 fs::write(&path, &new_content)
449 .map_err(|e| format!("Failed to write file '{}': {}", file_path, e))?;
450
451 Ok(format!(
452 "Successfully made {} replacement(s) in '{}'{}",
453 replacement_count, file_path, match_info
454 ))
455 })
456 }
457
458 fn display_config(&self) -> DisplayConfig {
459 DisplayConfig {
460 display_name: "Edit File".to_string(),
461 display_title: Box::new(|input| {
462 input
463 .get("file_path")
464 .and_then(|v| v.as_str())
465 .map(|p| {
466 Path::new(p)
467 .file_name()
468 .and_then(|n| n.to_str())
469 .unwrap_or(p)
470 .to_string()
471 })
472 .unwrap_or_default()
473 }),
474 display_content: Box::new(|input, result| {
475 let old_str = input
476 .get("old_string")
477 .and_then(|v| v.as_str())
478 .unwrap_or("");
479 let new_str = input
480 .get("new_string")
481 .and_then(|v| v.as_str())
482 .unwrap_or("");
483
484 let content = format!(
485 "--- old\n+++ new\n- {}\n+ {}\n\n{}",
486 truncate_string(old_str, 100),
487 truncate_string(new_str, 100),
488 result
489 );
490
491 DisplayResult {
492 content,
493 content_type: ResultContentType::PlainText,
494 is_truncated: false,
495 full_length: 0,
496 }
497 }),
498 }
499 }
500
501 fn compact_summary(&self, input: &HashMap<String, serde_json::Value>, result: &str) -> String {
502 let filename = input
503 .get("file_path")
504 .and_then(|v| v.as_str())
505 .map(|p| {
506 Path::new(p)
507 .file_name()
508 .and_then(|n| n.to_str())
509 .unwrap_or(p)
510 })
511 .unwrap_or("unknown");
512
513 let status = if result.contains("Successfully") {
514 "ok"
515 } else {
516 "error"
517 };
518
519 format!("[EditFile: {} ({})]", filename, status)
520 }
521
522 fn required_permissions(
523 &self,
524 context: &ToolContext,
525 input: &HashMap<String, serde_json::Value>,
526 ) -> Option<Vec<PermissionRequest>> {
527 let file_path = input.get("file_path").and_then(|v| v.as_str())?;
529
530 let old_string = input
532 .get("old_string")
533 .and_then(|v| v.as_str())
534 .unwrap_or("");
535
536 let path = PathBuf::from(file_path);
538 if !path.is_absolute() {
539 return None;
540 }
541
542 let permission_request =
544 Self::build_permission_request(&context.tool_use_id, file_path, old_string);
545
546 Some(vec![permission_request])
547 }
548}
549
550fn truncate_string(s: &str, max_len: usize) -> String {
552 if s.len() <= max_len {
553 s.to_string()
554 } else {
555 format!("{}...", &s[..max_len])
556 }
557}
558
559#[cfg(test)]
560mod tests {
561 use super::*;
562 use crate::controller::types::ControllerEvent;
563 use crate::permissions::PermissionLevel;
564 use crate::permissions::PermissionPanelResponse;
565 use tempfile::TempDir;
566 use tokio::sync::mpsc;
567
568 fn create_test_registry() -> (Arc<PermissionRegistry>, mpsc::Receiver<ControllerEvent>) {
569 let (tx, rx) = mpsc::channel(16);
570 let registry = Arc::new(PermissionRegistry::new(tx));
571 (registry, rx)
572 }
573
574 fn grant_once() -> PermissionPanelResponse {
575 PermissionPanelResponse {
576 granted: true,
577 grant: None,
578 message: None,
579 }
580 }
581
582 fn deny(reason: &str) -> PermissionPanelResponse {
583 PermissionPanelResponse {
584 granted: false,
585 grant: None,
586 message: Some(reason.to_string()),
587 }
588 }
589
590 #[tokio::test]
591 async fn test_exact_replace_first() {
592 let (registry, mut event_rx) = create_test_registry();
593 let tool = EditFileTool::new(registry.clone());
594
595 let temp_dir = TempDir::new().unwrap();
596 let file_path = temp_dir.path().join("test.txt");
597 fs::write(&file_path, "foo bar foo baz").unwrap();
598
599 let mut input = HashMap::new();
600 input.insert(
601 "file_path".to_string(),
602 serde_json::Value::String(file_path.to_str().unwrap().to_string()),
603 );
604 input.insert(
605 "old_string".to_string(),
606 serde_json::Value::String("foo".to_string()),
607 );
608 input.insert(
609 "new_string".to_string(),
610 serde_json::Value::String("qux".to_string()),
611 );
612
613 let context = ToolContext {
614 session_id: 1,
615 tool_use_id: "test-edit-1".to_string(),
616 turn_id: None,
617 permissions_pre_approved: false,
618 };
619
620 let registry_clone = registry.clone();
622 tokio::spawn(async move {
623 if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
624 event_rx.recv().await
625 {
626 registry_clone
627 .respond_to_request(&tool_use_id, grant_once())
628 .await
629 .unwrap();
630 }
631 });
632
633 let result = tool.execute(context, input).await;
634 assert!(result.is_ok());
635 assert!(result.unwrap().contains("1 replacement"));
636 assert_eq!(fs::read_to_string(&file_path).unwrap(), "qux bar foo baz");
637 }
638
639 #[tokio::test]
640 async fn test_exact_replace_all() {
641 let (registry, mut event_rx) = create_test_registry();
642 let tool = EditFileTool::new(registry.clone());
643
644 let temp_dir = TempDir::new().unwrap();
645 let file_path = temp_dir.path().join("test.txt");
646 fs::write(&file_path, "foo bar foo baz foo").unwrap();
647
648 let mut input = HashMap::new();
649 input.insert(
650 "file_path".to_string(),
651 serde_json::Value::String(file_path.to_str().unwrap().to_string()),
652 );
653 input.insert(
654 "old_string".to_string(),
655 serde_json::Value::String("foo".to_string()),
656 );
657 input.insert(
658 "new_string".to_string(),
659 serde_json::Value::String("qux".to_string()),
660 );
661 input.insert("replace_all".to_string(), serde_json::Value::Bool(true));
662
663 let context = ToolContext {
664 session_id: 1,
665 tool_use_id: "test-edit-2".to_string(),
666 turn_id: None,
667 permissions_pre_approved: false,
668 };
669
670 let registry_clone = registry.clone();
671 tokio::spawn(async move {
672 if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
673 event_rx.recv().await
674 {
675 registry_clone
676 .respond_to_request(&tool_use_id, grant_once())
677 .await
678 .unwrap();
679 }
680 });
681
682 let result = tool.execute(context, input).await;
683 assert!(result.is_ok());
684 assert!(result.unwrap().contains("3 replacement"));
685 assert_eq!(
686 fs::read_to_string(&file_path).unwrap(),
687 "qux bar qux baz qux"
688 );
689 }
690
691 #[tokio::test]
692 async fn test_string_not_found() {
693 let (registry, mut event_rx) = create_test_registry();
694 let tool = EditFileTool::new(registry.clone());
695
696 let temp_dir = TempDir::new().unwrap();
697 let file_path = temp_dir.path().join("test.txt");
698 fs::write(&file_path, "hello world").unwrap();
699
700 let mut input = HashMap::new();
701 input.insert(
702 "file_path".to_string(),
703 serde_json::Value::String(file_path.to_str().unwrap().to_string()),
704 );
705 input.insert(
706 "old_string".to_string(),
707 serde_json::Value::String("notfound".to_string()),
708 );
709 input.insert(
710 "new_string".to_string(),
711 serde_json::Value::String("replacement".to_string()),
712 );
713
714 let context = ToolContext {
715 session_id: 1,
716 tool_use_id: "test-edit-3".to_string(),
717 turn_id: None,
718 permissions_pre_approved: false,
719 };
720
721 let registry_clone = registry.clone();
722 tokio::spawn(async move {
723 if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
724 event_rx.recv().await
725 {
726 registry_clone
727 .respond_to_request(&tool_use_id, grant_once())
728 .await
729 .unwrap();
730 }
731 });
732
733 let result = tool.execute(context, input).await;
734 assert!(result.is_err());
735 assert!(result.unwrap_err().contains("not found"));
736 }
737
738 #[tokio::test]
739 async fn test_file_not_found() {
740 let (registry, _event_rx) = create_test_registry();
741 let tool = EditFileTool::new(registry);
742
743 let mut input = HashMap::new();
744 input.insert(
745 "file_path".to_string(),
746 serde_json::Value::String("/nonexistent/file.txt".to_string()),
747 );
748 input.insert(
749 "old_string".to_string(),
750 serde_json::Value::String("foo".to_string()),
751 );
752 input.insert(
753 "new_string".to_string(),
754 serde_json::Value::String("bar".to_string()),
755 );
756
757 let context = ToolContext {
758 session_id: 1,
759 tool_use_id: "test-edit-4".to_string(),
760 turn_id: None,
761 permissions_pre_approved: false,
762 };
763
764 let result = tool.execute(context, input).await;
765 assert!(result.is_err());
766 assert!(result.unwrap_err().contains("does not exist"));
767 }
768
769 #[tokio::test]
770 async fn test_relative_path_rejected() {
771 let (registry, _event_rx) = create_test_registry();
772 let tool = EditFileTool::new(registry);
773
774 let mut input = HashMap::new();
775 input.insert(
776 "file_path".to_string(),
777 serde_json::Value::String("relative/path.txt".to_string()),
778 );
779 input.insert(
780 "old_string".to_string(),
781 serde_json::Value::String("foo".to_string()),
782 );
783 input.insert(
784 "new_string".to_string(),
785 serde_json::Value::String("bar".to_string()),
786 );
787
788 let context = ToolContext {
789 session_id: 1,
790 tool_use_id: "test-edit-5".to_string(),
791 turn_id: None,
792 permissions_pre_approved: false,
793 };
794
795 let result = tool.execute(context, input).await;
796 assert!(result.is_err());
797 assert!(result.unwrap_err().contains("absolute path"));
798 }
799
800 #[tokio::test]
801 async fn test_identical_strings_rejected() {
802 let (registry, _event_rx) = create_test_registry();
803 let tool = EditFileTool::new(registry);
804
805 let temp_dir = TempDir::new().unwrap();
806 let file_path = temp_dir.path().join("test.txt");
807 fs::write(&file_path, "hello world").unwrap();
808
809 let mut input = HashMap::new();
810 input.insert(
811 "file_path".to_string(),
812 serde_json::Value::String(file_path.to_str().unwrap().to_string()),
813 );
814 input.insert(
815 "old_string".to_string(),
816 serde_json::Value::String("same".to_string()),
817 );
818 input.insert(
819 "new_string".to_string(),
820 serde_json::Value::String("same".to_string()),
821 );
822
823 let context = ToolContext {
824 session_id: 1,
825 tool_use_id: "test-edit-6".to_string(),
826 turn_id: None,
827 permissions_pre_approved: false,
828 };
829
830 let result = tool.execute(context, input).await;
831 assert!(result.is_err());
832 assert!(result.unwrap_err().contains("identical"));
833 }
834
835 #[tokio::test]
836 async fn test_permission_denied() {
837 let (registry, mut event_rx) = create_test_registry();
838 let tool = EditFileTool::new(registry.clone());
839
840 let temp_dir = TempDir::new().unwrap();
841 let file_path = temp_dir.path().join("test.txt");
842 fs::write(&file_path, "hello world").unwrap();
843
844 let mut input = HashMap::new();
845 input.insert(
846 "file_path".to_string(),
847 serde_json::Value::String(file_path.to_str().unwrap().to_string()),
848 );
849 input.insert(
850 "old_string".to_string(),
851 serde_json::Value::String("hello".to_string()),
852 );
853 input.insert(
854 "new_string".to_string(),
855 serde_json::Value::String("goodbye".to_string()),
856 );
857
858 let context = ToolContext {
859 session_id: 1,
860 tool_use_id: "test-edit-7".to_string(),
861 turn_id: None,
862 permissions_pre_approved: false,
863 };
864
865 let registry_clone = registry.clone();
866 tokio::spawn(async move {
867 if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
868 event_rx.recv().await
869 {
870 registry_clone
871 .respond_to_request(&tool_use_id, deny("Not allowed"))
872 .await
873 .unwrap();
874 }
875 });
876
877 let result = tool.execute(context, input).await;
878 assert!(result.is_err());
879 assert!(result.unwrap_err().contains("Permission denied"));
880 }
881
882 #[tokio::test]
883 async fn test_whitespace_insensitive_match() {
884 let (registry, mut event_rx) = create_test_registry();
885 let tool = EditFileTool::new(registry.clone());
886
887 let temp_dir = TempDir::new().unwrap();
888 let file_path = temp_dir.path().join("test.txt");
889 fs::write(&file_path, "fn foo() {\n bar();\n}").unwrap();
891
892 let mut input = HashMap::new();
893 input.insert(
894 "file_path".to_string(),
895 serde_json::Value::String(file_path.to_str().unwrap().to_string()),
896 );
897 input.insert(
899 "old_string".to_string(),
900 serde_json::Value::String("fn foo() {\nbar();\n}".to_string()),
901 );
902 input.insert(
903 "new_string".to_string(),
904 serde_json::Value::String("fn foo() {\n baz();\n}".to_string()),
905 );
906 input.insert("fuzzy_match".to_string(), serde_json::Value::Bool(true));
907
908 let context = ToolContext {
909 session_id: 1,
910 tool_use_id: "test-edit-8".to_string(),
911 turn_id: None,
912 permissions_pre_approved: false,
913 };
914
915 let registry_clone = registry.clone();
916 tokio::spawn(async move {
917 if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
918 event_rx.recv().await
919 {
920 registry_clone
921 .respond_to_request(&tool_use_id, grant_once())
922 .await
923 .unwrap();
924 }
925 });
926
927 let result = tool.execute(context, input).await;
928 assert!(result.is_ok());
929 let result_str = result.unwrap();
930 assert!(result_str.contains("1 replacement"));
931 }
932
933 #[test]
934 fn test_normalize_whitespace() {
935 assert_eq!(
936 EditFileTool::normalize_whitespace(" hello world "),
937 "hello world"
938 );
939 assert_eq!(EditFileTool::normalize_whitespace("a\n\nb\tc"), "a b c");
940 }
941
942 #[test]
943 fn test_find_all_exact_matches() {
944 let content = "foo bar foo baz foo";
945 let matches = EditFileTool::find_all_exact_matches(content, "foo");
946 assert_eq!(matches.len(), 3);
947 assert_eq!(matches[0], (0, 3));
948 assert_eq!(matches[1], (8, 11));
949 assert_eq!(matches[2], (16, 19));
950 }
951
952 #[test]
953 fn test_compact_summary() {
954 let (registry, _rx) = create_test_registry();
955 let tool = EditFileTool::new(registry);
956
957 let mut input = HashMap::new();
958 input.insert(
959 "file_path".to_string(),
960 serde_json::Value::String("/path/to/file.rs".to_string()),
961 );
962
963 let result = "Successfully made 2 replacement(s) in '/path/to/file.rs'";
964 let summary = tool.compact_summary(&input, result);
965 assert_eq!(summary, "[EditFile: file.rs (ok)]");
966 }
967
968 #[test]
969 fn test_build_permission_request() {
970 let request = EditFileTool::build_permission_request(
971 "test-tool-use-id",
972 "/path/to/file.rs",
973 "old code",
974 );
975 assert_eq!(request.description, "Edit file: /path/to/file.rs");
976 assert!(request.reason.unwrap().contains("old code"));
977 assert_eq!(request.target, GrantTarget::path("/path/to/file.rs", false));
978 assert_eq!(request.required_level, PermissionLevel::Write);
979 }
980}