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#[derive(Debug, serde::Serialize)]
43struct EditResult {
44    file: String,
45    success: bool,
46    message: String,
47}
48
49#[async_trait]
50impl Tool for MultiEditTool {
51    fn id(&self) -> &str {
52        "multiedit"
53    }
54
55    fn name(&self) -> &str {
56        "Multi Edit"
57    }
58
59    fn description(&self) -> &str {
60        "Edit multiple files atomically. Each edit replaces an old string with a new string. \
61         All edits are validated before any changes are applied. If any edit fails validation, \
62         no changes are made."
63    }
64
65    fn parameters(&self) -> Value {
66        json!({
67            "type": "object",
68            "properties": {
69                "edits": {
70                    "type": "array",
71                    "description": "Array of edit operations to apply",
72                    "items": {
73                        "type": "object",
74                        "properties": {
75                            "file": {
76                                "type": "string",
77                                "description": "Path to the file to edit"
78                            },
79                            "old_string": {
80                                "type": "string",
81                                "description": "The exact string to find and replace"
82                            },
83                            "new_string": {
84                                "type": "string",
85                                "description": "The string to replace it with"
86                            }
87                        },
88                        "required": ["file", "old_string", "new_string"]
89                    }
90                }
91            },
92            "required": ["edits"]
93        })
94    }
95
96    async fn execute(&self, params: Value) -> Result<ToolResult> {
97        let params: MultiEditParams =
98            serde_json::from_value(params).context("Invalid parameters")?;
99
100        if params.edits.is_empty() {
101            return Ok(ToolResult::error("No edits provided"));
102        }
103
104        // Phase 1: Validation - read all files and check that old_string exists uniquely
105        let mut file_contents: Vec<(PathBuf, String, String, String)> = Vec::new();
106
107        for edit in &params.edits {
108            let path = PathBuf::from(&edit.file);
109
110            if !path.exists() {
111                return Ok(ToolResult::error(format!(
112                    "File does not exist: {}",
113                    edit.file
114                )));
115            }
116
117            let content = fs::read_to_string(&path)
118                .await
119                .with_context(|| format!("Failed to read file: {}", edit.file))?;
120
121            // Check that old_string exists exactly once
122            let matches: Vec<_> = content.match_indices(&edit.old_string).collect();
123
124            if matches.is_empty() {
125                return Ok(ToolResult::error(format!(
126                    "String not found in {}: {}",
127                    edit.file,
128                    if edit.old_string.len() > 50 {
129                        format!("{}...", &edit.old_string[..50])
130                    } else {
131                        edit.old_string.clone()
132                    }
133                )));
134            }
135
136            if matches.len() > 1 {
137                return Ok(ToolResult::error(format!(
138                    "String found {} times in {} (must be unique). Use more context to disambiguate.",
139                    matches.len(),
140                    edit.file
141                )));
142            }
143
144            file_contents.push((
145                path,
146                content,
147                edit.old_string.clone(),
148                edit.new_string.clone(),
149            ));
150        }
151
152        // Phase 2: Generate diffs for all changes
153        let mut total_added = 0;
154        let mut total_removed = 0;
155        let mut previews = Vec::new();
156
157        for (path, content, old_string, new_string) in &file_contents {
158            let new_content = content.replacen(old_string, new_string, 1);
159            let diff = TextDiff::from_lines(content, &new_content);
160
161            let mut diff_output = String::new();
162            let mut added = 0;
163            let mut removed = 0;
164
165            for change in diff.iter_all_changes() {
166                let (sign, style) = match change.tag() {
167                    ChangeTag::Delete => {
168                        removed += 1;
169                        ("-", "red")
170                    }
171                    ChangeTag::Insert => {
172                        added += 1;
173                        ("+", "green")
174                    }
175                    ChangeTag::Equal => (" ", "default"),
176                };
177
178                let line = format!("{}{}", sign, change);
179                if style == "red" {
180                    diff_output.push_str(&format!("\x1b[31m{}\x1b[0m", line.trim_end()));
181                } else if style == "green" {
182                    diff_output.push_str(&format!("\x1b[32m{}\x1b[0m", line.trim_end()));
183                } else {
184                    diff_output.push_str(&line.trim_end());
185                }
186                diff_output.push('\n');
187            }
188
189            previews.push(json!({
190                "file": path.display().to_string(),
191                "diff": diff_output.trim(),
192                "added": added,
193                "removed": removed
194            }));
195
196            total_added += added;
197            total_removed += removed;
198        }
199
200        // Instead of applying changes immediately, return confirmation prompt
201        let mut all_diffs = String::new();
202        for preview in &previews {
203            let file = preview["file"].as_str().unwrap();
204            let diff = preview["diff"].as_str().unwrap();
205            all_diffs.push_str(&format!("\n=== {} ===\n{}", file, diff));
206        }
207
208        let mut metadata = HashMap::new();
209        metadata.insert("requires_confirmation".to_string(), json!(true));
210        metadata.insert("total_files".to_string(), json!(file_contents.len()));
211        metadata.insert("total_added".to_string(), json!(total_added));
212        metadata.insert("total_removed".to_string(), json!(total_removed));
213        metadata.insert("previews".to_string(), json!(previews));
214
215        Ok(ToolResult {
216            output: format!(
217                "Multi-file changes require confirmation:{}\n\nTotal: {} files, +{} lines, -{} lines",
218                all_diffs,
219                file_contents.len(),
220                total_added,
221                total_removed
222            ),
223            success: true,
224            metadata,
225        })
226    }
227}