Skip to main content

agent_core/controller/tools/
edit_file.rs

1//! EditFile tool implementation for find-and-replace operations.
2//!
3//! This tool performs string replacement in files with optional fuzzy matching.
4//! It integrates with the PermissionRegistry to require user approval before
5//! modifying files.
6
7use 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
22/// EditFile tool name constant.
23pub const EDIT_FILE_TOOL_NAME: &str = "edit_file";
24
25/// EditFile tool description constant.
26pub 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
41/// EditFile tool JSON schema constant.
42pub 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/// Configuration for fuzzy matching behavior.
74#[derive(Debug, Clone)]
75pub struct FuzzyConfig {
76    /// Minimum similarity threshold (0.0 to 1.0).
77    pub threshold: f64,
78    /// Whether to normalize whitespace before comparison.
79    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/// Type of match found during search.
92#[derive(Debug, Clone, Copy, PartialEq)]
93pub enum MatchType {
94    /// Exact string match.
95    Exact,
96    /// Match after normalizing whitespace.
97    WhitespaceInsensitive,
98    /// Fuzzy match using similarity scoring.
99    Fuzzy,
100}
101
102/// Tool that performs find-and-replace operations on files.
103pub struct EditFileTool {
104    /// Reference to the permission registry for requesting write permissions.
105    permission_registry: Arc<PermissionRegistry>,
106}
107
108impl EditFileTool {
109    /// Create a new EditFileTool with permission registry.
110    pub fn new(permission_registry: Arc<PermissionRegistry>) -> Self {
111        Self {
112            permission_registry,
113        }
114    }
115
116    /// Build a permission request for editing a file.
117    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    /// Normalize whitespace for fuzzy comparison.
135    fn normalize_whitespace(s: &str) -> String {
136        s.split_whitespace().collect::<Vec<_>>().join(" ")
137    }
138
139    /// Multi-stage fuzzy matching.
140    /// Returns: Option<(start_byte, end_byte, similarity_score, match_type)>
141    fn find_match(
142        content: &str,
143        search: &str,
144        config: &FuzzyConfig,
145    ) -> Option<(usize, usize, f64, MatchType)> {
146        // Stage 1: Exact match
147        if let Some(start) = content.find(search) {
148            return Some((start, start + search.len(), 1.0, MatchType::Exact));
149        }
150
151        // Stage 2: Whitespace-insensitive exact match
152        if let Some(pos) = Self::find_normalized_position(content, search) {
153            return Some((pos.0, pos.1, 0.95, MatchType::WhitespaceInsensitive));
154        }
155
156        // Stage 3: Fuzzy match using sliding window
157        Self::find_fuzzy_match_sliding_window(content, search, config)
158            .map(|(start, end, sim)| (start, end, sim, MatchType::Fuzzy))
159    }
160
161    /// Find position in original content that corresponds to normalized match.
162    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        // Find matching line sequence
171        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                // Check if subsequent lines match
178                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                    // Calculate byte positions
194                    let start_byte: usize = content_lines[..i]
195                        .iter()
196                        .map(|l| l.len() + 1) // +1 for newline
197                        .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    /// Sliding window fuzzy match with similarity scoring.
211    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        // Slide window of search_line_count lines over content
227        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            // Calculate similarity
232            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                    // Calculate byte positions
249                    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    /// Find all exact matches.
266    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            // Extract required parameters
306            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            // Optional parameters
322            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            // Validate absolute path
341            if !path.is_absolute() {
342                return Err(format!(
343                    "file_path must be an absolute path, got: {}",
344                    file_path
345                ));
346            }
347
348            // Check file exists
349            if !path.exists() {
350                return Err(format!("File does not exist: {}", file_path));
351            }
352
353            // Check if old_string and new_string are the same
354            if old_string == new_string {
355                return Err("old_string and new_string are identical".to_string());
356            }
357
358            // Check if permission is already granted for this session
359            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                // Request permission from user
366                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                // Wait for user response
377                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            // Read file content
393            let content = fs::read_to_string(&path)
394                .map_err(|e| format!("Failed to read file '{}': {}", file_path, e))?;
395
396            // Perform replacement
397            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                // Exact match - replace all
426                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                // Exact match - replace first only
437                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            // Write modified content back
453            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
532/// Truncate string for display purposes.
533fn 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        // Grant permission in background
585        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        // File has extra spaces
845        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        // Search string has normalized whitespace
853        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}