use anyhow::{bail, Result}; use clap::Parser; use colored::Colorize; use dialoguer::{Confirm, Input, Select}; use semver::Version; use std::path::PathBuf; use crate::config::{Language, manager::ConfigManager}; use crate::git::{find_repo, GitRepo}; use crate::generator::ContentGenerator; use crate::git::tag::{ bump_version, get_latest_version, suggest_version_bump, TagBuilder, VersionBump, }; use crate::i18n::Messages; /// Generate and create Git tags #[derive(Parser)] pub struct TagCommand { /// Tag name #[arg(short, long)] name: Option, /// Semantic version bump #[arg(short, long, value_name = "TYPE")] bump: Option, /// Tag message #[arg(short, long)] message: Option, /// Generate message with AI #[arg(short, long)] generate: bool, /// Sign the tag #[arg(short = 'S', long)] sign: bool, /// Create lightweight tag (no annotation) #[arg(short, long)] lightweight: bool, /// Force overwrite existing tag #[arg(short, long)] force: bool, /// Push tag to remote after creation #[arg(short, long)] push: bool, /// Remote to push to #[arg(short, long, default_value = "origin")] remote: String, /// Dry run #[arg(long)] dry_run: bool, /// Skip interactive prompts #[arg(short = 'y', long)] yes: bool, } impl TagCommand { pub async fn execute(&self, config_path: Option) -> Result<()> { let repo = find_repo(std::env::current_dir()?.as_path())?; let manager = if let Some(ref path) = config_path { ConfigManager::with_path(path)? } else { ConfigManager::new()? }; let config = manager.config(); let language = manager.get_language().unwrap_or(Language::English); let messages = Messages::new(language); // Determine tag name let tag_name = if let Some(name) = &self.name { name.clone() } else if let Some(bump_str) = &self.bump { // Calculate bumped version let prefix = &config.tag.version_prefix; let latest = get_latest_version(&repo, prefix)? .unwrap_or_else(|| Version::new(0, 0, 0)); let bump = VersionBump::from_str(bump_str)?; let new_version = bump_version(&latest, bump, None); format!("{}{}", prefix, new_version) } else { // Interactive mode self.select_version_interactive(&repo, &config.tag.version_prefix, &messages).await? }; // Validate tag name (if it looks like a version) if tag_name.starts_with('v') || tag_name.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) { let version_str = tag_name.trim_start_matches('v'); if let Err(e) = crate::utils::validators::validate_semver(version_str) { println!("{}: {}", "Warning".yellow(), e); if !self.yes { let proceed = Confirm::new() .with_prompt("Proceed with this tag name anyway?") .default(true) .interact()?; if !proceed { bail!("{}", messages.tag_cancelled()); } } } } // Generate or get tag message let message = if self.lightweight { None } else if let Some(msg) = &self.message { Some(msg.clone()) } else if self.generate || (config.tag.auto_generate && !self.yes) { Some(self.generate_tag_message(&repo, &tag_name, &messages).await?) } else if !self.yes { Some(self.input_message_interactive(&tag_name, &messages)?) } else { Some(format!("Release {}", tag_name)) }; // Show preview println!("\n{}", "─".repeat(60)); println!("{}", messages.tag_preview().bold()); println!("{}", "─".repeat(60)); println!("Name: {}", tag_name.cyan()); if let Some(ref msg) = message { println!("Message:\n{}", msg); } else { println!("Type: {}", "lightweight".yellow()); } println!("{}", "─".repeat(60)); if !self.yes { let confirm = Confirm::new() .with_prompt(messages.create_tag()) .default(true) .interact()?; if !confirm { println!("{}", messages.tag_cancelled().yellow()); return Ok(()); } } if self.dry_run { println!("\n{} {}", messages.dry_run(), "- tag not created.".yellow()); return Ok(()); } let builder = TagBuilder::new() .name(&tag_name) .message_opt(message) .annotate(!self.lightweight) .sign(self.sign) .force(self.force); builder.execute(&repo)?; println!("{} {}", messages.tag_created().green(), tag_name.cyan()); // Push if requested or ask user if self.push { println!("{}", messages.pushing_tag(&self.remote)); repo.push(&self.remote, &format!("refs/tags/{}", tag_name))?; println!("{}", messages.pushed_tag(&self.remote)); } else if !self.yes && !self.dry_run { let should_push = Confirm::new() .with_prompt(messages.push_after_tag()) .default(false) .interact()?; if should_push { println!("{}", messages.pushing_tag(&self.remote)); repo.push(&self.remote, &format!("refs/tags/{}", tag_name))?; println!("{}", messages.pushed_tag(&self.remote)); } } Ok(()) } async fn select_version_interactive(&self, repo: &GitRepo, prefix: &str, messages: &Messages) -> Result { loop { let latest = get_latest_version(repo, prefix)?; println!("\n{}", messages.version_selection().bold()); if let Some(ref version) = latest { println!("{} {}{}", messages.latest_version(), prefix, version); } else { println!("{}", messages.no_existing_version_tags()); } let options = vec![ messages.auto_detect_bump(), messages.bump_major_version(), messages.bump_minor_version(), messages.bump_patch_version(), messages.enter_custom_version(), messages.enter_custom_tag_name(), ]; let selection = Select::new() .with_prompt(messages.select_option()) .items(&options) .default(0) .interact()?; match selection { 0 => { // Auto-detect let commits = repo.get_commits(50)?; let bump = suggest_version_bump(&commits); let version = latest.as_ref() .map(|v| bump_version(v, bump, None)) .unwrap_or_else(|| Version::new(0, 1, 0)); println!("{} {:?} → {}{}", messages.suggested_bump(), bump, prefix, version); let confirm = Confirm::new() .with_prompt(messages.use_this_version()) .default(true) .interact()?; if confirm { return Ok(format!("{}{}", prefix, version)); } // User rejected, continue the loop } 1 => { let version = latest.as_ref() .map(|v| bump_version(v, VersionBump::Major, None)) .unwrap_or_else(|| Version::new(1, 0, 0)); return Ok(format!("{}{}", prefix, version)); } 2 => { let version = latest.as_ref() .map(|v| bump_version(v, VersionBump::Minor, None)) .unwrap_or_else(|| Version::new(0, 1, 0)); return Ok(format!("{}{}", prefix, version)); } 3 => { let version = latest.as_ref() .map(|v| bump_version(v, VersionBump::Patch, None)) .unwrap_or_else(|| Version::new(0, 0, 1)); return Ok(format!("{}{}", prefix, version)); } 4 => { let input: String = Input::new() .with_prompt(messages.enter_version()) .interact_text()?; let version = Version::parse(&input)?; return Ok(format!("{}{}", prefix, version)); } 5 => { let input: String = Input::new() .with_prompt(messages.enter_tag_name()) .interact_text()?; return Ok(input); } _ => unreachable!(), } } } async fn generate_tag_message(&self, repo: &GitRepo, version: &str, messages: &Messages) -> Result { let manager = ConfigManager::new()?; let language = manager.get_language().unwrap_or(Language::English); let tags = repo.get_tags()?; let commits = if let Some(latest_tag) = tags.first() { repo.get_commits_between(&latest_tag.name, "HEAD")? } else { repo.get_commits(50)? }; if commits.is_empty() { return Ok(format!("Release {}", version)); } println!("{}", messages.ai_generating_tag(commits.len())); let generator = ContentGenerator::new(&manager).await?; generator.generate_tag_message(version, &commits, language).await } fn input_message_interactive(&self, version: &str, messages: &Messages) -> Result { let default_msg = format!("Release {}", version); let use_editor = Confirm::new() .with_prompt(messages.open_editor()) .default(false) .interact()?; if use_editor { crate::utils::editor::edit_content(&default_msg) } else { Ok(Input::new() .with_prompt(messages.tag_message()) .default(default_msg) .interact_text()?) } } } // Helper trait trait TagBuilderExt { fn message_opt(self, message: Option) -> Self; } impl TagBuilderExt for TagBuilder { fn message_opt(self, message: Option) -> Self { if let Some(m) = message { self.message(m) } else { self } } }