gitstack 5.3.0

Git history viewer with insights - Author stats, file heatmap, code ownership
Documentation
//! Smart staging
//!
//! Suggest logical groups of files to stage together based on directory structure,
//! change coupling, and test-implementation pairing.

use crate::git::FileStatus;
use crate::stats::ChangeCouplingAnalysis;

/// A suggested staging group
#[derive(Debug, Clone)]
pub struct StagingGroup {
    /// Files in this group
    pub files: Vec<String>,
    /// Reason for grouping
    pub reason: String,
    /// Suggested commit message for this group
    pub suggested_message: String,
}

/// Suggest groups of files to stage together
///
/// Grouping rules:
/// 1. Files in the same directory → 1 group
/// 2. High coupling files (from Change Coupling analysis) → 1 group
/// 3. Test files paired with implementation files → 1 group
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();
    }

    // Group by directory
    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());
    }

    // Separate test files and pair with implementation
    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);
        // Only the unstaged file should appear
        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());
        // Root files should use "." as directory
        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);
    }
}