codetether_agent/tool/
multiedit.rs1use 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 let mut file_contents: Vec<(PathBuf, String, String, String)> = Vec::new();
106
107 for edit in ¶ms.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 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 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 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}