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 crate::permissions::{GrantTarget, PermissionLevel, PermissionRegistry, PermissionRequest};
17use super::types::{
18 DisplayConfig, DisplayResult, Executable, ResultContentType, ToolContext, ToolType,
19};
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 { permission_registry }
155 }
156
157 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 fn normalize_whitespace(s: &str) -> String {
178 s.split_whitespace().collect::<Vec<_>>().join(" ")
179 }
180
181 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 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 fn find_fuzzy_match(
211 content: &str,
212 search: &str,
213 config: &FuzzyConfig,
214 ) -> Vec<(usize, usize, f64, MatchType)> {
215 if let Some(start) = content.find(search) {
217 return vec![(start, start + search.len(), 1.0, MatchType::Exact)];
218 }
219
220 if let Some((start, end)) = Self::find_normalized_position(content, search) {
222 return vec![(start, end, 0.95, MatchType::WhitespaceInsensitive)];
223 }
224
225 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 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 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 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 Self::validate_no_overlaps(&planned)?;
371
372 Ok(planned)
373 }
374
375 fn edits_overlap(a: &PlannedEdit, b: &PlannedEdit) -> bool {
377 a.start < b.end && b.start < a.end
378 }
379
380 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 fn apply_edits(content: &str, mut edits: Vec<PlannedEdit>) -> String {
398 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 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 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 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 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 let content = fs::read_to_string(&path)
523 .map_err(|e| format!("Failed to read file '{}': {}", file_path, e))?;
524
525 let planned = Self::plan_edits(&content, &edits)?;
527
528 let new_content = Self::apply_edits(&content, planned.clone());
530
531 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 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 fs::write(&path, &new_content)
585 .map_err(|e| format!("Failed to write file '{}': {}", file_path, e))?;
586
587 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 let file_path = input.get("file_path")?.as_str()?;
681
682 let edits_value = input.get("edits")?;
684 let edits = Self::parse_edits(edits_value).ok()?;
685
686 let permission_request =
688 Self::build_permission_request("preview", file_path, edits.len());
689
690 Some(vec![permission_request])
691 }
692}
693
694fn 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 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 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 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 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 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}