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"), } } } pub struct AnalysisResult { pub commit_type: CommitType, pub scope: Option, pub confidence: f64, pub description: String, pub reasons: Vec, } pub fn analyze_changes(staged: &super::git::StagedChanges) -> AnalysisResult { let files: Vec<&super::git::ChangedFile> = staged.files.iter().collect(); if files.is_empty() { return AnalysisResult { commit_type: CommitType::Chore, scope: None, confidence: 1.0, description: "empty commit".to_string(), reasons: vec!["No files changed".to_string()], }; } let mut type_scores: std::collections::HashMap)> = std::collections::HashMap::new(); for file in &files { let path = std::path::Path::new(&file.path); let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); let (type_, reason) = classify_file(&file.path, &file.status, ext, file_name); type_scores .entry(type_) .or_insert((0.0, Vec::new())) .0 += 1.0; type_scores.get_mut(&type_).unwrap().1.push(reason); } let (best_type, (max_score, reasons)) = type_scores .into_iter() .max_by(|(a, (score_a, _)), (b, (score_b, _))| { score_a.partial_cmp(&score_b).unwrap_or(std::cmp::Ordering::Equal) }) .unwrap_or((CommitType::Chore, (1.0, vec!["default".to()]))); let scope = extract_scope(&files.first().unwrap().path); let description = generate_description(&files); let confidence = (max_score as usize) as f64 / files.len() as f64; AnalysisResult { commit_type: best_type, scope, confidence, description, reasons, } } fn classify_file( path: &str, status: &super::git::FileStatus, ext: &str, file_name: &str, ) -> (CommitType, String) { if ext == "md" || path.to_lowercase().contains("readme") || path.to_lowercase().contains("changelog") { return (CommitType::Docs, format!("Documentation file: {}", path)); } if path.contains("test") || path.contains("_test") || ext == "test" { return (CommitType::Test, format!("Test file: {}", path)); } if path.contains(".github") || path.contains("workflow") || path.contains("ci") { return (CommitType::Ci, format!("CI configuration: {}", path)); } if file_name == "Cargo.toml" || file_name == "Cargo.lock" || ext == "toml" { return (CommitType::Build, format!("Build configuration: {}", path)); } match status { super::git::FileStatus::Added if ext == "rs" => { (CommitType::Feat, format!("New feature file: {}", path)) } super::git::FileStatus::Modified if ext == "rs" => { (CommitType::Fix, format!("Modified source file: {}", path)) } super::git::FileStatus::Deleted => { (CommitType::Fix, format!("Deleted file: {}", path)) } super::git::FileStatus::Renamed => { (CommitType::Refactor, format!("Renamed file: {}", path)) } _ => (CommitType::Chore, format!("Other change: {}", path)), } } pub fn extract_scope(path: &str) -> Option { let path = std::path::Path::new(path); let components: Vec = path .components() .filter_map(|c| c.as_os_str().to_str().map(|s| s.to_string())) .collect(); if components.is_empty() { return None; } let first = components.first().unwrap(); if first == "src" || first == "lib" || first == "tests" || first == "benches" { Some(String::from("src")) } else { Some(first.clone()) } } pub fn get_all_commit_types() -> Vec { vec![ CommitType::Feat, CommitType::Fix, CommitType::Docs, CommitType::Style, CommitType::Refactor, CommitType::Test, CommitType::Chore, CommitType::Build, CommitType::Ci, CommitType::Perf, ] } fn generate_description(files: &[&super::git::ChangedFile]) -> String { if files.is_empty() { return String::from("empty commit"); } if files.len() == 1 { let f = files[0]; let action = match f.status { super::git::FileStatus::Added => "add", super::git::FileStatus::Deleted => "remove", super::git::FileStatus::Modified => "update", super::git::FileStatus::Renamed => "rename", super::git::FileStatus::Unknown => "change", }; let file_name = std::path::Path::new(&f.path) .file_name() .and_then(|n| n.to_str()) .unwrap_or(&f.path); return format!("{} {}", action, file_name); } let total = files.len(); let added: usize = files.iter().filter(|f| f.is_new).count(); let modified: usize = files.iter().filter(|f| matches!(f.status, super::git::FileStatus::Modified)).count(); if added == total { format!("add {} new files", total) } else if modified == total { format!("update {} files", total) } else { format!("change {} files ({} new, {} modified)", total, added, modified) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_extract_scope_src() { assert_eq!(extract_scope("src/main.rs"), Some(String::from("src"))); } #[test] fn test_extract_scope_nested() { assert_eq!(extract_scope("src/utils/helper.rs"), Some(String::from("src"))); } #[test] fn test_extract_scope_root() { assert_eq!(extract_scope("main.rs"), Some(String::from("main.rs"))); } }