Add analyzer.rs and convention.rs modules
Some checks failed
CI / test (push) Has been cancelled

This commit is contained in:
2026-01-31 11:39:06 +00:00
parent 67af39ae8c
commit c185c7019c

281
src/analyzer.rs Normal file
View File

@@ -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<String>,
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<Vec<ChangedFile>> {
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<Vec<ChangedFile>> {
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<CommitSuggestion> {
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);
}
}