gitstack 5.3.0

Git history viewer with insights - Author stats, file heatmap, code ownership
Documentation
//! PR split suggestions
//!
//! Analyze staged files and suggest splitting into multiple PRs based on
//! directory structure and change coupling analysis.

use crate::git::FileStatus;
use crate::stats::ChangeCouplingAnalysis;
use std::collections::HashMap;

/// A suggested split group
#[derive(Debug, Clone)]
pub struct SplitGroup {
    /// Files in this group
    pub files: Vec<String>,
    /// Reason for grouping
    pub reason: String,
    /// Suggested PR title
    pub suggested_title: String,
}

/// PR split suggestion containing multiple groups
#[derive(Debug, Clone)]
pub struct SplitSuggestion {
    pub groups: Vec<SplitGroup>,
}

/// Minimum number of staged files to trigger split suggestion
const MIN_FILES_FOR_SPLIT: usize = 10;

/// Suggest splitting staged changes into multiple PRs
///
/// Only suggests splits when:
/// - There are 10+ staged files
/// - At least 2 independent clusters are detected
pub fn suggest_splits(
    statuses: &[FileStatus],
    coupling: Option<&ChangeCouplingAnalysis>,
) -> Option<SplitSuggestion> {
    let staged: Vec<&FileStatus> = statuses.iter().filter(|s| s.kind.is_staged()).collect();

    if staged.len() < MIN_FILES_FOR_SPLIT {
        return None;
    }

    // Group by top-level directory
    let mut dir_groups: HashMap<String, Vec<String>> = HashMap::new();
    for s in &staged {
        let dir = s.path.split('/').next().unwrap_or("root").to_string();
        dir_groups.entry(dir).or_default().push(s.path.clone());
    }

    // Only suggest if there are 2+ distinct groups
    if dir_groups.len() < 2 {
        return None;
    }

    // Merge small groups (< 3 files) into "misc"
    let mut groups: Vec<SplitGroup> = Vec::new();
    let mut misc_files: Vec<String> = Vec::new();

    for (dir, files) in &dir_groups {
        if files.len() < 3 {
            misc_files.extend(files.clone());
        } else {
            groups.push(SplitGroup {
                files: files.clone(),
                reason: format!("Files in {}/", dir),
                suggested_title: format!("Update {} module", dir),
            });
        }
    }

    if !misc_files.is_empty() {
        groups.push(SplitGroup {
            files: misc_files,
            reason: "Miscellaneous changes".to_string(),
            suggested_title: "Minor updates".to_string(),
        });
    }

    // If coupling data is available, refine groups
    if let Some(_coupling) = coupling {
        // TODO: Use coupling data to merge related groups
    }

    if groups.len() >= 2 {
        Some(SplitSuggestion { groups })
    } else {
        None
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::git::FileStatusKind;

    fn make_staged(path: &str) -> FileStatus {
        FileStatus {
            path: path.to_string(),
            kind: FileStatusKind::StagedNew,
        }
    }

    fn make_unstaged(path: &str) -> FileStatus {
        FileStatus {
            path: path.to_string(),
            kind: FileStatusKind::Modified,
        }
    }

    #[test]
    fn test_suggest_splits_empty() {
        assert!(suggest_splits(&[], None).is_none());
    }

    #[test]
    fn test_suggest_splits_too_few_files() {
        let statuses: Vec<FileStatus> = (0..5)
            .map(|i| make_staged(&format!("src/file{}.rs", i)))
            .collect();
        assert!(suggest_splits(&statuses, None).is_none());
    }

    #[test]
    fn test_suggest_splits_exactly_threshold() {
        // 10 files but single directory → None
        let statuses: Vec<FileStatus> = (0..10)
            .map(|i| make_staged(&format!("src/file{}.rs", i)))
            .collect();
        assert!(suggest_splits(&statuses, None).is_none());
    }

    #[test]
    fn test_suggest_splits_single_directory() {
        let statuses: Vec<FileStatus> = (0..15)
            .map(|i| make_staged(&format!("src/file{}.rs", i)))
            .collect();
        assert!(suggest_splits(&statuses, None).is_none());
    }

    #[test]
    fn test_suggest_splits_multiple_directories() {
        let mut statuses = Vec::new();
        for i in 0..6 {
            statuses.push(make_staged(&format!("src/file{}.rs", i)));
        }
        for i in 0..6 {
            statuses.push(make_staged(&format!("tests/test{}.rs", i)));
        }
        let result = suggest_splits(&statuses, None);
        assert!(result.is_some());
        let suggestion = result.unwrap();
        assert!(suggestion.groups.len() >= 2);
    }

    #[test]
    fn test_suggest_splits_ignores_unstaged() {
        let mut statuses = Vec::new();
        for i in 0..6 {
            statuses.push(make_staged(&format!("src/file{}.rs", i)));
        }
        for i in 0..6 {
            statuses.push(make_unstaged(&format!("tests/test{}.rs", i)));
        }
        // Only 6 staged → below threshold
        assert!(suggest_splits(&statuses, None).is_none());
    }

    #[test]
    fn test_suggest_splits_small_groups_go_to_misc() {
        let mut statuses = Vec::new();
        // 5 files in src/ (>= 3 → own group)
        for i in 0..5 {
            statuses.push(make_staged(&format!("src/file{}.rs", i)));
        }
        // 4 files in tests/ (>= 3 → own group)
        for i in 0..4 {
            statuses.push(make_staged(&format!("tests/test{}.rs", i)));
        }
        // 1 file in docs/ (< 3 → misc)
        statuses.push(make_staged("docs/readme.md"));
        // 1 file in ci/ (< 3 → misc)
        statuses.push(make_staged("ci/pipeline.yml"));

        let result = suggest_splits(&statuses, None);
        assert!(result.is_some());
        let suggestion = result.unwrap();
        // src group, tests group, misc group
        assert!(suggestion.groups.len() >= 3);
        let misc = suggestion
            .groups
            .iter()
            .find(|g| g.reason == "Miscellaneous changes");
        assert!(misc.is_some());
        assert_eq!(misc.unwrap().files.len(), 2);
    }

    #[test]
    fn test_suggest_splits_three_directories() {
        let mut statuses = Vec::new();
        for i in 0..4 {
            statuses.push(make_staged(&format!("src/file{}.rs", i)));
        }
        for i in 0..4 {
            statuses.push(make_staged(&format!("tests/test{}.rs", i)));
        }
        for i in 0..4 {
            statuses.push(make_staged(&format!("docs/doc{}.md", i)));
        }
        let result = suggest_splits(&statuses, None);
        assert!(result.is_some());
        assert_eq!(result.unwrap().groups.len(), 3);
    }

    #[test]
    fn test_split_group_has_suggested_title() {
        let mut statuses = Vec::new();
        for i in 0..6 {
            statuses.push(make_staged(&format!("src/file{}.rs", i)));
        }
        for i in 0..6 {
            statuses.push(make_staged(&format!("tests/test{}.rs", i)));
        }
        let result = suggest_splits(&statuses, None).unwrap();
        for group in &result.groups {
            assert!(!group.suggested_title.is_empty());
            assert!(!group.reason.is_empty());
        }
    }
}