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 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        // Phase 1: Validation - read all files and check that old_string exists uniquely
174        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            // Check that old_string exists exactly once
192            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        // Phase 2: Generate diffs for all changes
219        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 no confirmation provided, return diffs for review
266        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        // Handle confirmation
293        if confirm == Some(true) {
294            // Apply all changes
295            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}