1use crate::subagents::config::IntelligenceLevel;
7use crate::tools::tree::TreeTool;
8use serde::Deserialize;
9use serde::Serialize;
10use std::path::Path;
11use std::path::PathBuf;
12use thiserror::Error;
13use tracing::debug;
14use tracing::info;
15
16#[derive(Error, Debug)]
18pub enum PatchError {
19 #[error("File not found: {path}")]
20 FileNotFound { path: String },
21
22 #[error("Parse error in {file}: {error}")]
23 ParseError { file: String, error: String },
24
25 #[error("Language not supported: {language}")]
26 UnsupportedLanguage { language: String },
27
28 #[error("Symbol not found: {symbol}")]
29 SymbolNotFound { symbol: String },
30
31 #[error("IO error: {0}")]
32 Io(#[from] std::io::Error),
33
34 #[error("Regex error: {0}")]
35 Regex(#[from] regex::Error),
36
37 #[error("Tree-sitter error: {0}")]
38 TreeSitter(String),
39}
40
41pub type PatchResult<T> = Result<T, PatchError>;
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
45pub enum RenameScope {
46 File(PathBuf),
47 Directory(PathBuf),
48 Workspace,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct RenameStats {
54 pub files_changed: usize,
55 pub occurrences_replaced: usize,
56 pub tokens_saved: usize, }
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct ExtractStats {
62 pub files_changed: usize,
63 pub lines_extracted: usize,
64 pub tokens_saved: usize, }
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct ImportStats {
70 pub files_changed: usize,
71 pub imports_updated: usize,
72 pub tokens_saved: usize, }
74
75pub struct PatchTool {
77 _tree_tool: TreeTool,
78}
79
80impl PatchTool {
81 pub fn new() -> Self {
83 Self {
84 _tree_tool: TreeTool::new(IntelligenceLevel::Medium)
85 .expect("Failed to initialize TreeTool"),
86 }
87 }
88
89 pub async fn rename_symbol(
91 &self,
92 old_name: &str,
93 new_name: &str,
94 scope: RenameScope,
95 ) -> PatchResult<RenameStats> {
96 info!(
97 "Starting bulk rename: '{}' -> '{}' in scope {:?}",
98 old_name, new_name, scope
99 );
100
101 let files = self.collect_files_in_scope(&scope).await?;
102 let mut files_changed = 0;
103 let mut total_occurrences = 0;
104
105 for file_path in files {
106 let content = tokio::fs::read_to_string(&file_path).await?;
107
108 let occurrences = self
110 .find_symbol_occurrences(&file_path, old_name, &content)
111 .await?;
112
113 if !occurrences.is_empty() {
114 let new_content =
115 self.replace_symbol_occurrences(&content, &occurrences, old_name, new_name);
116 tokio::fs::write(&file_path, new_content).await?;
117
118 files_changed += 1;
119 total_occurrences += occurrences.len();
120 debug!(
121 "Updated {} occurrences in {:?}",
122 occurrences.len(),
123 file_path
124 );
125 }
126 }
127
128 let tokens_saved = self.calculate_rename_tokens_saved(files_changed, total_occurrences);
129
130 Ok(RenameStats {
131 files_changed,
132 occurrences_replaced: total_occurrences,
133 tokens_saved,
134 })
135 }
136
137 pub async fn extract_function(
139 &self,
140 file: &str,
141 start_line: usize,
142 end_line: usize,
143 new_function_name: &str,
144 ) -> PatchResult<ExtractStats> {
145 info!(
146 "Extracting function '{}' from {}:{}-{}",
147 new_function_name, file, start_line, end_line
148 );
149
150 let file_path = Path::new(file);
151 let content = tokio::fs::read_to_string(file_path).await?;
152 let lines: Vec<&str> = content.lines().collect();
153
154 if start_line == 0 || end_line >= lines.len() || start_line > end_line {
155 return Err(PatchError::TreeSitter("Invalid line range".to_string()));
156 }
157
158 let extracted_lines = &lines[start_line - 1..end_line];
160 let extracted_code = extracted_lines.join("\n");
161
162 let (params, return_type) = self
164 .analyze_extracted_code(&extracted_code, file_path)
165 .await?;
166
167 let new_function = self.generate_function_declaration(
169 new_function_name,
170 ¶ms,
171 &return_type,
172 &extracted_code,
173 );
174
175 let function_call = self.generate_function_call(new_function_name, ¶ms);
177
178 let mut new_lines = lines.clone();
180
181 new_lines.splice(
183 start_line - 1..end_line,
184 std::iter::once(function_call.as_str()),
185 );
186
187 new_lines.push("");
189 new_lines.push(&new_function);
190
191 let new_content = new_lines.join("\n");
192 tokio::fs::write(file_path, new_content).await?;
193
194 let lines_extracted = end_line - start_line + 1;
195 let tokens_saved = self.calculate_extract_tokens_saved(lines_extracted);
196
197 Ok(ExtractStats {
198 files_changed: 1,
199 lines_extracted,
200 tokens_saved,
201 })
202 }
203
204 pub async fn update_imports(
206 &self,
207 old_import: &str,
208 new_import: &str,
209 ) -> PatchResult<ImportStats> {
210 info!("Updating imports: '{}' -> '{}'", old_import, new_import);
211
212 let files = self.collect_all_source_files().await?;
213 let mut files_changed = 0;
214 let mut total_imports = 0;
215
216 for file_path in files {
217 let content = tokio::fs::read_to_string(&file_path).await?;
218
219 let import_locations = self
221 .find_import_statements(&file_path, old_import, &content)
222 .await?;
223
224 if !import_locations.is_empty() {
225 let new_content =
226 self.replace_import_statements(&content, &import_locations, new_import);
227 tokio::fs::write(&file_path, new_content).await?;
228
229 files_changed += 1;
230 total_imports += import_locations.len();
231 debug!(
232 "Updated {} imports in {:?}",
233 import_locations.len(),
234 file_path
235 );
236 }
237 }
238
239 let tokens_saved = self.calculate_import_tokens_saved(files_changed, total_imports);
240
241 Ok(ImportStats {
242 files_changed,
243 imports_updated: total_imports,
244 tokens_saved,
245 })
246 }
247
248 async fn collect_files_in_scope(&self, scope: &RenameScope) -> PatchResult<Vec<PathBuf>> {
251 match scope {
252 RenameScope::File(path) => Ok(vec![path.clone()]),
253 RenameScope::Directory(dir) => {
254 let mut files = Vec::new();
255 let mut entries = tokio::fs::read_dir(dir).await?;
256
257 while let Some(entry) = entries.next_entry().await? {
258 let path = entry.path();
259 if path.is_file() && self.is_source_file(&path) {
260 files.push(path);
261 }
262 }
263 Ok(files)
264 }
265 RenameScope::Workspace => self.collect_all_source_files().await,
266 }
267 }
268
269 async fn collect_all_source_files(&self) -> PatchResult<Vec<PathBuf>> {
270 let mut files = Vec::new();
271
272 for extension in &["rs", "js", "ts", "py", "java", "cpp", "c", "h", "hpp", "go"] {
274 let pattern = format!("**/*.{}", extension);
275 if let Ok(found_files) = glob::glob(&pattern) {
276 for entry in found_files.flatten() {
277 files.push(entry);
278 }
279 }
280 }
281
282 Ok(files)
283 }
284
285 fn is_source_file(&self, path: &Path) -> bool {
286 path.extension()
287 .and_then(|ext| ext.to_str())
288 .map(|ext| {
289 matches!(
290 ext,
291 "rs" | "js" | "ts" | "py" | "java" | "cpp" | "c" | "h" | "hpp" | "go"
292 )
293 })
294 .unwrap_or(false)
295 }
296
297 async fn find_symbol_occurrences(
298 &self,
299 _file_path: &Path,
300 symbol: &str,
301 content: &str,
302 ) -> PatchResult<Vec<(usize, usize)>> {
303 let pattern = format!(r"\b{}\b", regex::escape(symbol));
306 let regex = regex::Regex::new(&pattern)?;
307 let mut occurrences = Vec::new();
308
309 for (line_idx, line) in content.lines().enumerate() {
310 for match_ in regex.find_iter(line) {
311 occurrences.push((line_idx, match_.start()));
312 }
313 }
314
315 Ok(occurrences)
316 }
317
318 fn replace_symbol_occurrences(
319 &self,
320 content: &str,
321 occurrences: &[(usize, usize)],
322 old_name: &str,
323 new_name: &str,
324 ) -> String {
325 let mut lines: Vec<String> = content.lines().map(String::from).collect();
326
327 for &(line_idx, _col_idx) in occurrences.iter().rev() {
329 if let Some(line) = lines.get_mut(line_idx) {
330 let old_pattern = format!(r"\b{}\b", regex::escape(old_name));
332 let re = regex::Regex::new(&old_pattern).unwrap();
333 *line = re.replace_all(line, new_name).to_string();
334 }
335 }
336
337 lines.join("\n")
338 }
339
340 async fn analyze_extracted_code(
341 &self,
342 _code: &str,
343 file_path: &Path,
344 ) -> PatchResult<(Vec<String>, String)> {
345 let params = Vec::new(); let return_type = if file_path.extension().and_then(|e| e.to_str()) == Some("rs") {
350 "()".to_string() } else {
352 "void".to_string() };
354
355 Ok((params, return_type))
356 }
357
358 fn generate_function_declaration(
359 &self,
360 name: &str,
361 params: &[String],
362 return_type: &str,
363 body: &str,
364 ) -> String {
365 if return_type == "()" && params.is_empty() {
367 format!(
368 "fn {}() {{\n{}\n}}",
369 name,
370 body.lines()
371 .map(|l| format!(" {}", l))
372 .collect::<Vec<_>>()
373 .join("\n")
374 )
375 } else {
376 format!(
377 "fn {}({}) -> {} {{\n{}\n}}",
378 name,
379 params.join(", "),
380 return_type,
381 body.lines()
382 .map(|l| format!(" {}", l))
383 .collect::<Vec<_>>()
384 .join("\n")
385 )
386 }
387 }
388
389 fn generate_function_call(&self, name: &str, params: &[String]) -> String {
390 format!(" {}({});", name, params.join(", "))
391 }
392
393 async fn find_import_statements(
394 &self,
395 _file_path: &Path,
396 old_import: &str,
397 content: &str,
398 ) -> PatchResult<Vec<usize>> {
399 let mut locations = Vec::new();
400
401 for (line_idx, line) in content.lines().enumerate() {
402 if line.contains("import") && line.contains(old_import) {
403 locations.push(line_idx);
404 }
405 }
406
407 Ok(locations)
408 }
409
410 fn replace_import_statements(
411 &self,
412 content: &str,
413 locations: &[usize],
414 new_import: &str,
415 ) -> String {
416 let mut lines: Vec<String> = content.lines().map(String::from).collect();
417
418 for &line_idx in locations {
419 if let Some(line) = lines.get_mut(line_idx) {
420 if line.contains("import") {
423 let parts: Vec<&str> = line.split_whitespace().collect();
425 if parts.len() >= 2 {
426 *line = format!("{} {}", parts[0], new_import);
427 }
428 }
429 }
430 }
431
432 lines.join("\n")
433 }
434
435 const fn calculate_rename_tokens_saved(
436 &self,
437 files_changed: usize,
438 occurrences: usize,
439 ) -> usize {
440 files_changed * 50 + occurrences * 10
443 }
444
445 const fn calculate_extract_tokens_saved(&self, lines_extracted: usize) -> usize {
446 lines_extracted * 30 + 200 }
450
451 const fn calculate_import_tokens_saved(&self, files_changed: usize, imports: usize) -> usize {
452 files_changed * 30 + imports * 15
454 }
455}
456
457impl Default for PatchTool {
458 fn default() -> Self {
459 Self::new()
460 }
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466 use std::fs;
467 use tempfile::tempdir;
468
469 #[tokio::test]
470 async fn test_rename_in_single_file() {
471 let dir = tempdir().unwrap();
472 let file_path = dir.path().join("test.rs");
473
474 fs::write(&file_path, "fn old_name() {}\nlet x = old_name();").unwrap();
475
476 let tool = PatchTool::new();
477 let stats = tool
478 .rename_symbol("old_name", "new_name", RenameScope::File(file_path.clone()))
479 .await
480 .unwrap();
481
482 assert_eq!(stats.files_changed, 1);
483 assert!(stats.occurrences_replaced > 0);
484 assert!(stats.tokens_saved > 0);
485 }
486
487 #[tokio::test]
488 async fn test_extract_function() {
489 let dir = tempdir().unwrap();
490 let file_path = dir.path().join("test.rs");
491
492 fs::write(
493 &file_path,
494 r#"
495fn main() {
496 let x = 1;
497 let y = 2;
498 println!("{}", x + y);
499}
500 "#
501 .trim(),
502 )
503 .unwrap();
504
505 let tool = PatchTool::new();
506 let stats = tool
507 .extract_function(file_path.to_str().unwrap(), 2, 4, "calculate_and_print")
508 .await
509 .unwrap();
510
511 assert_eq!(stats.files_changed, 1);
512 assert_eq!(stats.lines_extracted, 3);
513 assert!(stats.tokens_saved > 0);
514 }
515}