fix: resolve module structure issues by separating CLI and git modules
Some checks failed
CI / build (push) Has been cancelled
Some checks failed
CI / build (push) Has been cancelled
- 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
This commit is contained in:
410
src/analyzer.rs
410
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<String>,
|
||||
@@ -36,115 +41,7 @@ pub struct AnalysisResult {
|
||||
pub reasons: Vec<String>,
|
||||
}
|
||||
|
||||
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<CommitType, (f64, Vec<String>)> = 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<String> {
|
||||
let path = std::path::Path::new(path);
|
||||
let components: Vec<String> = 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<CommitType> {
|
||||
vec![
|
||||
const CONVENTIONAL_TYPES: [CommitType; 10] = [
|
||||
CommitType::Feat,
|
||||
CommitType::Fix,
|
||||
CommitType::Docs,
|
||||
@@ -155,59 +52,292 @@ pub fn get_all_commit_types() -> Vec<CommitType> {
|
||||
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")],
|
||||
};
|
||||
}
|
||||
|
||||
fn generate_description(files: &[&super::git::ChangedFile]) -> String {
|
||||
let mut type_scores: HashMap<CommitType, (f64, Vec<String>)> = HashMap::new();
|
||||
let mut scopes: HashMap<String, usize> = 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<String>) {
|
||||
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<String> {
|
||||
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("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);
|
||||
|
||||
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)
|
||||
if file.is_new {
|
||||
return format!("add {}", filename);
|
||||
} else if file.is_deleted {
|
||||
return format!("remove {}", filename);
|
||||
} else {
|
||||
format!("change {} files ({} new, {} modified)", total, added, modified)
|
||||
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<CommitType> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user