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 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        // Phase 1: Validation - read all files and check that old_string exists uniquely
103        let mut file_contents: Vec<(PathBuf, String, String, String)> = Vec::new();
104        let mut previews: Vec<EditPreview> = Vec::new();
105
106        for edit in &params.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            // Check that old_string exists exactly once
121            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        // Phase 2: Generate diffs for all changes
148        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 no confirmation provided, return diffs for review
195        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        // Handle confirmation
222        if params.confirm == Some(true) {
223            // Apply all changes
224            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}