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 crate::permissions::{GrantTarget, PermissionLevel, PermissionRegistry, PermissionRequest};
17use super::types::{
18 DisplayConfig, DisplayResult, Executable, ResultContentType, ToolContext, ToolType,
19};
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(tool_use_id: &str, file_path: &str, old_string: &str) -> PermissionRequest {
117 let path = file_path;
118 let truncated_old = truncate_string(old_string, 30);
119 let reason = format!("Replace '{}' in file", truncated_old);
120
121 PermissionRequest::new(
122 tool_use_id,
123 GrantTarget::path(path, false),
124 PermissionLevel::Write,
125 &format!("Edit file: {}", path),
126 )
127 .with_reason(reason)
128 .with_tool(EDIT_FILE_TOOL_NAME)
129 }
130
131 fn normalize_whitespace(s: &str) -> String {
133 s.split_whitespace().collect::<Vec<_>>().join(" ")
134 }
135
136 fn find_match(
139 content: &str,
140 search: &str,
141 config: &FuzzyConfig,
142 ) -> Option<(usize, usize, f64, MatchType)> {
143 if let Some(start) = content.find(search) {
145 return Some((start, start + search.len(), 1.0, MatchType::Exact));
146 }
147
148 if let Some(pos) = Self::find_normalized_position(content, search) {
150 return Some((pos.0, pos.1, 0.95, MatchType::WhitespaceInsensitive));
151 }
152
153 Self::find_fuzzy_match_sliding_window(content, search, config)
155 .map(|(start, end, sim)| (start, end, sim, MatchType::Fuzzy))
156 }
157
158 fn find_normalized_position(content: &str, search: &str) -> Option<(usize, usize)> {
160 let search_lines: Vec<&str> = search.lines().collect();
161 let content_lines: Vec<&str> = content.lines().collect();
162
163 if search_lines.is_empty() {
164 return None;
165 }
166
167 let first_search_normalized = Self::normalize_whitespace(search_lines[0]);
169
170 for (i, content_line) in content_lines.iter().enumerate() {
171 let content_normalized = Self::normalize_whitespace(content_line);
172
173 if content_normalized == first_search_normalized {
174 let mut all_match = true;
176 for (j, search_line) in search_lines.iter().enumerate().skip(1) {
177 if i + j >= content_lines.len() {
178 all_match = false;
179 break;
180 }
181 let cn = Self::normalize_whitespace(content_lines[i + j]);
182 let sn = Self::normalize_whitespace(search_line);
183 if cn != sn {
184 all_match = false;
185 break;
186 }
187 }
188
189 if all_match {
190 let start_byte: usize = content_lines[..i]
192 .iter()
193 .map(|l| l.len() + 1) .sum();
195 let end_line = i + search_lines.len();
196 let matched_text = content_lines[i..end_line].join("\n");
197 let end_byte = start_byte + matched_text.len();
198
199 return Some((start_byte, end_byte));
200 }
201 }
202 }
203
204 None
205 }
206
207 fn find_fuzzy_match_sliding_window(
209 content: &str,
210 search: &str,
211 config: &FuzzyConfig,
212 ) -> Option<(usize, usize, f64)> {
213 let search_lines: Vec<&str> = search.lines().collect();
214 let content_lines: Vec<&str> = content.lines().collect();
215 let search_line_count = search_lines.len();
216
217 if search_line_count == 0 || content_lines.len() < search_line_count {
218 return None;
219 }
220
221 let mut best_match: Option<(usize, usize, f64)> = None;
222
223 for window_start in 0..=(content_lines.len() - search_line_count) {
225 let window_end = window_start + search_line_count;
226 let window: Vec<&str> = content_lines[window_start..window_end].to_vec();
227
228 let window_text = if config.normalize_whitespace {
230 Self::normalize_whitespace(&window.join("\n"))
231 } else {
232 window.join("\n")
233 };
234
235 let search_text = if config.normalize_whitespace {
236 Self::normalize_whitespace(search)
237 } else {
238 search.to_string()
239 };
240
241 let similarity = normalized_levenshtein(&search_text, &window_text);
242
243 if similarity >= config.threshold {
244 if best_match.is_none() || similarity > best_match.unwrap().2 {
245 let start_byte: usize = content_lines[..window_start]
247 .iter()
248 .map(|l| l.len() + 1)
249 .sum();
250
251 let matched_text = content_lines[window_start..window_end].join("\n");
252 let end_byte = start_byte + matched_text.len();
253
254 best_match = Some((start_byte, end_byte, similarity));
255 }
256 }
257 }
258
259 best_match
260 }
261
262 fn find_all_exact_matches(content: &str, search: &str) -> Vec<(usize, usize)> {
264 let mut matches = Vec::new();
265 let mut start = 0;
266
267 while let Some(pos) = content[start..].find(search) {
268 let actual_start = start + pos;
269 matches.push((actual_start, actual_start + search.len()));
270 start = actual_start + search.len();
271 }
272
273 matches
274 }
275}
276
277impl Executable for EditFileTool {
278 fn name(&self) -> &str {
279 EDIT_FILE_TOOL_NAME
280 }
281
282 fn description(&self) -> &str {
283 EDIT_FILE_TOOL_DESCRIPTION
284 }
285
286 fn input_schema(&self) -> &str {
287 EDIT_FILE_TOOL_SCHEMA
288 }
289
290 fn tool_type(&self) -> ToolType {
291 ToolType::TextEdit
292 }
293
294 fn execute(
295 &self,
296 context: ToolContext,
297 input: HashMap<String, serde_json::Value>,
298 ) -> Pin<Box<dyn Future<Output = Result<String, String>> + Send>> {
299 let permission_registry = self.permission_registry.clone();
300
301 Box::pin(async move {
302 let file_path = input
304 .get("file_path")
305 .and_then(|v| v.as_str())
306 .ok_or_else(|| "Missing required 'file_path' parameter".to_string())?;
307
308 let old_string = input
309 .get("old_string")
310 .and_then(|v| v.as_str())
311 .ok_or_else(|| "Missing required 'old_string' parameter".to_string())?;
312
313 let new_string = input
314 .get("new_string")
315 .and_then(|v| v.as_str())
316 .ok_or_else(|| "Missing required 'new_string' parameter".to_string())?;
317
318 let replace_all = input
320 .get("replace_all")
321 .and_then(|v| v.as_bool())
322 .unwrap_or(false);
323
324 let fuzzy_match = input
325 .get("fuzzy_match")
326 .and_then(|v| v.as_bool())
327 .unwrap_or(false);
328
329 let fuzzy_threshold = input
330 .get("fuzzy_threshold")
331 .and_then(|v| v.as_f64())
332 .unwrap_or(0.7)
333 .clamp(0.0, 1.0);
334
335 let path = PathBuf::from(file_path);
336
337 if !path.is_absolute() {
339 return Err(format!(
340 "file_path must be an absolute path, got: {}",
341 file_path
342 ));
343 }
344
345 if !path.exists() {
347 return Err(format!("File does not exist: {}", file_path));
348 }
349
350 if old_string == new_string {
352 return Err("old_string and new_string are identical".to_string());
353 }
354
355 if !context.permissions_pre_approved {
357 let permission_request = Self::build_permission_request(&context.tool_use_id, file_path, old_string);
358 let response_rx = permission_registry
359 .request_permission(context.session_id, permission_request, context.turn_id.clone())
360 .await
361 .map_err(|e| format!("Failed to request permission: {}", e))?;
362
363 let response = response_rx
364 .await
365 .map_err(|_| "Permission request was cancelled".to_string())?;
366
367 if !response.granted {
368 let reason = response
369 .message
370 .unwrap_or_else(|| "Permission denied by user".to_string());
371 return Err(format!(
372 "Permission denied to edit '{}': {}",
373 file_path, reason
374 ));
375 }
376 }
377
378 let content = fs::read_to_string(&path)
380 .map_err(|e| format!("Failed to read file '{}': {}", file_path, e))?;
381
382 let (new_content, replacement_count, match_info) = if fuzzy_match {
384 let config = FuzzyConfig {
385 threshold: fuzzy_threshold,
386 normalize_whitespace: true,
387 };
388
389 if let Some((start, end, similarity, match_type)) =
390 Self::find_match(&content, old_string, &config)
391 {
392 let mut new_content = String::with_capacity(content.len());
393 new_content.push_str(&content[..start]);
394 new_content.push_str(new_string);
395 new_content.push_str(&content[end..]);
396
397 let match_info = format!(
398 " (match type: {:?}, similarity: {:.1}%)",
399 match_type,
400 similarity * 100.0
401 );
402 (new_content, 1, match_info)
403 } else {
404 return Err(format!(
405 "No match found for '{}' with threshold {:.0}%",
406 truncate_string(old_string, 50),
407 fuzzy_threshold * 100.0
408 ));
409 }
410 } else if replace_all {
411 let matches = Self::find_all_exact_matches(&content, old_string);
413 if matches.is_empty() {
414 return Err(format!(
415 "String not found in file: '{}'",
416 truncate_string(old_string, 50)
417 ));
418 }
419 let new_content = content.replace(old_string, new_string);
420 (new_content, matches.len(), String::new())
421 } else {
422 if let Some(start) = content.find(old_string) {
424 let end = start + old_string.len();
425 let mut new_content = String::with_capacity(content.len());
426 new_content.push_str(&content[..start]);
427 new_content.push_str(new_string);
428 new_content.push_str(&content[end..]);
429 (new_content, 1, String::new())
430 } else {
431 return Err(format!(
432 "String not found in file: '{}'",
433 truncate_string(old_string, 50)
434 ));
435 }
436 };
437
438 fs::write(&path, &new_content)
440 .map_err(|e| format!("Failed to write file '{}': {}", file_path, e))?;
441
442 Ok(format!(
443 "Successfully made {} replacement(s) in '{}'{}",
444 replacement_count, file_path, match_info
445 ))
446 })
447 }
448
449 fn display_config(&self) -> DisplayConfig {
450 DisplayConfig {
451 display_name: "Edit File".to_string(),
452 display_title: Box::new(|input| {
453 input
454 .get("file_path")
455 .and_then(|v| v.as_str())
456 .map(|p| {
457 Path::new(p)
458 .file_name()
459 .and_then(|n| n.to_str())
460 .unwrap_or(p)
461 .to_string()
462 })
463 .unwrap_or_default()
464 }),
465 display_content: Box::new(|input, result| {
466 let old_str = input
467 .get("old_string")
468 .and_then(|v| v.as_str())
469 .unwrap_or("");
470 let new_str = input
471 .get("new_string")
472 .and_then(|v| v.as_str())
473 .unwrap_or("");
474
475 let content = format!(
476 "--- old\n+++ new\n- {}\n+ {}\n\n{}",
477 truncate_string(old_str, 100),
478 truncate_string(new_str, 100),
479 result
480 );
481
482 DisplayResult {
483 content,
484 content_type: ResultContentType::PlainText,
485 is_truncated: false,
486 full_length: 0,
487 }
488 }),
489 }
490 }
491
492 fn compact_summary(
493 &self,
494 input: &HashMap<String, serde_json::Value>,
495 result: &str,
496 ) -> String {
497 let filename = input
498 .get("file_path")
499 .and_then(|v| v.as_str())
500 .map(|p| {
501 Path::new(p)
502 .file_name()
503 .and_then(|n| n.to_str())
504 .unwrap_or(p)
505 })
506 .unwrap_or("unknown");
507
508 let status = if result.contains("Successfully") {
509 "ok"
510 } else {
511 "error"
512 };
513
514 format!("[EditFile: {} ({})]", filename, status)
515 }
516
517 fn required_permissions(
518 &self,
519 context: &ToolContext,
520 input: &HashMap<String, serde_json::Value>,
521 ) -> Option<Vec<PermissionRequest>> {
522 let file_path = input
524 .get("file_path")
525 .and_then(|v| v.as_str())?;
526
527 let old_string = input
529 .get("old_string")
530 .and_then(|v| v.as_str())
531 .unwrap_or("");
532
533 let path = PathBuf::from(file_path);
535 if !path.is_absolute() {
536 return None;
537 }
538
539 let permission_request = Self::build_permission_request(
541 &context.tool_use_id,
542 file_path,
543 old_string,
544 );
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::permissions::PermissionPanelResponse;
563 use crate::controller::types::ControllerEvent;
564 use crate::permissions::PermissionLevel;
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 { granted: true, grant: None, message: None }
576 }
577
578 fn deny(reason: &str) -> PermissionPanelResponse {
579 PermissionPanelResponse { granted: false, grant: None, message: Some(reason.to_string()) }
580 }
581
582 #[tokio::test]
583 async fn test_exact_replace_first() {
584 let (registry, mut event_rx) = create_test_registry();
585 let tool = EditFileTool::new(registry.clone());
586
587 let temp_dir = TempDir::new().unwrap();
588 let file_path = temp_dir.path().join("test.txt");
589 fs::write(&file_path, "foo bar foo baz").unwrap();
590
591 let mut input = HashMap::new();
592 input.insert(
593 "file_path".to_string(),
594 serde_json::Value::String(file_path.to_str().unwrap().to_string()),
595 );
596 input.insert(
597 "old_string".to_string(),
598 serde_json::Value::String("foo".to_string()),
599 );
600 input.insert(
601 "new_string".to_string(),
602 serde_json::Value::String("qux".to_string()),
603 );
604
605 let context = ToolContext {
606 session_id: 1,
607 tool_use_id: "test-edit-1".to_string(),
608 turn_id: None,
609 permissions_pre_approved: false,
610 };
611
612 let registry_clone = registry.clone();
614 tokio::spawn(async move {
615 if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
616 event_rx.recv().await
617 {
618 registry_clone
619 .respond_to_request(&tool_use_id, grant_once())
620 .await
621 .unwrap();
622 }
623 });
624
625 let result = tool.execute(context, input).await;
626 assert!(result.is_ok());
627 assert!(result.unwrap().contains("1 replacement"));
628 assert_eq!(fs::read_to_string(&file_path).unwrap(), "qux bar foo baz");
629 }
630
631 #[tokio::test]
632 async fn test_exact_replace_all() {
633 let (registry, mut event_rx) = create_test_registry();
634 let tool = EditFileTool::new(registry.clone());
635
636 let temp_dir = TempDir::new().unwrap();
637 let file_path = temp_dir.path().join("test.txt");
638 fs::write(&file_path, "foo bar foo baz foo").unwrap();
639
640 let mut input = HashMap::new();
641 input.insert(
642 "file_path".to_string(),
643 serde_json::Value::String(file_path.to_str().unwrap().to_string()),
644 );
645 input.insert(
646 "old_string".to_string(),
647 serde_json::Value::String("foo".to_string()),
648 );
649 input.insert(
650 "new_string".to_string(),
651 serde_json::Value::String("qux".to_string()),
652 );
653 input.insert("replace_all".to_string(), serde_json::Value::Bool(true));
654
655 let context = ToolContext {
656 session_id: 1,
657 tool_use_id: "test-edit-2".to_string(),
658 turn_id: None,
659 permissions_pre_approved: false,
660 };
661
662 let registry_clone = registry.clone();
663 tokio::spawn(async move {
664 if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
665 event_rx.recv().await
666 {
667 registry_clone
668 .respond_to_request(&tool_use_id, grant_once())
669 .await
670 .unwrap();
671 }
672 });
673
674 let result = tool.execute(context, input).await;
675 assert!(result.is_ok());
676 assert!(result.unwrap().contains("3 replacement"));
677 assert_eq!(fs::read_to_string(&file_path).unwrap(), "qux bar qux baz qux");
678 }
679
680 #[tokio::test]
681 async fn test_string_not_found() {
682 let (registry, mut event_rx) = create_test_registry();
683 let tool = EditFileTool::new(registry.clone());
684
685 let temp_dir = TempDir::new().unwrap();
686 let file_path = temp_dir.path().join("test.txt");
687 fs::write(&file_path, "hello world").unwrap();
688
689 let mut input = HashMap::new();
690 input.insert(
691 "file_path".to_string(),
692 serde_json::Value::String(file_path.to_str().unwrap().to_string()),
693 );
694 input.insert(
695 "old_string".to_string(),
696 serde_json::Value::String("notfound".to_string()),
697 );
698 input.insert(
699 "new_string".to_string(),
700 serde_json::Value::String("replacement".to_string()),
701 );
702
703 let context = ToolContext {
704 session_id: 1,
705 tool_use_id: "test-edit-3".to_string(),
706 turn_id: None,
707 permissions_pre_approved: false,
708 };
709
710 let registry_clone = registry.clone();
711 tokio::spawn(async move {
712 if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
713 event_rx.recv().await
714 {
715 registry_clone
716 .respond_to_request(&tool_use_id, grant_once())
717 .await
718 .unwrap();
719 }
720 });
721
722 let result = tool.execute(context, input).await;
723 assert!(result.is_err());
724 assert!(result.unwrap_err().contains("not found"));
725 }
726
727 #[tokio::test]
728 async fn test_file_not_found() {
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("/nonexistent/file.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-4".to_string(),
749 turn_id: None,
750 permissions_pre_approved: false,
751 };
752
753 let result = tool.execute(context, input).await;
754 assert!(result.is_err());
755 assert!(result.unwrap_err().contains("does not exist"));
756 }
757
758 #[tokio::test]
759 async fn test_relative_path_rejected() {
760 let (registry, _event_rx) = create_test_registry();
761 let tool = EditFileTool::new(registry);
762
763 let mut input = HashMap::new();
764 input.insert(
765 "file_path".to_string(),
766 serde_json::Value::String("relative/path.txt".to_string()),
767 );
768 input.insert(
769 "old_string".to_string(),
770 serde_json::Value::String("foo".to_string()),
771 );
772 input.insert(
773 "new_string".to_string(),
774 serde_json::Value::String("bar".to_string()),
775 );
776
777 let context = ToolContext {
778 session_id: 1,
779 tool_use_id: "test-edit-5".to_string(),
780 turn_id: None,
781 permissions_pre_approved: false,
782 };
783
784 let result = tool.execute(context, input).await;
785 assert!(result.is_err());
786 assert!(result.unwrap_err().contains("absolute path"));
787 }
788
789 #[tokio::test]
790 async fn test_identical_strings_rejected() {
791 let (registry, _event_rx) = create_test_registry();
792 let tool = EditFileTool::new(registry);
793
794 let temp_dir = TempDir::new().unwrap();
795 let file_path = temp_dir.path().join("test.txt");
796 fs::write(&file_path, "hello world").unwrap();
797
798 let mut input = HashMap::new();
799 input.insert(
800 "file_path".to_string(),
801 serde_json::Value::String(file_path.to_str().unwrap().to_string()),
802 );
803 input.insert(
804 "old_string".to_string(),
805 serde_json::Value::String("same".to_string()),
806 );
807 input.insert(
808 "new_string".to_string(),
809 serde_json::Value::String("same".to_string()),
810 );
811
812 let context = ToolContext {
813 session_id: 1,
814 tool_use_id: "test-edit-6".to_string(),
815 turn_id: None,
816 permissions_pre_approved: false,
817 };
818
819 let result = tool.execute(context, input).await;
820 assert!(result.is_err());
821 assert!(result.unwrap_err().contains("identical"));
822 }
823
824 #[tokio::test]
825 async fn test_permission_denied() {
826 let (registry, mut event_rx) = create_test_registry();
827 let tool = EditFileTool::new(registry.clone());
828
829 let temp_dir = TempDir::new().unwrap();
830 let file_path = temp_dir.path().join("test.txt");
831 fs::write(&file_path, "hello world").unwrap();
832
833 let mut input = HashMap::new();
834 input.insert(
835 "file_path".to_string(),
836 serde_json::Value::String(file_path.to_str().unwrap().to_string()),
837 );
838 input.insert(
839 "old_string".to_string(),
840 serde_json::Value::String("hello".to_string()),
841 );
842 input.insert(
843 "new_string".to_string(),
844 serde_json::Value::String("goodbye".to_string()),
845 );
846
847 let context = ToolContext {
848 session_id: 1,
849 tool_use_id: "test-edit-7".to_string(),
850 turn_id: None,
851 permissions_pre_approved: false,
852 };
853
854 let registry_clone = registry.clone();
855 tokio::spawn(async move {
856 if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
857 event_rx.recv().await
858 {
859 registry_clone
860 .respond_to_request(&tool_use_id, deny("Not allowed"))
861 .await
862 .unwrap();
863 }
864 });
865
866 let result = tool.execute(context, input).await;
867 assert!(result.is_err());
868 assert!(result.unwrap_err().contains("Permission denied"));
869 }
870
871 #[tokio::test]
872 async fn test_whitespace_insensitive_match() {
873 let (registry, mut event_rx) = create_test_registry();
874 let tool = EditFileTool::new(registry.clone());
875
876 let temp_dir = TempDir::new().unwrap();
877 let file_path = temp_dir.path().join("test.txt");
878 fs::write(&file_path, "fn foo() {\n bar();\n}").unwrap();
880
881 let mut input = HashMap::new();
882 input.insert(
883 "file_path".to_string(),
884 serde_json::Value::String(file_path.to_str().unwrap().to_string()),
885 );
886 input.insert(
888 "old_string".to_string(),
889 serde_json::Value::String("fn foo() {\nbar();\n}".to_string()),
890 );
891 input.insert(
892 "new_string".to_string(),
893 serde_json::Value::String("fn foo() {\n baz();\n}".to_string()),
894 );
895 input.insert("fuzzy_match".to_string(), serde_json::Value::Bool(true));
896
897 let context = ToolContext {
898 session_id: 1,
899 tool_use_id: "test-edit-8".to_string(),
900 turn_id: None,
901 permissions_pre_approved: false,
902 };
903
904 let registry_clone = registry.clone();
905 tokio::spawn(async move {
906 if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
907 event_rx.recv().await
908 {
909 registry_clone
910 .respond_to_request(&tool_use_id, grant_once())
911 .await
912 .unwrap();
913 }
914 });
915
916 let result = tool.execute(context, input).await;
917 assert!(result.is_ok());
918 let result_str = result.unwrap();
919 assert!(result_str.contains("1 replacement"));
920 }
921
922 #[test]
923 fn test_normalize_whitespace() {
924 assert_eq!(
925 EditFileTool::normalize_whitespace(" hello world "),
926 "hello world"
927 );
928 assert_eq!(
929 EditFileTool::normalize_whitespace("a\n\nb\tc"),
930 "a b c"
931 );
932 }
933
934 #[test]
935 fn test_find_all_exact_matches() {
936 let content = "foo bar foo baz foo";
937 let matches = EditFileTool::find_all_exact_matches(content, "foo");
938 assert_eq!(matches.len(), 3);
939 assert_eq!(matches[0], (0, 3));
940 assert_eq!(matches[1], (8, 11));
941 assert_eq!(matches[2], (16, 19));
942 }
943
944 #[test]
945 fn test_compact_summary() {
946 let (registry, _rx) = create_test_registry();
947 let tool = EditFileTool::new(registry);
948
949 let mut input = HashMap::new();
950 input.insert(
951 "file_path".to_string(),
952 serde_json::Value::String("/path/to/file.rs".to_string()),
953 );
954
955 let result = "Successfully made 2 replacement(s) in '/path/to/file.rs'";
956 let summary = tool.compact_summary(&input, result);
957 assert_eq!(summary, "[EditFile: file.rs (ok)]");
958 }
959
960 #[test]
961 fn test_build_permission_request() {
962 let request = EditFileTool::build_permission_request("test-tool-use-id", "/path/to/file.rs", "old code");
963 assert_eq!(request.description, "Edit file: /path/to/file.rs");
964 assert!(request.reason.unwrap().contains("old code"));
965 assert_eq!(request.target, GrantTarget::path("/path/to/file.rs", false));
966 assert_eq!(request.required_level, PermissionLevel::Write);
967 }
968}