From 72af0e23a86322fcced68f3fe0fed5530ea1d9bf Mon Sep 17 00:00:00 2001 From: 7000pctAUTO Date: Sun, 1 Feb 2026 12:20:55 +0000 Subject: [PATCH] Initial upload: Auto Commit Message Generator with CI/CD workflow --- src/analyzer.rs | 343 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 343 insertions(+) create mode 100644 src/analyzer.rs diff --git a/src/analyzer.rs b/src/analyzer.rs new file mode 100644 index 0000000..82d33bc --- /dev/null +++ b/src/analyzer.rs @@ -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, + pub confidence: f64, + pub description: String, + pub reasons: Vec, +} + +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)> = HashMap::new(); + let mut scopes: HashMap = 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) { + 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 { + 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 { + 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")); + } +}