governor-core 1.3.0

Core domain and application logic for cargo-governor
Documentation
//! Version strategy trait

use async_trait::async_trait;

use crate::domain::{
    commit::CommitHistory,
    version::{BumpType, VersionRecommendation},
    workspace::WorkspaceMetadata,
};

/// Error type for version strategy
#[derive(Debug, thiserror::Error)]
pub enum StrategyError {
    /// Failed to analyze commits
    #[error("Failed to analyze commits: {0}")]
    AnalysisFailed(String),

    /// No commits found since last release
    #[error("No commits found since last release")]
    NoCommits,

    /// Could not determine version bump
    #[error("Could not determine version bump")]
    IndeterminateBump,

    /// IO error
    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),

    /// Git error
    #[error("Git error: {0}")]
    Git(String),
}

/// Strategy for determining version bumps from commit history
#[async_trait]
pub trait VersionStrategy: Send + Sync {
    /// Get the name of this strategy
    fn name(&self) -> &str;

    /// Analyze commits and recommend a version bump
    async fn analyze(
        &self,
        context: &AnalysisContext,
    ) -> Result<VersionRecommendation, StrategyError>;
}

/// Context for version analysis
#[derive(Debug, Clone)]
pub struct AnalysisContext {
    /// Workspace metadata
    pub workspace: WorkspaceMetadata,
    /// Commit history since last release
    pub commits: CommitHistory,
    /// Current version
    pub current_version: Option<String>,
    /// Whether to force a specific bump
    pub force_bump: Option<BumpType>,
    /// Tag pattern to look for
    pub tag_pattern: Option<String>,
}

impl AnalysisContext {
    /// Create a new analysis context
    #[must_use]
    pub const fn new(workspace: WorkspaceMetadata, commits: CommitHistory) -> Self {
        Self {
            workspace,
            commits,
            current_version: None,
            force_bump: None,
            tag_pattern: None,
        }
    }

    /// Set the current version
    #[must_use]
    pub fn with_current_version(mut self, version: String) -> Self {
        self.current_version = Some(version);
        self
    }

    /// Set force bump
    #[must_use]
    pub const fn with_force_bump(mut self, bump: BumpType) -> Self {
        self.force_bump = Some(bump);
        self
    }

    /// Set tag pattern
    #[must_use]
    pub fn with_tag_pattern(mut self, pattern: String) -> Self {
        self.tag_pattern = Some(pattern);
        self
    }
}

/// Conventional commits version strategy
pub struct ConventionalStrategy {
    /// Commit types that trigger major bumps
    pub major_types: Vec<String>,
    /// Commit types that trigger minor bumps
    pub minor_types: Vec<String>,
    /// Commit types that trigger patch bumps
    pub patch_types: Vec<String>,
}

impl Default for ConventionalStrategy {
    fn default() -> Self {
        Self {
            major_types: vec!["feat!".to_string(), "BREAKING CHANGE".to_string()],
            minor_types: vec!["feat".to_string()],
            patch_types: vec!["fix".to_string(), "perf".to_string(), "revert".to_string()],
        }
    }
}

impl ConventionalStrategy {
    /// Create a new conventional strategy
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Determine bump type from commits
    fn determine_bump(&self, commits: &CommitHistory) -> BumpType {
        // Check for breaking changes first
        for commit in commits.breaking_changes() {
            if let Some(ct) = commit.commit_type {
                let type_str = ct.to_string();
                if self.major_types.contains(&type_str) || commit.breaking {
                    return BumpType::Major;
                }
            }
        }

        // Count commit types
        let mut has_minor = false;
        let mut has_patch = false;

        for commit in &commits.commits {
            if let Some(ct) = commit.commit_type {
                let type_str = ct.to_string();
                if self.minor_types.contains(&type_str) {
                    has_minor = true;
                } else if self.patch_types.contains(&type_str) {
                    has_patch = true;
                }
            }
        }

        if has_minor {
            BumpType::Minor
        } else if has_patch {
            BumpType::Patch
        } else {
            BumpType::None
        }
    }
}

/// Build forced version recommendation
fn build_forced_recommendation(
    context: &AnalysisContext,
    force_bump: BumpType,
) -> Result<VersionRecommendation, StrategyError> {
    use crate::domain::version::SemanticVersion;

    let current = SemanticVersion::parse(context.current_version.as_deref().unwrap_or("0.0.0"))
        .map_err(|e| StrategyError::AnalysisFailed(e.to_string()))?;
    let recommended = force_bump.apply_to(&current);

    Ok(VersionRecommendation {
        current,
        bump: force_bump,
        recommended,
        confidence: 1.0,
        reasoning: "Forced bump type".to_string(),
        breaking_changes: Vec::new(),
        features: Vec::new(),
        fixes: Vec::new(),
    })
}

/// Build breaking changes list from commits
fn build_breaking_changes(commits: &CommitHistory) -> Vec<crate::domain::version::BreakingChange> {
    commits
        .breaking_changes()
        .iter()
        .map(|c| crate::domain::version::BreakingChange {
            commit_hash: c.hash.clone(),
            short_hash: c.short_hash.clone(),
            message: c.message.clone(),
            breaking_description: c.scope.as_ref().map_or_else(
                || "Breaking change detected".to_string(),
                |scope| format!("Breaking change in {scope}"),
            ),
            affected_crates: Vec::new(),
            migration_complexity: if c.scope.as_ref().is_some_and(|s| s == "api") {
                crate::domain::version::MigrationComplexity::Medium
            } else {
                crate::domain::version::MigrationComplexity::Simple
            },
        })
        .collect()
}

/// Build features list from commits
fn build_features(commits: &CommitHistory) -> Vec<crate::domain::version::Feature> {
    commits
        .features()
        .iter()
        .map(|c| crate::domain::version::Feature {
            commit_hash: c.hash.clone(),
            short_hash: c.short_hash.clone(),
            message: c.message.clone(),
            scope: c.scope.clone(),
            affected_crates: Vec::new(),
        })
        .collect()
}

/// Build fixes list from commits
fn build_fixes(commits: &CommitHistory) -> Vec<crate::domain::version::Fix> {
    commits
        .fixes()
        .iter()
        .map(|c| crate::domain::version::Fix {
            commit_hash: c.hash.clone(),
            short_hash: c.short_hash.clone(),
            message: c.message.clone(),
            scope: c.scope.clone(),
            affected_crates: Vec::new(),
        })
        .collect()
}

/// Build reasoning string based on changes
fn build_reasoning(
    bump: BumpType,
    breaking_count: usize,
    features_count: usize,
    fixes_count: usize,
) -> String {
    let mut reasoning = format!(
        "Recommended {} bump based on commit analysis",
        match bump {
            BumpType::Major => "major",
            BumpType::Minor => "minor",
            BumpType::Patch => "patch",
            BumpType::None => "no",
        }
    );

    if breaking_count > 0 {
        use std::fmt::Write;
        writeln!(reasoning, " ({breaking_count} breaking changes)").unwrap();
    } else if features_count > 0 {
        use std::fmt::Write;
        writeln!(reasoning, " ({features_count} features)").unwrap();
    } else if fixes_count > 0 {
        use std::fmt::Write;
        writeln!(reasoning, " ({fixes_count} fixes)").unwrap();
    }

    reasoning
}

/// Calculate confidence based on conventional commit adherence
fn calculate_confidence(commits: &CommitHistory) -> f64 {
    if commits.is_empty() {
        return 0.0;
    }
    let conventional_count = commits
        .commits
        .iter()
        .filter(|c| c.is_conventional())
        .count();
    let total = commits.commits.len();
    if total == 0 {
        return 0.0;
    }
    #[allow(clippy::cast_precision_loss)]
    {
        conventional_count as f64 / total as f64
    }
}

#[async_trait]
impl VersionStrategy for ConventionalStrategy {
    fn name(&self) -> &'static str {
        "conventional"
    }

    async fn analyze(
        &self,
        context: &AnalysisContext,
    ) -> Result<VersionRecommendation, StrategyError> {
        // If force bump is set, use it
        if let Some(force_bump) = context.force_bump {
            return build_forced_recommendation(context, force_bump);
        }

        // Determine bump from commits
        let bump = self.determine_bump(&context.commits);

        // Parse current version
        let current = crate::domain::version::SemanticVersion::parse(
            context.current_version.as_deref().unwrap_or("0.0.0"),
        )
        .map_err(|e| StrategyError::AnalysisFailed(e.to_string()))?;

        let recommended = bump.apply_to(&current);

        // Convert commits to structured output
        let breaking_changes = build_breaking_changes(&context.commits);
        let features = build_features(&context.commits);
        let fixes = build_fixes(&context.commits);

        // Build reasoning
        let reasoning = build_reasoning(bump, breaking_changes.len(), features.len(), fixes.len());

        // Calculate confidence
        let confidence = calculate_confidence(&context.commits);

        Ok(VersionRecommendation {
            current,
            bump,
            recommended,
            confidence,
            reasoning,
            breaking_changes,
            features,
            fixes,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::domain::commit::{Commit, CommitHistory};
    use chrono::Utc;

    #[test]
    fn test_conventional_strategy_feature() {
        let strategy = ConventionalStrategy::new();
        let commits = vec![Commit::new(
            "abc123".to_string(),
            "feat: add new feature".to_string(),
            "Author".to_string(),
            "a@a.com".to_string(),
            Utc::now(),
        )];
        let history = CommitHistory::new(commits);

        let bump = strategy.determine_bump(&history);
        assert_eq!(bump, BumpType::Minor);
    }

    #[test]
    fn test_conventional_strategy_fix() {
        let strategy = ConventionalStrategy::new();
        let commits = vec![Commit::new(
            "abc123".to_string(),
            "fix: bug fix".to_string(),
            "Author".to_string(),
            "a@a.com".to_string(),
            Utc::now(),
        )];
        let history = CommitHistory::new(commits);

        let bump = strategy.determine_bump(&history);
        assert_eq!(bump, BumpType::Patch);
    }

    #[test]
    fn test_conventional_strategy_breaking() {
        let strategy = ConventionalStrategy::new();
        let commits = vec![Commit::new(
            "abc123".to_string(),
            "feat!: breaking change".to_string(),
            "Author".to_string(),
            "a@a.com".to_string(),
            Utc::now(),
        )];
        let history = CommitHistory::new(commits);

        let bump = strategy.determine_bump(&history);
        assert_eq!(bump, BumpType::Major);
    }
}