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
29fn apply_exact_replace(
30 content: &str,
31 old_string: &str,
32 new_string: &str,
33 file: &str,
34 index: usize,
35) -> std::result::Result<String, String> {
36 let count = content.matches(old_string).count();
37 if count == 0 {
38 let preview: String = old_string.chars().take(80).collect();
39 return Err(format!(
40 "edits[{index}] {file}: old_string not found. First 80 chars: \"{preview}\""
41 ));
42 }
43 if count > 1 {
44 return Err(format!(
45 "edits[{index}] {file}: old_string found {count} times (must be unique). Add more context."
46 ));
47 }
48 Ok(content.replacen(old_string, new_string, 1))
49}
50
51#[async_trait]
52impl Tool for MultiEditTool {
53 fn id(&self) -> &str {
54 "multiedit"
55 }
56
57 fn name(&self) -> &str {
58 "Multi Edit"
59 }
60
61 fn description(&self) -> &str {
62 "Apply multiple file edits atomically. Validates all edits, then writes all changes. \
63 If any edit fails validation, no files are modified. Each edit replaces old_string \
64 with new_string in the given file. Morph backend is available only when explicitly \
65 enabled with CODETETHER_MORPH_TOOL_BACKEND=1; instruction/update can guide Morph \
66 behavior per edit."
67 }
68
69 fn parameters(&self) -> Value {
70 json!({
71 "type": "object",
72 "properties": {
73 "edits": {
74 "type": "array",
75 "description": "Array of edit operations to apply atomically",
76 "items": {
77 "type": "object",
78 "properties": {
79 "file": {
80 "type": "string",
81 "description": "Path to the file to edit"
82 },
83 "old_string": {
84 "type": "string",
85 "description": "The exact string to find and replace (must appear exactly once)"
86 },
87 "new_string": {
88 "type": "string",
89 "description": "The replacement string"
90 },
91 "instruction": {
92 "type": "string",
93 "description": "Optional Morph instruction for this edit."
94 },
95 "update": {
96 "type": "string",
97 "description": "Optional Morph update snippet for this edit."
98 }
99 },
100 "required": ["file"]
101 }
102 }
103 },
104 "required": ["edits"]
105 })
106 }
107
108 async fn execute(&self, params: Value) -> Result<ToolResult> {
109 let edits_val = match params.get("edits") {
111 Some(v) => v,
112 None => {
113 return Ok(ToolResult::structured_error(
114 "INVALID_ARGUMENT",
115 "multiedit",
116 "Missing required field 'edits'. Provide an array of {file, old_string, new_string} objects.",
117 Some(vec!["edits"]),
118 Some(json!({
119 "edits": [
120 {"file": "src/main.rs", "old_string": "old code", "new_string": "new code"}
121 ]
122 })),
123 ));
124 }
125 };
126
127 let edits_arr = match edits_val.as_array() {
128 Some(a) => a,
129 None => {
130 return Ok(ToolResult::structured_error(
131 "INVALID_ARGUMENT",
132 "multiedit",
133 "'edits' must be an array, not a single object.",
134 Some(vec!["edits"]),
135 Some(json!({
136 "edits": [
137 {"file": "src/main.rs", "old_string": "old code", "new_string": "new code"}
138 ]
139 })),
140 ));
141 }
142 };
143
144 if edits_arr.is_empty() {
145 return Ok(ToolResult::error(
146 "No edits provided. 'edits' array is empty.",
147 ));
148 }
149
150 struct EditOp {
152 file: String,
153 old_string: Option<String>,
154 new_string: Option<String>,
155 instruction: Option<String>,
156 update: Option<String>,
157 use_morph: bool,
158 }
159
160 let mut ops: Vec<EditOp> = Vec::with_capacity(edits_arr.len());
161 let morph_enabled = super::morph_backend::should_use_morph_backend();
162 for (i, entry) in edits_arr.iter().enumerate() {
163 let file = match entry.get("file").and_then(|v| v.as_str()) {
164 Some(f) => f.to_string(),
165 None => {
166 return Ok(ToolResult::structured_error(
167 "INVALID_ARGUMENT",
168 "multiedit",
169 &format!("edits[{i}]: missing or non-string 'file' field"),
170 Some(vec!["edits", "file"]),
171 Some(
172 json!({"file": "src/example.rs", "old_string": "...", "new_string": "..."}),
173 ),
174 ));
175 }
176 };
177 let old_string = entry
178 .get("old_string")
179 .and_then(|v| v.as_str())
180 .map(str::to_string);
181 let new_string = entry
182 .get("new_string")
183 .and_then(|v| v.as_str())
184 .map(str::to_string);
185 let instruction = entry
186 .get("instruction")
187 .and_then(|v| v.as_str())
188 .map(str::to_string);
189 let update = entry
190 .get("update")
191 .and_then(|v| v.as_str())
192 .map(str::to_string);
193 let use_morph = morph_enabled && (instruction.is_some() || update.is_some());
194
195 if !use_morph && (old_string.is_none() || new_string.is_none()) {
196 return Ok(ToolResult::structured_error(
197 "INVALID_ARGUMENT",
198 "multiedit",
199 &format!(
200 "edits[{i}] ({file}): provide old_string/new_string, or enable Morph backend and provide instruction/update"
201 ),
202 Some(vec!["edits"]),
203 Some(json!({
204 "file": file,
205 "old_string": "exact text to find",
206 "new_string": "replacement text",
207 "instruction": "Optional Morph instruction",
208 "update": "Optional Morph update snippet"
209 })),
210 ));
211 }
212
213 ops.push(EditOp {
214 file,
215 old_string,
216 new_string,
217 instruction,
218 update,
219 use_morph,
220 });
221 }
222
223 let mut validated: Vec<(PathBuf, String, String)> = Vec::with_capacity(ops.len());
226 let mut errors: Vec<String> = Vec::new();
227
228 let mut content_cache: HashMap<PathBuf, String> = HashMap::new();
231
232 for (i, op) in ops.iter().enumerate() {
233 let path = PathBuf::from(&op.file);
234
235 let content = if let Some(cached) = content_cache.get(&path) {
237 cached.clone()
238 } else if path.exists() {
239 match fs::read_to_string(&path).await {
240 Ok(c) => c,
241 Err(e) => {
242 errors.push(format!("edits[{i}] {}: cannot read file: {e}", op.file));
243 continue;
244 }
245 }
246 } else {
247 errors.push(format!("edits[{i}] {}: file does not exist", op.file));
248 continue;
249 };
250
251 let new_content = if op.use_morph {
252 let inferred_instruction = op
253 .instruction
254 .clone()
255 .or_else(|| {
256 op.old_string
257 .as_deref()
258 .zip(op.new_string.as_deref())
259 .map(|(old, new)| {
260 format!(
261 "Replace the target snippet exactly once while preserving behavior.\nOld snippet:\n{old}\n\nNew snippet:\n{new}"
262 )
263 })
264 })
265 .unwrap_or_else(|| {
266 "Apply the requested update precisely and return only the updated file."
267 .to_string()
268 });
269 let inferred_update = op
270 .update
271 .clone()
272 .or_else(|| {
273 op.old_string
274 .as_deref()
275 .zip(op.new_string.as_deref())
276 .map(|(old, new)| {
277 format!(
278 "// Replace this snippet:\n{old}\n// With this snippet:\n{new}\n// ...existing code..."
279 )
280 })
281 })
282 .unwrap_or_else(|| "// ...existing code...".to_string());
283
284 match super::morph_backend::apply_edit_with_morph(
285 &content,
286 &inferred_instruction,
287 &inferred_update,
288 )
289 .await
290 {
291 Ok(c) => c,
292 Err(e) => {
293 if let (Some(old_string), Some(new_string)) =
294 (op.old_string.as_deref(), op.new_string.as_deref())
295 {
296 tracing::warn!(
297 file = %op.file,
298 error = %e,
299 "Morph backend failed for multiedit op; falling back to exact replacement"
300 );
301 match apply_exact_replace(&content, old_string, new_string, &op.file, i)
302 {
303 Ok(c) => c,
304 Err(msg) => {
305 errors.push(msg);
306 continue;
307 }
308 }
309 } else {
310 errors
311 .push(format!("edits[{i}] {}: Morph backend failed: {e}", op.file));
312 continue;
313 }
314 }
315 }
316 } else {
317 let old_string = op.old_string.as_deref().unwrap_or_default();
318 let new_string = op.new_string.as_deref().unwrap_or_default();
319 match apply_exact_replace(&content, old_string, new_string, &op.file, i) {
320 Ok(c) => c,
321 Err(msg) => {
322 errors.push(msg);
323 continue;
324 }
325 }
326 };
327
328 content_cache.insert(path.clone(), new_content.clone());
329 validated.push((path, content, new_content));
330 }
331
332 if !errors.is_empty() {
333 let error_list = errors.join("\n");
334 return Ok(ToolResult {
335 output: format!(
336 "Validation failed for {} of {} edits. No files were modified.\n\n{error_list}",
337 errors.len(),
338 ops.len()
339 ),
340 success: false,
341 metadata: HashMap::new(),
342 });
343 }
344
345 let mut written: HashMap<PathBuf, bool> = HashMap::new();
349 let mut write_errors: Vec<String> = Vec::new();
350
351 for (path, _original, _new) in &validated {
352 if written.contains_key(path) {
353 continue;
354 }
355 let final_content = content_cache
356 .get(path)
357 .ok_or_else(|| anyhow::anyhow!("path {path:?} not found in content cache"))?;
358 match fs::write(path, final_content).await {
359 Ok(()) => {
360 written.insert(path.clone(), true);
361 }
362 Err(e) => {
363 write_errors.push(format!("{}: write failed: {e}", path.display()));
364 written.insert(path.clone(), false);
365 }
366 }
367 }
368
369 if !write_errors.is_empty() {
370 return Ok(ToolResult {
371 output: format!(
372 "Write errors (some files may have been partially updated):\n{}",
373 write_errors.join("\n")
374 ),
375 success: false,
376 metadata: HashMap::new(),
377 });
378 }
379
380 let unique_files = written.len();
382 let total_edits = ops.len();
383 let mut summary_lines: Vec<String> = Vec::new();
384 for (path, original, new_content) in &validated {
385 let old_lines = original.lines().count();
386 let new_lines = new_content.lines().count();
387 let delta = new_lines as i64 - old_lines as i64;
388 let sign = if delta >= 0 { "+" } else { "" };
389 summary_lines.push(format!("✓ {} ({sign}{delta} lines)", path.display()));
390 }
391
392 Ok(ToolResult::success(format!(
393 "Applied {total_edits} edit(s) across {unique_files} file(s):\n{}",
394 summary_lines.join("\n")
395 )))
396 }
397}