use async_trait::async_trait;
use crate::domain::{
commit::CommitHistory,
version::{BumpType, VersionRecommendation},
workspace::WorkspaceMetadata,
};
#[derive(Debug, thiserror::Error)]
pub enum StrategyError {
#[error("Failed to analyze commits: {0}")]
AnalysisFailed(String),
#[error("No commits found since last release")]
NoCommits,
#[error("Could not determine version bump")]
IndeterminateBump,
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Git error: {0}")]
Git(String),
}
#[async_trait]
pub trait VersionStrategy: Send + Sync {
fn name(&self) -> &str;
async fn analyze(
&self,
context: &AnalysisContext,
) -> Result<VersionRecommendation, StrategyError>;
}
#[derive(Debug, Clone)]
pub struct AnalysisContext {
pub workspace: WorkspaceMetadata,
pub commits: CommitHistory,
pub current_version: Option<String>,
pub force_bump: Option<BumpType>,
pub tag_pattern: Option<String>,
}
impl AnalysisContext {
#[must_use]
pub const fn new(workspace: WorkspaceMetadata, commits: CommitHistory) -> Self {
Self {
workspace,
commits,
current_version: None,
force_bump: None,
tag_pattern: None,
}
}
#[must_use]
pub fn with_current_version(mut self, version: String) -> Self {
self.current_version = Some(version);
self
}
#[must_use]
pub const fn with_force_bump(mut self, bump: BumpType) -> Self {
self.force_bump = Some(bump);
self
}
#[must_use]
pub fn with_tag_pattern(mut self, pattern: String) -> Self {
self.tag_pattern = Some(pattern);
self
}
}
pub struct ConventionalStrategy {
pub major_types: Vec<String>,
pub minor_types: Vec<String>,
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 {
#[must_use]
pub fn new() -> Self {
Self::default()
}
fn determine_bump(&self, commits: &CommitHistory) -> BumpType {
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;
}
}
}
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
}
}
}
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(¤t);
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(),
})
}
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()
}
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()
}
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()
}
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
}
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 let Some(force_bump) = context.force_bump {
return build_forced_recommendation(context, force_bump);
}
let bump = self.determine_bump(&context.commits);
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(¤t);
let breaking_changes = build_breaking_changes(&context.commits);
let features = build_features(&context.commits);
let fixes = build_fixes(&context.commits);
let reasoning = build_reasoning(bump, breaking_changes.len(), features.len(), fixes.len());
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);
}
}