use super::{Tool, ToolResult};
use anyhow::Result;
use async_trait::async_trait;
use serde_json::{Value, json};
use std::path::PathBuf;
use tokio::fs;
pub struct AdvancedEditTool;
impl Default for AdvancedEditTool {
fn default() -> Self {
Self::new()
}
}
impl AdvancedEditTool {
pub fn new() -> Self {
Self
}
}
fn levenshtein(a: &str, b: &str) -> usize {
if a.is_empty() {
return b.len();
}
if b.is_empty() {
return a.len();
}
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
let mut matrix = vec![vec![0usize; b.len() + 1]; a.len() + 1];
for i in 0..=a.len() {
matrix[i][0] = i;
}
for j in 0..=b.len() {
matrix[0][j] = j;
}
for i in 1..=a.len() {
for j in 1..=b.len() {
let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
matrix[i][j] = (matrix[i - 1][j] + 1)
.min(matrix[i][j - 1] + 1)
.min(matrix[i - 1][j - 1] + cost);
}
}
matrix[a.len()][b.len()]
}
type Replacer = fn(&str, &str) -> Vec<String>;
fn simple_replacer(content: &str, find: &str) -> Vec<String> {
if content.contains(find) {
vec![find.to_string()]
} else {
vec![]
}
}
fn line_trimmed_replacer(content: &str, find: &str) -> Vec<String> {
let orig_lines: Vec<&str> = content.lines().collect();
let mut search_lines: Vec<&str> = find.lines().collect();
if search_lines.last() == Some(&"") {
search_lines.pop();
}
let mut results = vec![];
for i in 0..=orig_lines.len().saturating_sub(search_lines.len()) {
let mut matches = true;
for j in 0..search_lines.len() {
if orig_lines.get(i + j).map(|l| l.trim()) != Some(search_lines[j].trim()) {
matches = false;
break;
}
}
if matches {
let matched: Vec<&str> = orig_lines[i..i + search_lines.len()].to_vec();
results.push(matched.join("\n"));
}
}
results
}
fn block_anchor_replacer(content: &str, find: &str) -> Vec<String> {
let orig_lines: Vec<&str> = content.lines().collect();
let mut search_lines: Vec<&str> = find.lines().collect();
if search_lines.len() < 3 {
return vec![];
}
if search_lines.last() == Some(&"") {
search_lines.pop();
}
let first = search_lines[0].trim();
let last = search_lines.last().unwrap().trim();
let mut candidates = vec![];
for i in 0..orig_lines.len() {
if orig_lines[i].trim() != first {
continue;
}
for j in (i + 2)..orig_lines.len() {
if orig_lines[j].trim() == last {
candidates.push((i, j));
break;
}
}
}
if candidates.is_empty() {
return vec![];
}
if candidates.len() == 1 {
let (start, end) = candidates[0];
return vec![orig_lines[start..=end].join("\n")];
}
let mut best = None;
let mut best_sim = -1.0f64;
for (start, end) in candidates {
let block_size = end - start + 1;
let mut sim = 0.0;
let lines_to_check = (search_lines.len() - 2).min(block_size - 2);
if lines_to_check > 0 {
for j in 1..search_lines.len().min(block_size) - 1 {
let orig = orig_lines[start + j].trim();
let search = search_lines[j].trim();
let max_len = orig.len().max(search.len());
if max_len > 0 {
let dist = levenshtein(orig, search);
sim += 1.0 - (dist as f64 / max_len as f64);
}
}
sim /= lines_to_check as f64;
} else {
sim = 1.0;
}
if sim > best_sim {
best_sim = sim;
best = Some((start, end));
}
}
if best_sim >= 0.3 {
if let Some((s, e)) = best {
return vec![orig_lines[s..=e].join("\n")];
}
}
vec![]
}
fn whitespace_normalized_replacer(content: &str, find: &str) -> Vec<String> {
let normalize = |s: &str| s.split_whitespace().collect::<Vec<_>>().join(" ");
let norm_find = normalize(find);
let mut results = vec![];
for line in content.lines() {
if normalize(line) == norm_find {
results.push(line.to_string());
}
}
let find_lines: Vec<&str> = find.lines().collect();
if find_lines.len() > 1 {
let lines: Vec<&str> = content.lines().collect();
for i in 0..=lines.len().saturating_sub(find_lines.len()) {
let block = lines[i..i + find_lines.len()].join("\n");
if normalize(&block) == norm_find {
results.push(block);
}
}
}
results
}
fn indentation_flexible_replacer(content: &str, find: &str) -> Vec<String> {
let remove_indent = |s: &str| {
let lines: Vec<&str> = s.lines().collect();
let non_empty: Vec<_> = lines.iter().filter(|l| !l.trim().is_empty()).collect();
if non_empty.is_empty() {
return s.to_string();
}
let min_indent = non_empty
.iter()
.map(|l| l.len() - l.trim_start().len())
.min()
.unwrap_or(0);
lines
.iter()
.map(|l| {
if l.len() >= min_indent {
&l[min_indent..]
} else {
*l
}
})
.collect::<Vec<_>>()
.join("\n")
};
let norm_find = remove_indent(find);
let lines: Vec<&str> = content.lines().collect();
let find_lines: Vec<&str> = find.lines().collect();
let mut results = vec![];
for i in 0..=lines.len().saturating_sub(find_lines.len()) {
let block = lines[i..i + find_lines.len()].join("\n");
if remove_indent(&block) == norm_find {
results.push(block);
}
}
results
}
fn trimmed_boundary_replacer(content: &str, find: &str) -> Vec<String> {
let trimmed = find.trim();
if trimmed == find {
return vec![];
}
if content.contains(trimmed) {
return vec![trimmed.to_string()];
}
vec![]
}
fn replace(content: &str, old: &str, new: &str, replace_all: bool) -> Result<String> {
if old == new {
anyhow::bail!("oldString and newString must be different");
}
let replacers: Vec<Replacer> = vec![
simple_replacer,
line_trimmed_replacer,
block_anchor_replacer,
whitespace_normalized_replacer,
indentation_flexible_replacer,
trimmed_boundary_replacer,
];
for replacer in replacers {
let matches = replacer(content, old);
for search in matches {
if !content.contains(&search) {
continue;
}
if replace_all {
return Ok(content.replace(&search, new));
}
let first = content.find(&search);
let last = content.rfind(&search);
if first != last {
continue; }
if let Some(idx) = first {
return Ok(format!(
"{}{}{}",
&content[..idx],
new,
&content[idx + search.len()..]
));
}
}
}
anyhow::bail!("oldString not found in content. Provide more context or check for typos.")
}
#[async_trait]
impl Tool for AdvancedEditTool {
fn id(&self) -> &str {
"edit"
}
fn name(&self) -> &str {
"Edit"
}
fn description(&self) -> &str {
"Edit a file by replacing oldString with newString. Uses multiple matching strategies \
including exact match, line-trimmed, block anchor, whitespace normalized, and \
indentation flexible matching. Fails if match is ambiguous."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"filePath": {"type": "string", "description": "Absolute path to file"},
"oldString": {"type": "string", "description": "Text to replace"},
"newString": {"type": "string", "description": "Replacement text"},
"replaceAll": {"type": "boolean", "description": "Replace all occurrences", "default": false}
},
"required": ["filePath", "oldString", "newString"]
})
}
async fn execute(&self, params: Value) -> Result<ToolResult> {
let example = json!({
"filePath": "/absolute/path/to/file.rs",
"oldString": "text to find",
"newString": "replacement text"
});
let file_path = match params.get("filePath").and_then(|v| v.as_str()) {
Some(s) if !s.is_empty() => s.to_string(),
_ => {
return Ok(ToolResult::structured_error(
"MISSING_FIELD",
"edit",
"filePath is required and must be a non-empty string (absolute path to the file)",
Some(vec!["filePath"]),
Some(example),
));
}
};
let old_string = match params.get("oldString").and_then(|v| v.as_str()) {
Some(s) => s.to_string(),
None => {
return Ok(ToolResult::structured_error(
"MISSING_FIELD",
"edit",
"oldString is required (the exact text to find and replace)",
Some(vec!["oldString"]),
Some(json!({
"filePath": file_path,
"oldString": "text to find in file",
"newString": "replacement text"
})),
));
}
};
let new_string = match params.get("newString").and_then(|v| v.as_str()) {
Some(s) => s.to_string(),
None => {
return Ok(ToolResult::structured_error(
"MISSING_FIELD",
"edit",
"newString is required (the text to replace oldString with)",
Some(vec!["newString"]),
Some(json!({
"filePath": file_path,
"oldString": old_string,
"newString": "replacement text"
})),
));
}
};
let replace_all = params
.get("replaceAll")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let path = PathBuf::from(&file_path);
if !path.exists() {
return Ok(ToolResult::structured_error(
"FILE_NOT_FOUND",
"edit",
&format!("File not found: {file_path}"),
None,
Some(json!({
"hint": "Use an absolute path. List directory contents first to verify the file exists.",
"filePath": file_path
})),
));
}
if old_string == new_string {
return Ok(ToolResult::error(
"oldString and newString must be different",
));
}
if old_string.is_empty() {
fs::write(&path, &new_string).await?;
return Ok(ToolResult::success(format!("Created file: {file_path}")));
}
let content = fs::read_to_string(&path).await?;
let new_content = match replace(&content, &old_string, &new_string, replace_all) {
Ok(c) => c,
Err(_) => {
return Ok(ToolResult::structured_error(
"NOT_FOUND",
"edit",
"oldString not found in file content. Provide more surrounding context or check for typos, whitespace, and indentation.",
None,
Some(json!({
"hint": "Read the file first to see its exact content, then copy the text you want to replace verbatim including whitespace.",
"filePath": file_path,
"oldString": "<copy exact text from file including whitespace and indentation>",
"newString": "replacement text"
})),
));
}
};
fs::write(&path, &new_content).await?;
let old_lines = old_string.lines().count();
let new_lines = new_string.lines().count();
Ok(ToolResult::success(format!(
"Edit applied: {old_lines} line(s) replaced with {new_lines} line(s) in {file_path}"
))
.with_metadata("file", json!(file_path)))
}
}