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 = if edit.old_string.len() > 50 {
155 format!("{}...", &edit.old_string[..50])
156 } else {
157 edit.old_string.clone()
158 };
159 edit_results.push(EditResult {
160 file: edit.file.clone(),
161 success: false,
162 message: format!("String not found: {}", preview),
163 });
164 continue;
165 }
166
167 if matches.len() > 1 {
168 edit_results.push(EditResult {
169 file: edit.file.clone(),
170 success: false,
171 message: format!(
172 "String found {} times (must be unique). Use more context to disambiguate.",
173 matches.len()
174 ),
175 });
176 continue;
177 }
178
179 file_contents.push((
181 path.clone(),
182 content.clone(),
183 edit.old_string.clone(),
184 edit.new_string.clone(),
185 ));
186
187 let new_content = content.replacen(&edit.old_string, &edit.new_string, 1);
189 let diff = TextDiff::from_lines(&content, &new_content);
190
191 let mut diff_output = String::new();
192 let mut added = 0;
193 let mut removed = 0;
194
195 for change in diff.iter_all_changes() {
196 let (sign, style) = match change.tag() {
197 ChangeTag::Delete => {
198 removed += 1;
199 ("-", "red")
200 }
201 ChangeTag::Insert => {
202 added += 1;
203 ("+", "green")
204 }
205 ChangeTag::Equal => (" ", "default"),
206 };
207
208 let line = format!("{}{}", sign, change);
209 if style == "red" {
210 diff_output.push_str(&format!("\x1b[31m{}\x1b[0m", line.trim_end()));
211 } else if style == "green" {
212 diff_output.push_str(&format!("\x1b[32m{}\x1b[0m", line.trim_end()));
213 } else {
214 diff_output.push_str(&line.trim_end());
215 }
216 diff_output.push('\n');
217 }
218
219 previews.push(json!({
220 "file": path.display().to_string(),
221 "diff": diff_output.trim(),
222 "added": added,
223 "removed": removed
224 }));
225
226 total_added += added;
227 total_removed += removed;
228
229 edit_results.push(EditResult {
231 file: edit.file.clone(),
232 success: true,
233 message: format!("Validated: +{} lines, -{} lines", added, removed),
234 });
235 }
236
237 let failed_edits: Vec<&EditResult> = edit_results.iter().filter(|r| !r.success).collect();
239 let successful_edits: Vec<&EditResult> =
240 edit_results.iter().filter(|r| r.success).collect();
241
242 let summary = MultiEditSummary {
244 results: edit_results.clone(),
245 total_files: params.edits.len(),
246 success_count: successful_edits.len(),
247 failed_count: failed_edits.len(),
248 total_added,
249 total_removed,
250 };
251
252 if !failed_edits.is_empty() {
253 let mut error_summary = String::new();
255 for result in &failed_edits {
256 error_summary.push_str(&format!("\n✗ {}: {}", result.file, result.message));
257 }
258
259 let output = format!(
260 "Validation failed for {} of {} edits:{}",
261 failed_edits.len(),
262 params.edits.len(),
263 error_summary
264 );
265
266 return Ok(ToolResult {
267 output,
268 success: false,
269 metadata: {
270 let mut m = HashMap::new();
271 m.insert("summary".to_string(), json!(summary));
272 m.insert("edit_results".to_string(), json!(edit_results));
273 m.insert("failed_count".to_string(), json!(failed_edits.len()));
274 m.insert("success_count".to_string(), json!(successful_edits.len()));
275 m
276 },
277 });
278 }
279
280 let mut all_diffs = String::new();
282 for preview in &previews {
283 let file = preview["file"].as_str().unwrap();
284 let diff = preview["diff"].as_str().unwrap();
285 all_diffs.push_str(&format!("\n=== {} ===\n{}", file, diff));
286 }
287
288 let mut edit_summary = String::new();
290 for result in &edit_results {
291 edit_summary.push_str(&format!("\n✓ {}: {}", result.file, result.message));
292 }
293
294 let output = format!(
295 "Multi-file changes require confirmation:{}{}{}\n\nTotal: {} files, +{} lines, -{} lines",
296 all_diffs,
297 if edit_summary.is_empty() {
298 ""
299 } else {
300 "\n\nEdit summary:"
301 },
302 edit_summary,
303 file_contents.len(),
304 total_added,
305 total_removed
306 );
307
308 let mut metadata = HashMap::new();
309 metadata.insert("requires_confirmation".to_string(), json!(true));
310 metadata.insert("summary".to_string(), json!(summary));
311 metadata.insert("edit_results".to_string(), json!(edit_results));
312 metadata.insert("total_files".to_string(), json!(file_contents.len()));
313 metadata.insert("total_added".to_string(), json!(total_added));
314 metadata.insert("total_removed".to_string(), json!(total_removed));
315 metadata.insert("previews".to_string(), json!(previews));
316
317 Ok(ToolResult {
318 output,
319 success: true,
320 metadata,
321 })
322 }
323}