1use async_trait::async_trait;
4use serde::Deserialize;
5use serde_json::{json, Value};
6use std::path::PathBuf;
7use tokio::fs;
8use similar::TextDiff;
9
10use super::{Tool, ToolContext, ToolResult, ToolError};
11
12pub struct EditTool;
14
15#[derive(Debug, Deserialize)]
16struct EditParams {
17 file_path: String,
18 old_string: String,
19 new_string: String,
20 #[serde(default)]
21 replace_all: bool,
22}
23
24#[async_trait]
25impl Tool for EditTool {
26 fn id(&self) -> &str {
27 "edit"
28 }
29
30 fn description(&self) -> &str {
31 "Edit files using find-and-replace with smart matching strategies"
32 }
33
34 fn parameters_schema(&self) -> Value {
35 json!({
36 "type": "object",
37 "properties": {
38 "file_path": {
39 "type": "string",
40 "description": "Path to the file to edit"
41 },
42 "old_string": {
43 "type": "string",
44 "description": "Text to find and replace"
45 },
46 "new_string": {
47 "type": "string",
48 "description": "Replacement text"
49 },
50 "replace_all": {
51 "type": "boolean",
52 "description": "Replace all occurrences (default: false)",
53 "default": false
54 }
55 },
56 "required": ["file_path", "old_string", "new_string"]
57 })
58 }
59
60 async fn execute(
61 &self,
62 args: Value,
63 ctx: ToolContext,
64 ) -> Result<ToolResult, ToolError> {
65 let params: EditParams = serde_json::from_value(args)
66 .map_err(|e| ToolError::InvalidParameters(e.to_string()))?;
67
68 if params.old_string == params.new_string {
69 return Err(ToolError::InvalidParameters(
70 "old_string and new_string cannot be the same".to_string()
71 ));
72 }
73
74 let path = if PathBuf::from(¶ms.file_path).is_absolute() {
76 PathBuf::from(¶ms.file_path)
77 } else {
78 ctx.working_directory.join(¶ms.file_path)
79 };
80
81 let content = fs::read_to_string(&path).await?;
83
84 let strategies: [Box<dyn ReplacementStrategy>; 4] = [
86 Box::new(SimpleReplacer),
87 Box::new(LineTrimmedReplacer),
88 Box::new(WhitespaceNormalizedReplacer),
89 Box::new(IndentationFlexibleReplacer),
90 ];
91
92 let mut replacements = 0;
93 let mut new_content = content.clone();
94
95 for strategy in &strategies {
96 let result = strategy.replace(&content, ¶ms.old_string, ¶ms.new_string, params.replace_all);
97 if result.count > 0 {
98 new_content = result.content;
99 replacements = result.count;
100 break;
101 }
102 }
103
104 if replacements == 0 {
105 return Err(ToolError::ExecutionFailed(format!(
106 "Could not find '{}' in {}. The file might have been modified since you last read it.",
107 params.old_string.chars().take(100).collect::<String>(),
108 params.file_path
109 )));
110 }
111
112 fs::write(&path, &new_content).await?;
114
115 let diff = TextDiff::from_lines(&content, &new_content);
117 let mut diff_output = String::new();
118 for change in diff.iter_all_changes() {
119 match change.tag() {
120 similar::ChangeTag::Delete => diff_output.push_str(&format!("- {}", change)),
121 similar::ChangeTag::Insert => diff_output.push_str(&format!("+ {}", change)),
122 similar::ChangeTag::Equal => {},
123 }
124 }
125
126 let metadata = json!({
127 "path": path.to_string_lossy(),
128 "replacements": replacements,
129 "replace_all": params.replace_all,
130 "diff": diff_output,
131 });
132
133 Ok(ToolResult {
134 title: format!("Made {} replacement{} in {}",
135 replacements,
136 if replacements == 1 { "" } else { "s" },
137 params.file_path
138 ),
139 metadata,
140 output: format!(
141 "Successfully replaced {} occurrence{} of '{}' with '{}' in {}",
142 replacements,
143 if replacements == 1 { "" } else { "s" },
144 params.old_string.chars().take(50).collect::<String>(),
145 params.new_string.chars().take(50).collect::<String>(),
146 params.file_path
147 ),
148 })
149 }
150}
151
152pub trait ReplacementStrategy: Send + Sync {
154 fn replace(&self, content: &str, old: &str, new: &str, replace_all: bool) -> ReplaceResult;
155}
156
157pub struct ReplaceResult {
158 pub content: String,
159 pub count: usize,
160}
161
162pub struct SimpleReplacer;
164
165impl ReplacementStrategy for SimpleReplacer {
166 fn replace(&self, content: &str, old: &str, new: &str, replace_all: bool) -> ReplaceResult {
167 if replace_all {
168 let count = content.matches(old).count();
169 ReplaceResult {
170 content: content.replace(old, new),
171 count,
172 }
173 } else {
174 if let Some(pos) = content.find(old) {
175 let mut result = content.to_string();
176 result.replace_range(pos..pos + old.len(), new);
177 ReplaceResult { content: result, count: 1 }
178 } else {
179 ReplaceResult { content: content.to_string(), count: 0 }
180 }
181 }
182 }
183}
184
185pub struct LineTrimmedReplacer;
187
188impl ReplacementStrategy for LineTrimmedReplacer {
189 fn replace(&self, content: &str, old: &str, new: &str, replace_all: bool) -> ReplaceResult {
190 let old_lines: Vec<&str> = old.lines().collect();
191 let content_lines: Vec<&str> = content.lines().collect();
192
193 if old_lines.is_empty() {
194 return ReplaceResult { content: content.to_string(), count: 0 };
195 }
196
197 let mut result_lines: Vec<String> = Vec::new();
198 let mut i = 0;
199 let mut count = 0;
200
201 while i < content_lines.len() {
202 let mut matched = true;
203
204 if i + old_lines.len() > content_lines.len() {
206 result_lines.push(content_lines[i].to_string());
207 i += 1;
208 continue;
209 }
210
211 for (j, old_line) in old_lines.iter().enumerate() {
213 if content_lines[i + j].trim() != old_line.trim() {
214 matched = false;
215 break;
216 }
217 }
218
219 if matched {
220 for new_line in new.lines() {
222 result_lines.push(new_line.to_string());
223 }
224 i += old_lines.len();
225 count += 1;
226
227 if !replace_all {
228 result_lines.extend(content_lines[i..].iter().map(|s| s.to_string()));
230 break;
231 }
232 } else {
233 result_lines.push(content_lines[i].to_string());
234 i += 1;
235 }
236 }
237
238 ReplaceResult {
239 content: result_lines.join("\n"),
240 count,
241 }
242 }
243}
244
245pub struct WhitespaceNormalizedReplacer;
247
248impl ReplacementStrategy for WhitespaceNormalizedReplacer {
249 fn replace(&self, content: &str, old: &str, new: &str, replace_all: bool) -> ReplaceResult {
250 let normalize = |s: &str| s.split_whitespace().collect::<Vec<_>>().join(" ");
251 let old_normalized = normalize(old);
252
253 let content_normalized = normalize(content);
255 if let Some(_) = content_normalized.find(&old_normalized) {
256 SimpleReplacer.replace(content, old, new, replace_all)
258 } else {
259 ReplaceResult { content: content.to_string(), count: 0 }
260 }
261 }
262}
263
264pub struct IndentationFlexibleReplacer;
266
267impl ReplacementStrategy for IndentationFlexibleReplacer {
268 fn replace(&self, content: &str, old: &str, new: &str, replace_all: bool) -> ReplaceResult {
269 let old_lines: Vec<&str> = old.lines().collect();
271 if old_lines.is_empty() {
272 return ReplaceResult { content: content.to_string(), count: 0 };
273 }
274
275 let min_indent = old_lines.iter()
277 .filter(|line| !line.trim().is_empty())
278 .map(|line| line.len() - line.trim_start().len())
279 .min()
280 .unwrap_or(0);
281
282 let stripped_old: Vec<String> = old_lines.iter()
284 .map(|line| {
285 if line.trim().is_empty() {
286 line.to_string()
287 } else {
288 line.chars().skip(min_indent).collect()
289 }
290 })
291 .collect();
292
293 let content_lines: Vec<&str> = content.lines().collect();
295 let mut result_lines: Vec<String> = Vec::new();
296 let mut i = 0;
297 let mut count = 0;
298
299 while i < content_lines.len() {
300 let mut matched = true;
301 let mut found_indent = 0;
302
303 if i + stripped_old.len() > content_lines.len() {
304 result_lines.push(content_lines[i].to_string());
305 i += 1;
306 continue;
307 }
308
309 for (j, stripped_line) in stripped_old.iter().enumerate() {
311 let content_line = content_lines[i + j];
312
313 if stripped_line.trim().is_empty() {
314 if !content_line.trim().is_empty() {
315 matched = false;
316 break;
317 }
318 } else {
319 if j == 0 {
320 found_indent = content_line.len() - content_line.trim_start().len();
322 }
323
324 let expected_content = if stripped_line.trim().is_empty() {
325 ""
326 } else {
327 &format!("{}{}", " ".repeat(found_indent), stripped_line.trim_start())
328 };
329
330 if content_line != expected_content {
331 matched = false;
332 break;
333 }
334 }
335 }
336
337 if matched {
338 let indent_str = " ".repeat(found_indent);
340 for new_line in new.lines() {
341 if new_line.trim().is_empty() {
342 result_lines.push("".to_string());
343 } else {
344 result_lines.push(format!("{}{}", indent_str, new_line.trim_start()));
345 }
346 }
347 i += stripped_old.len();
348 count += 1;
349
350 if !replace_all {
351 result_lines.extend(content_lines[i..].iter().map(|s| s.to_string()));
352 break;
353 }
354 } else {
355 result_lines.push(content_lines[i].to_string());
356 i += 1;
357 }
358 }
359
360 ReplaceResult {
361 content: result_lines.join("\n"),
362 count,
363 }
364 }
365}