1use anyhow::Result;
7use async_trait::async_trait;
8use serde_json::{Value, json};
9use std::collections::HashMap;
10use std::path::PathBuf;
11use tokio::fs;
12
13use super::{Tool, ToolResult};
14
15pub struct MultiEditTool;
16
17impl Default for MultiEditTool {
18 fn default() -> Self {
19 Self::new()
20 }
21}
22
23impl MultiEditTool {
24 pub fn new() -> Self {
25 Self
26 }
27}
28
29#[async_trait]
30impl Tool for MultiEditTool {
31 fn id(&self) -> &str {
32 "multiedit"
33 }
34
35 fn name(&self) -> &str {
36 "Multi Edit"
37 }
38
39 fn description(&self) -> &str {
40 "Apply multiple file edits atomically. Validates all edits, then writes all changes. \
41 If any edit fails validation, no files are modified. Each edit replaces old_string \
42 with new_string in the given file."
43 }
44
45 fn parameters(&self) -> Value {
46 json!({
47 "type": "object",
48 "properties": {
49 "edits": {
50 "type": "array",
51 "description": "Array of edit operations to apply atomically",
52 "items": {
53 "type": "object",
54 "properties": {
55 "file": {
56 "type": "string",
57 "description": "Path to the file to edit"
58 },
59 "old_string": {
60 "type": "string",
61 "description": "The exact string to find and replace (must appear exactly once)"
62 },
63 "new_string": {
64 "type": "string",
65 "description": "The replacement string"
66 }
67 },
68 "required": ["file", "old_string", "new_string"]
69 }
70 }
71 },
72 "required": ["edits"]
73 })
74 }
75
76 async fn execute(&self, params: Value) -> Result<ToolResult> {
77 let edits_val = match params.get("edits") {
79 Some(v) => v,
80 None => {
81 return Ok(ToolResult::structured_error(
82 "INVALID_ARGUMENT",
83 "multiedit",
84 "Missing required field 'edits'. Provide an array of {file, old_string, new_string} objects.",
85 Some(vec!["edits"]),
86 Some(json!({
87 "edits": [
88 {"file": "src/main.rs", "old_string": "old code", "new_string": "new code"}
89 ]
90 })),
91 ));
92 }
93 };
94
95 let edits_arr = match edits_val.as_array() {
96 Some(a) => a,
97 None => {
98 return Ok(ToolResult::structured_error(
99 "INVALID_ARGUMENT",
100 "multiedit",
101 "'edits' must be an array, not a single object.",
102 Some(vec!["edits"]),
103 Some(json!({
104 "edits": [
105 {"file": "src/main.rs", "old_string": "old code", "new_string": "new code"}
106 ]
107 })),
108 ));
109 }
110 };
111
112 if edits_arr.is_empty() {
113 return Ok(ToolResult::error(
114 "No edits provided. 'edits' array is empty.",
115 ));
116 }
117
118 struct EditOp {
120 file: String,
121 old_string: String,
122 new_string: String,
123 }
124
125 let mut ops: Vec<EditOp> = Vec::with_capacity(edits_arr.len());
126 for (i, entry) in edits_arr.iter().enumerate() {
127 let file = match entry.get("file").and_then(|v| v.as_str()) {
128 Some(f) => f.to_string(),
129 None => {
130 return Ok(ToolResult::structured_error(
131 "INVALID_ARGUMENT",
132 "multiedit",
133 &format!("edits[{i}]: missing or non-string 'file' field"),
134 Some(vec!["edits", "file"]),
135 Some(
136 json!({"file": "src/example.rs", "old_string": "...", "new_string": "..."}),
137 ),
138 ));
139 }
140 };
141 let old_string = match entry.get("old_string").and_then(|v| v.as_str()) {
142 Some(s) => s.to_string(),
143 None => {
144 return Ok(ToolResult::structured_error(
145 "INVALID_ARGUMENT",
146 "multiedit",
147 &format!("edits[{i}] ({file}): missing or non-string 'old_string' field"),
148 Some(vec!["edits", "old_string"]),
149 Some(
150 json!({"file": file, "old_string": "exact text to find", "new_string": "replacement"}),
151 ),
152 ));
153 }
154 };
155 let new_string = match entry.get("new_string").and_then(|v| v.as_str()) {
156 Some(s) => s.to_string(),
157 None => {
158 return Ok(ToolResult::structured_error(
159 "INVALID_ARGUMENT",
160 "multiedit",
161 &format!("edits[{i}] ({file}): missing or non-string 'new_string' field"),
162 Some(vec!["edits", "new_string"]),
163 Some(
164 json!({"file": file, "old_string": old_string, "new_string": "replacement text"}),
165 ),
166 ));
167 }
168 };
169 ops.push(EditOp {
170 file,
171 old_string,
172 new_string,
173 });
174 }
175
176 let mut validated: Vec<(PathBuf, String, String)> = Vec::with_capacity(ops.len());
179 let mut errors: Vec<String> = Vec::new();
180
181 let mut content_cache: HashMap<PathBuf, String> = HashMap::new();
184
185 for (i, op) in ops.iter().enumerate() {
186 let path = PathBuf::from(&op.file);
187
188 let content = if let Some(cached) = content_cache.get(&path) {
190 cached.clone()
191 } else if path.exists() {
192 match fs::read_to_string(&path).await {
193 Ok(c) => c,
194 Err(e) => {
195 errors.push(format!("edits[{i}] {}: cannot read file: {e}", op.file));
196 continue;
197 }
198 }
199 } else {
200 errors.push(format!("edits[{i}] {}: file does not exist", op.file));
201 continue;
202 };
203
204 let count = content.matches(&op.old_string).count();
205 if count == 0 {
206 let preview: String = op.old_string.chars().take(80).collect();
207 errors.push(format!(
208 "edits[{i}] {}: old_string not found. First 80 chars: \"{}\"",
209 op.file, preview
210 ));
211 continue;
212 }
213 if count > 1 {
214 errors.push(format!(
215 "edits[{i}] {}: old_string found {count} times (must be unique). Add more context.",
216 op.file
217 ));
218 continue;
219 }
220
221 let new_content = content.replacen(&op.old_string, &op.new_string, 1);
222 content_cache.insert(path.clone(), new_content.clone());
223 validated.push((path, content, new_content));
224 }
225
226 if !errors.is_empty() {
227 let error_list = errors.join("\n");
228 return Ok(ToolResult {
229 output: format!(
230 "Validation failed for {} of {} edits. No files were modified.\n\n{error_list}",
231 errors.len(),
232 ops.len()
233 ),
234 success: false,
235 metadata: HashMap::new(),
236 });
237 }
238
239 let mut written: HashMap<PathBuf, bool> = HashMap::new();
243 let mut write_errors: Vec<String> = Vec::new();
244
245 for (path, _original, _new) in &validated {
246 if written.contains_key(path) {
247 continue;
248 }
249 let final_content = content_cache.get(path).unwrap();
250 match fs::write(path, final_content).await {
251 Ok(()) => {
252 written.insert(path.clone(), true);
253 }
254 Err(e) => {
255 write_errors.push(format!("{}: write failed: {e}", path.display()));
256 written.insert(path.clone(), false);
257 }
258 }
259 }
260
261 if !write_errors.is_empty() {
262 return Ok(ToolResult {
263 output: format!(
264 "Write errors (some files may have been partially updated):\n{}",
265 write_errors.join("\n")
266 ),
267 success: false,
268 metadata: HashMap::new(),
269 });
270 }
271
272 let unique_files = written.len();
274 let total_edits = ops.len();
275 let mut summary_lines: Vec<String> = Vec::new();
276 for (path, original, new_content) in &validated {
277 let old_lines = original.lines().count();
278 let new_lines = new_content.lines().count();
279 let delta = new_lines as i64 - old_lines as i64;
280 let sign = if delta >= 0 { "+" } else { "" };
281 summary_lines.push(format!("✓ {} ({sign}{delta} lines)", path.display()));
282 }
283
284 Ok(ToolResult::success(format!(
285 "Applied {total_edits} edit(s) across {unique_files} file(s):\n{}",
286 summary_lines.join("\n")
287 )))
288 }
289}