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
25fn 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
54/// Simple exact match
55fn 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
63/// Match with trimmed lines
64fn 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
87/// Block anchor matching (first/last line anchors)
88fn 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    // Multiple candidates: find best by similarity
119    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
152/// Whitespace normalized matching
153fn 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    // Multi-line
163    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
176/// Indentation flexible matching
177fn 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
214/// Trimmed boundary matching
215fn 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
226/// Apply replacement with multiple strategies
227fn 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; // Multiple matches, need more context
252            }
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        // Creating new file
367        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}