1use anyhow::{Context, Result};
6use async_trait::async_trait;
7use serde::{Deserialize, Serialize};
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
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct EditOperation {
18 pub file: String,
19 pub old_string: String,
20 pub new_string: String,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct EditPreview {
25 pub file: String,
26 pub diff: String,
27 pub added: usize,
28 pub removed: usize,
29}
30
31pub struct ConfirmMultiEditTool;
32
33impl ConfirmMultiEditTool {
34 pub fn new() -> Self {
35 Self
36 }
37}
38
39#[async_trait]
40impl Tool for ConfirmMultiEditTool {
41 fn id(&self) -> &str {
42 "confirm_multiedit"
43 }
44
45 fn name(&self) -> &str {
46 "Confirm Multi Edit"
47 }
48
49 fn description(&self) -> &str {
50 "Edit multiple files with confirmation. Shows diffs for all changes and requires user confirmation before applying."
51 }
52
53 fn parameters(&self) -> Value {
54 json!({
55 "type": "object",
56 "properties": {
57 "edits": {
58 "type": "array",
59 "description": "Array of edit operations to preview",
60 "items": {
61 "type": "object",
62 "properties": {
63 "file": {
64 "type": "string",
65 "description": "Path to the file to edit"
66 },
67 "old_string": {
68 "type": "string",
69 "description": "The exact string to find and replace"
70 },
71 "new_string": {
72 "type": "string",
73 "description": "The string to replace it with"
74 }
75 },
76 "required": ["file", "old_string", "new_string"]
77 }
78 },
79 "confirm": {
80 "type": "boolean",
81 "description": "Set to true to confirm and apply all changes, false to reject all",
82 "default": null
83 }
84 },
85 "required": ["edits"]
86 })
87 }
88
89 async fn execute(&self, input: Value) -> Result<ToolResult> {
90 let edits_val = match input.get("edits").and_then(|v| v.as_array()) {
91 Some(arr) if !arr.is_empty() => arr,
92 Some(_) => {
93 return Ok(ToolResult::structured_error(
94 "INVALID_FIELD",
95 "confirm_multiedit",
96 "edits array must contain at least one edit operation",
97 Some(vec!["edits"]),
98 Some(
99 json!({"edits": [{"file": "path/to/file", "old_string": "old", "new_string": "new"}]}),
100 ),
101 ));
102 }
103 None => {
104 return Ok(ToolResult::structured_error(
105 "MISSING_FIELD",
106 "confirm_multiedit",
107 "edits is required and must be an array of edit objects with 'file', 'old_string', 'new_string' fields",
108 Some(vec!["edits"]),
109 Some(
110 json!({"edits": [{"file": "path/to/file", "old_string": "old", "new_string": "new"}]}),
111 ),
112 ));
113 }
114 };
115
116 let mut edits = Vec::new();
117 for (i, edit_val) in edits_val.iter().enumerate() {
118 let file = match edit_val.get("file").and_then(|v| v.as_str()) {
119 Some(s) => s.to_string(),
120 None => {
121 return Ok(ToolResult::structured_error(
122 "INVALID_FIELD",
123 "confirm_multiedit",
124 &format!("edits[{i}].file is required"),
125 Some(vec!["file"]),
126 Some(
127 json!({"file": "path/to/file", "old_string": "old", "new_string": "new"}),
128 ),
129 ));
130 }
131 };
132 let old_string = match edit_val.get("old_string").and_then(|v| v.as_str()) {
133 Some(s) => s.to_string(),
134 None => {
135 return Ok(ToolResult::structured_error(
136 "INVALID_FIELD",
137 "confirm_multiedit",
138 &format!("edits[{i}].old_string is required"),
139 Some(vec!["old_string"]),
140 Some(
141 json!({"file": file, "old_string": "text to find", "new_string": "replacement"}),
142 ),
143 ));
144 }
145 };
146 let new_string = match edit_val.get("new_string").and_then(|v| v.as_str()) {
147 Some(s) => s.to_string(),
148 None => {
149 return Ok(ToolResult::structured_error(
150 "INVALID_FIELD",
151 "confirm_multiedit",
152 &format!("edits[{i}].new_string is required"),
153 Some(vec!["new_string"]),
154 Some(
155 json!({"file": file, "old_string": old_string, "new_string": "replacement"}),
156 ),
157 ));
158 }
159 };
160 edits.push(EditOperation {
161 file,
162 old_string,
163 new_string,
164 });
165 }
166
167 let confirm = input.get("confirm").and_then(|v| v.as_bool());
168
169 if edits.is_empty() {
170 return Ok(ToolResult::error("No edits provided"));
171 }
172
173 let mut file_contents: Vec<(PathBuf, String, String, String)> = Vec::new();
175 let mut previews: Vec<EditPreview> = Vec::new();
176
177 for edit in &edits {
178 let path = PathBuf::from(&edit.file);
179
180 if !path.exists() {
181 return Ok(ToolResult::error(format!(
182 "File does not exist: {}",
183 edit.file
184 )));
185 }
186
187 let content = fs::read_to_string(&path)
188 .await
189 .with_context(|| format!("Failed to read file: {}", edit.file))?;
190
191 let matches: Vec<_> = content.match_indices(&edit.old_string).collect();
193
194 if matches.is_empty() {
195 return Ok(ToolResult::error(format!(
196 "String not found in {}: {}",
197 edit.file,
198 truncate_with_ellipsis(&edit.old_string, 50)
199 )));
200 }
201
202 if matches.len() > 1 {
203 return Ok(ToolResult::error(format!(
204 "String found {} times in {} (must be unique). Use more context to disambiguate.",
205 matches.len(),
206 edit.file
207 )));
208 }
209
210 file_contents.push((
211 path,
212 content,
213 edit.old_string.clone(),
214 edit.new_string.clone(),
215 ));
216 }
217
218 let mut total_added = 0;
220 let mut total_removed = 0;
221
222 for (path, content, old_string, new_string) in &file_contents {
223 let new_content = content.replacen(old_string, new_string, 1);
224 let diff = TextDiff::from_lines(content, &new_content);
225
226 let mut diff_output = String::new();
227 let mut added = 0;
228 let mut removed = 0;
229
230 for change in diff.iter_all_changes() {
231 let (sign, style) = match change.tag() {
232 ChangeTag::Delete => {
233 removed += 1;
234 ("-", "red")
235 }
236 ChangeTag::Insert => {
237 added += 1;
238 ("+", "green")
239 }
240 ChangeTag::Equal => (" ", "default"),
241 };
242
243 let line = format!("{}{}", sign, change);
244 if style == "red" {
245 diff_output.push_str(&format!("\x1b[31m{}\x1b[0m", line.trim_end()));
246 } else if style == "green" {
247 diff_output.push_str(&format!("\x1b[32m{}\x1b[0m", line.trim_end()));
248 } else {
249 diff_output.push_str(&line.trim_end());
250 }
251 diff_output.push('\n');
252 }
253
254 previews.push(EditPreview {
255 file: path.display().to_string(),
256 diff: diff_output.trim().to_string(),
257 added,
258 removed,
259 });
260
261 total_added += added;
262 total_removed += removed;
263 }
264
265 if confirm.is_none() {
267 let mut all_diffs = String::new();
268 for preview in &previews {
269 all_diffs.push_str(&format!("\n=== {} ===\n{}", preview.file, preview.diff));
270 }
271
272 let mut metadata = HashMap::new();
273 metadata.insert("requires_confirmation".to_string(), json!(true));
274 metadata.insert("total_files".to_string(), json!(previews.len()));
275 metadata.insert("total_added".to_string(), json!(total_added));
276 metadata.insert("total_removed".to_string(), json!(total_removed));
277 metadata.insert("previews".to_string(), json!(previews));
278
279 return Ok(ToolResult {
280 output: format!(
281 "Multi-file changes require confirmation:{}\n\nTotal: {} files, +{} lines, -{} lines",
282 all_diffs,
283 previews.len(),
284 total_added,
285 total_removed
286 ),
287 success: true,
288 metadata,
289 });
290 }
291
292 if confirm == Some(true) {
294 for (path, content, old_string, new_string) in file_contents {
296 let new_content = content.replacen(&old_string, &new_string, 1);
297 fs::write(&path, &new_content).await?;
298 }
299
300 Ok(ToolResult::success(format!(
301 "✓ Applied {} file changes",
302 previews.len()
303 )))
304 } else {
305 Ok(ToolResult::success("✗ All changes rejected by user"))
306 }
307 }
308}
309
310fn truncate_with_ellipsis(value: &str, max_chars: usize) -> String {
311 if max_chars == 0 {
312 return String::new();
313 }
314
315 let mut chars = value.chars();
316 let mut output = String::new();
317 for _ in 0..max_chars {
318 if let Some(ch) = chars.next() {
319 output.push(ch);
320 } else {
321 return value.to_string();
322 }
323 }
324
325 if chars.next().is_some() {
326 format!("{output}...")
327 } else {
328 output
329 }
330}