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