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