use crate::filesystem::validate_path;
use crate::prelude::*;
use std::path::PathBuf;
use strsim::normalized_levenshtein;
#[derive(Debug, Deserialize, JsonSchema)]
pub struct EditBlockInput {
pub file_path: PathBuf,
pub old_string: String,
pub new_string: String,
#[serde(default = "default_replacements")]
pub expected_replacements: usize,
#[serde(default = "default_fuzzy")]
pub enable_fuzzy: bool,
#[serde(default = "default_threshold")]
pub fuzzy_threshold: f32,
}
fn default_replacements() -> usize {
1
}
fn default_fuzzy() -> bool {
true
}
fn default_threshold() -> f32 {
0.7
}
#[derive(Debug)]
struct FuzzyMatch {
start: usize,
end: usize,
similarity: f64,
matched_text: String,
}
pub struct EditBlockTool {
base_path: PathBuf,
}
impl Default for EditBlockTool {
fn default() -> Self {
Self::new()
}
}
impl EditBlockTool {
pub fn new() -> Self {
Self {
base_path: std::env::current_dir().expect("Failed to get current working directory"),
}
}
pub fn with_base_path(base_path: PathBuf) -> Self {
Self { base_path }
}
fn find_fuzzy_match(text: &str, pattern: &str, threshold: f32) -> Option<FuzzyMatch> {
let pattern_len = pattern.len();
if pattern_len == 0 || pattern_len > text.len() {
return None;
}
let mut best_match: Option<FuzzyMatch> = None;
let mut best_similarity = threshold as f64;
for start in 0..=(text.len() - pattern_len) {
let end = (start + pattern_len).min(text.len());
let window = &text[start..end];
let similarity = normalized_levenshtein(pattern, window);
if similarity > best_similarity {
best_similarity = similarity;
best_match = Some(FuzzyMatch {
start,
end,
similarity,
matched_text: window.to_string(),
});
}
}
for window_size in [
pattern_len.saturating_sub(pattern_len / 10),
pattern_len + pattern_len / 10,
] {
if window_size == 0 || window_size > text.len() {
continue;
}
for start in 0..=(text.len() - window_size) {
let end = (start + window_size).min(text.len());
let window = &text[start..end];
let similarity = normalized_levenshtein(pattern, window);
if similarity > best_similarity {
best_similarity = similarity;
best_match = Some(FuzzyMatch {
start,
end,
similarity,
matched_text: window.to_string(),
});
}
}
}
best_match
}
fn detect_line_ending(content: &str) -> &str {
if content.contains("\r\n") {
"\r\n"
} else {
"\n"
}
}
}
impl Tool for EditBlockTool {
type Input = EditBlockInput;
fn name(&self) -> &str {
"edit_block"
}
fn description(&self) -> &str {
"Edit a file by replacing text. Supports exact matching with fallback to fuzzy matching. Preserves file line endings."
}
async fn execute(&self, input: Self::Input) -> std::result::Result<ToolResult, ToolError> {
let path = validate_path(&self.base_path, &input.file_path)
.map_err(|e| ToolError::from(e.to_string()))?;
let content = tokio::fs::read_to_string(&path)
.await
.map_err(|e| ToolError::from(format!("Failed to read file: {}", e)))?;
let line_ending = Self::detect_line_ending(&content);
let replacement_count = content.matches(&input.old_string).count();
let (new_content, actual_replacements, method) = if replacement_count > 0 {
let new_content = content.replace(&input.old_string, &input.new_string);
(new_content, replacement_count, "exact".to_string())
} else if input.enable_fuzzy {
match Self::find_fuzzy_match(&content, &input.old_string, input.fuzzy_threshold) {
Some(fuzzy_match) => {
let new_content = format!(
"{}{}{}",
&content[..fuzzy_match.start],
&input.new_string,
&content[fuzzy_match.end..]
);
let info = format!(
"fuzzy (similarity: {:.1}%)\nMatched text:\n{}",
fuzzy_match.similarity * 100.0,
fuzzy_match.matched_text
);
(new_content, 1, info)
}
None => {
return Err(format!(
"No match found for the specified text (tried exact and fuzzy matching with threshold {:.1}%)",
input.fuzzy_threshold * 100.0
).into());
}
}
} else {
return Err("No exact match found and fuzzy matching is disabled".into());
};
if actual_replacements != input.expected_replacements {
return Err(format!(
"Expected {} replacement(s) but found {}",
input.expected_replacements, actual_replacements
)
.into());
}
let final_content = if line_ending == "\r\n" {
let normalized = new_content.replace("\r\n", "\n");
normalized.replace('\n', "\r\n")
} else {
new_content
};
tokio::fs::write(&path, final_content.as_bytes())
.await
.map_err(|e| ToolError::from(format!("Failed to write file: {}", e)))?;
let old_lines = input.old_string.lines().count();
let new_lines = input.new_string.lines().count();
let line_diff = new_lines as i64 - old_lines as i64;
let line_change = if line_diff > 0 {
format!("(\x1b[32m+{} lines\x1b[0m)", line_diff)
} else if line_diff < 0 {
format!("(\x1b[31m{} lines\x1b[0m)", line_diff)
} else {
"(no change in line count)".to_string()
};
let content = format!(
"Successfully edited {} using {} matching\n{} replacement(s) {}",
input.file_path.display(),
method,
actual_replacements,
line_change
);
Ok(content.into())
}
fn format_input_plain(&self, params: &serde_json::Value) -> String {
let file_path = params
.get("file_path")
.and_then(|v| v.as_str())
.unwrap_or("?");
let old_string = params
.get("old_string")
.and_then(|v| v.as_str())
.unwrap_or("");
let new_string = params
.get("new_string")
.and_then(|v| v.as_str())
.unwrap_or("");
let mut output = format!("edit_block: {}\n", file_path);
output.push_str("--- old\n");
for line in old_string.lines() {
output.push_str(&format!("- {}\n", line));
}
output.push_str("+++ new\n");
for line in new_string.lines() {
output.push_str(&format!("+ {}\n", line));
}
output
}
fn format_input_ansi(&self, params: &serde_json::Value) -> String {
let file_path = params
.get("file_path")
.and_then(|v| v.as_str())
.unwrap_or("?");
let old_string = params
.get("old_string")
.and_then(|v| v.as_str())
.unwrap_or("");
let new_string = params
.get("new_string")
.and_then(|v| v.as_str())
.unwrap_or("");
let mut output = format!("\x1b[1medit_block:\x1b[0m {}\n", file_path);
output.push_str("\x1b[31m--- old\x1b[0m\n");
for line in old_string.lines() {
output.push_str(&format!("\x1b[31m- {}\x1b[0m\n", line));
}
output.push_str("\x1b[32m+++ new\x1b[0m\n");
for line in new_string.lines() {
output.push_str(&format!("\x1b[32m+ {}\x1b[0m\n", line));
}
output
}
fn format_input_markdown(&self, params: &serde_json::Value) -> String {
let file_path = params
.get("file_path")
.and_then(|v| v.as_str())
.unwrap_or("?");
let old_string = params
.get("old_string")
.and_then(|v| v.as_str())
.unwrap_or("");
let new_string = params
.get("new_string")
.and_then(|v| v.as_str())
.unwrap_or("");
let mut output = format!("**edit_block:** `{}`\n\n```diff\n", file_path);
for line in old_string.lines() {
output.push_str(&format!("- {}\n", line));
}
for line in new_string.lines() {
output.push_str(&format!("+ {}\n", line));
}
output.push_str("```\n");
output
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_tool_metadata() {
let tool: EditBlockTool = Default::default();
assert_eq!(tool.name(), "edit_block");
assert!(!tool.description().is_empty());
let tool2 = EditBlockTool::new();
assert_eq!(tool2.name(), "edit_block");
}
#[test]
fn test_format_methods() {
let tool = EditBlockTool::new();
let params =
serde_json::json!({"file_path": "test.txt", "old_string": "old", "new_string": "new"});
assert!(!tool.format_input_plain(¶ms).is_empty());
assert!(!tool.format_input_ansi(¶ms).is_empty());
assert!(!tool.format_input_markdown(¶ms).is_empty());
let result = ToolResult::from("Edited file");
assert!(!tool.format_output_plain(&result).is_empty());
assert!(!tool.format_output_ansi(&result).is_empty());
assert!(!tool.format_output_markdown(&result).is_empty());
}
#[test]
fn test_default_values() {
let input: EditBlockInput = serde_json::from_value(serde_json::json!({
"file_path": "test.txt",
"old_string": "old",
"new_string": "new"
}))
.unwrap();
assert_eq!(input.expected_replacements, 1);
assert!(input.enable_fuzzy);
assert!((input.fuzzy_threshold - 0.7).abs() < 0.001);
}
#[tokio::test]
async fn test_edit_block_exact() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.txt");
fs::write(&file_path, "Hello, World!\nThis is a test.").unwrap();
let tool = EditBlockTool::with_base_path(temp_dir.path().to_path_buf());
let input = EditBlockInput {
file_path: PathBuf::from("test.txt"),
old_string: "World".to_string(),
new_string: "Rust".to_string(),
expected_replacements: 1,
enable_fuzzy: false,
fuzzy_threshold: 0.7,
};
let result = tool.execute(input).await.unwrap();
assert!(result.as_text().contains("exact matching"));
let content = fs::read_to_string(&file_path).unwrap();
assert_eq!(content, "Hello, Rust!\nThis is a test.");
}
#[tokio::test]
async fn test_edit_block_fuzzy() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.txt");
fs::write(&file_path, "Hello, World!\nThis is a test.").unwrap();
let tool = EditBlockTool::with_base_path(temp_dir.path().to_path_buf());
let input = EditBlockInput {
file_path: PathBuf::from("test.txt"),
old_string: "Wrld".to_string(), new_string: "Rust".to_string(),
expected_replacements: 1,
enable_fuzzy: true,
fuzzy_threshold: 0.7,
};
let result = tool.execute(input).await.unwrap();
assert!(result.as_text().contains("fuzzy"));
}
#[tokio::test]
async fn test_edit_block_preserves_line_endings() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.txt");
fs::write(&file_path, "Line1\r\nLine2\r\n").unwrap();
let tool = EditBlockTool::with_base_path(temp_dir.path().to_path_buf());
let input = EditBlockInput {
file_path: PathBuf::from("test.txt"),
old_string: "Line1".to_string(),
new_string: "First".to_string(),
expected_replacements: 1,
enable_fuzzy: false,
fuzzy_threshold: 0.7,
};
tool.execute(input).await.unwrap();
let content = fs::read_to_string(&file_path).unwrap();
assert!(content.contains("\r\n"));
}
#[tokio::test]
async fn test_edit_block_lf_only() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("lf.txt");
let original = "Line 1\nLine 2\nLine 3\n";
fs::write(&file_path, original).unwrap();
let tool = EditBlockTool::with_base_path(temp_dir.path().to_path_buf());
let input = EditBlockInput {
file_path: PathBuf::from("lf.txt"),
old_string: "Line 2".to_string(),
new_string: "Modified Line 2".to_string(),
expected_replacements: 1,
enable_fuzzy: false,
fuzzy_threshold: 0.7,
};
tool.execute(input).await.unwrap();
let bytes = fs::read(&file_path).unwrap();
let content = String::from_utf8(bytes).unwrap();
assert!(content.contains("Modified Line 2"));
assert!(content.contains("\n"));
assert!(!content.contains("\r\n"));
}
#[tokio::test]
async fn test_edit_block_crlf_only() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("crlf.txt");
let original = "Line 1\r\nLine 2\r\nLine 3\r\n";
fs::write(&file_path, original).unwrap();
let tool = EditBlockTool::with_base_path(temp_dir.path().to_path_buf());
let input = EditBlockInput {
file_path: PathBuf::from("crlf.txt"),
old_string: "Line 2".to_string(),
new_string: "Modified Line 2".to_string(),
expected_replacements: 1,
enable_fuzzy: false,
fuzzy_threshold: 0.7,
};
tool.execute(input).await.unwrap();
let bytes = fs::read(&file_path).unwrap();
let content = String::from_utf8(bytes).unwrap();
assert!(content.contains("Modified Line 2"));
assert!(content.contains("\r\n"));
let crlf_count = content.matches("\r\n").count();
assert!(crlf_count >= 2); }
#[tokio::test]
async fn test_edit_block_mixed_line_endings() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("mixed.txt");
let original = "Line 1\nLine 2\r\nLine 3\rLine 4";
fs::write(&file_path, original).unwrap();
let tool = EditBlockTool::with_base_path(temp_dir.path().to_path_buf());
let input = EditBlockInput {
file_path: PathBuf::from("mixed.txt"),
old_string: "Line 2".to_string(),
new_string: "Modified Line 2".to_string(),
expected_replacements: 1,
enable_fuzzy: false,
fuzzy_threshold: 0.7,
};
tool.execute(input).await.unwrap();
let bytes = fs::read(&file_path).unwrap();
let content = String::from_utf8(bytes).unwrap();
assert!(content.contains("Modified Line 2"));
assert!(content.contains("\n") || content.contains("\r"));
}
#[tokio::test]
async fn test_edit_block_empty_file() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("empty.txt");
fs::write(&file_path, "").unwrap();
let tool = EditBlockTool::with_base_path(temp_dir.path().to_path_buf());
let input = EditBlockInput {
file_path: PathBuf::from("empty.txt"),
old_string: "nonexistent".to_string(),
new_string: "something".to_string(),
expected_replacements: 1,
enable_fuzzy: false,
fuzzy_threshold: 0.7,
};
let result = tool.execute(input).await;
assert!(result.is_err() || result.unwrap().as_text().contains("not found"));
}
#[tokio::test]
async fn test_edit_block_utf8_content() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("utf8.txt");
let original = "Hello 世界\nÜmläüts äöü\n🎵 Music\n";
fs::write(&file_path, original).unwrap();
let tool = EditBlockTool::with_base_path(temp_dir.path().to_path_buf());
let input = EditBlockInput {
file_path: PathBuf::from("utf8.txt"),
old_string: "Ümläüts äöü".to_string(),
new_string: "Modified äöü".to_string(),
expected_replacements: 1,
enable_fuzzy: false,
fuzzy_threshold: 0.7,
};
tool.execute(input).await.unwrap();
let content = fs::read_to_string(&file_path).unwrap();
assert!(content.contains("Modified äöü"));
assert!(content.contains("世界"));
assert!(content.contains("🎵"));
}
#[tokio::test]
async fn test_edit_block_crlf_replacement_with_crlf_in_new_string() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("crlf_replace.txt");
let original = "Line 1\r\nLine 2\r\nLine 3\r\n";
fs::write(&file_path, original).unwrap();
let tool = EditBlockTool::with_base_path(temp_dir.path().to_path_buf());
let input = EditBlockInput {
file_path: PathBuf::from("crlf_replace.txt"),
old_string: "Line 2".to_string(),
new_string: "New Line 2\r\nExtra Line".to_string(),
expected_replacements: 1,
enable_fuzzy: false,
fuzzy_threshold: 0.7,
};
tool.execute(input).await.unwrap();
let bytes = fs::read(&file_path).unwrap();
let content = String::from_utf8(bytes).unwrap();
assert!(
!content.contains("\r\r\n"),
"Bug: CRLF was doubled to \\r\\r\\n! Content bytes: {:?}",
content.as_bytes()
);
assert!(content.contains("New Line 2\r\nExtra Line"));
}
#[tokio::test]
async fn test_edit_block_multiple_occurrences() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("multi.txt");
let original = "Item A\nItem A\nItem B\nItem A\n";
fs::write(&file_path, original).unwrap();
let tool = EditBlockTool::with_base_path(temp_dir.path().to_path_buf());
let input = EditBlockInput {
file_path: PathBuf::from("multi.txt"),
old_string: "Item A".to_string(),
new_string: "Item X".to_string(),
expected_replacements: 3, enable_fuzzy: false,
fuzzy_threshold: 0.7,
};
tool.execute(input).await.unwrap();
let content = fs::read_to_string(&file_path).unwrap();
let x_count = content.matches("Item X").count();
let a_count = content.matches("Item A").count();
assert_eq!(x_count, 3);
assert_eq!(a_count, 0);
}
#[test]
fn test_fuzzy_match_empty_pattern() {
let result = EditBlockTool::find_fuzzy_match("some text", "", 0.5);
assert!(result.is_none(), "Empty pattern should return None");
}
#[test]
fn test_fuzzy_match_pattern_longer_than_text() {
let result =
EditBlockTool::find_fuzzy_match("short", "this pattern is much longer than text", 0.5);
assert!(
result.is_none(),
"Pattern longer than text should return None"
);
}
#[test]
fn test_fuzzy_match_exact_match() {
let result = EditBlockTool::find_fuzzy_match("hello world", "world", 0.5);
assert!(result.is_some());
let m = result.unwrap();
assert_eq!(m.matched_text, "world");
assert!(
(m.similarity - 1.0).abs() < 0.001,
"Exact match should have similarity 1.0"
);
}
#[test]
fn test_fuzzy_match_finds_similar() {
let result = EditBlockTool::find_fuzzy_match("hello world goodbye", "wrld", 0.5);
assert!(result.is_some());
let m = result.unwrap();
assert!(m.similarity > 0.5);
}
#[test]
fn test_fuzzy_match_below_threshold() {
let result = EditBlockTool::find_fuzzy_match("hello world", "xyz", 0.99);
assert!(result.is_none(), "Nothing should match with high threshold");
}
#[test]
fn test_fuzzy_match_variable_window_skip_large() {
let result = EditBlockTool::find_fuzzy_match("abcdefghij", "abcdefghij", 0.5);
assert!(result.is_some()); }
#[test]
fn test_fuzzy_match_smaller_window() {
let result = EditBlockTool::find_fuzzy_match("xxxABCDEFGHIxxx", "ABCDEFGHIJ", 0.5);
assert!(result.is_some());
}
#[test]
fn test_fuzzy_match_continue_branch() {
let long_pattern = "a".repeat(100);
let text = "a".repeat(105);
let result = EditBlockTool::find_fuzzy_match(&text, &long_pattern, 0.5);
assert!(result.is_some()); }
}