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")); } }