Skip to main content

codetether_agent/tool/
multiedit.rs

1//! Multi-Edit Tool
2//!
3//! Apply multiple file edits atomically. Validates all edits first, then
4//! writes all changes only if every edit passes validation.
5
6use 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        // ── Parse with detailed error messages ──────────────────────
110        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        // ── Extract and validate each edit entry ────────────────────
151        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        // ── Phase 1: Validate all edits (no writes yet) ────────────
224        // Each validated edit becomes (path, original_content, new_content).
225        let mut validated: Vec<(PathBuf, String, String)> = Vec::with_capacity(ops.len());
226        let mut errors: Vec<String> = Vec::new();
227
228        // When multiple edits target the same file, we must chain them
229        // on the accumulated content rather than re-reading from disk.
230        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            // Read or reuse cached content
236            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        // ── Phase 2: Write all changes ──────────────────────────────
346        // Deduplicate by path — use the final accumulated content from
347        // the cache (handles multiple edits to the same file).
348        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        // ── Build summary ───────────────────────────────────────────
381        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}