use crate::git::FileStatus;
use crate::stats::ChangeCouplingAnalysis;
#[derive(Debug, Clone)]
pub struct StagingGroup {
pub files: Vec<String>,
pub reason: String,
pub suggested_message: String,
}
pub fn suggest_groups(
statuses: &[FileStatus],
_coupling: Option<&ChangeCouplingAnalysis>,
) -> Vec<StagingGroup> {
use std::collections::HashMap;
let unstaged: Vec<&FileStatus> = statuses.iter().filter(|s| !s.kind.is_staged()).collect();
if unstaged.is_empty() {
return Vec::new();
}
let mut dir_groups: HashMap<String, Vec<String>> = HashMap::new();
for s in &unstaged {
let dir = s
.path
.rsplit_once('/')
.map(|(d, _)| d.to_string())
.unwrap_or_else(|| ".".to_string());
dir_groups.entry(dir).or_default().push(s.path.clone());
}
let mut groups = Vec::new();
for (dir, files) in &dir_groups {
let (test_files, impl_files): (Vec<_>, Vec<_>) =
files.iter().partition(|f| is_test_file(f));
if !impl_files.is_empty() {
let scope = dir.rsplit('/').next().unwrap_or(dir);
groups.push(StagingGroup {
files: impl_files.into_iter().cloned().collect(),
reason: format!("Implementation files in {}/", dir),
suggested_message: format!("feat({}): update implementation", scope),
});
}
if !test_files.is_empty() {
let scope = dir.rsplit('/').next().unwrap_or(dir);
groups.push(StagingGroup {
files: test_files.into_iter().cloned().collect(),
reason: format!("Test files in {}/", dir),
suggested_message: format!("test({}): update tests", scope),
});
}
}
groups
}
fn is_test_file(path: &str) -> bool {
let lower = path.to_lowercase();
lower.contains("/tests/")
|| lower.contains("_test.")
|| lower.contains(".test.")
|| lower.contains(".spec.")
|| lower.starts_with("test_")
|| lower.starts_with("tests/")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::FileStatusKind;
fn make_unstaged(path: &str) -> FileStatus {
FileStatus {
path: path.to_string(),
kind: FileStatusKind::Modified,
}
}
fn make_staged(path: &str) -> FileStatus {
FileStatus {
path: path.to_string(),
kind: FileStatusKind::StagedNew,
}
}
#[test]
fn test_suggest_groups_empty() {
assert!(suggest_groups(&[], None).is_empty());
}
#[test]
fn test_suggest_groups_single_dir() {
let statuses = vec![make_unstaged("src/main.rs"), make_unstaged("src/lib.rs")];
let groups = suggest_groups(&statuses, None);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].files.len(), 2);
}
#[test]
fn test_suggest_groups_separates_tests() {
let statuses = vec![
make_unstaged("src/auth.rs"),
make_unstaged("src/auth_test.rs"),
];
let groups = suggest_groups(&statuses, None);
assert_eq!(groups.len(), 2);
let impl_group = groups.iter().find(|g| g.reason.contains("Implementation"));
let test_group = groups.iter().find(|g| g.reason.contains("Test"));
assert!(impl_group.is_some());
assert!(test_group.is_some());
}
#[test]
fn test_suggest_groups_multiple_dirs() {
let statuses = vec![
make_unstaged("src/main.rs"),
make_unstaged("src/lib.rs"),
make_unstaged("docs/readme.md"),
];
let groups = suggest_groups(&statuses, None);
assert!(groups.len() >= 2);
}
#[test]
fn test_suggest_groups_ignores_staged() {
let statuses = vec![
make_staged("src/already_staged.rs"),
make_unstaged("src/not_staged.rs"),
];
let groups = suggest_groups(&statuses, None);
let all_files: Vec<&String> = groups.iter().flat_map(|g| &g.files).collect();
assert!(all_files.contains(&&"src/not_staged.rs".to_string()));
assert!(!all_files.contains(&&"src/already_staged.rs".to_string()));
}
#[test]
fn test_suggest_groups_root_files() {
let statuses = vec![make_unstaged("Cargo.toml"), make_unstaged("README.md")];
let groups = suggest_groups(&statuses, None);
assert!(!groups.is_empty());
let all_files: Vec<&String> = groups.iter().flat_map(|g| &g.files).collect();
assert!(all_files.contains(&&"Cargo.toml".to_string()));
}
#[test]
fn test_suggest_groups_suggested_message_format() {
let statuses = vec![make_unstaged("src/auth.rs")];
let groups = suggest_groups(&statuses, None);
assert!(!groups.is_empty());
assert!(groups[0].suggested_message.contains("feat("));
}
#[test]
fn test_suggest_groups_test_message_format() {
let statuses = vec![make_unstaged("src/auth_test.rs")];
let groups = suggest_groups(&statuses, None);
assert!(!groups.is_empty());
let test_group = groups.iter().find(|g| g.reason.contains("Test")).unwrap();
assert!(test_group.suggested_message.contains("test("));
}
#[test]
fn test_is_test_file() {
assert!(is_test_file("tests/test_auth.rs"));
assert!(is_test_file("src/auth_test.rs"));
assert!(is_test_file("src/auth.test.js"));
assert!(is_test_file("src/auth.spec.ts"));
assert!(is_test_file("test_helper.rs"));
assert!(!is_test_file("src/auth.rs"));
assert!(!is_test_file("src/main.rs"));
}
#[test]
fn test_is_test_file_nested_tests_dir() {
assert!(is_test_file("src/module/tests/integration.rs"));
}
#[test]
fn test_staging_group_clone() {
let group = StagingGroup {
files: vec!["a.rs".to_string()],
reason: "test".to_string(),
suggested_message: "feat: test".to_string(),
};
let cloned = group.clone();
assert_eq!(cloned.files, group.files);
assert_eq!(cloned.reason, group.reason);
}
}