1use anyhow::Result;
6use rig::completion::ToolDefinition;
7use rig::tool::Tool;
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeSet;
10
11use crate::context::{ChangeType, RecentCommit};
12use crate::define_tool_error;
13use crate::git::StagedFile;
14
15use super::common::{get_current_repo, parameters_schema};
16
17define_tool_error!(GitError);
18
19fn add_change(changes: &mut Vec<&'static str>, change: &'static str) {
21 if !changes.contains(&change) {
22 changes.push(change);
23 }
24}
25
26fn is_function_def(line: &str, ext: &str) -> bool {
28 match ext {
29 "rs" => {
30 line.starts_with("pub fn ")
31 || line.starts_with("fn ")
32 || line.starts_with("pub async fn ")
33 || line.starts_with("async fn ")
34 }
35 "ts" | "tsx" | "js" | "jsx" => {
36 line.starts_with("function ")
37 || line.starts_with("async function ")
38 || line.contains(" = () =>")
39 || line.contains(" = async () =>")
40 }
41 "py" => line.starts_with("def ") || line.starts_with("async def "),
42 "go" => line.starts_with("func "),
43 _ => false,
44 }
45}
46
47fn is_import(line: &str, ext: &str) -> bool {
49 match ext {
50 "rs" => line.starts_with("use ") || line.starts_with("pub use "),
51 "ts" | "tsx" | "js" | "jsx" => line.starts_with("import ") || line.starts_with("export "),
52 "py" => line.starts_with("import ") || line.starts_with("from "),
53 "go" => line.starts_with("import "),
54 _ => false,
55 }
56}
57
58fn is_type_def(line: &str, ext: &str) -> bool {
60 match ext {
61 "rs" => {
62 line.starts_with("pub struct ")
63 || line.starts_with("struct ")
64 || line.starts_with("pub enum ")
65 || line.starts_with("enum ")
66 }
67 "ts" | "tsx" | "js" | "jsx" => {
68 line.starts_with("interface ")
69 || line.starts_with("type ")
70 || line.starts_with("class ")
71 }
72 "py" => line.starts_with("class "),
73 "go" => line.starts_with("type "),
74 _ => false,
75 }
76}
77
78#[allow(clippy::cognitive_complexity)]
80fn detect_semantic_changes(diff: &str, path: &str) -> Vec<&'static str> {
81 use std::path::Path;
82
83 let mut changes = Vec::new();
84
85 let ext = Path::new(path)
87 .extension()
88 .and_then(|e| e.to_str())
89 .map(str::to_lowercase)
90 .unwrap_or_default();
91
92 let supported = matches!(
94 ext.as_str(),
95 "rs" | "ts" | "tsx" | "js" | "jsx" | "py" | "go"
96 );
97
98 if supported {
99 for line in diff
101 .lines()
102 .filter(|l| l.starts_with('+') && !l.starts_with("+++"))
103 {
104 let line = line.trim_start_matches('+').trim();
105
106 if is_function_def(line, &ext) {
107 add_change(&mut changes, "adds function");
108 }
109 if is_import(line, &ext) {
110 add_change(&mut changes, "modifies imports");
111 }
112 if is_type_def(line, &ext) {
113 add_change(&mut changes, "adds type");
114 }
115 if ext == "rs" && line.starts_with("impl ") {
117 add_change(&mut changes, "adds impl");
118 }
119 }
120 }
121
122 let has_deletions = diff
124 .lines()
125 .any(|l| l.starts_with('-') && !l.starts_with("---"));
126 let has_additions = diff
127 .lines()
128 .any(|l| l.starts_with('+') && !l.starts_with("+++"));
129
130 if has_deletions && has_additions && changes.is_empty() {
131 changes.push("refactors code");
132 } else if has_deletions && !has_additions {
133 changes.push("removes code");
134 }
135
136 changes
137}
138
139#[allow(clippy::case_sensitive_file_extension_comparisons)]
142fn calculate_relevance_score(file: &StagedFile) -> (f32, Vec<&'static str>) {
143 let mut score: f32 = 0.5; let mut reasons = Vec::new();
145 let path = file.path.to_lowercase();
146
147 match file.change_type {
149 ChangeType::Added => {
150 score += 0.15;
151 reasons.push("new file");
152 }
153 ChangeType::Modified => {
154 score += 0.1;
155 }
156 ChangeType::Deleted => {
157 score += 0.05;
158 reasons.push("deleted");
159 }
160 }
161
162 if path.ends_with(".rs")
164 || path.ends_with(".py")
165 || path.ends_with(".ts")
166 || path.ends_with(".tsx")
167 || path.ends_with(".js")
168 || path.ends_with(".jsx")
169 || path.ends_with(".go")
170 || path.ends_with(".java")
171 || path.ends_with(".kt")
172 || path.ends_with(".swift")
173 || path.ends_with(".c")
174 || path.ends_with(".cpp")
175 || path.ends_with(".h")
176 {
177 score += 0.15;
178 reasons.push("source code");
179 } else if path.ends_with(".toml")
180 || path.ends_with(".json")
181 || path.ends_with(".yaml")
182 || path.ends_with(".yml")
183 {
184 score += 0.1;
185 reasons.push("config");
186 } else if path.ends_with(".md") || path.ends_with(".txt") || path.ends_with(".rst") {
187 score += 0.02;
188 reasons.push("docs");
189 }
190
191 if path.contains("/src/") || path.starts_with("src/") {
193 score += 0.1;
194 reasons.push("core source");
195 }
196 if path.contains("/test") || path.contains("_test.") || path.contains(".test.") {
197 score -= 0.1;
198 reasons.push("test file");
199 }
200 if path.contains("generated") || path.contains(".lock") || path.contains("package-lock") {
201 score -= 0.2;
202 reasons.push("generated/lock");
203 }
204 if path.contains("/vendor/") || path.contains("/node_modules/") {
205 score -= 0.3;
206 reasons.push("vendored");
207 }
208
209 let diff_lines = file.diff.lines().count();
211 if diff_lines > 10 && diff_lines < 200 {
212 score += 0.1;
213 reasons.push("substantive changes");
214 } else if diff_lines >= 200 {
215 score += 0.05;
216 reasons.push("large diff");
217 }
218
219 let semantic_changes = detect_semantic_changes(&file.diff, &file.path);
221 for change in semantic_changes {
222 if !reasons.contains(&change) {
223 if change == "adds function" || change == "adds type" || change == "adds impl" {
225 score += 0.1;
226 }
227 reasons.push(change);
228 }
229 }
230
231 score = score.clamp(0.0, 1.0);
233
234 (score, reasons)
235}
236
237struct ScoredFile<'a> {
239 file: &'a StagedFile,
240 score: f32,
241 reasons: Vec<&'static str>,
242}
243
244fn format_diff_output(
246 scored_files: &[ScoredFile],
247 total_files: usize,
248 is_filtered: bool,
249 include_diffs: bool,
250) -> String {
251 let mut output = String::new();
252 let showing = scored_files.len();
253
254 let additions: usize = scored_files
256 .iter()
257 .map(|sf| sf.file.diff.lines().filter(|l| l.starts_with('+')).count())
258 .sum();
259 let deletions: usize = scored_files
260 .iter()
261 .map(|sf| sf.file.diff.lines().filter(|l| l.starts_with('-')).count())
262 .sum();
263 let total_lines = additions + deletions;
264
265 let (size, guidance) = if is_filtered {
267 ("Filtered", "Showing requested files only.")
268 } else if total_files <= 3 && total_lines < 100 {
269 ("Small", "Focus on all files equally.")
270 } else if total_files <= 10 && total_lines < 500 {
271 ("Medium", "Prioritize files with >60% relevance.")
272 } else {
273 (
274 "Large",
275 "Use files=['path1','path2'] with detail='standard' to analyze specific files.",
276 )
277 };
278
279 let files_info = if is_filtered {
281 format!("{showing} of {total_files} files")
282 } else {
283 format!("{total_files} files")
284 };
285 output.push_str(&format!(
286 "=== CHANGES SUMMARY ===\n{files_info} | +{additions} -{deletions} | Size: {size} ({total_lines} lines)\nGuidance: {guidance}\n\n"
287 ));
288
289 output.push_str("Files by importance:\n");
291 for sf in scored_files {
292 let reasons = if sf.reasons.is_empty() {
293 String::new()
294 } else {
295 format!(" ({})", sf.reasons.join(", "))
296 };
297 output.push_str(&format!(
298 " [{:.0}%] {:?} {}{reasons}\n",
299 sf.score * 100.0,
300 sf.file.change_type,
301 sf.file.path
302 ));
303 }
304 output.push('\n');
305
306 if include_diffs {
308 output.push_str("=== DIFFS ===\n");
309 for sf in scored_files {
310 output.push_str(&format!(
311 "--- {} [{:.0}% relevance]\n",
312 sf.file.path,
313 sf.score * 100.0
314 ));
315 output.push_str(&sf.file.diff);
316 output.push('\n');
317 }
318 } else if is_filtered {
319 output.push_str("(Use detail='standard' to see full diffs for these files)\n");
320 } else {
321 output.push_str(
322 "(Use detail='standard' with files=['file1','file2'] to see specific diffs)\n",
323 );
324 }
325
326 output
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize)]
331pub struct GitStatus;
332
333#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
334pub struct GitStatusArgs {
335 #[serde(default)]
336 pub include_unstaged: bool,
337}
338
339impl Tool for GitStatus {
340 const NAME: &'static str = "git_status";
341 type Error = GitError;
342 type Args = GitStatusArgs;
343 type Output = String;
344
345 async fn definition(&self, _: String) -> ToolDefinition {
346 ToolDefinition {
347 name: "git_status".to_string(),
348 description: "Get current Git repository status including staged and unstaged files"
349 .to_string(),
350 parameters: parameters_schema::<GitStatusArgs>(),
351 }
352 }
353
354 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
355 let repo = get_current_repo().map_err(GitError::from)?;
356
357 let files_info = repo
358 .extract_files_info(args.include_unstaged)
359 .map_err(GitError::from)?;
360
361 let mut output = String::new();
362 output.push_str(&format!("Branch: {}\n", files_info.branch));
363 output.push_str(&format!(
364 "Files changed: {}\n",
365 files_info.staged_files.len()
366 ));
367
368 for file in &files_info.staged_files {
369 output.push_str(&format!(" {}: {:?}\n", file.path, file.change_type));
370 }
371
372 Ok(output)
373 }
374}
375
376#[derive(Debug, Clone, Serialize, Deserialize)]
378pub struct GitDiff;
379
380#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, Default)]
382#[serde(rename_all = "lowercase")]
383pub enum DetailLevel {
384 #[default]
386 Summary,
387 Standard,
389}
390
391#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
392pub struct GitDiffArgs {
393 #[serde(default)]
395 pub from: Option<String>,
396 #[serde(default)]
398 pub to: Option<String>,
399 #[serde(default)]
401 pub detail: DetailLevel,
402 #[serde(default)]
404 pub files: Option<Vec<String>>,
405}
406
407impl Tool for GitDiff {
408 const NAME: &'static str = "git_diff";
409 type Error = GitError;
410 type Args = GitDiffArgs;
411 type Output = String;
412
413 async fn definition(&self, _: String) -> ToolDefinition {
414 ToolDefinition {
415 name: "git_diff".to_string(),
416 description: "Get Git diff for file changes. Returns summary by default (file list with relevance scores). Use detail='standard' with files=['path1','path2'] to get full diffs for specific files. Progressive approach: call once for summary, then again with files filter for important ones.".to_string(),
417 parameters: parameters_schema::<GitDiffArgs>(),
418 }
419 }
420
421 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
422 let repo = get_current_repo().map_err(GitError::from)?;
423
424 let from = args.from.filter(|s| !s.is_empty());
426 let to = args.to.filter(|s| !s.is_empty());
427
428 let files = match (from.as_deref(), to.as_deref()) {
433 (None | Some("staged"), None) | (Some("staged"), Some("HEAD")) => {
434 let files_info = repo.extract_files_info(false).map_err(GitError::from)?;
436 files_info.staged_files
437 }
438 (Some(from), Some(to)) => {
439 repo.get_commit_range_files(from, to)
441 .map_err(GitError::from)?
442 }
443 (None, Some(_)) => {
444 return Err(GitError(
446 "Cannot specify 'to' without 'from'. Use both or neither.".to_string(),
447 ));
448 }
449 (Some(from), None) => {
450 repo.get_commit_range_files(from, "HEAD")
452 .map_err(GitError::from)?
453 }
454 };
455
456 let mut scored_files: Vec<ScoredFile> = files
458 .iter()
459 .map(|file| {
460 let (score, reasons) = calculate_relevance_score(file);
461 ScoredFile {
462 file,
463 score,
464 reasons,
465 }
466 })
467 .collect();
468
469 scored_files.sort_by(|a, b| {
471 b.score
472 .partial_cmp(&a.score)
473 .unwrap_or(std::cmp::Ordering::Equal)
474 });
475
476 let total_files = scored_files.len();
478
479 let is_filtered = args.files.is_some();
481 if let Some(ref filter) = args.files {
482 scored_files.retain(|sf| filter.iter().any(|f| sf.file.path.contains(f)));
483 }
484
485 let include_diffs = matches!(args.detail, DetailLevel::Standard);
487 Ok(format_diff_output(
488 &scored_files,
489 total_files,
490 is_filtered,
491 include_diffs,
492 ))
493 }
494}
495
496#[derive(Debug, Clone, Serialize, Deserialize)]
498pub struct GitLog;
499
500#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
501pub struct GitLogArgs {
502 #[serde(default)]
503 pub count: Option<usize>,
504 #[serde(default)]
505 pub from: Option<String>,
506 #[serde(default)]
507 pub to: Option<String>,
508}
509
510impl Tool for GitLog {
511 const NAME: &'static str = "git_log";
512 type Error = GitError;
513 type Args = GitLogArgs;
514 type Output = String;
515
516 async fn definition(&self, _: String) -> ToolDefinition {
517 ToolDefinition {
518 name: "git_log".to_string(),
519 description: "Get Git commit history".to_string(),
520 parameters: parameters_schema::<GitLogArgs>(),
521 }
522 }
523
524 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
525 let repo = get_current_repo().map_err(GitError::from)?;
526
527 if let Some(from) = args.from {
528 let to = args.to.unwrap_or_else(|| "HEAD".to_string());
529 let commits = repo
530 .get_commits_in_range(&from, &to)
531 .map_err(GitError::from)?;
532 return Ok(format_git_log_output(
533 &format!("Commits from {from} to {to}:"),
534 &commits,
535 true,
536 ));
537 }
538
539 if args.to.is_some() {
540 return Err(GitError::from(anyhow::anyhow!(
541 "git_log requires `from` when `to` is provided"
542 )));
543 }
544
545 let commits = repo
546 .get_recent_commits(args.count.unwrap_or(10))
547 .map_err(GitError::from)?;
548
549 Ok(format_git_log_output("Recent commits:", &commits, false))
550 }
551}
552
553fn format_git_log_output(
554 header: &str,
555 commits: &[RecentCommit],
556 include_contributors: bool,
557) -> String {
558 let mut output = String::new();
559 output.push_str(header);
560 output.push('\n');
561
562 for commit in commits {
563 let title = commit.message.lines().next().unwrap_or_default().trim();
564 output.push_str(&format!("{}: {} ({})\n", commit.hash, title, commit.author));
565 }
566
567 if include_contributors {
568 let contributors: BTreeSet<String> = commits
569 .iter()
570 .map(|commit| commit.author.trim())
571 .filter(|author| !author.is_empty() && !is_bot_author(author))
572 .map(ToOwned::to_owned)
573 .collect();
574
575 if !contributors.is_empty() {
576 output.push_str("\nContributors (excluding bots):\n");
577 for contributor in contributors {
578 output.push_str(&format!("- {contributor}\n"));
579 }
580 }
581 }
582
583 output
584}
585
586fn is_bot_author(author: &str) -> bool {
587 let normalized = author.trim().to_ascii_lowercase();
588
589 normalized.contains("[bot]")
590 || normalized.contains("dependabot")
591 || normalized.contains("renovate")
592 || normalized.contains("github-actions")
593 || normalized.ends_with(" bot")
594 || normalized.ends_with("-bot")
595 || normalized == "bot"
596}
597
598#[derive(Debug, Clone, Serialize, Deserialize)]
600pub struct GitRepoInfo;
601
602#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
603pub struct GitRepoInfoArgs {}
604
605impl Tool for GitRepoInfo {
606 const NAME: &'static str = "git_repo_info";
607 type Error = GitError;
608 type Args = GitRepoInfoArgs;
609 type Output = String;
610
611 async fn definition(&self, _: String) -> ToolDefinition {
612 ToolDefinition {
613 name: "git_repo_info".to_string(),
614 description: "Get general information about the Git repository".to_string(),
615 parameters: parameters_schema::<GitRepoInfoArgs>(),
616 }
617 }
618
619 async fn call(&self, _args: Self::Args) -> Result<Self::Output, Self::Error> {
620 let repo = get_current_repo().map_err(GitError::from)?;
621
622 let branch = repo.get_current_branch().map_err(GitError::from)?;
623 let remote_url = repo.get_remote_url().unwrap_or("None").to_string();
624
625 let mut output = String::new();
626 output.push_str("Repository Information:\n");
627 output.push_str(&format!("Current Branch: {branch}\n"));
628 output.push_str(&format!("Remote URL: {remote_url}\n"));
629 output.push_str(&format!(
630 "Repository Path: {}\n",
631 repo.repo_path().display()
632 ));
633
634 Ok(output)
635 }
636}
637
638#[derive(Debug, Clone, Serialize, Deserialize)]
640pub struct GitChangedFiles;
641
642#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
643pub struct GitChangedFilesArgs {
644 #[serde(default)]
645 pub from: Option<String>,
646 #[serde(default)]
647 pub to: Option<String>,
648}
649
650impl Tool for GitChangedFiles {
651 const NAME: &'static str = "git_changed_files";
652 type Error = GitError;
653 type Args = GitChangedFilesArgs;
654 type Output = String;
655
656 async fn definition(&self, _: String) -> ToolDefinition {
657 ToolDefinition {
658 name: "git_changed_files".to_string(),
659 description: "Get list of files that have changed between commits or branches"
660 .to_string(),
661 parameters: parameters_schema::<GitChangedFilesArgs>(),
662 }
663 }
664
665 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
666 let repo = get_current_repo().map_err(GitError::from)?;
667
668 let from = args.from.filter(|s| !s.is_empty());
670 let mut to = args.to.filter(|s| !s.is_empty());
671
672 if from.is_some() && to.is_none() {
674 to = Some("HEAD".to_string());
675 }
676
677 let files = match (from, to) {
678 (Some(from), Some(to)) => {
679 let range_files = repo
681 .get_commit_range_files(&from, &to)
682 .map_err(GitError::from)?;
683 range_files.iter().map(|f| f.path.clone()).collect()
684 }
685 (None, Some(to)) => {
686 repo.get_file_paths_for_commit(&to)
688 .map_err(GitError::from)?
689 }
690 (Some(_from), None) => {
691 return Err(GitError(
693 "Cannot specify 'from' without 'to' for file listing".to_string(),
694 ));
695 }
696 (None, None) => {
697 let files_info = repo.extract_files_info(false).map_err(GitError::from)?;
699 files_info.file_paths
700 }
701 };
702
703 let mut output = String::new();
704 output.push_str("Changed files:\n");
705
706 for file in files {
707 output.push_str(&format!(" {file}\n"));
708 }
709
710 Ok(output)
711 }
712}