1use super::{Tool, ToolResult};
4use anyhow::Result;
5use async_trait::async_trait;
6use serde_json::{Value, json};
7use std::path::PathBuf;
8use tokio::fs;
9
10pub struct AdvancedEditTool;
11
12impl Default for AdvancedEditTool {
13 fn default() -> Self {
14 Self::new()
15 }
16}
17
18impl AdvancedEditTool {
19 pub fn new() -> Self {
20 Self
21 }
22}
23
24fn levenshtein(a: &str, b: &str) -> usize {
28 if a.is_empty() {
29 return b.len();
30 }
31 if b.is_empty() {
32 return a.len();
33 }
34 let a: Vec<char> = a.chars().collect();
35 let b: Vec<char> = b.chars().collect();
36 let (short, long) = if a.len() < b.len() {
37 (&a, &b)
38 } else {
39 (&b, &a)
40 };
41 let mut prev_row: Vec<usize> = (0..=short.len()).collect();
42 let mut curr_row = vec![0; short.len() + 1];
43 for (i, &c_long) in long.iter().enumerate() {
44 curr_row[0] = i + 1;
45 for (j, &c_short) in short.iter().enumerate() {
46 let cost = if c_long == c_short { 0 } else { 1 };
47 curr_row[j + 1] = (curr_row[j] + 1)
48 .min(prev_row[j + 1] + 1)
49 .min(prev_row[j] + cost);
50 }
51 prev_row.copy_from_slice(&curr_row);
52 }
53 prev_row[short.len()]
54}
55
56type Replacer = fn(&str, &str) -> Vec<String>;
57
58fn simple_replacer(content: &str, find: &str) -> Vec<String> {
60 if content.contains(find) {
61 vec![find.to_string()]
62 } else {
63 vec![]
64 }
65}
66
67fn line_trimmed_replacer(content: &str, find: &str) -> Vec<String> {
69 let orig_lines: Vec<&str> = content.lines().collect();
70 let mut search_lines: Vec<&str> = find.lines().collect();
71 if search_lines.last() == Some(&"") {
72 search_lines.pop();
73 }
74 let mut results = vec![];
75 for i in 0..=orig_lines.len().saturating_sub(search_lines.len()) {
76 let mut matches = true;
77 for j in 0..search_lines.len() {
78 if orig_lines.get(i + j).map(|l| l.trim()) != Some(search_lines[j].trim()) {
79 matches = false;
80 break;
81 }
82 }
83 if matches {
84 let matched: Vec<&str> = orig_lines[i..i + search_lines.len()].to_vec();
85 results.push(matched.join("\n"));
86 }
87 }
88 results
89}
90
91fn block_anchor_replacer(content: &str, find: &str) -> Vec<String> {
93 let orig_lines: Vec<&str> = content.lines().collect();
94 let mut search_lines: Vec<&str> = find.lines().collect();
95 if search_lines.len() < 3 {
96 return vec![];
97 }
98 if search_lines.last() == Some(&"") {
99 search_lines.pop();
100 }
101 let first = search_lines[0].trim();
102 let last = match search_lines.last() {
103 Some(l) => l.trim(),
104 None => return vec![],
105 };
106 let mut candidates = vec![];
107 for i in 0..orig_lines.len() {
108 if orig_lines[i].trim() != first {
109 continue;
110 }
111 for j in (i + 2)..orig_lines.len() {
112 if orig_lines[j].trim() == last {
113 candidates.push((i, j));
114 break;
115 }
116 }
117 }
118 if candidates.is_empty() {
119 return vec![];
120 }
121 if candidates.len() == 1 {
122 let (start, end) = candidates[0];
123 return vec![orig_lines[start..=end].join("\n")];
124 }
125 let mut best = None;
127 let mut best_sim = -1.0f64;
128 for (start, end) in candidates {
129 let block_size = end - start + 1;
130 let mut sim = 0.0;
131 let lines_to_check = (search_lines.len() - 2).min(block_size - 2);
132 if lines_to_check > 0 {
133 for j in 1..search_lines.len().min(block_size) - 1 {
134 let orig = orig_lines[start + j].trim();
135 let search = search_lines[j].trim();
136 let max_len = orig.len().max(search.len());
137 if max_len > 0 {
138 let dist = levenshtein(orig, search);
139 sim += 1.0 - (dist as f64 / max_len as f64);
140 }
141 }
142 sim /= lines_to_check as f64;
143 } else {
144 sim = 1.0;
145 }
146 if sim > best_sim {
147 best_sim = sim;
148 best = Some((start, end));
149 }
150 }
151 if best_sim >= 0.3
152 && let Some((s, e)) = best
153 {
154 return vec![orig_lines[s..=e].join("\n")];
155 }
156 vec![]
157}
158
159fn whitespace_normalized_replacer(content: &str, find: &str) -> Vec<String> {
161 let normalize = |s: &str| s.split_whitespace().collect::<Vec<_>>().join(" ");
162 let norm_find = normalize(find);
163 let mut results = vec![];
164 for line in content.lines() {
165 if normalize(line) == norm_find {
166 results.push(line.to_string());
167 }
168 }
169 let find_lines: Vec<&str> = find.lines().collect();
171 if find_lines.len() > 1 {
172 let lines: Vec<&str> = content.lines().collect();
173 for i in 0..=lines.len().saturating_sub(find_lines.len()) {
174 let block = lines[i..i + find_lines.len()].join("\n");
175 if normalize(&block) == norm_find {
176 results.push(block);
177 }
178 }
179 }
180 results
181}
182
183fn indentation_flexible_replacer(content: &str, find: &str) -> Vec<String> {
185 let remove_indent = |s: &str| {
186 let lines: Vec<&str> = s.lines().collect();
187 let non_empty: Vec<_> = lines.iter().filter(|l| !l.trim().is_empty()).collect();
188 if non_empty.is_empty() {
189 return s.to_string();
190 }
191 let min_indent = non_empty
192 .iter()
193 .map(|l| l.len() - l.trim_start().len())
194 .min()
195 .unwrap_or(0);
196 lines
197 .iter()
198 .map(|l| {
199 if l.len() >= min_indent {
200 &l[min_indent..]
201 } else {
202 *l
203 }
204 })
205 .collect::<Vec<_>>()
206 .join("\n")
207 };
208 let norm_find = remove_indent(find);
209 let lines: Vec<&str> = content.lines().collect();
210 let find_lines: Vec<&str> = find.lines().collect();
211 let mut results = vec![];
212 for i in 0..=lines.len().saturating_sub(find_lines.len()) {
213 let block = lines[i..i + find_lines.len()].join("\n");
214 if remove_indent(&block) == norm_find {
215 results.push(block);
216 }
217 }
218 results
219}
220
221fn trimmed_boundary_replacer(content: &str, find: &str) -> Vec<String> {
223 let trimmed = find.trim();
224 if trimmed == find {
225 return vec![];
226 }
227 if content.contains(trimmed) {
228 return vec![trimmed.to_string()];
229 }
230 vec![]
231}
232
233fn replace(content: &str, old: &str, new: &str, replace_all: bool) -> Result<String> {
235 if old == new {
236 anyhow::bail!("oldString and newString must be different");
237 }
238 let replacers: Vec<Replacer> = vec![
239 simple_replacer,
240 line_trimmed_replacer,
241 block_anchor_replacer,
242 whitespace_normalized_replacer,
243 indentation_flexible_replacer,
244 trimmed_boundary_replacer,
245 ];
246 for replacer in replacers {
247 let matches = replacer(content, old);
248 for search in matches {
249 if !content.contains(&search) {
250 continue;
251 }
252 if replace_all {
253 return Ok(content.replace(&search, new));
254 }
255 let first = content.find(&search);
256 let last = content.rfind(&search);
257 if first != last {
258 continue; }
260 if let Some(idx) = first {
261 return Ok(format!(
262 "{}{}{}",
263 &content[..idx],
264 new,
265 &content[idx + search.len()..]
266 ));
267 }
268 }
269 }
270 anyhow::bail!("oldString not found in content. Provide more context or check for typos.")
271}
272
273#[async_trait]
274impl Tool for AdvancedEditTool {
275 fn id(&self) -> &str {
276 "edit"
277 }
278 fn name(&self) -> &str {
279 "Edit"
280 }
281 fn description(&self) -> &str {
282 "Edit a file by replacing oldString with newString. Uses multiple matching strategies \
283 including exact match, line-trimmed, block anchor, whitespace normalized, and \
284 indentation flexible matching. Fails if match is ambiguous."
285 }
286 fn parameters(&self) -> Value {
287 json!({
288 "type": "object",
289 "properties": {
290 "filePath": {"type": "string", "description": "Absolute path to file"},
291 "oldString": {"type": "string", "description": "Text to replace"},
292 "newString": {"type": "string", "description": "Replacement text"},
293 "replaceAll": {"type": "boolean", "description": "Replace all occurrences", "default": false}
294 },
295 "required": ["filePath", "oldString", "newString"]
296 })
297 }
298
299 async fn execute(&self, params: Value) -> Result<ToolResult> {
300 let example = json!({
301 "filePath": "/absolute/path/to/file.rs",
302 "oldString": "text to find",
303 "newString": "replacement text"
304 });
305
306 let file_path = match params.get("filePath").and_then(|v| v.as_str()) {
307 Some(s) if !s.is_empty() => s.to_string(),
308 _ => {
309 return Ok(ToolResult::structured_error(
310 "MISSING_FIELD",
311 "edit",
312 "filePath is required and must be a non-empty string (absolute path to the file)",
313 Some(vec!["filePath"]),
314 Some(example),
315 ));
316 }
317 };
318 let old_string = match params.get("oldString").and_then(|v| v.as_str()) {
319 Some(s) => s.to_string(),
320 None => {
321 return Ok(ToolResult::structured_error(
322 "MISSING_FIELD",
323 "edit",
324 "oldString is required (the exact text to find and replace)",
325 Some(vec!["oldString"]),
326 Some(json!({
327 "filePath": file_path,
328 "oldString": "text to find in file",
329 "newString": "replacement text"
330 })),
331 ));
332 }
333 };
334 let new_string = match params.get("newString").and_then(|v| v.as_str()) {
335 Some(s) => s.to_string(),
336 None => {
337 return Ok(ToolResult::structured_error(
338 "MISSING_FIELD",
339 "edit",
340 "newString is required (the text to replace oldString with)",
341 Some(vec!["newString"]),
342 Some(json!({
343 "filePath": file_path,
344 "oldString": old_string,
345 "newString": "replacement text"
346 })),
347 ));
348 }
349 };
350 let replace_all = params
351 .get("replaceAll")
352 .and_then(|v| v.as_bool())
353 .unwrap_or(false);
354
355 let path = PathBuf::from(&file_path);
356 if !path.exists() {
357 return Ok(ToolResult::structured_error(
358 "FILE_NOT_FOUND",
359 "edit",
360 &format!("File not found: {file_path}"),
361 None,
362 Some(json!({
363 "hint": "Use an absolute path. List directory contents first to verify the file exists.",
364 "filePath": file_path
365 })),
366 ));
367 }
368 if old_string == new_string {
369 return Ok(ToolResult::error(
370 "oldString and newString must be different",
371 ));
372 }
373 if old_string.is_empty() {
375 fs::write(&path, &new_string).await?;
376 return Ok(ToolResult::success(format!("Created file: {file_path}")));
377 }
378 let content = fs::read_to_string(&path).await?;
379 let new_content = match replace(&content, &old_string, &new_string, replace_all) {
380 Ok(c) => c,
381 Err(_) => {
382 return Ok(ToolResult::structured_error(
383 "NOT_FOUND",
384 "edit",
385 "oldString not found in file content. Provide more surrounding context or check for typos, whitespace, and indentation.",
386 None,
387 Some(json!({
388 "hint": "Read the file first to see its exact content, then copy the text you want to replace verbatim including whitespace.",
389 "filePath": file_path,
390 "oldString": "<copy exact text from file including whitespace and indentation>",
391 "newString": "replacement text"
392 })),
393 ));
394 }
395 };
396 fs::write(&path, &new_content).await?;
397 let old_lines = old_string.lines().count();
398 let new_lines = new_string.lines().count();
399 Ok(ToolResult::success(format!(
400 "Edit applied: {old_lines} line(s) replaced with {new_lines} line(s) in {file_path}"
401 ))
402 .with_metadata("file", json!(file_path)))
403 }
404}