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::{json, Value};
9use std::path::PathBuf;
10use tokio::fs;
11
12use super::{Tool, ToolResult};
13
14pub struct MultiEditTool;
15
16impl Default for MultiEditTool {
17    fn default() -> Self {
18        Self::new()
19    }
20}
21
22impl MultiEditTool {
23    pub fn new() -> Self {
24        Self
25    }
26}
27
28#[derive(Debug, Deserialize)]
29struct MultiEditParams {
30    edits: Vec<EditOperation>,
31}
32
33#[derive(Debug, Deserialize)]
34struct EditOperation {
35    file: String,
36    old_string: String,
37    new_string: String,
38}
39
40#[derive(Debug, serde::Serialize)]
41struct EditResult {
42    file: String,
43    success: bool,
44    message: String,
45}
46
47#[async_trait]
48impl Tool for MultiEditTool {
49    fn id(&self) -> &str {
50        "multiedit"
51    }
52
53    fn name(&self) -> &str {
54        "Multi Edit"
55    }
56
57    fn description(&self) -> &str {
58        "Edit multiple files atomically. Each edit replaces an old string with a new string. \
59         All edits are validated before any changes are applied. If any edit fails validation, \
60         no changes are made."
61    }
62
63    fn parameters(&self) -> Value {
64        json!({
65            "type": "object",
66            "properties": {
67                "edits": {
68                    "type": "array",
69                    "description": "Array of edit operations to apply",
70                    "items": {
71                        "type": "object",
72                        "properties": {
73                            "file": {
74                                "type": "string",
75                                "description": "Path to the file to edit"
76                            },
77                            "old_string": {
78                                "type": "string",
79                                "description": "The exact string to find and replace"
80                            },
81                            "new_string": {
82                                "type": "string",
83                                "description": "The string to replace it with"
84                            }
85                        },
86                        "required": ["file", "old_string", "new_string"]
87                    }
88                }
89            },
90            "required": ["edits"]
91        })
92    }
93
94    async fn execute(&self, params: Value) -> Result<ToolResult> {
95        let params: MultiEditParams = serde_json::from_value(params)
96            .context("Invalid parameters")?;
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        
105        for edit in &params.edits {
106            let path = PathBuf::from(&edit.file);
107            
108            if !path.exists() {
109                return Ok(ToolResult::error(format!(
110                    "File does not exist: {}",
111                    edit.file
112                )));
113            }
114
115            let content = fs::read_to_string(&path)
116                .await
117                .with_context(|| format!("Failed to read file: {}", edit.file))?;
118
119            // Check that old_string exists exactly once
120            let matches: Vec<_> = content.match_indices(&edit.old_string).collect();
121            
122            if matches.is_empty() {
123                return Ok(ToolResult::error(format!(
124                    "String not found in {}: {}",
125                    edit.file,
126                    if edit.old_string.len() > 50 {
127                        format!("{}...", &edit.old_string[..50])
128                    } else {
129                        edit.old_string.clone()
130                    }
131                )));
132            }
133            
134            if matches.len() > 1 {
135                return Ok(ToolResult::error(format!(
136                    "String found {} times in {} (must be unique). Use more context to disambiguate.",
137                    matches.len(),
138                    edit.file
139                )));
140            }
141
142            file_contents.push((
143                path,
144                content,
145                edit.old_string.clone(),
146                edit.new_string.clone(),
147            ));
148        }
149
150        // Phase 2: Apply all edits
151        let mut results: Vec<EditResult> = Vec::new();
152        
153        for (path, content, old_string, new_string) in file_contents {
154            let new_content = content.replacen(&old_string, &new_string, 1);
155            
156            fs::write(&path, &new_content)
157                .await
158                .with_context(|| format!("Failed to write file: {}", path.display()))?;
159
160            results.push(EditResult {
161                file: path.display().to_string(),
162                success: true,
163                message: format!(
164                    "Replaced {} chars with {} chars",
165                    old_string.len(),
166                    new_string.len()
167                ),
168            });
169        }
170
171        let output = format!(
172            "Successfully applied {} edits:\n{}",
173            results.len(),
174            results
175                .iter()
176                .map(|r| format!("  ✓ {}: {}", r.file, r.message))
177                .collect::<Vec<_>>()
178                .join("\n")
179        );
180
181        Ok(ToolResult::success(output)
182            .with_metadata("edits", json!(results)))
183    }
184}