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::ask_for_permissions::{PermissionCategory, PermissionRequest};
17use super::permission_registry::PermissionRegistry;
18use super::types::{
19 DisplayConfig, DisplayResult, Executable, ResultContentType, ToolContext, ToolType,
20};
21
22pub const MULTI_EDIT_TOOL_NAME: &str = "multi_edit";
24
25pub 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
46pub 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#[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#[derive(Debug, Clone, Copy, PartialEq)]
114enum MatchType {
115 Exact,
116 WhitespaceInsensitive,
117 Fuzzy,
118}
119
120#[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#[derive(Debug, Clone)]
132struct PlannedEdit {
133 edit_index: usize,
135 start: usize,
137 end: usize,
139 new_string: String,
141 match_type: MatchType,
143 similarity: f64,
145}
146
147pub struct MultiEditTool {
149 permission_registry: Arc<PermissionRegistry>,
150}
151
152impl MultiEditTool {
153 pub fn new(permission_registry: Arc<PermissionRegistry>) -> Self {
155 Self { permission_registry }
156 }
157
158 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 fn normalize_whitespace(s: &str) -> String {
176 s.split_whitespace().collect::<Vec<_>>().join(" ")
177 }
178
179 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 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 fn find_fuzzy_match(
209 content: &str,
210 search: &str,
211 config: &FuzzyConfig,
212 ) -> Vec<(usize, usize, f64, MatchType)> {
213 if let Some(start) = content.find(search) {
215 return vec![(start, start + search.len(), 1.0, MatchType::Exact)];
216 }
217
218 if let Some((start, end)) = Self::find_normalized_position(content, search) {
220 return vec![(start, end, 0.95, MatchType::WhitespaceInsensitive)];
221 }
222
223 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 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 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 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 Self::validate_no_overlaps(&planned)?;
369
370 Ok(planned)
371 }
372
373 fn edits_overlap(a: &PlannedEdit, b: &PlannedEdit) -> bool {
375 a.start < b.end && b.start < a.end
376 }
377
378 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 fn apply_edits(content: &str, mut edits: Vec<PlannedEdit>) -> String {
396 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 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 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 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 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 let content = fs::read_to_string(&path)
521 .map_err(|e| format!("Failed to read file '{}': {}", file_path, e))?;
522
523 let planned = Self::plan_edits(&content, &edits)?;
525
526 let new_content = Self::apply_edits(&content, planned.clone());
528
529 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 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 fs::write(&path, &new_content)
587 .map_err(|e| format!("Failed to write file '{}': {}", file_path, e))?;
588
589 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
677fn 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 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 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 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 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 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}