Skip to main content

codetether_agent/tool/
advanced_edit.rs

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