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