use crate::subagents::config::IntelligenceLevel;
use crate::tools::tree::TreeTool;
use serde::Deserialize;
use serde::Serialize;
use std::path::Path;
use std::path::PathBuf;
use thiserror::Error;
use tracing::debug;
use tracing::info;
#[derive(Error, Debug)]
pub enum PatchError {
#[error("File not found: {path}")]
FileNotFound { path: String },
#[error("Parse error in {file}: {error}")]
ParseError { file: String, error: String },
#[error("Language not supported: {language}")]
UnsupportedLanguage { language: String },
#[error("Symbol not found: {symbol}")]
SymbolNotFound { symbol: String },
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Regex error: {0}")]
Regex(#[from] regex::Error),
#[error("Tree-sitter error: {0}")]
TreeSitter(String),
}
pub type PatchResult<T> = Result<T, PatchError>;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum RenameScope {
File(PathBuf),
Directory(PathBuf),
Workspace,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RenameStats {
pub files_changed: usize,
pub occurrences_replaced: usize,
pub tokens_saved: usize, }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtractStats {
pub files_changed: usize,
pub lines_extracted: usize,
pub tokens_saved: usize, }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImportStats {
pub files_changed: usize,
pub imports_updated: usize,
pub tokens_saved: usize, }
pub struct PatchTool {
_tree_tool: TreeTool,
}
impl PatchTool {
pub fn new() -> Self {
Self {
_tree_tool: TreeTool::new(IntelligenceLevel::Medium)
.expect("Failed to initialize TreeTool"),
}
}
pub async fn rename_symbol(
&self,
old_name: &str,
new_name: &str,
scope: RenameScope,
) -> PatchResult<RenameStats> {
info!(
"Starting bulk rename: '{}' -> '{}' in scope {:?}",
old_name, new_name, scope
);
let files = self.collect_files_in_scope(&scope).await?;
let mut files_changed = 0;
let mut total_occurrences = 0;
for file_path in files {
let content = tokio::fs::read_to_string(&file_path).await?;
let occurrences = self
.find_symbol_occurrences(&file_path, old_name, &content)
.await?;
if !occurrences.is_empty() {
let new_content =
self.replace_symbol_occurrences(&content, &occurrences, old_name, new_name);
tokio::fs::write(&file_path, new_content).await?;
files_changed += 1;
total_occurrences += occurrences.len();
debug!(
"Updated {} occurrences in {:?}",
occurrences.len(),
file_path
);
}
}
let tokens_saved = self.calculate_rename_tokens_saved(files_changed, total_occurrences);
Ok(RenameStats {
files_changed,
occurrences_replaced: total_occurrences,
tokens_saved,
})
}
pub async fn extract_function(
&self,
file: &str,
start_line: usize,
end_line: usize,
new_function_name: &str,
) -> PatchResult<ExtractStats> {
info!(
"Extracting function '{}' from {}:{}-{}",
new_function_name, file, start_line, end_line
);
let file_path = Path::new(file);
let content = tokio::fs::read_to_string(file_path).await?;
let lines: Vec<&str> = content.lines().collect();
if start_line == 0 || end_line >= lines.len() || start_line > end_line {
return Err(PatchError::TreeSitter("Invalid line range".to_string()));
}
let extracted_lines = &lines[start_line - 1..end_line];
let extracted_code = extracted_lines.join("\n");
let (params, return_type) = self
.analyze_extracted_code(&extracted_code, file_path)
.await?;
let new_function = self.generate_function_declaration(
new_function_name,
¶ms,
&return_type,
&extracted_code,
);
let function_call = self.generate_function_call(new_function_name, ¶ms);
let mut new_lines = lines.clone();
new_lines.splice(
start_line - 1..end_line,
std::iter::once(function_call.as_str()),
);
new_lines.push("");
new_lines.push(&new_function);
let new_content = new_lines.join("\n");
tokio::fs::write(file_path, new_content).await?;
let lines_extracted = end_line - start_line + 1;
let tokens_saved = self.calculate_extract_tokens_saved(lines_extracted);
Ok(ExtractStats {
files_changed: 1,
lines_extracted,
tokens_saved,
})
}
pub async fn update_imports(
&self,
old_import: &str,
new_import: &str,
) -> PatchResult<ImportStats> {
info!("Updating imports: '{}' -> '{}'", old_import, new_import);
let files = self.collect_all_source_files().await?;
let mut files_changed = 0;
let mut total_imports = 0;
for file_path in files {
let content = tokio::fs::read_to_string(&file_path).await?;
let import_locations = self
.find_import_statements(&file_path, old_import, &content)
.await?;
if !import_locations.is_empty() {
let new_content =
self.replace_import_statements(&content, &import_locations, new_import);
tokio::fs::write(&file_path, new_content).await?;
files_changed += 1;
total_imports += import_locations.len();
debug!(
"Updated {} imports in {:?}",
import_locations.len(),
file_path
);
}
}
let tokens_saved = self.calculate_import_tokens_saved(files_changed, total_imports);
Ok(ImportStats {
files_changed,
imports_updated: total_imports,
tokens_saved,
})
}
async fn collect_files_in_scope(&self, scope: &RenameScope) -> PatchResult<Vec<PathBuf>> {
match scope {
RenameScope::File(path) => Ok(vec![path.clone()]),
RenameScope::Directory(dir) => {
let mut files = Vec::new();
let mut entries = tokio::fs::read_dir(dir).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.is_file() && self.is_source_file(&path) {
files.push(path);
}
}
Ok(files)
}
RenameScope::Workspace => self.collect_all_source_files().await,
}
}
async fn collect_all_source_files(&self) -> PatchResult<Vec<PathBuf>> {
let mut files = Vec::new();
for extension in &["rs", "js", "ts", "py", "java", "cpp", "c", "h", "hpp", "go"] {
let pattern = format!("**/*.{}", extension);
if let Ok(found_files) = glob::glob(&pattern) {
for entry in found_files.flatten() {
files.push(entry);
}
}
}
Ok(files)
}
fn is_source_file(&self, path: &Path) -> bool {
path.extension()
.and_then(|ext| ext.to_str())
.map(|ext| {
matches!(
ext,
"rs" | "js" | "ts" | "py" | "java" | "cpp" | "c" | "h" | "hpp" | "go"
)
})
.unwrap_or(false)
}
async fn find_symbol_occurrences(
&self,
_file_path: &Path,
symbol: &str,
content: &str,
) -> PatchResult<Vec<(usize, usize)>> {
let pattern = format!(r"\b{}\b", regex::escape(symbol));
let regex = regex::Regex::new(&pattern)?;
let mut occurrences = Vec::new();
for (line_idx, line) in content.lines().enumerate() {
for match_ in regex.find_iter(line) {
occurrences.push((line_idx, match_.start()));
}
}
Ok(occurrences)
}
fn replace_symbol_occurrences(
&self,
content: &str,
occurrences: &[(usize, usize)],
old_name: &str,
new_name: &str,
) -> String {
let mut lines: Vec<String> = content.lines().map(String::from).collect();
for &(line_idx, _col_idx) in occurrences.iter().rev() {
if let Some(line) = lines.get_mut(line_idx) {
let old_pattern = format!(r"\b{}\b", regex::escape(old_name));
let re = regex::Regex::new(&old_pattern).unwrap();
*line = re.replace_all(line, new_name).to_string();
}
}
lines.join("\n")
}
async fn analyze_extracted_code(
&self,
_code: &str,
file_path: &Path,
) -> PatchResult<(Vec<String>, String)> {
let params = Vec::new();
let return_type = if file_path.extension().and_then(|e| e.to_str()) == Some("rs") {
"()".to_string() } else {
"void".to_string() };
Ok((params, return_type))
}
fn generate_function_declaration(
&self,
name: &str,
params: &[String],
return_type: &str,
body: &str,
) -> String {
if return_type == "()" && params.is_empty() {
format!(
"fn {}() {{\n{}\n}}",
name,
body.lines()
.map(|l| format!(" {}", l))
.collect::<Vec<_>>()
.join("\n")
)
} else {
format!(
"fn {}({}) -> {} {{\n{}\n}}",
name,
params.join(", "),
return_type,
body.lines()
.map(|l| format!(" {}", l))
.collect::<Vec<_>>()
.join("\n")
)
}
}
fn generate_function_call(&self, name: &str, params: &[String]) -> String {
format!(" {}({});", name, params.join(", "))
}
async fn find_import_statements(
&self,
_file_path: &Path,
old_import: &str,
content: &str,
) -> PatchResult<Vec<usize>> {
let mut locations = Vec::new();
for (line_idx, line) in content.lines().enumerate() {
if line.contains("import") && line.contains(old_import) {
locations.push(line_idx);
}
}
Ok(locations)
}
fn replace_import_statements(
&self,
content: &str,
locations: &[usize],
new_import: &str,
) -> String {
let mut lines: Vec<String> = content.lines().map(String::from).collect();
for &line_idx in locations {
if let Some(line) = lines.get_mut(line_idx) {
if line.contains("import") {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
*line = format!("{} {}", parts[0], new_import);
}
}
}
}
lines.join("\n")
}
const fn calculate_rename_tokens_saved(
&self,
files_changed: usize,
occurrences: usize,
) -> usize {
files_changed * 50 + occurrences * 10
}
const fn calculate_extract_tokens_saved(&self, lines_extracted: usize) -> usize {
lines_extracted * 30 + 200 }
const fn calculate_import_tokens_saved(&self, files_changed: usize, imports: usize) -> usize {
files_changed * 30 + imports * 15
}
}
impl Default for PatchTool {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[tokio::test]
async fn test_rename_in_single_file() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("test.rs");
fs::write(&file_path, "fn old_name() {}\nlet x = old_name();").unwrap();
let tool = PatchTool::new();
let stats = tool
.rename_symbol("old_name", "new_name", RenameScope::File(file_path.clone()))
.await
.unwrap();
assert_eq!(stats.files_changed, 1);
assert!(stats.occurrences_replaced > 0);
assert!(stats.tokens_saved > 0);
}
#[tokio::test]
async fn test_extract_function() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("test.rs");
fs::write(
&file_path,
r#"
fn main() {
let x = 1;
let y = 2;
println!("{}", x + y);
}
"#
.trim(),
)
.unwrap();
let tool = PatchTool::new();
let stats = tool
.extract_function(file_path.to_str().unwrap(), 2, 4, "calculate_and_print")
.await
.unwrap();
assert_eq!(stats.files_changed, 1);
assert_eq!(stats.lines_extracted, 3);
assert!(stats.tokens_saved > 0);
}
}