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 ConfirmMultiEditInput {
18 pub edits: Vec<EditOperation>,
19 pub confirm: Option<bool>,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct EditOperation {
24 pub file: String,
25 pub old_string: String,
26 pub new_string: String,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct EditPreview {
31 pub file: String,
32 pub diff: String,
33 pub added: usize,
34 pub removed: usize,
35}
36
37pub struct ConfirmMultiEditTool;
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 params: ConfirmMultiEditInput = serde_json::from_value(input)?;
97
98 if params.edits.is_empty() {
99 return Ok(ToolResult::error("No edits provided"));
100 }
101
102 let mut file_contents: Vec<(PathBuf, String, String, String)> = Vec::new();
104 let mut previews: Vec<EditPreview> = Vec::new();
105
106 for edit in ¶ms.edits {
107 let path = PathBuf::from(&edit.file);
108
109 if !path.exists() {
110 return Ok(ToolResult::error(format!(
111 "File does not exist: {}",
112 edit.file
113 )));
114 }
115
116 let content = fs::read_to_string(&path)
117 .await
118 .with_context(|| format!("Failed to read file: {}", edit.file))?;
119
120 let matches: Vec<_> = content.match_indices(&edit.old_string).collect();
122
123 if matches.is_empty() {
124 return Ok(ToolResult::error(format!(
125 "String not found in {}: {}",
126 edit.file,
127 truncate_with_ellipsis(&edit.old_string, 50)
128 )));
129 }
130
131 if matches.len() > 1 {
132 return Ok(ToolResult::error(format!(
133 "String found {} times in {} (must be unique). Use more context to disambiguate.",
134 matches.len(),
135 edit.file
136 )));
137 }
138
139 file_contents.push((
140 path,
141 content,
142 edit.old_string.clone(),
143 edit.new_string.clone(),
144 ));
145 }
146
147 let mut total_added = 0;
149 let mut total_removed = 0;
150
151 for (path, content, old_string, new_string) in &file_contents {
152 let new_content = content.replacen(old_string, new_string, 1);
153 let diff = TextDiff::from_lines(content, &new_content);
154
155 let mut diff_output = String::new();
156 let mut added = 0;
157 let mut removed = 0;
158
159 for change in diff.iter_all_changes() {
160 let (sign, style) = match change.tag() {
161 ChangeTag::Delete => {
162 removed += 1;
163 ("-", "red")
164 }
165 ChangeTag::Insert => {
166 added += 1;
167 ("+", "green")
168 }
169 ChangeTag::Equal => (" ", "default"),
170 };
171
172 let line = format!("{}{}", sign, change);
173 if style == "red" {
174 diff_output.push_str(&format!("\x1b[31m{}\x1b[0m", line.trim_end()));
175 } else if style == "green" {
176 diff_output.push_str(&format!("\x1b[32m{}\x1b[0m", line.trim_end()));
177 } else {
178 diff_output.push_str(&line.trim_end());
179 }
180 diff_output.push('\n');
181 }
182
183 previews.push(EditPreview {
184 file: path.display().to_string(),
185 diff: diff_output.trim().to_string(),
186 added,
187 removed,
188 });
189
190 total_added += added;
191 total_removed += removed;
192 }
193
194 if params.confirm.is_none() {
196 let mut all_diffs = String::new();
197 for preview in &previews {
198 all_diffs.push_str(&format!("\n=== {} ===\n{}", preview.file, preview.diff));
199 }
200
201 let mut metadata = HashMap::new();
202 metadata.insert("requires_confirmation".to_string(), json!(true));
203 metadata.insert("total_files".to_string(), json!(previews.len()));
204 metadata.insert("total_added".to_string(), json!(total_added));
205 metadata.insert("total_removed".to_string(), json!(total_removed));
206 metadata.insert("previews".to_string(), json!(previews));
207
208 return Ok(ToolResult {
209 output: format!(
210 "Multi-file changes require confirmation:{}\n\nTotal: {} files, +{} lines, -{} lines",
211 all_diffs,
212 previews.len(),
213 total_added,
214 total_removed
215 ),
216 success: true,
217 metadata,
218 });
219 }
220
221 if params.confirm == Some(true) {
223 for (path, content, old_string, new_string) in file_contents {
225 let new_content = content.replacen(&old_string, &new_string, 1);
226 fs::write(&path, &new_content).await?;
227 }
228
229 Ok(ToolResult::success(format!(
230 "✓ Applied {} file changes",
231 previews.len()
232 )))
233 } else {
234 Ok(ToolResult::success("✗ All changes rejected by user"))
235 }
236 }
237}
238
239fn truncate_with_ellipsis(value: &str, max_chars: usize) -> String {
240 if max_chars == 0 {
241 return String::new();
242 }
243
244 let mut chars = value.chars();
245 let mut output = String::new();
246 for _ in 0..max_chars {
247 if let Some(ch) = chars.next() {
248 output.push(ch);
249 } else {
250 return value.to_string();
251 }
252 }
253
254 if chars.next().is_some() {
255 format!("{output}...")
256 } else {
257 output
258 }
259}