Skip to main content

agent_air_runtime/controller/tools/
multi_edit.rs

1//! MultiEdit tool implementation for atomic multiple find-and-replace operations.
2//!
3//! This tool performs multiple string replacements on a single file atomically.
4//! All edits are validated before any are applied, ensuring the file is either
5//! fully updated or unchanged if any edit fails.
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/// MultiEdit tool name constant.
22pub const MULTI_EDIT_TOOL_NAME: &str = "multi_edit";
23
24/// MultiEdit tool description constant.
25pub const MULTI_EDIT_TOOL_DESCRIPTION: &str = r#"Performs multiple find-and-replace operations on a single file atomically.
26
27Usage:
28- The file_path parameter must be an absolute path
29- Provide an array of edits, each with old_string and new_string
30- All edits are validated before any are applied
31- If any edit fails validation, no changes are made
32- Edits are applied in order, accounting for position shifts
33
34Features:
35- Single permission request for all edits
36- Atomic: all edits succeed or none are applied
37- Automatic position adjustment as earlier edits shift content
38- Optional fuzzy matching per edit
39- Dry-run mode to preview changes
40
41Returns:
42- Success message with count of edits applied
43- Error if any edit cannot be applied (no changes made)"#;
44
45/// MultiEdit tool JSON schema constant.
46pub const MULTI_EDIT_TOOL_SCHEMA: &str = r#"{
47    "type": "object",
48    "properties": {
49        "file_path": {
50            "type": "string",
51            "description": "The absolute path to the file to edit"
52        },
53        "edits": {
54            "type": "array",
55            "description": "Array of edit operations to apply in order",
56            "items": {
57                "type": "object",
58                "properties": {
59                    "old_string": {
60                        "type": "string",
61                        "description": "The string to find and replace"
62                    },
63                    "new_string": {
64                        "type": "string",
65                        "description": "The string to replace with"
66                    },
67                    "replace_all": {
68                        "type": "boolean",
69                        "description": "Replace all occurrences (default: false, first only)"
70                    },
71                    "fuzzy_match": {
72                        "type": "boolean",
73                        "description": "Enable fuzzy matching for this edit (default: false)"
74                    },
75                    "fuzzy_threshold": {
76                        "type": "number",
77                        "description": "Similarity threshold for fuzzy matching (0.0-1.0, default: 0.7)"
78                    }
79                },
80                "required": ["old_string", "new_string"]
81            },
82            "minItems": 1,
83            "maxItems": 50
84        },
85        "dry_run": {
86            "type": "boolean",
87            "description": "If true, validate edits and return preview without applying. Default: false"
88        }
89    },
90    "required": ["file_path", "edits"]
91}"#;
92
93const MAX_EDITS: usize = 50;
94
95/// Configuration for fuzzy matching behavior.
96#[derive(Debug, Clone)]
97struct FuzzyConfig {
98    threshold: f64,
99    normalize_whitespace: bool,
100}
101
102impl Default for FuzzyConfig {
103    fn default() -> Self {
104        Self {
105            threshold: 0.7,
106            normalize_whitespace: true,
107        }
108    }
109}
110
111/// Type of match found during search.
112#[derive(Debug, Clone, Copy, PartialEq)]
113enum MatchType {
114    Exact,
115    WhitespaceInsensitive,
116    Fuzzy,
117}
118
119/// Input for a single edit operation.
120#[derive(Debug, Clone)]
121struct EditInput {
122    old_string: String,
123    new_string: String,
124    replace_all: bool,
125    fuzzy_match: bool,
126    fuzzy_threshold: f64,
127}
128
129/// A planned edit with resolved byte positions.
130#[derive(Debug, Clone)]
131struct PlannedEdit {
132    /// Index in the original edits array (for error reporting).
133    edit_index: usize,
134    /// Start byte position in original content.
135    start: usize,
136    /// End byte position in original content.
137    end: usize,
138    /// The replacement string.
139    new_string: String,
140    /// Match type for reporting.
141    match_type: MatchType,
142    /// Similarity score (1.0 for exact).
143    similarity: f64,
144}
145
146/// Tool that performs multiple find-and-replace operations atomically.
147pub struct MultiEditTool {
148    permission_registry: Arc<PermissionRegistry>,
149}
150
151impl MultiEditTool {
152    /// Create a new MultiEditTool with permission registry.
153    pub fn new(permission_registry: Arc<PermissionRegistry>) -> Self {
154        Self {
155            permission_registry,
156        }
157    }
158
159    /// Build a permission request for multi-edit operation.
160    fn build_permission_request(
161        tool_use_id: &str,
162        file_path: &str,
163        edit_count: usize,
164    ) -> PermissionRequest {
165        let path = file_path;
166        let reason = format!("Apply {} find-and-replace operations", edit_count);
167
168        PermissionRequest::new(
169            tool_use_id,
170            GrantTarget::path(path, false),
171            PermissionLevel::Write,
172            format!("Multi-edit file: {}", path),
173        )
174        .with_reason(reason)
175        .with_tool(MULTI_EDIT_TOOL_NAME)
176    }
177
178    /// Normalize whitespace for fuzzy comparison.
179    fn normalize_whitespace(s: &str) -> String {
180        s.split_whitespace().collect::<Vec<_>>().join(" ")
181    }
182
183    /// Find the first exact match.
184    fn find_first_exact_match(content: &str, search: &str) -> Vec<(usize, usize, f64, MatchType)> {
185        if let Some(start) = content.find(search) {
186            vec![(start, start + search.len(), 1.0, MatchType::Exact)]
187        } else {
188            vec![]
189        }
190    }
191
192    /// Find all exact matches.
193    fn find_all_exact_matches(content: &str, search: &str) -> Vec<(usize, usize, f64, MatchType)> {
194        let mut matches = Vec::new();
195        let mut start = 0;
196
197        while let Some(pos) = content[start..].find(search) {
198            let actual_start = start + pos;
199            matches.push((
200                actual_start,
201                actual_start + search.len(),
202                1.0,
203                MatchType::Exact,
204            ));
205            start = actual_start + search.len();
206        }
207
208        matches
209    }
210
211    /// Find match using fuzzy matching.
212    fn find_fuzzy_match(
213        content: &str,
214        search: &str,
215        config: &FuzzyConfig,
216    ) -> Vec<(usize, usize, f64, MatchType)> {
217        // Stage 1: Try exact match first
218        if let Some(start) = content.find(search) {
219            return vec![(start, start + search.len(), 1.0, MatchType::Exact)];
220        }
221
222        // Stage 2: Try whitespace-insensitive match
223        if let Some((start, end)) = Self::find_normalized_position(content, search) {
224            return vec![(start, end, 0.95, MatchType::WhitespaceInsensitive)];
225        }
226
227        // Stage 3: Fuzzy match using sliding window
228        if let Some((start, end, similarity)) =
229            Self::find_fuzzy_match_sliding_window(content, search, config)
230        {
231            return vec![(start, end, similarity, MatchType::Fuzzy)];
232        }
233
234        vec![]
235    }
236
237    /// Find position in original content that corresponds to normalized match.
238    fn find_normalized_position(content: &str, search: &str) -> Option<(usize, usize)> {
239        let search_lines: Vec<&str> = search.lines().collect();
240        let content_lines: Vec<&str> = content.lines().collect();
241
242        if search_lines.is_empty() {
243            return None;
244        }
245
246        let first_search_normalized = Self::normalize_whitespace(search_lines[0]);
247
248        for (i, content_line) in content_lines.iter().enumerate() {
249            let content_normalized = Self::normalize_whitespace(content_line);
250
251            if content_normalized == first_search_normalized {
252                let mut all_match = true;
253                for (j, search_line) in search_lines.iter().enumerate().skip(1) {
254                    if i + j >= content_lines.len() {
255                        all_match = false;
256                        break;
257                    }
258                    let cn = Self::normalize_whitespace(content_lines[i + j]);
259                    let sn = Self::normalize_whitespace(search_line);
260                    if cn != sn {
261                        all_match = false;
262                        break;
263                    }
264                }
265
266                if all_match {
267                    let start_byte: usize = content_lines[..i].iter().map(|l| l.len() + 1).sum();
268                    let end_line = i + search_lines.len();
269                    let matched_text = content_lines[i..end_line].join("\n");
270                    let end_byte = start_byte + matched_text.len();
271
272                    return Some((start_byte, end_byte));
273                }
274            }
275        }
276
277        None
278    }
279
280    /// Sliding window fuzzy match with similarity scoring.
281    fn find_fuzzy_match_sliding_window(
282        content: &str,
283        search: &str,
284        config: &FuzzyConfig,
285    ) -> Option<(usize, usize, f64)> {
286        let search_lines: Vec<&str> = search.lines().collect();
287        let content_lines: Vec<&str> = content.lines().collect();
288        let search_line_count = search_lines.len();
289
290        if search_line_count == 0 || content_lines.len() < search_line_count {
291            return None;
292        }
293
294        let mut best_match: Option<(usize, usize, f64)> = None;
295
296        for window_start in 0..=(content_lines.len() - search_line_count) {
297            let window_end = window_start + search_line_count;
298            let window: Vec<&str> = content_lines[window_start..window_end].to_vec();
299
300            let window_text = if config.normalize_whitespace {
301                Self::normalize_whitespace(&window.join("\n"))
302            } else {
303                window.join("\n")
304            };
305
306            let search_text = if config.normalize_whitespace {
307                Self::normalize_whitespace(search)
308            } else {
309                search.to_string()
310            };
311
312            let similarity = normalized_levenshtein(&search_text, &window_text);
313
314            if similarity >= config.threshold
315                && (best_match.is_none() || similarity > best_match.unwrap().2)
316            {
317                let start_byte: usize = content_lines[..window_start]
318                    .iter()
319                    .map(|l| l.len() + 1)
320                    .sum();
321
322                let matched_text = content_lines[window_start..window_end].join("\n");
323                let end_byte = start_byte + matched_text.len();
324
325                best_match = Some((start_byte, end_byte, similarity));
326            }
327        }
328
329        best_match
330    }
331
332    /// Plan all edits without applying them.
333    fn plan_edits(content: &str, edits: &[EditInput]) -> Result<Vec<PlannedEdit>, String> {
334        let mut planned = Vec::new();
335
336        for (index, edit) in edits.iter().enumerate() {
337            let config = FuzzyConfig {
338                threshold: edit.fuzzy_threshold,
339                normalize_whitespace: true,
340            };
341
342            let matches = if edit.fuzzy_match {
343                Self::find_fuzzy_match(content, &edit.old_string, &config)
344            } else if edit.replace_all {
345                Self::find_all_exact_matches(content, &edit.old_string)
346            } else {
347                Self::find_first_exact_match(content, &edit.old_string)
348            };
349
350            if matches.is_empty() {
351                return Err(format!(
352                    "Edit {}: string not found: '{}'",
353                    index + 1,
354                    truncate_string(&edit.old_string, 50)
355                ));
356            }
357
358            for (start, end, similarity, match_type) in matches {
359                planned.push(PlannedEdit {
360                    edit_index: index,
361                    start,
362                    end,
363                    new_string: edit.new_string.clone(),
364                    match_type,
365                    similarity,
366                });
367            }
368        }
369
370        // Validate no overlaps
371        Self::validate_no_overlaps(&planned)?;
372
373        Ok(planned)
374    }
375
376    /// Check if two edits overlap.
377    fn edits_overlap(a: &PlannedEdit, b: &PlannedEdit) -> bool {
378        a.start < b.end && b.start < a.end
379    }
380
381    /// Validate that no edits have overlapping regions.
382    fn validate_no_overlaps(edits: &[PlannedEdit]) -> Result<(), String> {
383        for i in 0..edits.len() {
384            for j in (i + 1)..edits.len() {
385                if Self::edits_overlap(&edits[i], &edits[j]) {
386                    return Err(format!(
387                        "Edits {} and {} have overlapping regions",
388                        edits[i].edit_index + 1,
389                        edits[j].edit_index + 1
390                    ));
391                }
392            }
393        }
394        Ok(())
395    }
396
397    /// Apply planned edits and return new content.
398    fn apply_edits(content: &str, mut edits: Vec<PlannedEdit>) -> String {
399        // Sort by position descending (apply end-to-start)
400        edits.sort_by(|a, b| b.start.cmp(&a.start));
401
402        let mut result = content.to_string();
403        for edit in edits {
404            result.replace_range(edit.start..edit.end, &edit.new_string);
405        }
406        result
407    }
408
409    /// Parse edits from JSON value.
410    fn parse_edits(value: &serde_json::Value) -> Result<Vec<EditInput>, String> {
411        let array = value.as_array().ok_or("'edits' must be an array")?;
412
413        array
414            .iter()
415            .enumerate()
416            .map(|(i, v)| {
417                let obj = v
418                    .as_object()
419                    .ok_or_else(|| format!("Edit {} must be an object", i + 1))?;
420
421                Ok(EditInput {
422                    old_string: obj
423                        .get("old_string")
424                        .and_then(|v| v.as_str())
425                        .ok_or_else(|| format!("Edit {}: missing 'old_string'", i + 1))?
426                        .to_string(),
427                    new_string: obj
428                        .get("new_string")
429                        .and_then(|v| v.as_str())
430                        .ok_or_else(|| format!("Edit {}: missing 'new_string'", i + 1))?
431                        .to_string(),
432                    replace_all: obj
433                        .get("replace_all")
434                        .and_then(|v| v.as_bool())
435                        .unwrap_or(false),
436                    fuzzy_match: obj
437                        .get("fuzzy_match")
438                        .and_then(|v| v.as_bool())
439                        .unwrap_or(false),
440                    fuzzy_threshold: obj
441                        .get("fuzzy_threshold")
442                        .and_then(|v| v.as_f64())
443                        .unwrap_or(0.7)
444                        .clamp(0.0, 1.0),
445                })
446            })
447            .collect()
448    }
449}
450
451impl Executable for MultiEditTool {
452    fn name(&self) -> &str {
453        MULTI_EDIT_TOOL_NAME
454    }
455
456    fn description(&self) -> &str {
457        MULTI_EDIT_TOOL_DESCRIPTION
458    }
459
460    fn input_schema(&self) -> &str {
461        MULTI_EDIT_TOOL_SCHEMA
462    }
463
464    fn tool_type(&self) -> ToolType {
465        ToolType::TextEdit
466    }
467
468    fn execute(
469        &self,
470        context: ToolContext,
471        input: HashMap<String, serde_json::Value>,
472    ) -> Pin<Box<dyn Future<Output = Result<String, String>> + Send>> {
473        let permission_registry = self.permission_registry.clone();
474
475        Box::pin(async move {
476            // Extract parameters
477            let file_path = input
478                .get("file_path")
479                .and_then(|v| v.as_str())
480                .ok_or_else(|| "Missing required 'file_path' parameter".to_string())?;
481
482            let edits_value = input
483                .get("edits")
484                .ok_or_else(|| "Missing required 'edits' parameter".to_string())?;
485
486            let edits = Self::parse_edits(edits_value)?;
487
488            let dry_run = input
489                .get("dry_run")
490                .and_then(|v| v.as_bool())
491                .unwrap_or(false);
492
493            // Validate inputs
494            if edits.is_empty() {
495                return Err("No edits provided".to_string());
496            }
497            if edits.len() > MAX_EDITS {
498                return Err(format!(
499                    "Too many edits: {} (max {})",
500                    edits.len(),
501                    MAX_EDITS
502                ));
503            }
504
505            let path = PathBuf::from(file_path);
506            if !path.is_absolute() {
507                return Err(format!(
508                    "file_path must be an absolute path, got: {}",
509                    file_path
510                ));
511            }
512            if !path.exists() {
513                return Err(format!("File does not exist: {}", file_path));
514            }
515
516            // Validate no identical old/new strings
517            for (i, edit) in edits.iter().enumerate() {
518                if edit.old_string == edit.new_string {
519                    return Err(format!(
520                        "Edit {}: old_string and new_string are identical",
521                        i + 1
522                    ));
523                }
524            }
525
526            // Read file
527            let content = fs::read_to_string(&path)
528                .map_err(|e| format!("Failed to read file '{}': {}", file_path, e))?;
529
530            // Plan edits (validates all can be applied)
531            let planned = Self::plan_edits(&content, &edits)?;
532
533            // Generate new content
534            let new_content = Self::apply_edits(&content, planned.clone());
535
536            // Dry run - return preview without applying
537            if dry_run {
538                let fuzzy_count = planned
539                    .iter()
540                    .filter(|e| e.match_type != MatchType::Exact)
541                    .count();
542
543                let mut summary = format!(
544                    "Dry run: {} edit(s) would be applied to '{}'\n",
545                    planned.len(),
546                    file_path
547                );
548
549                if fuzzy_count > 0 {
550                    summary.push_str(&format!("  ({} fuzzy matches)\n", fuzzy_count));
551                }
552
553                summary.push_str(&format!(
554                    "\nOriginal: {} bytes\nModified: {} bytes\nDelta: {} bytes",
555                    content.len(),
556                    new_content.len(),
557                    new_content.len() as i64 - content.len() as i64
558                ));
559
560                return Ok(summary);
561            }
562
563            // Request permission if not pre-approved by batch executor
564            if !context.permissions_pre_approved {
565                let permission_request =
566                    Self::build_permission_request(&context.tool_use_id, file_path, edits.len());
567                let response_rx = permission_registry
568                    .request_permission(
569                        context.session_id,
570                        permission_request,
571                        context.turn_id.clone(),
572                    )
573                    .await
574                    .map_err(|e| format!("Failed to request permission: {}", e))?;
575
576                let response = response_rx
577                    .await
578                    .map_err(|_| "Permission request was cancelled".to_string())?;
579
580                if !response.granted {
581                    let reason = response
582                        .message
583                        .unwrap_or_else(|| "Permission denied by user".to_string());
584                    return Err(format!(
585                        "Permission denied to edit '{}': {}",
586                        file_path, reason
587                    ));
588                }
589            }
590
591            // Write new content
592            fs::write(&path, &new_content)
593                .map_err(|e| format!("Failed to write file '{}': {}", file_path, e))?;
594
595            // Build success response
596            let edit_count = planned.len();
597            let fuzzy_edits: Vec<_> = planned
598                .iter()
599                .filter(|e| e.match_type != MatchType::Exact)
600                .collect();
601
602            let mut result = format!(
603                "Successfully applied {} edit(s) to '{}'",
604                edit_count, file_path
605            );
606
607            if !fuzzy_edits.is_empty() {
608                let avg_similarity: f64 = fuzzy_edits.iter().map(|e| e.similarity).sum::<f64>()
609                    / fuzzy_edits.len() as f64;
610                result.push_str(&format!(
611                    " ({} fuzzy matches, avg {:.0}% similarity)",
612                    fuzzy_edits.len(),
613                    avg_similarity * 100.0
614                ));
615            }
616
617            Ok(result)
618        })
619    }
620
621    fn display_config(&self) -> DisplayConfig {
622        DisplayConfig {
623            display_name: "Multi-Edit".to_string(),
624            display_title: Box::new(|input| {
625                let file = input
626                    .get("file_path")
627                    .and_then(|v| v.as_str())
628                    .map(|p| {
629                        Path::new(p)
630                            .file_name()
631                            .and_then(|n| n.to_str())
632                            .unwrap_or(p)
633                    })
634                    .unwrap_or("file");
635                let count = input
636                    .get("edits")
637                    .and_then(|v| v.as_array())
638                    .map(|a| a.len())
639                    .unwrap_or(0);
640                format!("{} ({} edits)", file, count)
641            }),
642            display_content: Box::new(|_input, result| DisplayResult {
643                content: result.to_string(),
644                content_type: ResultContentType::PlainText,
645                is_truncated: false,
646                full_length: 0,
647            }),
648        }
649    }
650
651    fn compact_summary(&self, input: &HashMap<String, serde_json::Value>, result: &str) -> String {
652        let filename = input
653            .get("file_path")
654            .and_then(|v| v.as_str())
655            .map(|p| {
656                Path::new(p)
657                    .file_name()
658                    .and_then(|n| n.to_str())
659                    .unwrap_or(p)
660            })
661            .unwrap_or("unknown");
662
663        let count = input
664            .get("edits")
665            .and_then(|v| v.as_array())
666            .map(|a| a.len())
667            .unwrap_or(0);
668
669        let status = if result.contains("Successfully") {
670            "ok"
671        } else {
672            "error"
673        };
674
675        format!("[MultiEdit: {} ({} edits, {})]", filename, count, status)
676    }
677
678    fn required_permissions(
679        &self,
680        _context: &ToolContext,
681        input: &HashMap<String, serde_json::Value>,
682    ) -> Option<Vec<PermissionRequest>> {
683        // Extract file_path from input
684        let file_path = input.get("file_path")?.as_str()?;
685
686        // Extract and parse edits array
687        let edits_value = input.get("edits")?;
688        let edits = Self::parse_edits(edits_value).ok()?;
689
690        // Build a single permission request for all edits
691        let permission_request = Self::build_permission_request("preview", file_path, edits.len());
692
693        Some(vec![permission_request])
694    }
695}
696
697/// Truncate string for display purposes.
698fn truncate_string(s: &str, max_len: usize) -> String {
699    if s.len() <= max_len {
700        s.to_string()
701    } else {
702        format!("{}...", &s[..max_len])
703    }
704}
705
706#[cfg(test)]
707mod tests {
708    use super::*;
709    use crate::controller::types::ControllerEvent;
710    use crate::permissions::PermissionPanelResponse;
711    use tempfile::TempDir;
712    use tokio::sync::mpsc;
713
714    fn create_test_registry() -> (Arc<PermissionRegistry>, mpsc::Receiver<ControllerEvent>) {
715        let (tx, rx) = mpsc::channel(16);
716        let registry = Arc::new(PermissionRegistry::new(tx));
717        (registry, rx)
718    }
719
720    fn grant_once() -> PermissionPanelResponse {
721        PermissionPanelResponse {
722            granted: true,
723            grant: None,
724            message: None,
725        }
726    }
727
728    fn deny(reason: &str) -> PermissionPanelResponse {
729        PermissionPanelResponse {
730            granted: false,
731            grant: None,
732            message: Some(reason.to_string()),
733        }
734    }
735
736    #[tokio::test]
737    async fn test_multiple_edits_success() {
738        let (registry, mut event_rx) = create_test_registry();
739        let tool = MultiEditTool::new(registry.clone());
740
741        let temp_dir = TempDir::new().unwrap();
742        let file_path = temp_dir.path().join("test.txt");
743        fs::write(&file_path, "foo bar baz foo").unwrap();
744
745        let mut input = HashMap::new();
746        input.insert(
747            "file_path".to_string(),
748            serde_json::Value::String(file_path.to_str().unwrap().to_string()),
749        );
750        input.insert(
751            "edits".to_string(),
752            serde_json::json!([
753                {"old_string": "foo", "new_string": "qux"},
754                {"old_string": "bar", "new_string": "quux"}
755            ]),
756        );
757
758        let context = ToolContext {
759            session_id: 1,
760            tool_use_id: "test-multi-1".to_string(),
761            turn_id: None,
762            permissions_pre_approved: false,
763        };
764
765        let registry_clone = registry.clone();
766        tokio::spawn(async move {
767            if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
768                event_rx.recv().await
769            {
770                registry_clone
771                    .respond_to_request(&tool_use_id, grant_once())
772                    .await
773                    .unwrap();
774            }
775        });
776
777        let result = tool.execute(context, input).await;
778        assert!(result.is_ok());
779        assert!(result.unwrap().contains("2 edit(s)"));
780        // First "foo" replaced, "bar" replaced, second "foo" still there
781        assert_eq!(fs::read_to_string(&file_path).unwrap(), "qux quux baz foo");
782    }
783
784    #[tokio::test]
785    async fn test_replace_all_in_multi_edit() {
786        let (registry, mut event_rx) = create_test_registry();
787        let tool = MultiEditTool::new(registry.clone());
788
789        let temp_dir = TempDir::new().unwrap();
790        let file_path = temp_dir.path().join("test.txt");
791        fs::write(&file_path, "foo bar foo baz foo").unwrap();
792
793        let mut input = HashMap::new();
794        input.insert(
795            "file_path".to_string(),
796            serde_json::Value::String(file_path.to_str().unwrap().to_string()),
797        );
798        input.insert(
799            "edits".to_string(),
800            serde_json::json!([
801                {"old_string": "foo", "new_string": "qux", "replace_all": true}
802            ]),
803        );
804
805        let context = ToolContext {
806            session_id: 1,
807            tool_use_id: "test-multi-2".to_string(),
808            turn_id: None,
809            permissions_pre_approved: false,
810        };
811
812        let registry_clone = registry.clone();
813        tokio::spawn(async move {
814            if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
815                event_rx.recv().await
816            {
817                registry_clone
818                    .respond_to_request(&tool_use_id, grant_once())
819                    .await
820                    .unwrap();
821            }
822        });
823
824        let result = tool.execute(context, input).await;
825        assert!(result.is_ok());
826        assert!(result.unwrap().contains("3 edit(s)"));
827        assert_eq!(
828            fs::read_to_string(&file_path).unwrap(),
829            "qux bar qux baz qux"
830        );
831    }
832
833    #[tokio::test]
834    async fn test_edit_not_found_fails_all() {
835        let (registry, _event_rx) = create_test_registry();
836        let tool = MultiEditTool::new(registry);
837
838        let temp_dir = TempDir::new().unwrap();
839        let file_path = temp_dir.path().join("test.txt");
840        fs::write(&file_path, "foo bar baz").unwrap();
841
842        let mut input = HashMap::new();
843        input.insert(
844            "file_path".to_string(),
845            serde_json::Value::String(file_path.to_str().unwrap().to_string()),
846        );
847        input.insert(
848            "edits".to_string(),
849            serde_json::json!([
850                {"old_string": "foo", "new_string": "qux"},
851                {"old_string": "notfound", "new_string": "xxx"}
852            ]),
853        );
854
855        let context = ToolContext {
856            session_id: 1,
857            tool_use_id: "test-multi-3".to_string(),
858            turn_id: None,
859            permissions_pre_approved: false,
860        };
861
862        let result = tool.execute(context, input).await;
863        assert!(result.is_err());
864        assert!(result.unwrap_err().contains("Edit 2"));
865        // File should be unchanged
866        assert_eq!(fs::read_to_string(&file_path).unwrap(), "foo bar baz");
867    }
868
869    #[tokio::test]
870    async fn test_overlapping_edits_rejected() {
871        let (registry, _event_rx) = create_test_registry();
872        let tool = MultiEditTool::new(registry);
873
874        let temp_dir = TempDir::new().unwrap();
875        let file_path = temp_dir.path().join("test.txt");
876        fs::write(&file_path, "foo bar baz").unwrap();
877
878        let mut input = HashMap::new();
879        input.insert(
880            "file_path".to_string(),
881            serde_json::Value::String(file_path.to_str().unwrap().to_string()),
882        );
883        input.insert(
884            "edits".to_string(),
885            serde_json::json!([
886                {"old_string": "foo bar", "new_string": "xxx"},
887                {"old_string": "bar baz", "new_string": "yyy"}
888            ]),
889        );
890
891        let context = ToolContext {
892            session_id: 1,
893            tool_use_id: "test-multi-4".to_string(),
894            turn_id: None,
895            permissions_pre_approved: false,
896        };
897
898        let result = tool.execute(context, input).await;
899        assert!(result.is_err());
900        assert!(result.unwrap_err().contains("overlapping"));
901    }
902
903    #[tokio::test]
904    async fn test_dry_run_no_changes() {
905        let (registry, _event_rx) = create_test_registry();
906        let tool = MultiEditTool::new(registry);
907
908        let temp_dir = TempDir::new().unwrap();
909        let file_path = temp_dir.path().join("test.txt");
910        fs::write(&file_path, "foo bar baz").unwrap();
911
912        let mut input = HashMap::new();
913        input.insert(
914            "file_path".to_string(),
915            serde_json::Value::String(file_path.to_str().unwrap().to_string()),
916        );
917        input.insert(
918            "edits".to_string(),
919            serde_json::json!([
920                {"old_string": "foo", "new_string": "qux"}
921            ]),
922        );
923        input.insert("dry_run".to_string(), serde_json::Value::Bool(true));
924
925        let context = ToolContext {
926            session_id: 1,
927            tool_use_id: "test-multi-5".to_string(),
928            turn_id: None,
929            permissions_pre_approved: false,
930        };
931
932        let result = tool.execute(context, input).await;
933        assert!(result.is_ok());
934        assert!(result.unwrap().contains("Dry run"));
935        // File should be unchanged
936        assert_eq!(fs::read_to_string(&file_path).unwrap(), "foo bar baz");
937    }
938
939    #[tokio::test]
940    async fn test_empty_edits_rejected() {
941        let (registry, _event_rx) = create_test_registry();
942        let tool = MultiEditTool::new(registry);
943
944        let temp_dir = TempDir::new().unwrap();
945        let file_path = temp_dir.path().join("test.txt");
946        fs::write(&file_path, "foo bar").unwrap();
947
948        let mut input = HashMap::new();
949        input.insert(
950            "file_path".to_string(),
951            serde_json::Value::String(file_path.to_str().unwrap().to_string()),
952        );
953        input.insert("edits".to_string(), serde_json::json!([]));
954
955        let context = ToolContext {
956            session_id: 1,
957            tool_use_id: "test-multi-6".to_string(),
958            turn_id: None,
959            permissions_pre_approved: false,
960        };
961
962        let result = tool.execute(context, input).await;
963        assert!(result.is_err());
964        assert!(result.unwrap_err().contains("No edits"));
965    }
966
967    #[tokio::test]
968    async fn test_identical_strings_rejected() {
969        let (registry, _event_rx) = create_test_registry();
970        let tool = MultiEditTool::new(registry);
971
972        let temp_dir = TempDir::new().unwrap();
973        let file_path = temp_dir.path().join("test.txt");
974        fs::write(&file_path, "foo bar").unwrap();
975
976        let mut input = HashMap::new();
977        input.insert(
978            "file_path".to_string(),
979            serde_json::Value::String(file_path.to_str().unwrap().to_string()),
980        );
981        input.insert(
982            "edits".to_string(),
983            serde_json::json!([
984                {"old_string": "foo", "new_string": "foo"}
985            ]),
986        );
987
988        let context = ToolContext {
989            session_id: 1,
990            tool_use_id: "test-multi-7".to_string(),
991            turn_id: None,
992            permissions_pre_approved: false,
993        };
994
995        let result = tool.execute(context, input).await;
996        assert!(result.is_err());
997        assert!(result.unwrap_err().contains("identical"));
998    }
999
1000    #[tokio::test]
1001    async fn test_permission_denied() {
1002        let (registry, mut event_rx) = create_test_registry();
1003        let tool = MultiEditTool::new(registry.clone());
1004
1005        let temp_dir = TempDir::new().unwrap();
1006        let file_path = temp_dir.path().join("test.txt");
1007        fs::write(&file_path, "foo bar").unwrap();
1008
1009        let mut input = HashMap::new();
1010        input.insert(
1011            "file_path".to_string(),
1012            serde_json::Value::String(file_path.to_str().unwrap().to_string()),
1013        );
1014        input.insert(
1015            "edits".to_string(),
1016            serde_json::json!([
1017                {"old_string": "foo", "new_string": "qux"}
1018            ]),
1019        );
1020
1021        let context = ToolContext {
1022            session_id: 1,
1023            tool_use_id: "test-multi-8".to_string(),
1024            turn_id: None,
1025            permissions_pre_approved: false,
1026        };
1027
1028        let registry_clone = registry.clone();
1029        tokio::spawn(async move {
1030            if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
1031                event_rx.recv().await
1032            {
1033                registry_clone
1034                    .respond_to_request(&tool_use_id, deny("Not allowed"))
1035                    .await
1036                    .unwrap();
1037            }
1038        });
1039
1040        let result = tool.execute(context, input).await;
1041        assert!(result.is_err());
1042        assert!(result.unwrap_err().contains("Permission denied"));
1043        // File should be unchanged
1044        assert_eq!(fs::read_to_string(&file_path).unwrap(), "foo bar");
1045    }
1046
1047    #[tokio::test]
1048    async fn test_fuzzy_match_in_multi_edit() {
1049        let (registry, mut event_rx) = create_test_registry();
1050        let tool = MultiEditTool::new(registry.clone());
1051
1052        let temp_dir = TempDir::new().unwrap();
1053        let file_path = temp_dir.path().join("test.txt");
1054        // File has extra spaces
1055        fs::write(&file_path, "fn  foo()  {\n    bar();\n}").unwrap();
1056
1057        let mut input = HashMap::new();
1058        input.insert(
1059            "file_path".to_string(),
1060            serde_json::Value::String(file_path.to_str().unwrap().to_string()),
1061        );
1062        input.insert(
1063            "edits".to_string(),
1064            serde_json::json!([
1065                {
1066                    "old_string": "fn foo() {\nbar();\n}",
1067                    "new_string": "fn foo() {\n    baz();\n}",
1068                    "fuzzy_match": true
1069                }
1070            ]),
1071        );
1072
1073        let context = ToolContext {
1074            session_id: 1,
1075            tool_use_id: "test-multi-9".to_string(),
1076            turn_id: None,
1077            permissions_pre_approved: false,
1078        };
1079
1080        let registry_clone = registry.clone();
1081        tokio::spawn(async move {
1082            if let Some(ControllerEvent::PermissionRequired { tool_use_id, .. }) =
1083                event_rx.recv().await
1084            {
1085                registry_clone
1086                    .respond_to_request(&tool_use_id, grant_once())
1087                    .await
1088                    .unwrap();
1089            }
1090        });
1091
1092        let result = tool.execute(context, input).await;
1093        assert!(result.is_ok());
1094        let result_str = result.unwrap();
1095        assert!(result_str.contains("1 edit(s)"));
1096        assert!(result_str.contains("fuzzy"));
1097    }
1098
1099    #[test]
1100    fn test_edits_overlap() {
1101        let a = PlannedEdit {
1102            edit_index: 0,
1103            start: 0,
1104            end: 5,
1105            new_string: "xxx".to_string(),
1106            match_type: MatchType::Exact,
1107            similarity: 1.0,
1108        };
1109        let b = PlannedEdit {
1110            edit_index: 1,
1111            start: 3,
1112            end: 8,
1113            new_string: "yyy".to_string(),
1114            match_type: MatchType::Exact,
1115            similarity: 1.0,
1116        };
1117        let c = PlannedEdit {
1118            edit_index: 2,
1119            start: 10,
1120            end: 15,
1121            new_string: "zzz".to_string(),
1122            match_type: MatchType::Exact,
1123            similarity: 1.0,
1124        };
1125
1126        assert!(MultiEditTool::edits_overlap(&a, &b));
1127        assert!(!MultiEditTool::edits_overlap(&a, &c));
1128        assert!(!MultiEditTool::edits_overlap(&b, &c));
1129    }
1130
1131    #[test]
1132    fn test_apply_edits_reverse_order() {
1133        let content = "foo bar baz";
1134        let edits = vec![
1135            PlannedEdit {
1136                edit_index: 0,
1137                start: 0,
1138                end: 3,
1139                new_string: "qux".to_string(),
1140                match_type: MatchType::Exact,
1141                similarity: 1.0,
1142            },
1143            PlannedEdit {
1144                edit_index: 1,
1145                start: 8,
1146                end: 11,
1147                new_string: "quux".to_string(),
1148                match_type: MatchType::Exact,
1149                similarity: 1.0,
1150            },
1151        ];
1152
1153        let result = MultiEditTool::apply_edits(content, edits);
1154        assert_eq!(result, "qux bar quux");
1155    }
1156
1157    #[test]
1158    fn test_compact_summary() {
1159        let (registry, _rx) = create_test_registry();
1160        let tool = MultiEditTool::new(registry);
1161
1162        let mut input = HashMap::new();
1163        input.insert(
1164            "file_path".to_string(),
1165            serde_json::Value::String("/path/to/file.rs".to_string()),
1166        );
1167        input.insert(
1168            "edits".to_string(),
1169            serde_json::json!([
1170                {"old_string": "a", "new_string": "b"},
1171                {"old_string": "c", "new_string": "d"}
1172            ]),
1173        );
1174
1175        let result = "Successfully applied 2 edit(s) to '/path/to/file.rs'";
1176        let summary = tool.compact_summary(&input, result);
1177        assert_eq!(summary, "[MultiEdit: file.rs (2 edits, ok)]");
1178    }
1179
1180    #[test]
1181    fn test_parse_edits() {
1182        let value = serde_json::json!([
1183            {"old_string": "foo", "new_string": "bar"},
1184            {"old_string": "baz", "new_string": "qux", "replace_all": true, "fuzzy_match": true, "fuzzy_threshold": 0.8}
1185        ]);
1186
1187        let edits = MultiEditTool::parse_edits(&value).unwrap();
1188        assert_eq!(edits.len(), 2);
1189        assert_eq!(edits[0].old_string, "foo");
1190        assert_eq!(edits[0].new_string, "bar");
1191        assert!(!edits[0].replace_all);
1192        assert!(!edits[0].fuzzy_match);
1193        assert_eq!(edits[1].old_string, "baz");
1194        assert!(edits[1].replace_all);
1195        assert!(edits[1].fuzzy_match);
1196        assert!((edits[1].fuzzy_threshold - 0.8).abs() < 0.001);
1197    }
1198}