From f474328f45e291519f4fbb36eaa4c156bc6fe5d0 Mon Sep 17 00:00:00 2001 From: 7000pctAUTO Date: Sun, 1 Feb 2026 12:48:43 +0000 Subject: [PATCH] fix: resolve module structure issues by separating CLI and git modules - Removed duplicated git module code from cli.rs - Created dedicated git.rs module with GitRepo, StagedChanges, ChangedFile, FileStatus definitions - Updated all modules (analyzer, generator, prompt) to import from super::git - Fixed module organization to resolve compilation errors --- src/analyzer.rs | 358 +++++++++++++++++++++++++++++++++--------------- 1 file changed, 244 insertions(+), 114 deletions(-) diff --git a/src/analyzer.rs b/src/analyzer.rs index 2f7f2cf..82d33bc 100644 --- a/src/analyzer.rs +++ b/src/analyzer.rs @@ -1,3 +1,7 @@ +use crate::git::{ChangedFile, FileStatus, StagedChanges}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Copy, PartialEq)] pub enum CommitType { Feat, Fix, @@ -28,6 +32,7 @@ impl std::fmt::Display for CommitType { } } +#[derive(Debug)] pub struct AnalysisResult { pub commit_type: CommitType, pub scope: Option, @@ -36,178 +41,303 @@ pub struct AnalysisResult { pub reasons: Vec, } -pub fn analyze_changes(staged: &super::git::StagedChanges) -> AnalysisResult { - let files: Vec<&super::git::ChangedFile> = staged.files.iter().collect(); +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, +]; - if files.is_empty() { +pub fn analyze_changes(staged: &StagedChanges) -> AnalysisResult { + if staged.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()], + description: String::from("empty commit"), + reasons: vec![String::from("No staged changes detected")], }; } - let mut type_scores: std::collections::HashMap)> = std::collections::HashMap::new(); + let mut type_scores: HashMap)> = HashMap::new(); + let mut scopes: HashMap = HashMap::new(); + let mut all_new_files = true; - 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(""); + for file in &staged.files { + all_new_files = all_new_files && file.is_new; - 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 (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 (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 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 scope = extract_scope(&files.first().unwrap().path); - let description = generate_description(&files); + let dominant_scope = scopes + .iter() + .max_by_key(|(_, count)| *count) + .map(|(scope, _)| scope.clone()); - let confidence = (max_score as usize) as f64 / files.len() as f64; + 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: best_type, - scope, + commit_type: dominant_type, + scope: dominant_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)); +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.contains("test") || path.contains("_test") || ext == "test" { - return (CommitType::Test, format!("Test file: {}", path)); + if path_lower.starts_with("docs/") || path_lower.ends_with(".md") { + reasons.push(format!("Documentation file detected: {}", path)); + return (CommitType::Docs, reasons); } - if path.contains(".github") || path.contains("workflow") || path.contains("ci") { - return (CommitType::Ci, format!("CI configuration: {}", path)); + if path_lower.starts_with(".github/") || path_lower.contains("workflow") { + reasons.push(format!("CI/CD file detected: {}", path)); + return (CommitType::Ci, reasons); } - if file_name == "Cargo.toml" || file_name == "Cargo.lock" || ext == "toml" { - return (CommitType::Build, format!("Build configuration: {}", path)); + if path.contains("Cargo.toml") || path.contains("package.json") || path.contains("go.mod") { + reasons.push(format!("Build configuration detected: {}", path)); + return (CommitType::Build, reasons); } - 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)), + 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)]) } -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(); +fn extract_scope(path: &str) -> Option { + let parts: Vec<&str> = path.split('/').collect(); - if components.is_empty() { - return None; + 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")); + } } - let first = components.first().unwrap(); - if first == "src" || first == "lib" || first == "tests" || first == "benches" { - Some(String::from("src")) - } else { - Some(first.clone()) - } + None } -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 { +fn generate_description(files: &[ChangedFile]) -> String { if files.is_empty() { - return String::from("empty commit"); + return String::from("no changes"); } 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 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 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) + 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_extract_scope_src() { + 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_extract_scope_nested() { - assert_eq!(extract_scope("src/utils/helper.rs"), Some(String::from("src"))); + 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_extract_scope_root() { - assert_eq!(extract_scope("main.rs"), Some(String::from("main.rs"))); + 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")); } }