1use anyhow::{Context, Result};
2use serde_json::Value;
3use std::fs;
4
5use super::Tool;
6
7pub struct MultiEditTool;
8
9impl Tool for MultiEditTool {
10 fn name(&self) -> &str {
11 "multiedit"
12 }
13
14 fn description(&self) -> &str {
15 "Edit multiple sections of a single file in one operation. Each edit specifies an old_text to find and new_text to replace it with. Edits are applied in reverse position order to preserve offsets."
16 }
17
18 fn input_schema(&self) -> Value {
19 serde_json::json!({
20 "type": "object",
21 "properties": {
22 "path": {
23 "type": "string",
24 "description": "File path to edit"
25 },
26 "edits": {
27 "type": "array",
28 "items": {
29 "type": "object",
30 "properties": {
31 "old_text": {
32 "type": "string",
33 "description": "Exact text to find in the file"
34 },
35 "new_text": {
36 "type": "string",
37 "description": "Replacement text"
38 }
39 },
40 "required": ["old_text", "new_text"]
41 },
42 "description": "Array of edits to apply"
43 }
44 },
45 "required": ["path", "edits"]
46 })
47 }
48
49 fn execute(&self, input: Value) -> Result<String> {
50 let path = input["path"]
51 .as_str()
52 .context("Missing required parameter 'path'")?;
53 let edits = input["edits"]
54 .as_array()
55 .context("Missing required parameter 'edits'")?;
56
57 if edits.is_empty() {
58 return Ok("No edits to apply.".to_string());
59 }
60
61 tracing::debug!("multiedit: {} edits on {}", edits.len(), path);
62
63 let mut content =
64 fs::read_to_string(path).with_context(|| format!("Failed to read file: {}", path))?;
65
66 let mut missing: Vec<String> = edits
67 .iter()
68 .filter_map(|e| {
69 let old = e["old_text"].as_str()?;
70 if !content.contains(old) {
71 Some(old.to_string())
72 } else {
73 None
74 }
75 })
76 .collect();
77
78 if !missing.is_empty() {
79 missing.dedup();
80 anyhow::bail!(
81 "old_text not found in {}: {}",
82 path,
83 missing
84 .iter()
85 .map(|s| format!("{:?}", s))
86 .collect::<Vec<_>>()
87 .join(", ")
88 );
89 }
90
91 let mut positioned: Vec<(usize, &str, &str)> = edits
92 .iter()
93 .filter_map(|e| {
94 let old = e["old_text"].as_str()?;
95 let new = e["new_text"].as_str()?;
96 let pos = content.find(old)?;
97 Some((pos, old, new))
98 })
99 .collect();
100
101 positioned.sort_by(|a, b| b.0.cmp(&a.0));
102
103 for (_, old, new) in &positioned {
104 let pos = content
105 .find(old)
106 .with_context(|| format!("old_text {:?} no longer found after prior edits", old))?;
107 content.replace_range(pos..pos + old.len(), new);
108 }
109
110 fs::write(path, &content).with_context(|| format!("Failed to write file: {}", path))?;
111
112 Ok(format!("Applied {} edit(s) to {}", edits.len(), path))
113 }
114}