Initial upload: Auto Commit Message Generator with CI/CD workflow
Some checks failed
CI / test (push) Has been cancelled

This commit is contained in:
2026-02-01 12:20:55 +00:00
parent d3f7b36f7e
commit 72af0e23a8

343
src/analyzer.rs Normal file
View File

@@ -0,0 +1,343 @@
use crate::git::{ChangedFile, FileStatus, StagedChanges};
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum CommitType {
Feat,
Fix,
Docs,
Style,
Refactor,
Test,
Chore,
Build,
Ci,
Perf,
}
impl std::fmt::Display for CommitType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CommitType::Feat => write!(f, "feat"),
CommitType::Fix => write!(f, "fix"),
CommitType::Docs => write!(f, "docs"),
CommitType::Style => write!(f, "style"),
CommitType::Refactor => write!(f, "refactor"),
CommitType::Test => write!(f, "test"),
CommitType::Chore => write!(f, "chore"),
CommitType::Build => write!(f, "build"),
CommitType::Ci => write!(f, "ci"),
CommitType::Perf => write!(f, "perf"),
}
}
}
#[derive(Debug)]
pub struct AnalysisResult {
pub commit_type: CommitType,
pub scope: Option<String>,
pub confidence: f64,
pub description: String,
pub reasons: Vec<String>,
}
const CONVENTIONAL_TYPES: [CommitType; 10] = [
CommitType::Feat,
CommitType::Fix,
CommitType::Docs,
CommitType::Style,
CommitType::Refactor,
CommitType::Test,
CommitType::Chore,
CommitType::Build,
CommitType::Ci,
CommitType::Perf,
];
pub fn analyze_changes(staged: &StagedChanges) -> AnalysisResult {
if staged.files.is_empty() {
return AnalysisResult {
commit_type: CommitType::Chore,
scope: None,
confidence: 1.0,
description: String::from("empty commit"),
reasons: vec![String::from("No staged changes detected")],
};
}
let mut type_scores: HashMap<CommitType, (f64, Vec<String>)> = HashMap::new();
let mut scopes: HashMap<String, usize> = HashMap::new();
let mut all_new_files = true;
for file in &staged.files {
all_new_files = all_new_files && file.is_new;
let (file_type, reasons) = classify_file(file);
let entry = type_scores.entry(file_type).or_insert((0.0, Vec::new()));
entry.0 += 1.0;
entry.1.extend(reasons);
if let Some(scope) = extract_scope(&file.path) {
*scopes.entry(scope).or_insert(0) += 1;
}
}
let dominant_type = type_scores
.iter()
.max_by(|a, b| a.1 .0.partial_cmp(&b.1 .0).unwrap())
.map(|(t, _)| *t)
.unwrap_or(CommitType::Chore);
let dominant_scope = scopes
.iter()
.max_by_key(|(_, count)| *count)
.map(|(scope, _)| scope.clone());
let total_files = staged.files.len() as f64;
let confidence = type_scores
.get(&dominant_type)
.map(|(score, _)| *score / total_files)
.unwrap_or(0.5);
let description = generate_description(&staged.files);
let reasons = type_scores
.get(&dominant_type)
.map(|(_, r)| r.clone())
.unwrap_or_default();
AnalysisResult {
commit_type: dominant_type,
scope: dominant_scope,
confidence,
description,
reasons,
}
}
fn classify_file(file: &ChangedFile) -> (CommitType, Vec<String>) {
let path = &file.path;
let mut reasons = Vec::new();
let mut score = 0.0;
let ext = path.split('.').last().unwrap_or("").to_lowercase();
let path_lower = path.to_lowercase();
if path_lower.starts_with("tests/")
|| path_lower.ends_with("_test.rs")
|| path_lower.ends_with("_tests.rs")
{
reasons.push(format!("Test file detected: {}", path));
return (CommitType::Test, reasons);
}
if path_lower.starts_with("docs/") || path_lower.ends_with(".md") {
reasons.push(format!("Documentation file detected: {}", path));
return (CommitType::Docs, reasons);
}
if path_lower.starts_with(".github/") || path_lower.contains("workflow") {
reasons.push(format!("CI/CD file detected: {}", path));
return (CommitType::Ci, reasons);
}
if path.contains("Cargo.toml") || path.contains("package.json") || path.contains("go.mod") {
reasons.push(format!("Build configuration detected: {}", path));
return (CommitType::Build, reasons);
}
if file.is_new && !path.ends_with(".md") {
reasons.push(format!("New feature file: {}", path));
return (CommitType::Feat, reasons);
}
if file.is_deleted {
reasons.push(format!("Deleted file: {}", path));
return if path.ends_with(".md") {
(CommitType::Docs, reasons)
} else {
(CommitType::Chore, reasons)
};
}
if file.status == FileStatus::Modified && !path.ends_with(".md") {
return (CommitType::Fix, vec![format!("Modified file: {}", path)]);
}
(CommitType::Chore, vec![format!("Other changes: {}", path)])
}
fn extract_scope(path: &str) -> Option<String> {
let parts: Vec<&str> = path.split('/').collect();
if parts.len() > 1 {
let potential_scope = parts[0];
let ignore = ["src", "tests", "docs", ".github", "target", "node_modules"];
if !ignore.contains(&potential_scope) {
return Some(potential_scope.to_string());
}
if potential_scope == "src" && parts.len() > 1 {
return Some(String::from("src"));
}
}
None
}
fn generate_description(files: &[ChangedFile]) -> String {
if files.is_empty() {
return String::from("no changes");
}
if files.len() == 1 {
let file = &files[0];
let filename = file.path.split('/').last().unwrap_or(&file.path);
if file.is_new {
return format!("add {}", filename);
} else if file.is_deleted {
return format!("remove {}", filename);
} else {
return format!("update {}", filename);
}
}
let extensions: std::collections::HashSet<&str> = files
.iter()
.map(|f| f.path.split('.').last().unwrap_or(""))
.collect();
if extensions.len() == 1 {
let ext = extensions.iter().next().unwrap();
return format!("update {} {} files", files.len(), ext);
}
format!("update {} files", files.len())
}
pub fn get_all_commit_types() -> Vec<CommitType> {
CONVENTIONAL_TYPES.to_vec()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::{ChangedFile, FileStatus, StagedChanges};
#[test]
fn test_classify_test_file() {
let file = ChangedFile {
path: String::from("tests/main_test.rs"),
status: FileStatus::Modified,
additions: 10,
deletions: 5,
is_new: false,
is_deleted: false,
is_renamed: false,
old_path: None,
};
let (commit_type, _) = classify_file(&file);
assert_eq!(commit_type, CommitType::Test);
}
#[test]
fn test_classify_docs_file() {
let file = ChangedFile {
path: String::from("README.md"),
status: FileStatus::Modified,
additions: 5,
deletions: 2,
is_new: false,
is_deleted: false,
is_renamed: false,
old_path: None,
};
let (commit_type, _) = classify_file(&file);
assert_eq!(commit_type, CommitType::Docs);
}
#[test]
fn test_classify_new_source_file() {
let file = ChangedFile {
path: String::from("src/new_feature.rs"),
status: FileStatus::Added,
additions: 50,
deletions: 0,
is_new: true,
is_deleted: false,
is_renamed: false,
old_path: None,
};
let (commit_type, _) = classify_file(&file);
assert_eq!(commit_type, CommitType::Feat);
}
#[test]
fn test_classify_github_workflow() {
let file = ChangedFile {
path: String::from(".github/workflows/ci.yml"),
status: FileStatus::Modified,
additions: 20,
deletions: 5,
is_new: false,
is_deleted: false,
is_renamed: false,
old_path: None,
};
let (commit_type, _) = classify_file(&file);
assert_eq!(commit_type, CommitType::Ci);
}
#[test]
fn test_extract_scope() {
assert_eq!(extract_scope("src/main.rs"), Some(String::from("src")));
assert_eq!(extract_scope("api/routes.rs"), Some(String::from("api")));
assert_eq!(extract_scope("README.md"), None);
assert_eq!(extract_scope("target/debug/build.rs"), None);
}
#[test]
fn test_generate_description_single_file() {
let files = vec![ChangedFile {
path: String::from("src/main.rs"),
status: FileStatus::Modified,
additions: 10,
deletions: 5,
is_new: false,
is_deleted: false,
is_renamed: false,
old_path: None,
}];
assert_eq!(generate_description(&files), String::from("update main.rs"));
}
#[test]
fn test_generate_description_multiple_files() {
let files = vec![
ChangedFile {
path: String::from("src/a.rs"),
status: FileStatus::Modified,
additions: 10,
deletions: 5,
is_new: false,
is_deleted: false,
is_renamed: false,
old_path: None,
},
ChangedFile {
path: String::from("src/b.rs"),
status: FileStatus::Modified,
additions: 8,
deletions: 3,
is_new: false,
is_deleted: false,
is_renamed: false,
old_path: None,
},
];
let desc = generate_description(&files);
assert!(desc.contains("update 2 files"));
}
}