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
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        // ── Parse with detailed error messages ──────────────────────
78        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        // ── Extract and validate each edit entry ────────────────────
119        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        // ── Phase 1: Validate all edits (no writes yet) ────────────
177        // Each validated edit becomes (path, original_content, new_content).
178        let mut validated: Vec<(PathBuf, String, String)> = Vec::with_capacity(ops.len());
179        let mut errors: Vec<String> = Vec::new();
180
181        // When multiple edits target the same file, we must chain them
182        // on the accumulated content rather than re-reading from disk.
183        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            // Read or reuse cached content
189            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        // ── Phase 2: Write all changes ──────────────────────────────
240        // Deduplicate by path — use the final accumulated content from
241        // the cache (handles multiple edits to the same file).
242        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        // ── Build summary ───────────────────────────────────────────
273        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}