This commit is contained in:
281
src/analyzer.rs
Normal file
281
src/analyzer.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user