Skip to main content

agent_core_runtime/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 crate::permissions::{GrantTarget, PermissionLevel, PermissionRegistry, PermissionRequest};
17use super::types::{
18    DisplayConfig, DisplayResult, Executable, ResultContentType, ToolContext, ToolType,
19};
20
21/// EditFile tool name constant.
22pub const EDIT_FILE_TOOL_NAME: &str = "edit_file";
23
24/// EditFile tool description constant.
25pub 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
40/// EditFile tool JSON schema constant.
41pub 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/// Configuration for fuzzy matching behavior.
73#[derive(Debug, Clone)]
74pub struct FuzzyConfig {
75    /// Minimum similarity threshold (0.0 to 1.0).
76    pub threshold: f64,
77    /// Whether to normalize whitespace before comparison.
78    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/// Type of match found during search.
91#[derive(Debug, Clone, Copy, PartialEq)]
92pub enum MatchType {
93    /// Exact string match.
94    Exact,
95    /// Match after normalizing whitespace.
96    WhitespaceInsensitive,
97    /// Fuzzy match using similarity scoring.
98    Fuzzy,
99}
100
101/// Tool that performs find-and-replace operations on files.
102pub struct EditFileTool {
103    /// Reference to the permission registry for requesting write permissions.
104    permission_registry: Arc<PermissionRegistry>,
105}
106
107impl EditFileTool {
108    /// Create a new EditFileTool with permission registry.
109    pub fn new(permission_registry: Arc<PermissionRegistry>) -> Self {
110        Self {
111            permission_registry,
112        }
113    }
114
115    /// Build a permission request for editing a file.
116    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    /// Normalize whitespace for fuzzy comparison.
132    fn normalize_whitespace(s: &str) -> String {
133        s.split_whitespace().collect::<Vec<_>>().join(" ")
134    }
135
136    /// Multi-stage fuzzy matching.
137    /// Returns: Option<(start_byte, end_byte, similarity_score, match_type)>
138    fn find_match(
139        content: &str,
140        search: &str,
141        config: &FuzzyConfig,
142    ) -> Option<(usize, usize, f64, MatchType)> {
143        // Stage 1: Exact match
144        if let Some(start) = content.find(search) {
145            return Some((start, start + search.len(), 1.0, MatchType::Exact));
146        }
147
148        // Stage 2: Whitespace-insensitive exact match
149        if let Some(pos) = Self::find_normalized_position(content, search) {
150            return Some((pos.0, pos.1, 0.95, MatchType::WhitespaceInsensitive));
151        }
152
153        // Stage 3: Fuzzy match using sliding window
154        Self::find_fuzzy_match_sliding_window(content, search, config)
155            .map(|(start, end, sim)| (start, end, sim, MatchType::Fuzzy))
156    }
157
158    /// Find position in original content that corresponds to normalized match.
159    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        // Find matching line sequence
168        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                // Check if subsequent lines match
175                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                    // Calculate byte positions
191                    let start_byte: usize = content_lines[..i]
192                        .iter()
193                        .map(|l| l.len() + 1) // +1 for newline
194                        .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    /// Sliding window fuzzy match with similarity scoring.
208    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        // Slide window of search_line_count lines over content
224        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            // Calculate similarity
229            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                    // Calculate byte positions
246                    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    /// Find all exact matches.
263    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            // Extract required parameters
303            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            // Optional parameters
319            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            // Validate absolute path
338            if !path.is_absolute() {
339                return Err(format!(
340                    "file_path must be an absolute path, got: {}",
341                    file_path
342                ));
343            }
344
345            // Check file exists
346            if !path.exists() {
347                return Err(format!("File does not exist: {}", file_path));
348            }
349
350            // Check if old_string and new_string are the same
351            if old_string == new_string {
352                return Err("old_string and new_string are identical".to_string());
353            }
354
355            // Request permission if not pre-approved by batch executor
356            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            // Read file content
379            let content = fs::read_to_string(&path)
380                .map_err(|e| format!("Failed to read file '{}': {}", file_path, e))?;
381
382            // Perform replacement
383            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                // Exact match - replace all
412                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                // Exact match - replace first only
423                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            // Write modified content back
439            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        // Extract file_path from input
523        let file_path = input
524            .get("file_path")
525            .and_then(|v| v.as_str())?;
526
527        // Extract old_string for permission request context
528        let old_string = input
529            .get("old_string")
530            .and_then(|v| v.as_str())
531            .unwrap_or("");
532
533        // Validate that the path is absolute
534        let path = PathBuf::from(file_path);
535        if !path.is_absolute() {
536            return None;
537        }
538
539        // Build the permission request using the existing helper method
540        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
550/// Truncate string for display purposes.
551fn 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        // Grant permission in background
613        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        // File has extra spaces
879        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        // Search string has normalized whitespace
887        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}