gitstack 5.3.0

Git history viewer with insights - Author stats, file heatmap, code ownership
Documentation
//! Change intent classification
//!
//! Classify the intent of a commit based on message, file patterns, and diff statistics.

/// The inferred intent of a change
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum ChangeIntent {
    Feature,
    Fix,
    Refactor,
    Docs,
    Test,
    Chore,
    #[default]
    Unknown,
}

impl ChangeIntent {
    /// Icon for displaying in the TUI
    pub fn icon(&self) -> &'static str {
        match self {
            Self::Feature => "\u{2726}",  //            Self::Fix => "\u{2727}",      //            Self::Refactor => "\u{21bb}", //            Self::Docs => "\u{25c7}",     //            Self::Test => "\u{25c8}",     //            Self::Chore => "\u{25cb}",    //            Self::Unknown => "\u{00b7}",  // ·
        }
    }

    /// Label for display
    pub fn label(&self) -> &'static str {
        match self {
            Self::Feature => "Feature",
            Self::Fix => "Fix",
            Self::Refactor => "Refactor",
            Self::Docs => "Docs",
            Self::Test => "Test",
            Self::Chore => "Chore",
            Self::Unknown => "Unknown",
        }
    }
}

/// Classify the intent of a commit based on its message and affected files
pub fn classify_intent(message: &str, files: &[String]) -> ChangeIntent {
    // 1. Conventional Commits prefix (highest priority)
    let msg_lower = message.to_lowercase();
    let trimmed = msg_lower.trim();

    if trimmed.starts_with("feat:") || trimmed.starts_with("feat(") {
        return ChangeIntent::Feature;
    }
    if trimmed.starts_with("fix:") || trimmed.starts_with("fix(") {
        return ChangeIntent::Fix;
    }
    if trimmed.starts_with("refactor:") || trimmed.starts_with("refactor(") {
        return ChangeIntent::Refactor;
    }
    if trimmed.starts_with("docs:") || trimmed.starts_with("docs(") {
        return ChangeIntent::Docs;
    }
    if trimmed.starts_with("test:") || trimmed.starts_with("test(") {
        return ChangeIntent::Test;
    }
    if trimmed.starts_with("chore:")
        || trimmed.starts_with("chore(")
        || trimmed.starts_with("ci:")
        || trimmed.starts_with("ci(")
        || trimmed.starts_with("build:")
        || trimmed.starts_with("build(")
        || trimmed.starts_with("style:")
        || trimmed.starts_with("style(")
        || trimmed.starts_with("perf:")
        || trimmed.starts_with("perf(")
    {
        return ChangeIntent::Chore;
    }

    // 2. Message keywords
    if msg_lower.contains("fix")
        || msg_lower.contains("bug")
        || msg_lower.contains("error")
        || msg_lower.contains("patch")
        || msg_lower.contains("hotfix")
    {
        return ChangeIntent::Fix;
    }
    if msg_lower.contains("add")
        || msg_lower.contains("new")
        || msg_lower.contains("implement")
        || msg_lower.contains("feature")
    {
        return ChangeIntent::Feature;
    }
    if msg_lower.contains("refactor")
        || msg_lower.contains("restructure")
        || msg_lower.contains("reorganize")
        || msg_lower.contains("clean")
    {
        return ChangeIntent::Refactor;
    }

    // 3. File pattern analysis
    if !files.is_empty() {
        let test_count = files.iter().filter(|f| is_test_file(f)).count();
        let doc_count = files.iter().filter(|f| is_doc_file(f)).count();

        if test_count > 0 && test_count == files.len() {
            return ChangeIntent::Test;
        }
        if doc_count > 0 && doc_count == files.len() {
            return ChangeIntent::Docs;
        }
    }

    ChangeIntent::Unknown
}

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/")
}

fn is_doc_file(path: &str) -> bool {
    let lower = path.to_lowercase();
    lower.ends_with(".md")
        || lower.starts_with("docs/")
        || lower.contains("/docs/")
        || lower.starts_with("readme")
        || lower.contains("license")
        || lower.contains("changelog")
}

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

    #[test]
    fn test_classify_conventional_commit_feat() {
        assert_eq!(
            classify_intent("feat: add login", &[]),
            ChangeIntent::Feature
        );
    }

    #[test]
    fn test_classify_conventional_commit_fix() {
        assert_eq!(
            classify_intent("fix: resolve crash", &[]),
            ChangeIntent::Fix
        );
    }

    #[test]
    fn test_classify_conventional_commit_refactor() {
        assert_eq!(
            classify_intent("refactor: simplify auth", &[]),
            ChangeIntent::Refactor
        );
    }

    #[test]
    fn test_classify_conventional_commit_docs() {
        assert_eq!(
            classify_intent("docs: update README", &[]),
            ChangeIntent::Docs
        );
    }

    #[test]
    fn test_classify_conventional_commit_test() {
        assert_eq!(
            classify_intent("test: add unit tests", &[]),
            ChangeIntent::Test
        );
    }

    #[test]
    fn test_classify_conventional_commit_chore() {
        assert_eq!(
            classify_intent("chore: update deps", &[]),
            ChangeIntent::Chore
        );
    }

    #[test]
    fn test_classify_keyword_fix() {
        assert_eq!(classify_intent("Fix the login bug", &[]), ChangeIntent::Fix);
    }

    #[test]
    fn test_classify_keyword_add() {
        assert_eq!(
            classify_intent("Add new dashboard", &[]),
            ChangeIntent::Feature
        );
    }

    #[test]
    fn test_classify_file_pattern_test_only() {
        let files = vec!["tests/test_auth.rs".to_string()];
        assert_eq!(classify_intent("update tests", &files), ChangeIntent::Test);
    }

    #[test]
    fn test_classify_file_pattern_docs_only() {
        let files = vec!["README.md".to_string(), "docs/guide.md".to_string()];
        assert_eq!(classify_intent("some message", &files), ChangeIntent::Docs);
    }

    #[test]
    fn test_classify_unknown() {
        assert_eq!(classify_intent("misc changes", &[]), ChangeIntent::Unknown);
    }

    #[test]
    fn test_change_intent_icon_non_empty() {
        for intent in [
            ChangeIntent::Feature,
            ChangeIntent::Fix,
            ChangeIntent::Refactor,
            ChangeIntent::Docs,
            ChangeIntent::Test,
            ChangeIntent::Chore,
            ChangeIntent::Unknown,
        ] {
            assert!(!intent.icon().is_empty());
        }
    }

    #[test]
    fn test_change_intent_label_non_empty() {
        for intent in [
            ChangeIntent::Feature,
            ChangeIntent::Fix,
            ChangeIntent::Refactor,
            ChangeIntent::Docs,
            ChangeIntent::Test,
            ChangeIntent::Chore,
            ChangeIntent::Unknown,
        ] {
            assert!(!intent.label().is_empty());
        }
    }

    #[test]
    fn test_change_intent_default_is_unknown() {
        assert_eq!(ChangeIntent::default(), ChangeIntent::Unknown);
    }

    #[test]
    fn test_classify_conventional_commit_with_scope() {
        assert_eq!(
            classify_intent("feat(auth): add OAuth", &[]),
            ChangeIntent::Feature
        );
        assert_eq!(
            classify_intent("fix(ui): button alignment", &[]),
            ChangeIntent::Fix
        );
        assert_eq!(
            classify_intent("refactor(core): simplify", &[]),
            ChangeIntent::Refactor
        );
        assert_eq!(
            classify_intent("docs(api): update guide", &[]),
            ChangeIntent::Docs
        );
        assert_eq!(
            classify_intent("test(auth): add unit tests", &[]),
            ChangeIntent::Test
        );
    }

    #[test]
    fn test_classify_ci_build_style_perf_as_chore() {
        assert_eq!(
            classify_intent("ci: update pipeline", &[]),
            ChangeIntent::Chore
        );
        assert_eq!(
            classify_intent("build: update deps", &[]),
            ChangeIntent::Chore
        );
        assert_eq!(
            classify_intent("style: format code", &[]),
            ChangeIntent::Chore
        );
        assert_eq!(
            classify_intent("perf: optimize query", &[]),
            ChangeIntent::Chore
        );
    }

    #[test]
    fn test_classify_keyword_bug() {
        assert_eq!(
            classify_intent("Fixed a critical bug in auth", &[]),
            ChangeIntent::Fix
        );
    }

    #[test]
    fn test_classify_keyword_error() {
        assert_eq!(
            classify_intent("Handle error in parser", &[]),
            ChangeIntent::Fix
        );
    }

    #[test]
    fn test_classify_keyword_hotfix() {
        assert_eq!(
            classify_intent("Hotfix for production crash", &[]),
            ChangeIntent::Fix
        );
    }

    #[test]
    fn test_classify_keyword_implement() {
        assert_eq!(
            classify_intent("Implement user dashboard", &[]),
            ChangeIntent::Feature
        );
    }

    #[test]
    fn test_classify_keyword_refactor() {
        assert_eq!(
            classify_intent("Refactor database layer", &[]),
            ChangeIntent::Refactor
        );
    }

    #[test]
    fn test_classify_keyword_clean() {
        assert_eq!(
            classify_intent("Clean up unused imports", &[]),
            ChangeIntent::Refactor
        );
    }

    #[test]
    fn test_classify_mixed_files_not_all_test() {
        let files = vec!["tests/test_auth.rs".to_string(), "src/auth.rs".to_string()];
        // Not all test files → won't classify as Test by file pattern
        assert_ne!(classify_intent("some message", &files), ChangeIntent::Test);
    }

    #[test]
    fn test_classify_mixed_files_not_all_docs() {
        let files = vec!["README.md".to_string(), "src/main.rs".to_string()];
        assert_ne!(classify_intent("some message", &files), ChangeIntent::Docs);
    }

    #[test]
    fn test_classify_conventional_takes_priority() {
        // "feat:" prefix should win even if message contains "fix"
        assert_eq!(
            classify_intent("feat: fix the login flow", &[]),
            ChangeIntent::Feature
        );
    }

    #[test]
    fn test_is_test_file_various() {
        assert!(is_test_file("tests/unit.rs"));
        assert!(is_test_file("src/models/user_test.rs"));
        assert!(is_test_file("src/components/Button.test.tsx"));
        assert!(is_test_file("src/api/auth.spec.js"));
        assert!(is_test_file("test_helper.py"));
        assert!(!is_test_file("src/testing_utils.rs")); // no test marker
    }

    #[test]
    fn test_is_doc_file_various() {
        assert!(is_doc_file("README.md"));
        assert!(is_doc_file("CHANGELOG.md"));
        assert!(is_doc_file("docs/guide.md"));
        assert!(is_doc_file("src/docs/api.md"));
        assert!(is_doc_file("LICENSE"));
        assert!(!is_doc_file("src/main.rs"));
    }

    #[test]
    fn test_change_intent_eq_hash() {
        use std::collections::HashSet;
        let mut set = HashSet::new();
        set.insert(ChangeIntent::Feature);
        set.insert(ChangeIntent::Fix);
        set.insert(ChangeIntent::Feature); // duplicate
        assert_eq!(set.len(), 2);
    }
}