use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
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 PatchTool {
lsp_service: Option<Arc<dyn LspService>>,
}
#[derive(Debug, Deserialize)]
struct PatchParams {
patch_text: String,
validate_with_lsp: Option<bool>,
max_fuzz: Option<u32>,
create_dirs: Option<bool>,
}
#[derive(Debug, Serialize)]
struct PatchMetadata {
modified_files: Vec<String>,
created_files: Vec<String>,
deleted_files: Vec<String>,
total_additions: usize,
total_removals: usize,
fuzz_level: u32,
diagnostics: HashMap<String, Vec<LspDiagnostic>>,
}
#[derive(Debug)]
struct FileChange {
change_type: ChangeType,
old_content: Option<String>,
new_content: Option<String>,
}
#[derive(Debug)]
enum ChangeType {
Create,
Update,
Delete,
}
#[derive(Debug)]
struct ParsedPatch {
changes: HashMap<PathBuf, FileChange>,
fuzz_level: u32,
}
impl PatchTool {
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);
}
fn parse_patch(&self, patch_text: &str) -> Result<ParsedPatch, ToolError> {
let mut changes = HashMap::new();
let mut current_file: Option<PathBuf> = None;
let mut old_content = String::new();
let mut new_content = String::new();
let mut in_hunk = false;
let fuzz_level = 0;
for line in patch_text.lines() {
if line.starts_with("--- ") {
if let Some(file_path) = current_file.take() {
self.finalize_file_change(&mut changes, file_path, &old_content, &new_content)?;
}
let file_path = line.strip_prefix("--- ").unwrap_or("");
if file_path != "/dev/null" {
current_file = Some(PathBuf::from(file_path));
old_content.clear();
new_content.clear();
}
in_hunk = false;
} else if line.starts_with("+++ ") {
let file_path = line.strip_prefix("+++ ").unwrap_or("");
if file_path != "/dev/null" && current_file.is_none() {
current_file = Some(PathBuf::from(file_path));
}
} else if line.starts_with("@@") {
in_hunk = true;
} else if in_hunk {
if line.starts_with('-') {
old_content.push_str(&line[1..]);
old_content.push('\n');
} else if line.starts_with('+') {
new_content.push_str(&line[1..]);
new_content.push('\n');
} else if line.starts_with(' ') {
old_content.push_str(&line[1..]);
old_content.push('\n');
new_content.push_str(&line[1..]);
new_content.push('\n');
}
}
}
if let Some(file_path) = current_file {
self.finalize_file_change(&mut changes, file_path, &old_content, &new_content)?;
}
Ok(ParsedPatch {
changes,
fuzz_level,
})
}
fn finalize_file_change(
&self,
changes: &mut HashMap<PathBuf, FileChange>,
file_path: PathBuf,
old_content: &str,
new_content: &str,
) -> Result<(), ToolError> {
let change_type = if old_content.is_empty() {
ChangeType::Create
} else if new_content.is_empty() {
ChangeType::Delete
} else {
ChangeType::Update
};
let change = FileChange {
change_type,
old_content: if old_content.is_empty() { None } else { Some(old_content.to_string()) },
new_content: if new_content.is_empty() { None } else { Some(new_content.to_string()) },
};
changes.insert(file_path, change);
Ok(())
}
async fn apply_patch(&self, patch: ParsedPatch, create_dirs: bool) -> Result<PatchMetadata, ToolError> {
let mut metadata = PatchMetadata {
modified_files: Vec::new(),
created_files: Vec::new(),
deleted_files: Vec::new(),
total_additions: 0,
total_removals: 0,
fuzz_level: patch.fuzz_level,
diagnostics: HashMap::new(),
};
for (file_path, change) in patch.changes {
match change.change_type {
ChangeType::Create => {
if let Some(content) = &change.new_content {
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 {}: {}", file_path.display(), e)))?;
metadata.created_files.push(file_path.display().to_string());
metadata.total_additions += content.lines().count();
}
}
ChangeType::Update => {
if let Some(content) = &change.new_content {
let old_content = fs::read_to_string(&file_path).await
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to read file {}: {}", file_path.display(), e)))?;
fs::write(&file_path, content).await
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to update file {}: {}", file_path.display(), e)))?;
metadata.modified_files.push(file_path.display().to_string());
metadata.total_additions += content.lines().count();
metadata.total_removals += old_content.lines().count();
}
}
ChangeType::Delete => {
fs::remove_file(&file_path).await
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to delete file {}: {}", file_path.display(), e)))?;
metadata.deleted_files.push(file_path.display().to_string());
if let Some(old_content) = &change.old_content {
metadata.total_removals += old_content.lines().count();
}
}
}
}
Ok(metadata)
}
async fn get_diagnostics(&self, file_paths: &[String]) -> HashMap<String, Vec<LspDiagnostic>> {
let mut diagnostics = HashMap::new();
if let Some(lsp) = &self.lsp_service {
for file_path_str in file_paths {
let file_path = Path::new(file_path_str);
if lsp.supports_file(file_path) {
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
match lsp.get_diagnostics(file_path).await {
Ok(file_diagnostics) => {
if !file_diagnostics.is_empty() {
diagnostics.insert(file_path_str.clone(), file_diagnostics);
}
}
Err(_) => {} }
if let Ok(content) = fs::read_to_string(file_path).await {
let _ = lsp.did_change_file(file_path, &content).await;
}
}
}
}
diagnostics
}
fn format_response(&self, metadata: &PatchMetadata) -> String {
let mut response = String::new();
response.push_str("Patch applied successfully!\n\n");
let total_files = metadata.created_files.len() + metadata.modified_files.len() + metadata.deleted_files.len();
response.push_str(&format!("Files affected: {}\n", total_files));
response.push_str(&format!("Changes: +{} lines, -{} lines\n", metadata.total_additions, metadata.total_removals));
if metadata.fuzz_level > 0 {
response.push_str(&format!("Fuzz level: {}\n", metadata.fuzz_level));
}
if !metadata.created_files.is_empty() {
response.push_str(&format!("\nCreated files ({}):\n", metadata.created_files.len()));
for file in &metadata.created_files {
response.push_str(&format!(" + {}\n", file));
}
}
if !metadata.modified_files.is_empty() {
response.push_str(&format!("\nModified files ({}):\n", metadata.modified_files.len()));
for file in &metadata.modified_files {
response.push_str(&format!(" ~ {}\n", file));
}
}
if !metadata.deleted_files.is_empty() {
response.push_str(&format!("\nDeleted files ({}):\n", metadata.deleted_files.len()));
for file in &metadata.deleted_files {
response.push_str(&format!(" - {}\n", file));
}
}
if !metadata.diagnostics.is_empty() {
response.push_str("\nLSP Diagnostics:\n");
for (file, diagnostics) in &metadata.diagnostics {
response.push_str(&format!(" {}:\n", file));
for diagnostic in diagnostics {
response.push_str(&format!(" {}: {} (line {})\n",
diagnostic.severity, diagnostic.message, diagnostic.line + 1));
}
}
}
response
}
}
impl Default for PatchTool {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Tool for PatchTool {
async fn execute(
&self,
parameters: serde_json::Value,
_host: &dyn HostIntegration,
) -> Result<ToolResponse, ToolError> {
let params: PatchParams = serde_json::from_value(parameters)
.map_err(|e| ToolError::InvalidParameters(format!("Invalid parameters: {}", e)))?;
if params.patch_text.trim().is_empty() {
return Err(ToolError::InvalidParameters("patch_text is required".to_string()));
}
let validate_with_lsp = params.validate_with_lsp.unwrap_or(true);
let max_fuzz = params.max_fuzz.unwrap_or(3);
let create_dirs = params.create_dirs.unwrap_or(false);
let patch = self.parse_patch(¶ms.patch_text)?;
if patch.fuzz_level > max_fuzz {
return Err(ToolError::ExecutionFailed(format!(
"Patch contains fuzzy matches (fuzz level: {}). Maximum allowed: {}. Please make your context lines more precise.",
patch.fuzz_level, max_fuzz
)));
}
let mut metadata = self.apply_patch(patch, create_dirs).await?;
if validate_with_lsp {
let all_files: Vec<String> = metadata.created_files.iter()
.chain(metadata.modified_files.iter())
.cloned()
.collect();
metadata.diagnostics = self.get_diagnostics(&all_files).await;
}
let response_content = self.format_response(&metadata);
let metadata_json = serde_json::to_value(&metadata)
.unwrap_or(serde_json::Value::Null);
let affected_files: Vec<PathBuf> = metadata.created_files.iter()
.chain(metadata.modified_files.iter())
.chain(metadata.deleted_files.iter())
.map(|s| PathBuf::from(s))
.collect();
Ok(ToolResponse {
content: response_content,
success: true,
metadata: metadata_json,
affected_files,
})
}
fn name(&self) -> &str {
"patch"
}
fn description(&self) -> &str {
"Apply Git-style patches to files. Supports creating, updating, and deleting files with unified diff format patches."
}
fn requires_permission(&self) -> Permission {
Permission::WriteFile(PathBuf::from(".")) }
fn parameter_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"patch_text": {
"type": "string",
"description": "Patch content in unified diff format (must start with '--- ' and '+++ ' lines)"
},
"validate_with_lsp": {
"type": "boolean",
"description": "Whether to validate patched files with LSP and get diagnostics (default: true)",
"default": true
},
"max_fuzz": {
"type": "integer",
"description": "Maximum fuzz level to allow for patch application (default: 3)",
"default": 3,
"minimum": 0,
"maximum": 10
},
"create_dirs": {
"type": "boolean",
"description": "Whether to create parent directories if they don't exist (default: false)",
"default": false
}
},
"required": ["patch_text"]
})
}
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_patch() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("new_file.txt");
let patch_text = format!(
"--- /dev/null\n+++ {}\n@@ -0,0 +1,2 @@\n+Hello\n+World\n",
file_path.display()
);
let tool = PatchTool::new();
let params = serde_json::json!({
"patch_text": patch_text,
"validate_with_lsp": false
});
let result = tool.execute(params, &crate::integration::MockHost).await.unwrap();
assert!(result.success);
assert!(result.content.contains("Patch applied successfully"));
let content = fs::read_to_string(&file_path).await.unwrap();
assert_eq!(content, "Hello\nWorld\n");
}
#[tokio::test]
async fn test_update_file_patch() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("existing_file.txt");
fs::write(&file_path, "Hello\nWorld\n").await.unwrap();
let patch_text = format!(
"--- {}\n+++ {}\n@@ -1,2 +1,2 @@\n Hello\n-World\n+Rust\n",
file_path.display(),
file_path.display()
);
let tool = PatchTool::new();
let params = serde_json::json!({
"patch_text": patch_text,
"validate_with_lsp": false
});
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\nRust\n");
}
#[tokio::test]
async fn test_delete_file_patch() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("to_delete.txt");
fs::write(&file_path, "Delete me").await.unwrap();
let patch_text = format!(
"--- {}\n+++ /dev/null\n@@ -1,1 +0,0 @@\n-Delete me\n",
file_path.display()
);
let tool = PatchTool::new();
let params = serde_json::json!({
"patch_text": patch_text,
"validate_with_lsp": false
});
let result = tool.execute(params, &crate::integration::MockHost).await.unwrap();
assert!(result.success);
assert!(!file_path.exists());
}
}