Skip to main content

codetether_agent/tool/
advanced_edit.rs

1//! Advanced Edit Tool with multiple replacement strategies (advanced edit tool)
2
3use 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
24/// Levenshtein distance for fuzzy matching.
25///
26/// Uses O(min(N,M)) space by keeping only the current and previous rows.
27fn 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
58/// Simple exact match
59fn 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
67/// Match with trimmed lines
68fn 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
91/// Block anchor matching (first/last line anchors)
92fn 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    // Multiple candidates: find best by similarity
126    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
159/// Whitespace normalized matching
160fn 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    // Multi-line
170    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
183/// Indentation flexible matching
184fn 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
221/// Trimmed boundary matching
222fn 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
233/// Apply replacement with multiple strategies
234fn 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; // Multiple matches, need more context
259            }
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        // Creating new file
374        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}