Skip to main content

codetether_agent/tool/
multiedit.rs

1//! Multi-Edit Tool
2//!
3//! Edit multiple files atomically with an array of replacements.
4
5use anyhow::{Context, Result};
6use async_trait::async_trait;
7use serde::Deserialize;
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
16pub struct MultiEditTool;
17
18impl Default for MultiEditTool {
19    fn default() -> Self {
20        Self::new()
21    }
22}
23
24impl MultiEditTool {
25    pub fn new() -> Self {
26        Self
27    }
28}
29
30#[derive(Debug, Deserialize)]
31struct MultiEditParams {
32    edits: Vec<EditOperation>,
33}
34
35#[derive(Debug, Deserialize)]
36struct EditOperation {
37    file: String,
38    old_string: String,
39    new_string: String,
40}
41
42/// Result of a single file edit operation
43#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
44pub struct EditResult {
45    pub file: String,
46    pub success: bool,
47    pub message: String,
48}
49
50/// Summary of all edit operations
51#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
52pub struct MultiEditSummary {
53    pub results: Vec<EditResult>,
54    pub total_files: usize,
55    pub success_count: usize,
56    pub failed_count: usize,
57    pub total_added: usize,
58    pub total_removed: usize,
59}
60
61#[async_trait]
62impl Tool for MultiEditTool {
63    fn id(&self) -> &str {
64        "multiedit"
65    }
66
67    fn name(&self) -> &str {
68        "Multi Edit"
69    }
70
71    fn description(&self) -> &str {
72        "Edit multiple files atomically. Each edit replaces an old string with a new string. \
73         All edits are validated before any changes are applied. If any edit fails validation, \
74         no changes are made."
75    }
76
77    fn parameters(&self) -> Value {
78        json!({
79            "type": "object",
80            "properties": {
81                "edits": {
82                    "type": "array",
83                    "description": "Array of edit operations to apply",
84                    "items": {
85                        "type": "object",
86                        "properties": {
87                            "file": {
88                                "type": "string",
89                                "description": "Path to the file to edit"
90                            },
91                            "old_string": {
92                                "type": "string",
93                                "description": "The exact string to find and replace"
94                            },
95                            "new_string": {
96                                "type": "string",
97                                "description": "The string to replace it with"
98                            }
99                        },
100                        "required": ["file", "old_string", "new_string"]
101                    }
102                }
103            },
104            "required": ["edits"]
105        })
106    }
107
108    async fn execute(&self, params: Value) -> Result<ToolResult> {
109        let params: MultiEditParams =
110            serde_json::from_value(params).context("Invalid parameters")?;
111
112        if params.edits.is_empty() {
113            return Ok(ToolResult::error("No edits provided"));
114        }
115
116        // Track results for each file edit
117        let mut edit_results: Vec<EditResult> = Vec::new();
118        let mut file_contents: Vec<(PathBuf, String, String, String)> = Vec::new();
119        let mut previews: Vec<Value> = Vec::new();
120        let mut total_added = 0;
121        let mut total_removed = 0;
122
123        // Phase 1: Validate all edits and collect file contents
124        for edit in &params.edits {
125            let path = PathBuf::from(&edit.file);
126
127            // Check if file exists
128            if !path.exists() {
129                edit_results.push(EditResult {
130                    file: edit.file.clone(),
131                    success: false,
132                    message: format!("File does not exist: {}", edit.file),
133                });
134                continue;
135            }
136
137            // Read file content
138            let content = match fs::read_to_string(&path).await {
139                Ok(c) => c,
140                Err(e) => {
141                    edit_results.push(EditResult {
142                        file: edit.file.clone(),
143                        success: false,
144                        message: format!("Failed to read file: {}", e),
145                    });
146                    continue;
147                }
148            };
149
150            // Check that old_string exists exactly once
151            let matches: Vec<_> = content.match_indices(&edit.old_string).collect();
152
153            if matches.is_empty() {
154                let preview = if edit.old_string.len() > 50 {
155                    format!("{}...", &edit.old_string[..50])
156                } else {
157                    edit.old_string.clone()
158                };
159                edit_results.push(EditResult {
160                    file: edit.file.clone(),
161                    success: false,
162                    message: format!("String not found: {}", preview),
163                });
164                continue;
165            }
166
167            if matches.len() > 1 {
168                edit_results.push(EditResult {
169                    file: edit.file.clone(),
170                    success: false,
171                    message: format!(
172                        "String found {} times (must be unique). Use more context to disambiguate.",
173                        matches.len()
174                    ),
175                });
176                continue;
177            }
178
179            // Validation passed - store for processing
180            file_contents.push((
181                path.clone(),
182                content.clone(),
183                edit.old_string.clone(),
184                edit.new_string.clone(),
185            ));
186
187            // Generate diff preview
188            let new_content = content.replacen(&edit.old_string, &edit.new_string, 1);
189            let diff = TextDiff::from_lines(&content, &new_content);
190
191            let mut diff_output = String::new();
192            let mut added = 0;
193            let mut removed = 0;
194
195            for change in diff.iter_all_changes() {
196                let (sign, style) = match change.tag() {
197                    ChangeTag::Delete => {
198                        removed += 1;
199                        ("-", "red")
200                    }
201                    ChangeTag::Insert => {
202                        added += 1;
203                        ("+", "green")
204                    }
205                    ChangeTag::Equal => (" ", "default"),
206                };
207
208                let line = format!("{}{}", sign, change);
209                if style == "red" {
210                    diff_output.push_str(&format!("\x1b[31m{}\x1b[0m", line.trim_end()));
211                } else if style == "green" {
212                    diff_output.push_str(&format!("\x1b[32m{}\x1b[0m", line.trim_end()));
213                } else {
214                    diff_output.push_str(&line.trim_end());
215                }
216                diff_output.push('\n');
217            }
218
219            previews.push(json!({
220                "file": path.display().to_string(),
221                "diff": diff_output.trim(),
222                "added": added,
223                "removed": removed
224            }));
225
226            total_added += added;
227            total_removed += removed;
228
229            // Mark as success for validation phase
230            edit_results.push(EditResult {
231                file: edit.file.clone(),
232                success: true,
233                message: format!("Validated: +{} lines, -{} lines", added, removed),
234            });
235        }
236
237        // Check if any edits failed validation
238        let failed_edits: Vec<&EditResult> = edit_results.iter().filter(|r| !r.success).collect();
239        let successful_edits: Vec<&EditResult> =
240            edit_results.iter().filter(|r| r.success).collect();
241
242        // Build structured summary
243        let summary = MultiEditSummary {
244            results: edit_results.clone(),
245            total_files: params.edits.len(),
246            success_count: successful_edits.len(),
247            failed_count: failed_edits.len(),
248            total_added,
249            total_removed,
250        };
251
252        if !failed_edits.is_empty() {
253            // Build human-readable error summary for output
254            let mut error_summary = String::new();
255            for result in &failed_edits {
256                error_summary.push_str(&format!("\n✗ {}: {}", result.file, result.message));
257            }
258
259            let output = format!(
260                "Validation failed for {} of {} edits:{}",
261                failed_edits.len(),
262                params.edits.len(),
263                error_summary
264            );
265
266            return Ok(ToolResult {
267                output,
268                success: false,
269                metadata: {
270                    let mut m = HashMap::new();
271                    m.insert("summary".to_string(), json!(summary));
272                    m.insert("edit_results".to_string(), json!(edit_results));
273                    m.insert("failed_count".to_string(), json!(failed_edits.len()));
274                    m.insert("success_count".to_string(), json!(successful_edits.len()));
275                    m
276                },
277            });
278        }
279
280        // All validations passed - return confirmation prompt with structured results
281        let mut all_diffs = String::new();
282        for preview in &previews {
283            let file = preview["file"].as_str().unwrap();
284            let diff = preview["diff"].as_str().unwrap();
285            all_diffs.push_str(&format!("\n=== {} ===\n{}", file, diff));
286        }
287
288        // Build human-readable summary for output
289        let mut edit_summary = String::new();
290        for result in &edit_results {
291            edit_summary.push_str(&format!("\n✓ {}: {}", result.file, result.message));
292        }
293
294        let output = format!(
295            "Multi-file changes require confirmation:{}{}{}\n\nTotal: {} files, +{} lines, -{} lines",
296            all_diffs,
297            if edit_summary.is_empty() {
298                ""
299            } else {
300                "\n\nEdit summary:"
301            },
302            edit_summary,
303            file_contents.len(),
304            total_added,
305            total_removed
306        );
307
308        let mut metadata = HashMap::new();
309        metadata.insert("requires_confirmation".to_string(), json!(true));
310        metadata.insert("summary".to_string(), json!(summary));
311        metadata.insert("edit_results".to_string(), json!(edit_results));
312        metadata.insert("total_files".to_string(), json!(file_contents.len()));
313        metadata.insert("total_added".to_string(), json!(total_added));
314        metadata.insert("total_removed".to_string(), json!(total_removed));
315        metadata.insert("previews".to_string(), json!(previews));
316
317        Ok(ToolResult {
318            output,
319            success: true,
320            metadata,
321        })
322    }
323}