codetether_agent/tool/
advanced_edit.rs1use 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
37fn 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
67fn 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
76fn 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
100fn 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 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
165fn 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 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
189fn 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
227fn 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
239fn 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; }
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 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}