grubble 4.7.2

Automatic semantic versioning based on conventional commits, optimized for AI-generated commit messages
use crate::config::Config;
use regex::Regex;

const BUMP_COMMIT_PREFIX: &str = "chore: bump version";

#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum BumpType {
    Major,
    Minor,
    Patch,
    None,
}

impl BumpType {
    pub fn as_str(&self) -> &'static str {
        match self {
            BumpType::Major => "major",
            BumpType::Minor => "minor",
            BumpType::Patch => "patch",
            BumpType::None => "none",
        }
    }

    pub fn label(&self) -> &'static str {
        match self {
            BumpType::Major => "Major",
            BumpType::Minor => "Minor",
            BumpType::Patch => "Patch",
            BumpType::None => "None",
        }
    }

    pub fn max(&self, other: BumpType) -> BumpType {
        match (self, other) {
            (BumpType::Major, _) | (_, BumpType::Major) => BumpType::Major,
            (BumpType::Minor, _) | (_, BumpType::Minor) => BumpType::Minor,
            (BumpType::Patch, _) | (_, BumpType::Patch) => BumpType::Patch,
            _ => BumpType::None,
        }
    }
}

pub struct AnalysisResult {
    pub bump: BumpType,
    pub triggering_commits: Vec<String>,
    pub unknown_commits: Vec<String>,
}

pub fn analyse_commits(commits: &[String], config: &Config) -> AnalysisResult {
    let substantive_commits: Vec<_> = commits
        .iter()
        .filter(|msg| !msg.starts_with(BUMP_COMMIT_PREFIX))
        .collect();

    if substantive_commits.is_empty() {
        return AnalysisResult {
            bump: BumpType::None,
            triggering_commits: vec![],
            unknown_commits: vec![],
        };
    }

    let mut bump = BumpType::None;
    let mut triggering_commits = Vec::new();
    let mut unknown_commits = Vec::new();

    let commit_type_regex = Regex::new(r"^([a-z]+)(?:\([^)]+\))?(!?):").unwrap();

    for msg in substantive_commits {
        if let Some(captures) = commit_type_regex.captures(msg) {
            let commit_type = captures.get(1).map(|m| m.as_str()).unwrap_or("");
            let has_exclamation = captures.get(2).map(|m| m.as_str()).unwrap_or("") == "!";

            let has_breaking = has_exclamation || msg.to_lowercase().contains("breaking change");

            let commit_bump = if has_breaking {
                BumpType::Major
            } else if let Some(bump_str) = config.types.get(commit_type) {
                match bump_str.as_str() {
                    "major" => BumpType::Major,
                    "minor" => BumpType::Minor,
                    "patch" => BumpType::Patch,
                    _ => BumpType::None,
                }
            } else {
                unknown_commits.push(msg.to_string());
                BumpType::None
            };

            bump = bump.max(commit_bump);

            if commit_bump != BumpType::None {
                triggering_commits.push(format!("{}: {}", commit_bump.label(), msg));
            }
        }
    }

    AnalysisResult {
        bump,
        triggering_commits,
        unknown_commits,
    }
}

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

    #[test]
    fn test_bump_type_max() {
        assert_eq!(BumpType::Major.max(BumpType::Minor), BumpType::Major);
        assert_eq!(BumpType::Minor.max(BumpType::Patch), BumpType::Minor);
        assert_eq!(BumpType::Patch.max(BumpType::None), BumpType::Patch);
    }

    #[test]
    fn test_analyse_commits_feat() {
        let commits = vec!["feat: add new feature".to_string()];
        let config = Config::default();
        let result = analyse_commits(&commits, &config);

        assert_eq!(result.bump, BumpType::Minor);
    }

    #[test]
    fn test_analyse_commits_fix() {
        let commits = vec!["fix: resolve bug".to_string()];
        let config = Config::default();
        let result = analyse_commits(&commits, &config);

        assert_eq!(result.bump, BumpType::Patch);
    }

    #[test]
    fn test_analyse_commits_breaking() {
        let commits = vec!["feat!: breaking change".to_string()];
        let config = Config::default();
        let result = analyse_commits(&commits, &config);

        assert_eq!(result.bump, BumpType::Major);
    }
}