use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::fs;
use crate::integration::HostIntegration;
use crate::lsp::{LspService, LspDiagnostic};
use crate::tools::{Tool, ToolError, ToolResponse, Permission};
pub struct EditTool {
lsp_service: Option<Arc<dyn LspService>>,
}
#[derive(Debug, Deserialize)]
struct EditParams {
file_path: PathBuf,
old_string: Option<String>,
new_string: Option<String>,
validate_with_lsp: Option<bool>,
create_dirs: Option<bool>,
}
#[derive(Debug, Serialize)]
struct EditMetadata {
diff: String,
additions: usize,
removals: usize,
diagnostics: Vec<LspDiagnostic>,
old_size: usize,
new_size: usize,
}
impl EditTool {
pub fn new() -> Self {
Self {
lsp_service: None,
}
}
pub fn set_lsp_service(&mut self, lsp_service: Arc<dyn LspService>) {
self.lsp_service = Some(lsp_service);
}
async fn create_file(&self, file_path: &Path, content: &str, create_dirs: bool) -> Result<EditMetadata, ToolError> {
if file_path.exists() {
return Err(ToolError::ExecutionFailed(format!(
"File already exists: {}. Use old_string parameter to edit existing files.",
file_path.display()
)));
}
if create_dirs {
if let Some(parent) = file_path.parent() {
fs::create_dir_all(parent).await
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to create directories: {}", e)))?;
}
}
fs::write(file_path, content).await
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to create file: {}", e)))?;
let diff = self.generate_diff("", content, file_path);
let lines: Vec<&str> = content.lines().collect();
Ok(EditMetadata {
diff,
additions: lines.len(),
removals: 0,
diagnostics: Vec::new(),
old_size: 0,
new_size: content.len(),
})
}
async fn delete_content(&self, file_path: &Path, old_string: &str) -> Result<EditMetadata, ToolError> {
let old_content = fs::read_to_string(file_path).await
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to read file: {}", e)))?;
let index = old_content.find(old_string)
.ok_or_else(|| ToolError::ExecutionFailed("old_string not found in file".to_string()))?;
if old_content.rfind(old_string) != Some(index) {
return Err(ToolError::ExecutionFailed(
"old_string appears multiple times in the file. Please provide more context to ensure a unique match".to_string()
));
}
let new_content = format!("{}{}", &old_content[..index], &old_content[index + old_string.len()..]);
fs::write(file_path, &new_content).await
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to write file: {}", e)))?;
let diff = self.generate_diff(&old_content, &new_content, file_path);
let old_lines: Vec<&str> = old_content.lines().collect();
let new_lines: Vec<&str> = new_content.lines().collect();
Ok(EditMetadata {
diff,
additions: 0,
removals: old_lines.len().saturating_sub(new_lines.len()),
diagnostics: Vec::new(),
old_size: old_content.len(),
new_size: new_content.len(),
})
}
async fn replace_content(&self, file_path: &Path, old_string: &str, new_string: &str) -> Result<EditMetadata, ToolError> {
let old_content = fs::read_to_string(file_path).await
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to read file: {}", e)))?;
let index = old_content.find(old_string)
.ok_or_else(|| ToolError::ExecutionFailed("old_string not found in file".to_string()))?;
if old_content.rfind(old_string) != Some(index) {
return Err(ToolError::ExecutionFailed(
"old_string appears multiple times in the file. Please provide more context to ensure a unique match".to_string()
));
}
let new_content = format!("{}{}{}",
&old_content[..index],
new_string,
&old_content[index + old_string.len()..]
);
fs::write(file_path, &new_content).await
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to write file: {}", e)))?;
let diff = self.generate_diff(&old_content, &new_content, file_path);
let old_lines: Vec<&str> = old_content.lines().collect();
let new_lines: Vec<&str> = new_content.lines().collect();
let additions = new_lines.len().saturating_sub(old_lines.len());
let removals = old_lines.len().saturating_sub(new_lines.len());
Ok(EditMetadata {
diff,
additions,
removals,
diagnostics: Vec::new(),
old_size: old_content.len(),
new_size: new_content.len(),
})
}
fn generate_diff(&self, old_content: &str, new_content: &str, file_path: &Path) -> String {
let old_lines: Vec<&str> = old_content.lines().collect();
let new_lines: Vec<&str> = new_content.lines().collect();
let mut diff = String::new();
diff.push_str(&format!("--- {}\n", file_path.display()));
diff.push_str(&format!("+++ {}\n", file_path.display()));
diff.push_str(&format!("@@ -{},{} +{},{} @@\n",
1, old_lines.len(), 1, new_lines.len()));
let max_lines = old_lines.len().max(new_lines.len());
for i in 0..max_lines {
match (old_lines.get(i), new_lines.get(i)) {
(Some(old_line), Some(new_line)) => {
if old_line != new_line {
diff.push_str(&format!("-{}\n", old_line));
diff.push_str(&format!("+{}\n", new_line));
} else {
diff.push_str(&format!(" {}\n", old_line));
}
}
(Some(old_line), None) => {
diff.push_str(&format!("-{}\n", old_line));
}
(None, Some(new_line)) => {
diff.push_str(&format!("+{}\n", new_line));
}
(None, None) => break,
}
}
diff
}
async fn get_diagnostics(&self, file_path: &Path) -> Vec<LspDiagnostic> {
if let Some(lsp) = &self.lsp_service {
if lsp.supports_file(file_path) {
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
match lsp.get_diagnostics(file_path).await {
Ok(diagnostics) => diagnostics,
Err(_) => Vec::new(),
}
} else {
Vec::new()
}
} else {
Vec::new()
}
}
fn format_response(&self, metadata: &EditMetadata, file_path: &Path) -> String {
let mut response = String::new();
response.push_str(&format!("Successfully edited {}\n\n", file_path.display()));
response.push_str(&format!("Changes: +{} lines, -{} lines\n", metadata.additions, metadata.removals));
response.push_str(&format!("File size: {} → {} bytes\n\n", metadata.old_size, metadata.new_size));
response.push_str("Diff:\n");
response.push_str(&metadata.diff);
if !metadata.diagnostics.is_empty() {
response.push_str("\n\nLSP Diagnostics:\n");
for diagnostic in &metadata.diagnostics {
response.push_str(&format!(" {}: {} (line {})\n",
diagnostic.severity, diagnostic.message, diagnostic.line + 1));
}
}
response
}
}
impl Default for EditTool {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Tool for EditTool {
async fn execute(
&self,
parameters: serde_json::Value,
_host: &dyn HostIntegration,
) -> Result<ToolResponse, ToolError> {
let params: EditParams = serde_json::from_value(parameters)
.map_err(|e| ToolError::InvalidParameters(format!("Invalid parameters: {}", e)))?;
if params.file_path.as_os_str().is_empty() {
return Err(ToolError::InvalidParameters("file_path is required".to_string()));
}
let file_path = ¶ms.file_path;
let validate_with_lsp = params.validate_with_lsp.unwrap_or(true);
let create_dirs = params.create_dirs.unwrap_or(false);
let mut metadata = match (¶ms.old_string, ¶ms.new_string) {
(None, Some(new_string)) => {
self.create_file(file_path, new_string, create_dirs).await?
}
(Some(old_string), Some(new_string)) if old_string.is_empty() => {
self.create_file(file_path, new_string, create_dirs).await?
}
(Some(old_string), None) => {
self.delete_content(file_path, old_string).await?
}
(Some(old_string), Some(new_string)) if new_string.is_empty() => {
self.delete_content(file_path, old_string).await?
}
(Some(old_string), Some(new_string)) => {
self.replace_content(file_path, old_string, new_string).await?
}
_ => {
return Err(ToolError::InvalidParameters(
"Either old_string or new_string (or both) must be provided".to_string()
));
}
};
if validate_with_lsp {
metadata.diagnostics = self.get_diagnostics(file_path).await;
if let Some(lsp) = &self.lsp_service {
if lsp.supports_file(file_path) {
if let Ok(content) = fs::read_to_string(file_path).await {
let _ = lsp.did_change_file(file_path, &content).await;
}
}
}
}
let response_content = self.format_response(&metadata, file_path);
let metadata_json = serde_json::to_value(&metadata)
.unwrap_or(serde_json::Value::Null);
Ok(ToolResponse {
content: response_content,
success: true,
metadata: metadata_json,
affected_files: vec![file_path.clone()],
})
}
fn name(&self) -> &str {
"edit"
}
fn description(&self) -> &str {
"Advanced file editing tool with LSP integration. Supports creating new files, replacing content, and deleting content with change tracking and validation."
}
fn requires_permission(&self) -> Permission {
Permission::WriteFile(PathBuf::from(".")) }
fn parameter_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Path to the file to edit (relative or absolute)"
},
"old_string": {
"type": "string",
"description": "String to replace or delete (omit for new file creation)"
},
"new_string": {
"type": "string",
"description": "String to insert or replace with (omit for deletion)"
},
"validate_with_lsp": {
"type": "boolean",
"description": "Whether to validate the edit with LSP and get diagnostics (default: true)",
"default": true
},
"create_dirs": {
"type": "boolean",
"description": "Whether to create parent directories if they don't exist (default: false)",
"default": false
}
},
"required": ["file_path"],
"oneOf": [
{
"description": "Create new file",
"required": ["new_string"],
"not": {"required": ["old_string"]}
},
{
"description": "Replace content",
"required": ["old_string", "new_string"]
},
{
"description": "Delete content",
"required": ["old_string"],
"not": {"required": ["new_string"]}
}
]
})
}
fn clone_box(&self) -> Box<dyn Tool> {
Box::new(Self {
lsp_service: self.lsp_service.clone(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[tokio::test]
async fn test_create_file() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.txt");
let tool = EditTool::new();
let params = serde_json::json!({
"file_path": file_path,
"new_string": "Hello, World!"
});
let result = tool.execute(params, &crate::integration::MockHost).await.unwrap();
assert!(result.success);
assert!(result.content.contains("Successfully edited"));
let content = fs::read_to_string(&file_path).await.unwrap();
assert_eq!(content, "Hello, World!");
}
#[tokio::test]
async fn test_replace_content() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.txt");
fs::write(&file_path, "Hello, World!").await.unwrap();
let tool = EditTool::new();
let params = serde_json::json!({
"file_path": file_path,
"old_string": "World",
"new_string": "Rust"
});
let result = tool.execute(params, &crate::integration::MockHost).await.unwrap();
assert!(result.success);
let content = fs::read_to_string(&file_path).await.unwrap();
assert_eq!(content, "Hello, Rust!");
}
#[tokio::test]
async fn test_delete_content() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.txt");
fs::write(&file_path, "Hello, World!\nGoodbye!").await.unwrap();
let tool = EditTool::new();
let params = serde_json::json!({
"file_path": file_path,
"old_string": ", World!"
});
let result = tool.execute(params, &crate::integration::MockHost).await.unwrap();
assert!(result.success);
let content = fs::read_to_string(&file_path).await.unwrap();
assert_eq!(content, "Hello\nGoodbye!");
}
#[tokio::test]
async fn test_multiple_occurrences_error() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.txt");
fs::write(&file_path, "test test test").await.unwrap();
let tool = EditTool::new();
let params = serde_json::json!({
"file_path": file_path,
"old_string": "test",
"new_string": "replaced"
});
let result = tool.execute(params, &crate::integration::MockHost).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("multiple times"));
}
}