Skip to main content

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