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