Skip to main content

codetether_agent/tool/
confirm_multiedit.rs

1//! Confirm Multi-Edit Tool
2//!
3//! Edit multiple files with user confirmation via diff display
4
5use 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        // Phase 1: Validation - read all files and check that old_string exists uniquely
180        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            // Check that old_string exists exactly once
198            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        // Phase 2: Generate diffs for all changes
225        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 no confirmation provided, return diffs for review
272        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        // Handle confirmation
299        if confirm == Some(true) {
300            // Apply all changes
301            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}