use anyhow::{Result, bail}; 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::generator::ContentGenerator; use crate::git::tag::{ ConfigVersion, TagBuilder, VersionBump, bump_version, get_latest_version, read_project_versions, suggest_version_bump, }; use crate::git::{GitRepo, find_repo}; use crate::i18n::Messages; use crate::utils::{print_progress, print_success, print_warning}; /// 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, /// Enable thinking mode for this tag (override config) #[arg(short = 't', long)] think: bool, /// Skip interactive prompts only (generation behavior unchanged) #[arg(short = 'y', long)] yes: bool, /// Auto-detect version from project config (Cargo.toml/pyproject.toml), /// falling back to commit analysis with confirmation. Mutually exclusive with --bump. #[arg(short = 'A', long, conflicts_with = "bump")] auto: 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 self.auto { self.auto_detect_version(&repo, &config.tag.version_prefix, &messages) .await? } 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) { print_warning(&format!("{}: {}", messages.warning(), e)); if !self.yes { let proceed = Confirm::new() .with_prompt(messages.proceed_invalid_tag_name()) .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 { Some( self.generate_tag_message(&repo, &tag_name, &messages) .await?, ) } else if !self.yes { Some(self.input_message_interactive(&tag_name, &messages)?) } else { Some(messages.release_default(&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 { print_warning(messages.tag_cancelled()); 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)?; print_success(&format!("{} {}", messages.tag_created(), tag_name.cyan())); // Push if requested or ask user if self.push { print_progress(&messages.pushing_tag(&self.remote)); repo.push(&self.remote, &format!("refs/tags/{}", tag_name))?; print_success(&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 { print_progress(&messages.pushing_tag(&self.remote)); repo.push(&self.remote, &format!("refs/tags/{}", tag_name))?; print_success(&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(messages.release_default(version)); } let generator = ContentGenerator::new_with_think(&manager, self.think, None).await?; let spinner = crate::utils::Spinner::start(&messages.ai_generating_tag(commits.len())); let result = generator .generate_tag_message(version, &commits, language) .await; spinner.finish_clear(); result } async fn auto_detect_version( &self, repo: &GitRepo, prefix: &str, messages: &Messages, ) -> Result { let project_dir = std::env::current_dir()?; // 1. Try reading from project config files let config_versions = read_project_versions(&project_dir); if !config_versions.is_empty() { if config_versions.len() == 1 { let cv = &config_versions[0]; let tag_name = format!("{}{}", prefix, cv.version); println!( "{} ({}): {}", messages.found_version_in(), cv.source, tag_name.cyan() ); return Ok(tag_name); } // Multiple config files: let user choose return self.select_config_version(&config_versions, prefix, messages); } // 2. Fall back to commit analysis println!("{}", messages.auto_detect_bump()); let commits = repo.get_commits(50)?; let bump = suggest_version_bump(&commits); let latest = get_latest_version(repo, prefix)?.unwrap_or_else(|| Version::new(0, 0, 0)); let version = bump_version(&latest, bump, None); let tag_name = format!("{}{}", prefix, version); println!( "{} {:?} → {}", messages.suggested_bump(), bump, tag_name.cyan() ); if !self.yes { let confirm = Confirm::new() .with_prompt(messages.use_this_version()) .default(true) .interact()?; if !confirm { // Fall through to interactive version selection return self .select_version_interactive(repo, prefix, messages) .await; } } Ok(tag_name) } fn select_config_version( &self, config_versions: &[ConfigVersion], prefix: &str, messages: &Messages, ) -> Result { println!("\n{}", messages.found_multiple_versions().bold()); let items: Vec = config_versions .iter() .map(|cv| format!("{} → {}{}", cv.source, prefix, cv.version)) .collect(); let selection = Select::new() .with_prompt(messages.select_version_to_use()) .items(&items) .default(0) .interact()?; let cv = &config_versions[selection]; Ok(format!("{}{}", prefix, cv.version)) } fn input_message_interactive(&self, version: &str, messages: &Messages) -> Result { let default_msg = messages.release_default(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 } } }