1use anyhow::{Context, Result};
6use async_trait::async_trait;
7use serde::Deserialize;
8use serde_json::{Value, json};
9use similar::{ChangeTag, TextDiff};
10use std::collections::HashMap;
11use std::path::PathBuf;
12use tokio::fs;
13
14use super::{Tool, ToolResult};
15
16pub struct MultiEditTool;
17
18impl Default for MultiEditTool {
19 fn default() -> Self {
20 Self::new()
21 }
22}
23
24impl MultiEditTool {
25 pub fn new() -> Self {
26 Self
27 }
28}
29
30#[derive(Debug, Deserialize)]
31struct MultiEditParams {
32 edits: Vec<EditOperation>,
33}
34
35#[derive(Debug, Deserialize)]
36struct EditOperation {
37 file: String,
38 old_string: String,
39 new_string: String,
40}
41
42#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
44pub struct EditResult {
45 pub file: String,
46 pub success: bool,
47 pub message: String,
48}
49
50#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
52pub struct MultiEditSummary {
53 pub results: Vec<EditResult>,
54 pub total_files: usize,
55 pub success_count: usize,
56 pub failed_count: usize,
57 pub total_added: usize,
58 pub total_removed: usize,
59}
60
61#[async_trait]
62impl Tool for MultiEditTool {
63 fn id(&self) -> &str {
64 "multiedit"
65 }
66
67 fn name(&self) -> &str {
68 "Multi Edit"
69 }
70
71 fn description(&self) -> &str {
72 "Edit multiple files atomically. Each edit replaces an old string with a new string. \
73 All edits are validated before any changes are applied. If any edit fails validation, \
74 no changes are made."
75 }
76
77 fn parameters(&self) -> Value {
78 json!({
79 "type": "object",
80 "properties": {
81 "edits": {
82 "type": "array",
83 "description": "Array of edit operations to apply",
84 "items": {
85 "type": "object",
86 "properties": {
87 "file": {
88 "type": "string",
89 "description": "Path to the file to edit"
90 },
91 "old_string": {
92 "type": "string",
93 "description": "The exact string to find and replace"
94 },
95 "new_string": {
96 "type": "string",
97 "description": "The string to replace it with"
98 }
99 },
100 "required": ["file", "old_string", "new_string"]
101 }
102 }
103 },
104 "required": ["edits"]
105 })
106 }
107
108 async fn execute(&self, params: Value) -> Result<ToolResult> {
109 let params: MultiEditParams =
110 serde_json::from_value(params).context("Invalid parameters")?;
111
112 if params.edits.is_empty() {
113 return Ok(ToolResult::error("No edits provided"));
114 }
115
116 let mut edit_results: Vec<EditResult> = Vec::new();
118 let mut file_contents: Vec<(PathBuf, String, String, String)> = Vec::new();
119 let mut previews: Vec<Value> = Vec::new();
120 let mut total_added = 0;
121 let mut total_removed = 0;
122
123 for edit in ¶ms.edits {
125 let path = PathBuf::from(&edit.file);
126
127 if !path.exists() {
129 edit_results.push(EditResult {
130 file: edit.file.clone(),
131 success: false,
132 message: format!("File does not exist: {}", edit.file),
133 });
134 continue;
135 }
136
137 let content = match fs::read_to_string(&path).await {
139 Ok(c) => c,
140 Err(e) => {
141 edit_results.push(EditResult {
142 file: edit.file.clone(),
143 success: false,
144 message: format!("Failed to read file: {}", e),
145 });
146 continue;
147 }
148 };
149
150 let matches: Vec<_> = content.match_indices(&edit.old_string).collect();
152
153 if matches.is_empty() {
154 let preview = truncate_with_ellipsis(&edit.old_string, 50);
155 edit_results.push(EditResult {
156 file: edit.file.clone(),
157 success: false,
158 message: format!("String not found: {}", preview),
159 });
160 continue;
161 }
162
163 if matches.len() > 1 {
164 edit_results.push(EditResult {
165 file: edit.file.clone(),
166 success: false,
167 message: format!(
168 "String found {} times (must be unique). Use more context to disambiguate.",
169 matches.len()
170 ),
171 });
172 continue;
173 }
174
175 file_contents.push((
177 path.clone(),
178 content.clone(),
179 edit.old_string.clone(),
180 edit.new_string.clone(),
181 ));
182
183 let new_content = content.replacen(&edit.old_string, &edit.new_string, 1);
185 let diff = TextDiff::from_lines(&content, &new_content);
186
187 let mut diff_output = String::new();
188 let mut added = 0;
189 let mut removed = 0;
190
191 for change in diff.iter_all_changes() {
192 let (sign, style) = match change.tag() {
193 ChangeTag::Delete => {
194 removed += 1;
195 ("-", "red")
196 }
197 ChangeTag::Insert => {
198 added += 1;
199 ("+", "green")
200 }
201 ChangeTag::Equal => (" ", "default"),
202 };
203
204 let line = format!("{}{}", sign, change);
205 if style == "red" {
206 diff_output.push_str(&format!("\x1b[31m{}\x1b[0m", line.trim_end()));
207 } else if style == "green" {
208 diff_output.push_str(&format!("\x1b[32m{}\x1b[0m", line.trim_end()));
209 } else {
210 diff_output.push_str(&line.trim_end());
211 }
212 diff_output.push('\n');
213 }
214
215 previews.push(json!({
216 "file": path.display().to_string(),
217 "diff": diff_output.trim(),
218 "added": added,
219 "removed": removed
220 }));
221
222 total_added += added;
223 total_removed += removed;
224
225 edit_results.push(EditResult {
227 file: edit.file.clone(),
228 success: true,
229 message: format!("Validated: +{} lines, -{} lines", added, removed),
230 });
231 }
232
233 let failed_edits: Vec<&EditResult> = edit_results.iter().filter(|r| !r.success).collect();
235 let successful_edits: Vec<&EditResult> =
236 edit_results.iter().filter(|r| r.success).collect();
237
238 let summary = MultiEditSummary {
240 results: edit_results.clone(),
241 total_files: params.edits.len(),
242 success_count: successful_edits.len(),
243 failed_count: failed_edits.len(),
244 total_added,
245 total_removed,
246 };
247
248 if !failed_edits.is_empty() {
249 let mut error_summary = String::new();
251 for result in &failed_edits {
252 error_summary.push_str(&format!("\n✗ {}: {}", result.file, result.message));
253 }
254
255 let output = format!(
256 "Validation failed for {} of {} edits:{}",
257 failed_edits.len(),
258 params.edits.len(),
259 error_summary
260 );
261
262 return Ok(ToolResult {
263 output,
264 success: false,
265 metadata: {
266 let mut m = HashMap::new();
267 m.insert("summary".to_string(), json!(summary));
268 m.insert("edit_results".to_string(), json!(edit_results));
269 m.insert("failed_count".to_string(), json!(failed_edits.len()));
270 m.insert("success_count".to_string(), json!(successful_edits.len()));
271 m
272 },
273 });
274 }
275
276 let mut all_diffs = String::new();
278 for preview in &previews {
279 let file = preview["file"].as_str().unwrap();
280 let diff = preview["diff"].as_str().unwrap();
281 all_diffs.push_str(&format!("\n=== {} ===\n{}", file, diff));
282 }
283
284 let mut edit_summary = String::new();
286 for result in &edit_results {
287 edit_summary.push_str(&format!("\n✓ {}: {}", result.file, result.message));
288 }
289
290 let output = format!(
291 "Multi-file changes require confirmation:{}{}{}\n\nTotal: {} files, +{} lines, -{} lines",
292 all_diffs,
293 if edit_summary.is_empty() {
294 ""
295 } else {
296 "\n\nEdit summary:"
297 },
298 edit_summary,
299 file_contents.len(),
300 total_added,
301 total_removed
302 );
303
304 let mut metadata = HashMap::new();
305 metadata.insert("requires_confirmation".to_string(), json!(true));
306 metadata.insert("summary".to_string(), json!(summary));
307 metadata.insert("edit_results".to_string(), json!(edit_results));
308 metadata.insert("total_files".to_string(), json!(file_contents.len()));
309 metadata.insert("total_added".to_string(), json!(total_added));
310 metadata.insert("total_removed".to_string(), json!(total_removed));
311 metadata.insert("previews".to_string(), json!(previews));
312
313 Ok(ToolResult {
314 output,
315 success: true,
316 metadata,
317 })
318 }
319}
320
321fn truncate_with_ellipsis(value: &str, max_chars: usize) -> String {
322 if max_chars == 0 {
323 return String::new();
324 }
325
326 let mut chars = value.chars();
327 let mut output = String::new();
328 for _ in 0..max_chars {
329 if let Some(ch) = chars.next() {
330 output.push(ch);
331 } else {
332 return value.to_string();
333 }
334 }
335
336 if chars.next().is_some() {
337 format!("{output}...")
338 } else {
339 output
340 }
341}