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 = truncate_with_ellipsis(&edit.old_string, 50);
155                edit_results.push(EditResult {
156                    file: edit.file.clone(),
157                    success: false,
158                    message: format!("String not found: {}", preview),
159                });
160                continue;
161            }
162
163            if matches.len() > 1 {
164                edit_results.push(EditResult {
165                    file: edit.file.clone(),
166                    success: false,
167                    message: format!(
168                        "String found {} times (must be unique). Use more context to disambiguate.",
169                        matches.len()
170                    ),
171                });
172                continue;
173            }
174
175            // Validation passed - store for processing
176            file_contents.push((
177                path.clone(),
178                content.clone(),
179                edit.old_string.clone(),
180                edit.new_string.clone(),
181            ));
182
183            // Generate diff preview
184            let new_content = content.replacen(&edit.old_string, &edit.new_string, 1);
185            let diff = TextDiff::from_lines(&content, &new_content);
186
187            let mut diff_output = String::new();
188            let mut added = 0;
189            let mut removed = 0;
190
191            for change in diff.iter_all_changes() {
192                let (sign, style) = match change.tag() {
193                    ChangeTag::Delete => {
194                        removed += 1;
195                        ("-", "red")
196                    }
197                    ChangeTag::Insert => {
198                        added += 1;
199                        ("+", "green")
200                    }
201                    ChangeTag::Equal => (" ", "default"),
202                };
203
204                let line = format!("{}{}", sign, change);
205                if style == "red" {
206                    diff_output.push_str(&format!("\x1b[31m{}\x1b[0m", line.trim_end()));
207                } else if style == "green" {
208                    diff_output.push_str(&format!("\x1b[32m{}\x1b[0m", line.trim_end()));
209                } else {
210                    diff_output.push_str(&line.trim_end());
211                }
212                diff_output.push('\n');
213            }
214
215            previews.push(json!({
216                "file": path.display().to_string(),
217                "diff": diff_output.trim(),
218                "added": added,
219                "removed": removed
220            }));
221
222            total_added += added;
223            total_removed += removed;
224
225            // Mark as success for validation phase
226            edit_results.push(EditResult {
227                file: edit.file.clone(),
228                success: true,
229                message: format!("Validated: +{} lines, -{} lines", added, removed),
230            });
231        }
232
233        // Check if any edits failed validation
234        let failed_edits: Vec<&EditResult> = edit_results.iter().filter(|r| !r.success).collect();
235        let successful_edits: Vec<&EditResult> =
236            edit_results.iter().filter(|r| r.success).collect();
237
238        // Build structured summary
239        let summary = MultiEditSummary {
240            results: edit_results.clone(),
241            total_files: params.edits.len(),
242            success_count: successful_edits.len(),
243            failed_count: failed_edits.len(),
244            total_added,
245            total_removed,
246        };
247
248        if !failed_edits.is_empty() {
249            // Build human-readable error summary for output
250            let mut error_summary = String::new();
251            for result in &failed_edits {
252                error_summary.push_str(&format!("\n✗ {}: {}", result.file, result.message));
253            }
254
255            let output = format!(
256                "Validation failed for {} of {} edits:{}",
257                failed_edits.len(),
258                params.edits.len(),
259                error_summary
260            );
261
262            return Ok(ToolResult {
263                output,
264                success: false,
265                metadata: {
266                    let mut m = HashMap::new();
267                    m.insert("summary".to_string(), json!(summary));
268                    m.insert("edit_results".to_string(), json!(edit_results));
269                    m.insert("failed_count".to_string(), json!(failed_edits.len()));
270                    m.insert("success_count".to_string(), json!(successful_edits.len()));
271                    m
272                },
273            });
274        }
275
276        // All validations passed - return confirmation prompt with structured results
277        let mut all_diffs = String::new();
278        for preview in &previews {
279            let file = preview["file"].as_str().unwrap();
280            let diff = preview["diff"].as_str().unwrap();
281            all_diffs.push_str(&format!("\n=== {} ===\n{}", file, diff));
282        }
283
284        // Build human-readable summary for output
285        let mut edit_summary = String::new();
286        for result in &edit_results {
287            edit_summary.push_str(&format!("\n✓ {}: {}", result.file, result.message));
288        }
289
290        let output = format!(
291            "Multi-file changes require confirmation:{}{}{}\n\nTotal: {} files, +{} lines, -{} lines",
292            all_diffs,
293            if edit_summary.is_empty() {
294                ""
295            } else {
296                "\n\nEdit summary:"
297            },
298            edit_summary,
299            file_contents.len(),
300            total_added,
301            total_removed
302        );
303
304        let mut metadata = HashMap::new();
305        metadata.insert("requires_confirmation".to_string(), json!(true));
306        metadata.insert("summary".to_string(), json!(summary));
307        metadata.insert("edit_results".to_string(), json!(edit_results));
308        metadata.insert("total_files".to_string(), json!(file_contents.len()));
309        metadata.insert("total_added".to_string(), json!(total_added));
310        metadata.insert("total_removed".to_string(), json!(total_removed));
311        metadata.insert("previews".to_string(), json!(previews));
312
313        Ok(ToolResult {
314            output,
315            success: true,
316            metadata,
317        })
318    }
319}
320
321fn truncate_with_ellipsis(value: &str, max_chars: usize) -> String {
322    if max_chars == 0 {
323        return String::new();
324    }
325
326    let mut chars = value.chars();
327    let mut output = String::new();
328    for _ in 0..max_chars {
329        if let Some(ch) = chars.next() {
330            output.push(ch);
331        } else {
332            return value.to_string();
333        }
334    }
335
336    if chars.next().is_some() {
337        format!("{output}...")
338    } else {
339        output
340    }
341}