From c185c7019cab21b2f9ab22d12723ba95ea1de3ef Mon Sep 17 00:00:00 2001 From: 7000pctAUTO Date: Sat, 31 Jan 2026 11:39:06 +0000 Subject: [PATCH] Add analyzer.rs and convention.rs modules --- src/analyzer.rs | 281 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 281 insertions(+) create mode 100644 src/analyzer.rs diff --git a/src/analyzer.rs b/src/analyzer.rs new file mode 100644 index 0000000..973527c --- /dev/null +++ b/src/analyzer.rs @@ -0,0 +1,281 @@ +use std::path::Path; +use git2::{Repository, Diff, DiffLine, DiffHunk}; +use crate::error::{Error, Result}; +use crate::convention::{CommitConvention, CommitType, Scope, CommitSuggestion}; + +#[derive(Debug, Clone)] +pub struct ChangedFile { + pub path: String, + pub old_path: Option, + pub status: ChangeStatus, + pub additions: usize, + pub deletions: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ChangeStatus { + Added, + Deleted, + Modified, + Renamed, + Copied, + TypeChanged, + Untracked, +} + +impl ChangeStatus { + pub fn from_status(status: git2::Delta) -> Self { + match status { + git2::Delta::Added => ChangeStatus::Added, + git2::Delta::Deleted => ChangeStatus::Deleted, + git2::Delta::Modified => ChangeStatus::Modified, + git2::Delta::Renamed => ChangeStatus::Renamed, + git2::Delta::Copied => ChangeStatus::Copied, + git2::Delta::TypeChanged => ChangeStatus::TypeChanged, + _ => ChangeStatus::Untracked, + } + } +} + +pub struct Analyzer { + convention: CommitConvention, +} + +impl Default for Analyzer { + fn default() -> Self { + Self::new() + } +} + +impl Analyzer { + pub fn new() -> Self { + Self { + convention: CommitConvention::default(), + } + } + + pub fn with_convention(convention: CommitConvention) -> Self { + Self { convention } + } + + pub fn analyze(&self, repo_path: &Path) -> Result> { + let repo = Repository::discover(repo_path).map_err(Error::from)?; + + let head = repo.head()?; + if head.is_branch() { + tracing::debug!("Analyzing on branch: {}", head.shorthand().unwrap_or("unknown")); + } + + let diff = repo.diff_index_to_workdir(None, None)?; + let mut changes = Vec::new(); + + for delta in diff.deltas() { + let new_path = delta.new_file().path().map(|p| p.to_string_lossy().to_string()); + let old_path = delta.old_file().path().map(|p| p.to_string_lossy().to_string()); + + let path = new_path.or(old_path).unwrap_or_default(); + + if self.convention.should_ignore(&path) { + continue; + } + + let status = ChangeStatus::from_status(delta.status()); + + let mut additions = 0; + let mut deletions = 0; + + let diff_lines = diff.get_delta(delta.index()).and_then(|d| d.to_owned().lines(None, None)); + if let Ok(lines) = diff_lines { + for line in lines { + match line.origin() { + '+' if line.new_lineno().is_some() => additions += 1, + '-' if line.old_lineno().is_some() => deletions += 1, + _ => {} + } + } + } + + changes.push(ChangedFile { + path, + old_path, + status, + additions, + deletions, + }); + } + + Ok(changes) + } + + pub fn analyze_staged(&self, repo_path: &Path) -> Result> { + let repo = Repository::discover(repo_path).map_err(Error::from)?; + + let head = repo.head()?; + let head_commit = head.peel_to_commit()?; + + let index = repo.index()?; + let diff = repo.diff_tree_to_index(Some(&head_commit.tree()?), Some(&index), None)?; + let mut changes = Vec::new(); + + for delta in diff.deltas() { + let new_path = delta.new_file().path().map(|p| p.to_string_lossy().to_string()); + let old_path = delta.old_file().path().map(|p| p.to_string_lossy().to_string()); + + let path = new_path.or(old_path).unwrap_or_default(); + + if self.convention.should_ignore(&path) { + continue; + } + + let status = ChangeStatus::from_status(delta.status()); + + let mut additions = 0; + let mut deletions = 0; + + changes.push(ChangedFile { + path, + old_path, + status, + additions, + deletions, + }); + } + + Ok(changes) + } + + pub fn generate_suggestions(&self, changes: &[ChangedFile]) -> Vec { + if changes.is_empty() { + return Vec::new(); + } + + let mut type_counts: Vec<(CommitType, usize, f64)> = Vec::new(); + let mut scopes: Vec<(Scope, usize)> = Vec::new(); + + for file in changes { + if let Some((commit_type, confidence)) = self.convention.detect_type(&file.path) { + let existing = type_counts.iter_mut().find(|(t, _, _)| *t == commit_type); + if let Some(existing) = existing { + existing.1 += 1; + existing.2 = (existing.2 + confidence) / 2.0; + } else { + type_counts.push((commit_type, 1, confidence)); + } + } + + if let Some(scope) = self.convention.detect_scope(&file.path) { + let existing = scopes.iter_mut().find(|(s, _)| s.name == scope.name); + if let Some(existing) = existing { + existing.1 += 1; + } else { + scopes.push((scope, 1)); + } + } + } + + type_counts.sort_by(|a, b| b.1.cmp(&a.1)); + scopes.sort_by(|a, b| b.1.cmp(&a.1)); + + let total_files = changes.len(); + + let dominant_type = type_counts.first().map(|(t, count, conf)| { + let confidence = (*count as f64 / total_files as f64) * conf; + (t.clone(), confidence) + }); + + let dominant_scope = scopes.first().map(|(s, _)| s.clone()); + + match dominant_type { + Some((commit_type, confidence)) => { + let description = self.generate_description(&commit_type, changes); + vec![CommitSuggestion { + commit_type, + scope: dominant_scope, + description, + confidence, + file_count: total_files, + }] + } + None => { + vec![CommitSuggestion { + commit_type: CommitType::Chore, + scope: None, + description: String::from("Update files"), + confidence: 0.5, + file_count: total_files, + }] + } + } + } + + fn generate_description(&self, commit_type: &CommitType, changes: &[ChangedFile]) -> String { + match commit_type { + CommitType::Feat => { + let feature_count = changes.iter().filter(|c| { + self.convention.detect_type(&c.path).map(|(t, _)| t == CommitType::Feat).unwrap_or(false) + }).count(); + if feature_count > 1 { + String::from("add multiple features") + } else { + String::from("add new feature") + } + } + CommitType::Fix => { + let fix_count = changes.iter().filter(|c| { + self.convention.detect_type(&c.path).map(|(t, _)| t == CommitType::Fix).unwrap_or(false) + }).count(); + if fix_count > 1 { + String::from("fix multiple issues") + } else { + String::from("fix bug") + } + } + CommitType::Docs => String::from("update documentation"), + CommitType::Style => String::from("improve formatting"), + CommitType::Refactor => String::from("refactor code"), + CommitType::Test => String::from("add/update tests"), + CommitType::Chore => String::from("update configuration"), + CommitType::Build => String::from("update build configuration"), + CommitType::Ci => String::from("update CI configuration"), + CommitType::Perf => String::from("improve performance"), + CommitType::Revert => String::from("revert changes"), + CommitType::Custom(_) => String::from("make changes"), + } + } + + pub fn convention(&self) -> &CommitConvention { + &self.convention + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_analyzer_new() { + let analyzer = Analyzer::new(); + assert!(!analyzer.convention().type_mapping.is_empty()); + } + + #[test] + fn test_commit_convention_default() { + let convention = CommitConvention::default(); + assert!(convention.type_mapping.contains_key("src/**/*.rs")); + assert!(!convention.scope_patterns.is_empty()); + } + + #[test] + fn test_commit_type_display() { + assert_eq!(CommitType::Feat.to_string(), "feat"); + assert_eq!(CommitType::Fix.to_string(), "fix"); + assert_eq!(CommitType::Docs.to_string(), "docs"); + } + + #[test] + fn test_change_status_from_status() { + assert_eq!(ChangeStatus::from_status(git2::Delta::Added), ChangeStatus::Added); + assert_eq!(ChangeStatus::from_status(git2::Delta::Deleted), ChangeStatus::Deleted); + assert_eq!(ChangeStatus::from_status(git2::Delta::Modified), ChangeStatus::Modified); + } +}