use super::GitRepo; use anyhow::{bail, Context, Result}; use semver::Version; /// Tag builder for creating tags pub struct TagBuilder { name: Option, message: Option, annotate: bool, sign: bool, force: bool, version_prefix: String, } impl TagBuilder { /// Create new tag builder pub fn new() -> Self { Self { name: None, message: None, annotate: true, sign: false, force: false, version_prefix: "v".to_string(), } } /// Set tag name pub fn name(mut self, name: impl Into) -> Self { self.name = Some(name.into()); self } /// Set tag message pub fn message(mut self, message: impl Into) -> Self { self.message = Some(message.into()); self } /// Create annotated tag pub fn annotate(mut self, annotate: bool) -> Self { self.annotate = annotate; self } /// Sign the tag pub fn sign(mut self, sign: bool) -> Self { self.sign = sign; self } /// Force overwrite existing tag pub fn force(mut self, force: bool) -> Self { self.force = force; self } /// Set version prefix pub fn version_prefix(mut self, prefix: impl Into) -> Self { self.version_prefix = prefix.into(); self } /// Set semantic version pub fn version(mut self, version: &Version) -> Self { self.name = Some(format!("{}{}", self.version_prefix, version)); self } /// Build tag message pub fn build_message(&self) -> Result { let message = self.message.as_ref() .cloned() .unwrap_or_else(|| { let name = self.name.as_deref().unwrap_or("unknown"); format!("Release {}", name) }); Ok(message) } /// Execute tag creation pub fn execute(&self, repo: &GitRepo) -> Result<()> { let name = self.name.as_ref() .ok_or_else(|| anyhow::anyhow!("Tag name is required"))?; if !self.force { let existing_tags = repo.get_tags()?; if existing_tags.iter().any(|t| t.name == *name) { bail!("Tag '{}' already exists. Use --force to overwrite.", name); } } let message = if self.annotate { Some(self.build_message()?.as_str().to_string()) } else { None }; repo.create_tag(name, message.as_deref(), self.sign)?; Ok(()) } /// Execute and push tag pub fn execute_and_push(&self, repo: &GitRepo, remote: &str) -> Result<()> { self.execute(repo)?; let name = self.name.as_ref().unwrap(); repo.push(remote, &format!("refs/tags/{}", name))?; Ok(()) } } impl Default for TagBuilder { fn default() -> Self { Self::new() } } /// Semantic version bump types #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum VersionBump { Major, Minor, Patch, Prerelease, } impl VersionBump { /// Parse from string pub fn from_str(s: &str) -> Result { match s.to_lowercase().as_str() { "major" => Ok(Self::Major), "minor" => Ok(Self::Minor), "patch" => Ok(Self::Patch), "prerelease" | "pre" => Ok(Self::Prerelease), _ => bail!("Invalid version bump: {}. Use: major, minor, patch, prerelease", s), } } /// Get all variants pub fn variants() -> &'static [&'static str] { &["major", "minor", "patch", "prerelease"] } } /// Get latest version tag from repository pub fn get_latest_version(repo: &GitRepo, prefix: &str) -> Result> { let tags = repo.get_tags()?; let mut versions: Vec = tags .iter() .filter_map(|t| { let name = &t.name; let version_str = name.strip_prefix(prefix).unwrap_or(name); Version::parse(version_str).ok() }) .collect(); versions.sort_by(|a, b| b.cmp(a)); // Descending order Ok(versions.into_iter().next()) } /// Bump version pub fn bump_version(version: &Version, bump: VersionBump, prerelease_id: Option<&str>) -> Version { match bump { VersionBump::Major => Version::new(version.major + 1, 0, 0), VersionBump::Minor => Version::new(version.major, version.minor + 1, 0), VersionBump::Patch => Version::new(version.major, version.minor, version.patch + 1), VersionBump::Prerelease => { let pre = prerelease_id.unwrap_or("alpha"); let pre_id = format!("{}.0", pre); Version::parse(&format!("{}-{}", version, pre_id)).unwrap_or_else(|_| version.clone()) } } } /// Suggest next version based on commits pub fn suggest_version_bump(commits: &[super::CommitInfo]) -> VersionBump { let mut has_breaking = false; let mut has_feature = false; let mut has_fix = false; for commit in commits { let msg = commit.message.to_lowercase(); if msg.contains("breaking change") || msg.contains("breaking-change") || msg.contains("breaking_change") { has_breaking = true; } if let Some(commit_type) = commit.commit_type() { match commit_type.as_str() { "feat" => has_feature = true, "fix" => has_fix = true, _ => {} } } } if has_breaking { VersionBump::Major } else if has_feature { VersionBump::Minor } else if has_fix { VersionBump::Patch } else { VersionBump::Patch } } /// Generate tag message from commits pub fn generate_tag_message(version: &str, commits: &[super::CommitInfo]) -> String { let mut message = format!("Release {}\n\n", version); // Group commits by type let mut features = vec![]; let mut fixes = vec![]; let mut other = vec![]; let mut breaking = vec![]; for commit in commits { let subject = commit.subject(); if commit.message.contains("BREAKING CHANGE") { breaking.push(subject.to_string()); } if let Some(commit_type) = commit.commit_type() { match commit_type.as_str() { "feat" => features.push(subject.to_string()), "fix" => fixes.push(subject.to_string()), _ => other.push(subject.to_string()), } } else { other.push(subject.to_string()); } } // Build message if !breaking.is_empty() { message.push_str("## Breaking Changes\n\n"); for item in &breaking { message.push_str(&format!("- {}\n", item)); } message.push('\n'); } if !features.is_empty() { message.push_str("## Features\n\n"); for item in &features { message.push_str(&format!("- {}\n", item)); } message.push('\n'); } if !fixes.is_empty() { message.push_str("## Bug Fixes\n\n"); for item in &fixes { message.push_str(&format!("- {}\n", item)); } message.push('\n'); } if !other.is_empty() { message.push_str("## Other Changes\n\n"); for item in &other { message.push_str(&format!("- {}\n", item)); } } message } /// Tag deletion helper pub fn delete_tag(repo: &GitRepo, name: &str, remote: Option<&str>) -> Result<()> { repo.delete_tag(name)?; if let Some(remote) = remote { use std::process::Command; let output = Command::new("git") .args(&["push", remote, ":refs/tags/{}"]) .current_dir(repo.path()) .output()?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); bail!("Failed to delete remote tag: {}", stderr); } } Ok(()) } /// List tags with filtering pub fn list_tags( repo: &GitRepo, pattern: Option<&str>, limit: Option, ) -> Result> { let tags = repo.get_tags()?; let filtered: Vec<_> = tags .into_iter() .filter(|t| { if let Some(p) = pattern { t.name.contains(p) } else { true } }) .collect(); if let Some(limit) = limit { Ok(filtered.into_iter().take(limit).collect()) } else { Ok(filtered) } }