Skip to main content

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