1use 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
21pub const MULTI_EDIT_TOOL_NAME: &str = "multi_edit";
23
24pub 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
45pub 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#[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#[derive(Debug, Clone, Copy, PartialEq)]
113enum MatchType {
114 Exact,
115 WhitespaceInsensitive,
116 Fuzzy,
117}
118
119#[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#[derive(Debug, Clone)]
131struct PlannedEdit {
132 edit_index: usize,
134 start: usize,
136 end: usize,
138 new_string: String,
140 match_type: MatchType,
142 similarity: f64,
144}
145
146pub struct MultiEditTool {
148 permission_registry: Arc<PermissionRegistry>,
149}
150
151impl MultiEditTool {
152 pub fn new(permission_registry: Arc<PermissionRegistry>) -> Self {
154 Self {
155 permission_registry,
156 }
157 }
158
159 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 fn normalize_whitespace(s: &str) -> String {
180 s.split_whitespace().collect::<Vec<_>>().join(" ")
181 }
182
183 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 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 fn find_fuzzy_match(
213 content: &str,
214 search: &str,
215 config: &FuzzyConfig,
216 ) -> Vec<(usize, usize, f64, MatchType)> {
217 if let Some(start) = content.find(search) {
219 return vec![(start, start + search.len(), 1.0, MatchType::Exact)];
220 }
221
222 if let Some((start, end)) = Self::find_normalized_position(content, search) {
224 return vec![(start, end, 0.95, MatchType::WhitespaceInsensitive)];
225 }
226
227 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 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 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 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 Self::validate_no_overlaps(&planned)?;
372
373 Ok(planned)
374 }
375
376 fn edits_overlap(a: &PlannedEdit, b: &PlannedEdit) -> bool {
378 a.start < b.end && b.start < a.end
379 }
380
381 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 fn apply_edits(content: &str, mut edits: Vec<PlannedEdit>) -> String {
399 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 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 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 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 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 let content = fs::read_to_string(&path)
528 .map_err(|e| format!("Failed to read file '{}': {}", file_path, e))?;
529
530 let planned = Self::plan_edits(&content, &edits)?;
532
533 let new_content = Self::apply_edits(&content, planned.clone());
535
536 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 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 fs::write(&path, &new_content)
593 .map_err(|e| format!("Failed to write file '{}': {}", file_path, e))?;
594
595 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 let file_path = input.get("file_path")?.as_str()?;
685
686 let edits_value = input.get("edits")?;
688 let edits = Self::parse_edits(edits_value).ok()?;
689
690 let permission_request = Self::build_permission_request("preview", file_path, edits.len());
692
693 Some(vec![permission_request])
694 }
695}
696
697fn 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 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 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 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 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 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}