diff --git a/src/analyzer.rs b/src/analyzer.rs index 82d33bc..508413b 100644 --- a/src/analyzer.rs +++ b/src/analyzer.rs @@ -1,343 +1,137 @@ -use crate::git::{ChangedFile, FileStatus, StagedChanges}; +use anyhow::{anyhow, Context, Result}; +use colored::Colorize; use std::collections::HashMap; +use std::path::Path; -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum CommitType { - Feat, - Fix, - Docs, - Style, - Refactor, - Test, - Chore, - Build, - Ci, - Perf, -} +pub fn analyze_git_status() -> Result>> { + let output = std::process::Command::new("git") + .args(&["status", "--porcelain"]) + .output() + .context("Failed to execute git status")?; -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"), - } + if !output.status.success() { + return Err(anyhow!("Git command failed")); } -} -#[derive(Debug)] -pub struct AnalysisResult { - pub commit_type: CommitType, - pub scope: Option, - pub confidence: f64, - pub description: String, - pub reasons: Vec, -} + let stdout = String::from_utf8_lossy(&output.stdout); + let mut changes = HashMap::new(); -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, -]; + for line in stdout.lines() { + if line.trim().is_empty() { + continue; + } -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 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", }; + + changes + .entry(change_type.to_string()) + .or_insert_with(Vec::new) + .push(path.to_string()); } - 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, - } + Ok(changes) } -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("")) +pub fn extract_scope(path: &str) -> Option { + let 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 extensions.len() == 1 { - let ext = extensions.iter().next().unwrap(); - return format!("update {} {} files", files.len(), ext); + if components.is_empty() { + return None; } - format!("update {} files", files.len()) + 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 { - CONVENTIONAL_TYPES.to_vec() +pub fn format_change_summary(changes: &HashMap>) -> String { + let mut summary = String::new(); + + let priority = vec!["added", "modified", "deleted", "renamed", "copied", "untracked"]; + + for change_type in priority { + if let Some(files) = changes.get(change_type) { + if files.is_empty() { + continue; + } + + let prefix = match change_type { + "added" => "📦 ", + "modified" => "✏️ ", + "deleted" => "🗑️ ", + "renamed" => "📝 ", + "copied" => "📄 ", + "untracked" => "❓ ", + _ => "📁 ", + }; + + summary.push_str(&format!("{}Files {}:\n", prefix, change_type)); + for file in files { + summary.push_str(&format!(" - {}\n", file)); + } + } + } + + summary } #[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() { + fn test_extract_scope_src() { 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")); + fn test_extract_scope_nested() { + assert_eq!(extract_scope("src/utils/helper.rs"), Some(String::from("src"))); } #[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")); + fn test_extract_scope_root() { + assert_eq!(extract_scope("main.rs"), Some(String::from("main.rs"))); + } + + #[test] + fn test_extract_scope_docs() { + assert_eq!(extract_scope("README.md"), Some(String::from("README.md"))); + } + + #[test] + fn test_format_change_summary() { + let mut changes = HashMap::new(); + 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:")); + assert!(summary.contains("✏️ Files modified:")); + assert!(summary.contains("src/main.rs")); + assert!(summary.contains("src/new_module.rs")); } }