diff --git a/src/analyzer.rs b/src/analyzer.rs index 508413b..58d45ce 100644 --- a/src/analyzer.rs +++ b/src/analyzer.rs @@ -3,49 +3,132 @@ use colored::Colorize; use std::collections::HashMap; use std::path::Path; -pub fn analyze_git_status() -> Result>> { - let output = std::process::Command::new("git") - .args(&["status", "--porcelain"]) - .output() - .context("Failed to execute git status")?; +pub enum CommitType { + Feat, + Fix, + Docs, + Style, + Refactor, + Test, + Chore, + Build, + Ci, + Perf, +} - if !output.status.success() { - return Err(anyhow!("Git command failed")); +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"), + } } +} - let stdout = String::from_utf8_lossy(&output.stdout); - let mut changes = HashMap::new(); +pub struct AnalysisResult { + pub commit_type: CommitType, + pub scope: Option, + pub confidence: f64, + pub description: String, + pub reasons: Vec, +} - for line in stdout.lines() { - if line.trim().is_empty() { - continue; - } +pub fn analyze_changes(staged: &crate::git::StagedChanges) -> AnalysisResult { + let files: Vec<&crate::git::ChangedFile> = staged.files.iter().collect(); - let parts: Vec<&str> = line.splitn(2, " ").collect(); - if parts.len() < 2 { - continue; - } - - let status = parts[0]; - let path = parts[1].trim(); - - let change_type = match status { - "M" => "modified", - "A" => "added", - "D" => "deleted", - "R" => "renamed", - "?" => "untracked", - "C" => "copied", - _ => "unknown", + 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()], }; - - changes - .entry(change_type.to_string()) - .or_insert_with(Vec::new) - .push(path.to_string()); } - Ok(changes) + let mut type_scores: HashMap)> = HashMap::new(); + + for file in &files { + let 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 parent = path.parent().and_then(|p| p.file_name()).and_then(|n| n.to_str()).unwrap_or(""); + + let (type_, reason) = classify_file(&file.path, &file.status, ext, file_name, parent); + 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: &crate::git::FileStatus, + ext: &str, + file_name: &str, + parent: &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 { + crate::git::FileStatus::Added if ext == "rs" => { + (CommitType::Feat, format!("New feature file: {}", path)) + } + crate::git::FileStatus::Modified if ext == "rs" => { + (CommitType::Fix, format!("Modified source file: {}", path)) + } + crate::git::FileStatus::Deleted => { + (CommitType::Fix, format!("Deleted file: {}", path)) + } + crate::git::FileStatus::Renamed => { + (CommitType::Refactor, format!("Renamed file: {}", path)) + } + _ => (CommitType::Chore, format!("Other change: {}", path)), + } } pub fn extract_scope(path: &str) -> Option { @@ -67,10 +150,10 @@ pub fn extract_scope(path: &str) -> Option { } } -pub fn format_change_summary(changes: &HashMap>) -> String { +pub fn format_change_summary(changes: HashMap>) -> String { let mut summary = String::new(); - let priority = vec!["added", "modified", "deleted", "renamed", "copied", "untracked"]; + let priority = vec!["added", "modified", "deleted", "renamed", "copied", "untacked"]; for change_type in priority { if let Some(files) = changes.get(change_type) { @@ -79,18 +162,18 @@ pub fn format_change_summary(changes: &HashMap>) -> String { } let prefix = match change_type { - "added" => "📦 ", - "modified" => "✏️ ", - "deleted" => "🗑️ ", - "renamed" => "📝 ", - "copied" => "📄 ", - "untracked" => "❓ ", - _ => "📁 ", + "added" => "➕ Files", + "modified" => "✏️ Files", + "deleted" => "🗑️ Files", + "renamed" => "📝 Files", + "copied" => "📋 Files", + "untacked" => "🔍 Files", + _ => "📄 Files", }; - summary.push_str(&format!("{}Files {}:\n", prefix, change_type)); + summary.push_str(&format!("{} {}:\n", prefix, change_type)); for file in files { - summary.push_str(&format!(" - {}\n", file)); + summary.push_str(&format!(" - {}\n", file)); } } } @@ -98,6 +181,55 @@ pub fn format_change_summary(changes: &HashMap>) -> String { summary } +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: &[&crate::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 { + crate::git::FileStatus::Added => "add", + crate::git::FileStatus::Deleted => "remove", + crate::git::FileStatus::Modified => "update", + crate::git::FileStatus::Renamed => "rename", + crate::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, crate::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::*; @@ -128,8 +260,8 @@ mod tests { changes.insert("modified".to_string(), vec!["src/main.rs".to_string(), "Cargo.toml".to_string()]); changes.insert("added".to_string(), vec!["src/new_module.rs".to_string()]); - let summary = format_change_summary(&changes); - assert!(summary.contains("📦 Files added:")); + let summary = format_change_summary(changes); + assert!(summary.contains("➕ Files added:")); assert!(summary.contains("✏️ Files modified:")); assert!(summary.contains("src/main.rs")); assert!(summary.contains("src/new_module.rs"));