Skip to main content

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