feat:(first commit)created repository and complete 0.1.0

This commit is contained in:
2026-01-30 14:18:32 +08:00
commit 5d4156e5e0
36 changed files with 8686 additions and 0 deletions

199
src/commands/changelog.rs Normal file
View File

@@ -0,0 +1,199 @@
use anyhow::{bail, Result};
use chrono::Utc;
use clap::Parser;
use colored::Colorize;
use dialoguer::{Confirm, Input};
use std::path::PathBuf;
use crate::config::manager::ConfigManager;
use crate::generator::ContentGenerator;
use crate::git::find_repo;
use crate::git::{changelog::*, CommitInfo, GitRepo};
/// Generate changelog
#[derive(Parser)]
pub struct ChangelogCommand {
/// Output file path
#[arg(short, long)]
output: Option<PathBuf>,
/// Version to generate changelog for
#[arg(short, long)]
version: Option<String>,
/// Generate from specific tag
#[arg(short, long)]
from: Option<String>,
/// Generate to specific ref
#[arg(short, long, default_value = "HEAD")]
to: String,
/// Initialize new changelog file
#[arg(short, long)]
init: bool,
/// Generate with AI
#[arg(short, long)]
generate: bool,
/// Prepend to existing changelog
#[arg(short, long)]
prepend: bool,
/// Include commit hashes
#[arg(long)]
include_hashes: bool,
/// Include authors
#[arg(long)]
include_authors: bool,
/// Format (keep-a-changelog, github-releases)
#[arg(short, long)]
format: Option<String>,
/// Dry run (output to stdout)
#[arg(long)]
dry_run: bool,
/// Skip interactive prompts
#[arg(short = 'y', long)]
yes: bool,
}
impl ChangelogCommand {
pub async fn execute(&self) -> Result<()> {
let repo = find_repo(".")?;
let manager = ConfigManager::new()?;
let config = manager.config();
// Initialize changelog if requested
if self.init {
let path = self.output.as_ref()
.map(|p| p.clone())
.unwrap_or_else(|| PathBuf::from(&config.changelog.path));
init_changelog(&path)?;
println!("{} Initialized changelog at {:?}", "".green(), path);
return Ok(());
}
// Determine output path
let output_path = self.output.as_ref()
.map(|p| p.clone())
.unwrap_or_else(|| PathBuf::from(&config.changelog.path));
// Determine format
let format = match self.format.as_deref() {
Some("github") | Some("github-releases") => ChangelogFormat::GitHubReleases,
Some("keep") | Some("keep-a-changelog") => ChangelogFormat::KeepAChangelog,
Some("custom") => ChangelogFormat::Custom,
None => ChangelogFormat::KeepAChangelog,
Some(f) => bail!("Unknown format: {}. Use: keep-a-changelog, github-releases", f),
};
// Get version
let version = if let Some(ref v) = self.version {
v.clone()
} else if !self.yes {
Input::new()
.with_prompt("Version")
.default("Unreleased".to_string())
.interact_text()?
} else {
"Unreleased".to_string()
};
// Get commits
println!("{} Fetching commits...", "".blue());
let commits = generate_from_history(&repo, self.from.as_deref(), Some(&self.to))?;
if commits.is_empty() {
bail!("No commits found in the specified range");
}
println!("{} Found {} commits", "".green(), commits.len());
// Generate changelog
let changelog = if self.generate || (config.changelog.auto_generate && !self.yes) {
self.generate_with_ai(&repo, &version, &commits).await?
} else {
self.generate_with_template(format, &version, &commits)?
};
// Output or write
if self.dry_run {
println!("\n{}", "".repeat(60));
println!("{}", changelog);
println!("{}", "".repeat(60));
return Ok(());
}
// Preview
if !self.yes {
println!("\n{}", "".repeat(60));
println!("{}", "Changelog preview:".bold());
println!("{}", "".repeat(60));
// Show first 20 lines
let preview: String = changelog.lines().take(20).collect::<Vec<_>>().join("\n");
println!("{}", preview);
if changelog.lines().count() > 20 {
println!("\n... ({} more lines)", changelog.lines().count() - 20);
}
println!("{}", "".repeat(60));
let confirm = Confirm::new()
.with_prompt(&format!("Write to {:?}?", output_path))
.default(true)
.interact()?;
if !confirm {
println!("{}", "Cancelled.".yellow());
return Ok(());
}
}
// Write to file
if self.prepend && output_path.exists() {
let existing = std::fs::read_to_string(&output_path)?;
let new_content = format!("{}\n{}", changelog, existing);
std::fs::write(&output_path, new_content)?;
} else {
std::fs::write(&output_path, changelog)?;
}
println!("{} Changelog written to {:?}", "".green(), output_path);
Ok(())
}
async fn generate_with_ai(
&self,
repo: &GitRepo,
version: &str,
commits: &[CommitInfo],
) -> Result<String> {
let manager = ConfigManager::new()?;
let config = manager.config();
println!("{} AI is generating changelog...", "🤖");
let generator = ContentGenerator::new(&config.llm).await?;
generator.generate_changelog_entry(version, commits).await
}
fn generate_with_template(
&self,
format: ChangelogFormat,
version: &str,
commits: &[CommitInfo],
) -> Result<String> {
let generator = ChangelogGenerator::new()
.format(format)
.include_hashes(self.include_hashes)
.include_authors(self.include_authors);
generator.generate(version, Utc::now(), commits)
}
}

321
src/commands/commit.rs Normal file
View File

@@ -0,0 +1,321 @@
use anyhow::{bail, Context, Result};
use clap::Parser;
use colored::Colorize;
use dialoguer::{Confirm, Input, Select};
use crate::config::manager::ConfigManager;
use crate::config::CommitFormat;
use crate::generator::ContentGenerator;
use crate::git::{find_repo, GitRepo};
use crate::git::commit::{CommitBuilder, create_date_commit_message, parse_commit_message};
use crate::utils::validators::get_commit_types;
/// Generate and execute conventional commits
#[derive(Parser)]
pub struct CommitCommand {
/// Commit type
#[arg(short, long)]
commit_type: Option<String>,
/// Commit scope
#[arg(short, long)]
scope: Option<String>,
/// Commit description/subject
#[arg(short, long)]
message: Option<String>,
/// Commit body
#[arg(long)]
body: Option<String>,
/// Mark as breaking change
#[arg(short, long)]
breaking: bool,
/// Use date-based commit message
#[arg(short, long)]
date: bool,
/// Manual input (skip AI generation)
#[arg(short, long)]
manual: bool,
/// Sign the commit
#[arg(short = 'S', long)]
sign: bool,
/// Amend previous commit
#[arg(long)]
amend: bool,
/// Stage all changes before committing
#[arg(short = 'a', long)]
all: bool,
/// Dry run (show message without committing)
#[arg(long)]
dry_run: bool,
/// Use conventional commit format
#[arg(long, group = "format")]
conventional: bool,
/// Use commitlint format
#[arg(long, group = "format")]
commitlint: bool,
/// Don't verify commit message
#[arg(long)]
no_verify: bool,
/// Skip interactive prompts
#[arg(short = 'y', long)]
yes: bool,
}
impl CommitCommand {
pub async fn execute(&self) -> Result<()> {
// Find git repository
let repo = find_repo(".")?;
// Check for changes
let status = repo.status_summary()?;
if status.clean && !self.amend {
bail!("No changes to commit. Working tree is clean.");
}
// Load configuration
let manager = ConfigManager::new()?;
let config = manager.config();
// Determine commit format
let format = if self.conventional {
CommitFormat::Conventional
} else if self.commitlint {
CommitFormat::Commitlint
} else {
config.commit.format
};
// Stage all if requested
if self.all {
repo.stage_all()?;
println!("{}", "✓ Staged all changes".green());
}
// Generate or build commit message
let commit_message = if self.date {
// Date-based commit
self.create_date_commit()
} else if self.manual || self.message.is_some() {
// Manual commit
self.create_manual_commit(format)?
} else if config.commit.auto_generate && !self.yes {
// AI-generated commit
self.generate_commit(&repo, format).await?
} else {
// Interactive commit creation
self.create_interactive_commit(format).await?
};
// Validate message
match format {
CommitFormat::Conventional => {
crate::utils::validators::validate_conventional_commit(&commit_message)?;
}
CommitFormat::Commitlint => {
crate::utils::validators::validate_commitlint_commit(&commit_message)?;
}
}
// Show commit preview
if !self.yes {
println!("\n{}", "".repeat(60));
println!("{}", "Commit preview:".bold());
println!("{}", "".repeat(60));
println!("{}", commit_message);
println!("{}", "".repeat(60));
let confirm = Confirm::new()
.with_prompt("Do you want to proceed with this commit?")
.default(true)
.interact()?;
if !confirm {
println!("{}", "Commit cancelled.".yellow());
return Ok(());
}
}
if self.dry_run {
println!("\n{}", "Dry run - commit not created.".yellow());
return Ok(());
}
// Execute commit
let result = if self.amend {
self.amend_commit(&repo, &commit_message)?;
None
} else {
Some(repo.commit(&commit_message, self.sign)?)
};
if let Some(commit_oid) = result {
println!("{} {}", "✓ Created commit".green().bold(), commit_oid.to_string()[..8].to_string().cyan());
} else {
println!("{} {}", "✓ Amended commit".green().bold(), "successfully");
}
Ok(())
}
fn create_date_commit(&self) -> String {
let prefix = self.commit_type.as_deref();
create_date_commit_message(prefix)
}
fn create_manual_commit(&self, format: CommitFormat) -> Result<String> {
let commit_type = self.commit_type.clone()
.ok_or_else(|| anyhow::anyhow!("Commit type required for manual commit. Use -t <type>"))?;
let description = self.message.clone()
.ok_or_else(|| anyhow::anyhow!("Description required for manual commit. Use -m <message>"))?;
let builder = CommitBuilder::new()
.commit_type(commit_type)
.description(description)
.scope_opt(self.scope.clone())
.body_opt(self.body.clone())
.breaking(self.breaking)
.format(format);
builder.build_message()
}
async fn generate_commit(&self, repo: &GitRepo, format: CommitFormat) -> Result<String> {
let manager = ConfigManager::new()?;
let config = manager.config();
// Check if LLM is configured
let generator = ContentGenerator::new(&config.llm).await
.context("Failed to initialize LLM. Use --manual for manual commit.")?;
println!("{} AI is analyzing your changes...", "🤖".to_string());
let generated = if self.yes {
generator.generate_commit_from_repo(repo, format).await?
} else {
generator.generate_commit_interactive(repo, format).await?
};
Ok(generated.to_conventional())
}
async fn create_interactive_commit(&self, format: CommitFormat) -> Result<String> {
let types = get_commit_types(format == CommitFormat::Commitlint);
// Select type
let type_idx = Select::new()
.with_prompt("Select commit type")
.items(types)
.interact()?;
let commit_type = types[type_idx].to_string();
// Enter scope (optional)
let scope: String = Input::new()
.with_prompt("Scope (optional, press Enter to skip)")
.allow_empty(true)
.interact_text()?;
let scope = if scope.is_empty() { None } else { Some(scope) };
// Enter description
let description: String = Input::new()
.with_prompt("Description")
.interact_text()?;
// Breaking change
let breaking = Confirm::new()
.with_prompt("Is this a breaking change?")
.default(false)
.interact()?;
// Add body
let add_body = Confirm::new()
.with_prompt("Add body to commit?")
.default(false)
.interact()?;
let body = if add_body {
let body_text = crate::utils::editor::edit_content("Enter commit body...")?;
if body_text.trim().is_empty() {
None
} else {
Some(body_text)
}
} else {
None
};
// Build commit
let builder = CommitBuilder::new()
.commit_type(commit_type)
.description(description)
.scope_opt(scope)
.body_opt(body)
.breaking(breaking)
.format(format);
builder.build_message()
}
fn amend_commit(&self, repo: &GitRepo, message: &str) -> Result<()> {
use std::process::Command;
let mut args = vec!["commit", "--amend", "-m", message];
if self.no_verify {
args.push("--no-verify");
}
if self.sign {
args.push("-S");
}
let output = Command::new("git")
.args(&args)
.current_dir(repo.path())
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to amend commit: {}", stderr);
}
Ok(())
}
}
// Helper trait for optional builder methods
trait CommitBuilderExt {
fn scope_opt(self, scope: Option<String>) -> Self;
fn body_opt(self, body: Option<String>) -> Self;
}
impl CommitBuilderExt for CommitBuilder {
fn scope_opt(self, scope: Option<String>) -> Self {
if let Some(s) = scope {
self.scope(s)
} else {
self
}
}
fn body_opt(self, body: Option<String>) -> Self {
if let Some(b) = body {
self.body(b)
} else {
self
}
}
}

518
src/commands/config.rs Normal file
View File

@@ -0,0 +1,518 @@
use anyhow::{bail, Context, Result};
use clap::{Parser, Subcommand};
use colored::Colorize;
use dialoguer::{Confirm, Input, Select};
use crate::config::manager::ConfigManager;
use crate::config::{CommitFormat, LlmConfig};
/// Manage configuration settings
#[derive(Parser)]
pub struct ConfigCommand {
#[command(subcommand)]
command: Option<ConfigSubcommand>,
}
#[derive(Subcommand)]
enum ConfigSubcommand {
/// Show current configuration
Show,
/// Edit configuration file
Edit,
/// Set configuration value
Set {
/// Key (e.g., llm.provider, commit.format)
key: String,
/// Value
value: String,
},
/// Get configuration value
Get {
/// Key
key: String,
},
/// Set LLM provider
SetLlm {
/// Provider (ollama, openai, anthropic)
#[arg(value_name = "PROVIDER")]
provider: Option<String>,
},
/// Set OpenAI API key
SetOpenAiKey {
/// API key
key: String,
},
/// Set Anthropic API key
SetAnthropicKey {
/// API key
key: String,
},
/// Configure Ollama settings
SetOllama {
/// Ollama server URL
#[arg(short, long)]
url: Option<String>,
/// Model name
#[arg(short, long)]
model: Option<String>,
},
/// Set commit format
SetCommitFormat {
/// Format (conventional, commitlint)
format: String,
},
/// Set version prefix for tags
SetVersionPrefix {
/// Prefix (e.g., 'v')
prefix: String,
},
/// Set changelog path
SetChangelogPath {
/// Path
path: String,
},
/// Reset configuration to defaults
Reset {
/// Skip confirmation
#[arg(short, long)]
force: bool,
},
/// Export configuration
Export {
/// Output file (defaults to stdout)
#[arg(short, long)]
output: Option<String>,
},
/// Import configuration
Import {
/// Input file
#[arg(short, long)]
file: String,
},
/// List available LLM models
ListModels,
/// Test LLM connection
TestLlm,
}
impl ConfigCommand {
pub async fn execute(&self) -> Result<()> {
match &self.command {
Some(ConfigSubcommand::Show) => self.show_config().await,
Some(ConfigSubcommand::Edit) => self.edit_config().await,
Some(ConfigSubcommand::Set { key, value }) => self.set_value(key, value).await,
Some(ConfigSubcommand::Get { key }) => self.get_value(key).await,
Some(ConfigSubcommand::SetLlm { provider }) => self.set_llm(provider.as_deref()).await,
Some(ConfigSubcommand::SetOpenAiKey { key }) => self.set_openai_key(key).await,
Some(ConfigSubcommand::SetAnthropicKey { key }) => self.set_anthropic_key(key).await,
Some(ConfigSubcommand::SetOllama { url, model }) => self.set_ollama(url.as_deref(), model.as_deref()).await,
Some(ConfigSubcommand::SetCommitFormat { format }) => self.set_commit_format(format).await,
Some(ConfigSubcommand::SetVersionPrefix { prefix }) => self.set_version_prefix(prefix).await,
Some(ConfigSubcommand::SetChangelogPath { path }) => self.set_changelog_path(path).await,
Some(ConfigSubcommand::Reset { force }) => self.reset(*force).await,
Some(ConfigSubcommand::Export { output }) => self.export_config(output.as_deref()).await,
Some(ConfigSubcommand::Import { file }) => self.import_config(file).await,
Some(ConfigSubcommand::ListModels) => self.list_models().await,
Some(ConfigSubcommand::TestLlm) => self.test_llm().await,
None => self.show_config().await,
}
}
async fn show_config(&self) -> Result<()> {
let manager = ConfigManager::new()?;
let config = manager.config();
println!("{}", "\nQuicCommit Configuration".bold());
println!("{}", "".repeat(60));
println!("\n{}", "General:".bold());
println!(" Config file: {}", manager.path().display());
println!(" Default profile: {}",
config.default_profile.as_deref().unwrap_or("(none)").cyan());
println!(" Profiles: {}", config.profiles.len());
println!("\n{}", "LLM Configuration:".bold());
println!(" Provider: {}", config.llm.provider.cyan());
println!(" Max tokens: {}", config.llm.max_tokens);
println!(" Temperature: {}", config.llm.temperature);
println!(" Timeout: {}s", config.llm.timeout);
match config.llm.provider.as_str() {
"ollama" => {
println!(" URL: {}", config.llm.ollama.url);
println!(" Model: {}", config.llm.ollama.model.cyan());
}
"openai" => {
println!(" Model: {}", config.llm.openai.model.cyan());
println!(" API key: {}",
if config.llm.openai.api_key.is_some() { "✓ set".green() } else { "✗ not set".red() });
}
"anthropic" => {
println!(" Model: {}", config.llm.anthropic.model.cyan());
println!(" API key: {}",
if config.llm.anthropic.api_key.is_some() { "✓ set".green() } else { "✗ not set".red() });
}
_ => {}
}
println!("\n{}", "Commit Configuration:".bold());
println!(" Format: {}", config.commit.format.to_string().cyan());
println!(" Auto-generate: {}", if config.commit.auto_generate { "yes".green() } else { "no".red() });
println!(" GPG sign: {}", if config.commit.gpg_sign { "yes".green() } else { "no".red() });
println!(" Max subject length: {}", config.commit.max_subject_length);
println!("\n{}", "Tag Configuration:".bold());
println!(" Version prefix: '{}'", config.tag.version_prefix);
println!(" Auto-generate: {}", if config.tag.auto_generate { "yes".green() } else { "no".red() });
println!(" GPG sign: {}", if config.tag.gpg_sign { "yes".green() } else { "no".red() });
println!(" Include changelog: {}", if config.tag.include_changelog { "yes".green() } else { "no".red() });
println!("\n{}", "Changelog Configuration:".bold());
println!(" Path: {}", config.changelog.path);
println!(" Auto-generate: {}", if config.changelog.auto_generate { "yes".green() } else { "no".red() });
println!(" Include hashes: {}", if config.changelog.include_hashes { "yes".green() } else { "no".red() });
println!(" Include authors: {}", if config.changelog.include_authors { "yes".green() } else { "no".red() });
println!(" Group by type: {}", if config.changelog.group_by_type { "yes".green() } else { "no".red() });
Ok(())
}
async fn edit_config(&self) -> Result<()> {
let manager = ConfigManager::new()?;
crate::utils::editor::edit_file(manager.path())?;
println!("{} Configuration updated", "".green());
Ok(())
}
async fn set_value(&self, key: &str, value: &str) -> Result<()> {
let mut manager = ConfigManager::new()?;
match key {
"llm.provider" => manager.set_llm_provider(value.to_string()),
"llm.max_tokens" => {
let tokens: u32 = value.parse()?;
manager.config_mut().llm.max_tokens = tokens;
}
"llm.temperature" => {
let temp: f32 = value.parse()?;
manager.config_mut().llm.temperature = temp;
}
"llm.timeout" => {
let timeout: u64 = value.parse()?;
manager.config_mut().llm.timeout = timeout;
}
"commit.format" => {
let format = match value {
"conventional" => CommitFormat::Conventional,
"commitlint" => CommitFormat::Commitlint,
_ => bail!("Invalid format: {}. Use: conventional, commitlint", value),
};
manager.set_commit_format(format);
}
"commit.auto_generate" => {
manager.set_auto_generate_commits(value == "true");
}
"tag.version_prefix" => manager.set_version_prefix(value.to_string()),
"changelog.path" => manager.set_changelog_path(value.to_string()),
_ => bail!("Unknown configuration key: {}", key),
}
manager.save()?;
println!("{} Set {} = {}", "".green(), key.cyan(), value);
Ok(())
}
async fn get_value(&self, key: &str) -> Result<()> {
let manager = ConfigManager::new()?;
let config = manager.config();
let value = match key {
"llm.provider" => &config.llm.provider,
"llm.max_tokens" => return Ok(println!("{}", config.llm.max_tokens)),
"llm.temperature" => return Ok(println!("{}", config.llm.temperature)),
"llm.timeout" => return Ok(println!("{}", config.llm.timeout)),
"commit.format" => return Ok(println!("{}", config.commit.format)),
"tag.version_prefix" => &config.tag.version_prefix,
"changelog.path" => &config.changelog.path,
_ => bail!("Unknown configuration key: {}", key),
};
println!("{}", value);
Ok(())
}
async fn set_llm(&self, provider: Option<&str>) -> Result<()> {
let mut manager = ConfigManager::new()?;
let provider = if let Some(p) = provider {
p.to_string()
} else {
let providers = vec!["ollama", "openai", "anthropic"];
let idx = Select::new()
.with_prompt("Select LLM provider")
.items(&providers)
.default(0)
.interact()?;
providers[idx].to_string()
};
manager.set_llm_provider(provider.clone());
// Configure provider-specific settings
match provider.as_str() {
"openai" => {
let api_key: String = Input::new()
.with_prompt("OpenAI API key")
.interact_text()?;
manager.set_openai_api_key(api_key);
let model: String = Input::new()
.with_prompt("Model")
.default("gpt-4".to_string())
.interact_text()?;
manager.config_mut().llm.openai.model = model;
}
"anthropic" => {
let api_key: String = Input::new()
.with_prompt("Anthropic API key")
.interact_text()?;
manager.set_anthropic_api_key(api_key);
}
"ollama" => {
let url: String = Input::new()
.with_prompt("Ollama URL")
.default("http://localhost:11434".to_string())
.interact_text()?;
manager.config_mut().llm.ollama.url = url;
let model: String = Input::new()
.with_prompt("Model")
.default("llama2".to_string())
.interact_text()?;
manager.config_mut().llm.ollama.model = model;
}
_ => {}
}
manager.save()?;
println!("{} Set LLM provider to {}", "".green(), provider.cyan());
Ok(())
}
async fn set_openai_key(&self, key: &str) -> Result<()> {
let mut manager = ConfigManager::new()?;
manager.set_openai_api_key(key.to_string());
manager.save()?;
println!("{} OpenAI API key set", "".green());
Ok(())
}
async fn set_anthropic_key(&self, key: &str) -> Result<()> {
let mut manager = ConfigManager::new()?;
manager.set_anthropic_api_key(key.to_string());
manager.save()?;
println!("{} Anthropic API key set", "".green());
Ok(())
}
async fn set_ollama(&self, url: Option<&str>, model: Option<&str>) -> Result<()> {
let mut manager = ConfigManager::new()?;
if let Some(u) = url {
manager.config_mut().llm.ollama.url = u.to_string();
}
if let Some(m) = model {
manager.config_mut().llm.ollama.model = m.to_string();
}
manager.save()?;
println!("{} Ollama configuration updated", "".green());
Ok(())
}
async fn set_commit_format(&self, format: &str) -> Result<()> {
let mut manager = ConfigManager::new()?;
let format = match format {
"conventional" => CommitFormat::Conventional,
"commitlint" => CommitFormat::Commitlint,
_ => bail!("Invalid format: {}. Use: conventional, commitlint", format),
};
manager.set_commit_format(format);
manager.save()?;
println!("{} Set commit format to {}", "".green(), format.to_string().cyan());
Ok(())
}
async fn set_version_prefix(&self, prefix: &str) -> Result<()> {
let mut manager = ConfigManager::new()?;
manager.set_version_prefix(prefix.to_string());
manager.save()?;
println!("{} Set version prefix to '{}'", "".green(), prefix);
Ok(())
}
async fn set_changelog_path(&self, path: &str) -> Result<()> {
let mut manager = ConfigManager::new()?;
manager.set_changelog_path(path.to_string());
manager.save()?;
println!("{} Set changelog path to {}", "".green(), path);
Ok(())
}
async fn reset(&self, force: bool) -> Result<()> {
if !force {
let confirm = Confirm::new()
.with_prompt("Are you sure you want to reset all configuration?")
.default(false)
.interact()?;
if !confirm {
println!("{}", "Cancelled.".yellow());
return Ok(());
}
}
let mut manager = ConfigManager::new()?;
manager.reset();
manager.save()?;
println!("{} Configuration reset to defaults", "".green());
Ok(())
}
async fn export_config(&self, output: Option<&str>) -> Result<()> {
let manager = ConfigManager::new()?;
let toml = manager.export()?;
if let Some(path) = output {
std::fs::write(path, toml)?;
println!("{} Configuration exported to {}", "".green(), path);
} else {
println!("{}", toml);
}
Ok(())
}
async fn import_config(&self, file: &str) -> Result<()> {
let toml = std::fs::read_to_string(file)?;
let mut manager = ConfigManager::new()?;
manager.import(&toml)?;
manager.save()?;
println!("{} Configuration imported from {}", "".green(), file);
Ok(())
}
async fn list_models(&self) -> Result<()> {
let manager = ConfigManager::new()?;
let config = manager.config();
match config.llm.provider.as_str() {
"ollama" => {
let client = crate::llm::OllamaClient::new(
&config.llm.ollama.url,
&config.llm.ollama.model,
);
println!("Fetching available models from Ollama...");
match client.list_models().await {
Ok(models) => {
println!("\n{}", "Available models:".bold());
for model in models {
let marker = if model == config.llm.ollama.model { "".green() } else { "".dimmed() };
println!("{} {}", marker, model);
}
}
Err(e) => {
println!("{} Failed to fetch models: {}", "".red(), e);
}
}
}
"openai" => {
if let Some(ref key) = config.llm.openai.api_key {
let client = crate::llm::OpenAiClient::new(
&config.llm.openai.base_url,
key,
&config.llm.openai.model,
)?;
println!("Fetching available models from OpenAI...");
match client.list_models().await {
Ok(models) => {
println!("\n{}", "Available models:".bold());
for model in models {
let marker = if model == config.llm.openai.model { "".green() } else { "".dimmed() };
println!("{} {}", marker, model);
}
}
Err(e) => {
println!("{} Failed to fetch models: {}", "".red(), e);
}
}
} else {
bail!("OpenAI API key not configured");
}
}
provider => {
println!("Listing models not supported for provider: {}", provider);
}
}
Ok(())
}
async fn test_llm(&self) -> Result<()> {
let manager = ConfigManager::new()?;
let config = manager.config();
println!("Testing LLM connection ({})...", config.llm.provider.cyan());
match crate::llm::LlmClient::from_config(&config.llm).await {
Ok(client) => {
if client.is_available().await {
println!("{} LLM connection successful!", "".green());
// Test generation
println!("Testing generation...");
match client.generate_commit_message("test", crate::config::CommitFormat::Conventional).await {
Ok(response) => {
println!("{} Generation test passed", "".green());
println!("Response: {}", response.description.dimmed());
}
Err(e) => {
println!("{} Generation test failed: {}", "".red(), e);
}
}
} else {
println!("{} LLM provider is not available", "".red());
}
}
Err(e) => {
println!("{} Failed to initialize LLM: {}", "".red(), e);
}
}
Ok(())
}
}

270
src/commands/init.rs Normal file
View File

@@ -0,0 +1,270 @@
use anyhow::{Context, Result};
use clap::Parser;
use colored::Colorize;
use dialoguer::{Confirm, Input, Select};
use crate::config::{GitProfile};
use crate::config::manager::ConfigManager;
use crate::config::profile::{GpgConfig, SshConfig};
use crate::utils::validators::validate_email;
/// Initialize quicommit configuration
#[derive(Parser)]
pub struct InitCommand {
/// Skip interactive setup
#[arg(short, long)]
yes: bool,
/// Reset existing configuration
#[arg(long)]
reset: bool,
}
impl InitCommand {
pub async fn execute(&self) -> Result<()> {
println!("{}", "🚀 Initializing QuicCommit...".bold().cyan());
let config_path = crate::config::AppConfig::default_path()?;
// Check if config already exists
if config_path.exists() && !self.reset {
if !self.yes {
let overwrite = Confirm::new()
.with_prompt("Configuration already exists. Overwrite?")
.default(false)
.interact()?;
if !overwrite {
println!("{}", "Initialization cancelled.".yellow());
return Ok(());
}
}
}
let mut manager = if self.reset {
ConfigManager::new()?
} else {
ConfigManager::new().or_else(|_| Ok::<_, anyhow::Error>(ConfigManager::default()))?
};
if self.yes {
// Quick setup with defaults
self.quick_setup(&mut manager).await?;
} else {
// Interactive setup
self.interactive_setup(&mut manager).await?;
}
manager.save()?;
println!("{}", "✅ QuicCommit initialized successfully!".bold().green());
println!("\nConfig file: {}", config_path.display());
println!("\nNext steps:");
println!(" 1. Create a profile: {}", "quicommit profile add".cyan());
println!(" 2. Configure LLM: {}", "quicommit config set-llm".cyan());
println!(" 3. Start committing: {}", "quicommit commit".cyan());
Ok(())
}
async fn quick_setup(&self, manager: &mut ConfigManager) -> Result<()> {
// Try to get git user info
let git_config = git2::Config::open_default()?;
let user_name = git_config.get_string("user.name").unwrap_or_else(|_| "User".to_string());
let user_email = git_config.get_string("user.email").unwrap_or_else(|_| "user@example.com".to_string());
let profile = GitProfile::new(
"default".to_string(),
user_name,
user_email,
);
manager.add_profile("default".to_string(), profile)?;
manager.set_default_profile(Some("default".to_string()))?;
// Set default LLM to Ollama
manager.set_llm_provider("ollama".to_string());
Ok(())
}
async fn interactive_setup(&self, manager: &mut ConfigManager) -> Result<()> {
println!("\n{}", "Let's set up your first profile:".bold());
// Profile name
let profile_name: String = Input::new()
.with_prompt("Profile name")
.default("personal".to_string())
.interact_text()?;
// User info
let git_config = git2::Config::open_default().ok();
let default_name = git_config.as_ref()
.and_then(|c| c.get_string("user.name").ok())
.unwrap_or_default();
let default_email = git_config.as_ref()
.and_then(|c| c.get_string("user.email").ok())
.unwrap_or_default();
let user_name: String = Input::new()
.with_prompt("Git user name")
.default(default_name)
.interact_text()?;
let user_email: String = Input::new()
.with_prompt("Git user email")
.default(default_email)
.validate_with(|input: &String| {
validate_email(input).map_err(|e| e.to_string())
})
.interact_text()?;
let description: String = Input::new()
.with_prompt("Profile description (optional)")
.allow_empty(true)
.interact_text()?;
let is_work = Confirm::new()
.with_prompt("Is this a work profile?")
.default(false)
.interact()?;
let organization = if is_work {
Some(Input::new()
.with_prompt("Organization/Company name")
.interact_text()?)
} else {
None
};
// SSH configuration
let setup_ssh = Confirm::new()
.with_prompt("Configure SSH key?")
.default(false)
.interact()?;
let ssh_config = if setup_ssh {
Some(self.setup_ssh_interactive().await?)
} else {
None
};
// GPG configuration
let setup_gpg = Confirm::new()
.with_prompt("Configure GPG signing?")
.default(false)
.interact()?;
let gpg_config = if setup_gpg {
Some(self.setup_gpg_interactive().await?)
} else {
None
};
// Create profile
let mut profile = GitProfile::new(
profile_name.clone(),
user_name,
user_email,
);
if !description.is_empty() {
profile.description = Some(description);
}
profile.is_work = is_work;
profile.organization = organization;
profile.ssh = ssh_config;
profile.gpg = gpg_config;
manager.add_profile(profile_name.clone(), profile)?;
manager.set_default_profile(Some(profile_name))?;
// LLM provider selection
println!("\n{}", "Select your preferred LLM provider:".bold());
let providers = vec!["Ollama (local)", "OpenAI", "Anthropic Claude"];
let provider_idx = Select::new()
.items(&providers)
.default(0)
.interact()?;
let provider = match provider_idx {
0 => "ollama",
1 => "openai",
2 => "anthropic",
_ => "ollama",
};
manager.set_llm_provider(provider.to_string());
// Configure API key if needed
if provider == "openai" {
let api_key: String = Input::new()
.with_prompt("OpenAI API key")
.interact_text()?;
manager.set_openai_api_key(api_key);
} else if provider == "anthropic" {
let api_key: String = Input::new()
.with_prompt("Anthropic API key")
.interact_text()?;
manager.set_anthropic_api_key(api_key);
}
Ok(())
}
async fn setup_ssh_interactive(&self) -> Result<SshConfig> {
use std::path::PathBuf;
let ssh_dir = dirs::home_dir()
.map(|h| h.join(".ssh"))
.unwrap_or_else(|| PathBuf::from("~/.ssh"));
let key_path: String = Input::new()
.with_prompt("SSH private key path")
.default(ssh_dir.join("id_rsa").display().to_string())
.interact_text()?;
let has_passphrase = Confirm::new()
.with_prompt("Does this key have a passphrase?")
.default(false)
.interact()?;
let passphrase = if has_passphrase {
Some(crate::utils::password_input("SSH key passphrase")?)
} else {
None
};
Ok(SshConfig {
private_key_path: Some(PathBuf::from(key_path)),
public_key_path: None,
passphrase,
agent_forwarding: false,
ssh_command: None,
known_hosts_file: None,
})
}
async fn setup_gpg_interactive(&self) -> Result<GpgConfig> {
let key_id: String = Input::new()
.with_prompt("GPG key ID")
.interact_text()?;
let use_agent = Confirm::new()
.with_prompt("Use GPG agent?")
.default(true)
.interact()?;
Ok(GpgConfig {
key_id,
program: "gpg".to_string(),
home_dir: None,
passphrase: None,
use_agent,
})
}
}

6
src/commands/mod.rs Normal file
View File

@@ -0,0 +1,6 @@
pub mod changelog;
pub mod commit;
pub mod config;
pub mod init;
pub mod profile;
pub mod tag;

492
src/commands/profile.rs Normal file
View File

@@ -0,0 +1,492 @@
use anyhow::{bail, Context, Result};
use clap::{Parser, Subcommand};
use colored::Colorize;
use dialoguer::{Confirm, Input, Select};
use crate::config::manager::ConfigManager;
use crate::config::{GitProfile};
use crate::config::profile::{GpgConfig, SshConfig};
use crate::git::find_repo;
use crate::utils::validators::validate_profile_name;
/// Manage Git profiles
#[derive(Parser)]
pub struct ProfileCommand {
#[command(subcommand)]
command: Option<ProfileSubcommand>,
}
#[derive(Subcommand)]
enum ProfileSubcommand {
/// Add a new profile
Add,
/// Remove a profile
Remove {
/// Profile name
name: String,
},
/// List all profiles
List,
/// Show profile details
Show {
/// Profile name
name: Option<String>,
},
/// Edit a profile
Edit {
/// Profile name
name: String,
},
/// Set default profile
SetDefault {
/// Profile name
name: String,
},
/// Set profile for current repository
SetRepo {
/// Profile name
name: String,
},
/// Apply profile to current repository
Apply {
/// Profile name (uses default if not specified)
name: Option<String>,
/// Apply globally instead of to current repo
#[arg(short, long)]
global: bool,
},
/// Switch between profiles interactively
Switch,
/// Copy/duplicate a profile
Copy {
/// Source profile name
from: String,
/// New profile name
to: String,
},
}
impl ProfileCommand {
pub async fn execute(&self) -> Result<()> {
match &self.command {
Some(ProfileSubcommand::Add) => self.add_profile().await,
Some(ProfileSubcommand::Remove { name }) => self.remove_profile(name).await,
Some(ProfileSubcommand::List) => self.list_profiles().await,
Some(ProfileSubcommand::Show { name }) => self.show_profile(name.as_deref()).await,
Some(ProfileSubcommand::Edit { name }) => self.edit_profile(name).await,
Some(ProfileSubcommand::SetDefault { name }) => self.set_default(name).await,
Some(ProfileSubcommand::SetRepo { name }) => self.set_repo(name).await,
Some(ProfileSubcommand::Apply { name, global }) => self.apply_profile(name.as_deref(), *global).await,
Some(ProfileSubcommand::Switch) => self.switch_profile().await,
Some(ProfileSubcommand::Copy { from, to }) => self.copy_profile(from, to).await,
None => self.list_profiles().await,
}
}
async fn add_profile(&self) -> Result<()> {
let mut manager = ConfigManager::new()?;
println!("{}", "\nAdd new profile".bold());
println!("{}", "".repeat(40));
let name: String = Input::new()
.with_prompt("Profile name")
.validate_with(|input: &String| {
validate_profile_name(input).map_err(|e| e.to_string())
})
.interact_text()?;
if manager.has_profile(&name) {
bail!("Profile '{}' already exists", name);
}
let user_name: String = Input::new()
.with_prompt("Git user name")
.interact_text()?;
let user_email: String = Input::new()
.with_prompt("Git user email")
.validate_with(|input: &String| {
crate::utils::validators::validate_email(input).map_err(|e| e.to_string())
})
.interact_text()?;
let description: String = Input::new()
.with_prompt("Description (optional)")
.allow_empty(true)
.interact_text()?;
let is_work = Confirm::new()
.with_prompt("Is this a work profile?")
.default(false)
.interact()?;
let organization = if is_work {
Some(Input::new()
.with_prompt("Organization")
.interact_text()?)
} else {
None
};
let mut profile = GitProfile::new(name.clone(), user_name, user_email);
if !description.is_empty() {
profile.description = Some(description);
}
profile.is_work = is_work;
profile.organization = organization;
// SSH configuration
let setup_ssh = Confirm::new()
.with_prompt("Configure SSH key?")
.default(false)
.interact()?;
if setup_ssh {
profile.ssh = Some(self.setup_ssh_interactive().await?);
}
// GPG configuration
let setup_gpg = Confirm::new()
.with_prompt("Configure GPG signing?")
.default(false)
.interact()?;
if setup_gpg {
profile.gpg = Some(self.setup_gpg_interactive().await?);
}
manager.add_profile(name.clone(), profile)?;
manager.save()?;
println!("{} Profile '{}' added successfully", "".green(), name.cyan());
// Offer to set as default
if manager.default_profile().is_none() {
let set_default = Confirm::new()
.with_prompt("Set as default profile?")
.default(true)
.interact()?;
if set_default {
manager.set_default_profile(Some(name.clone()))?;
manager.save()?;
println!("{} Set '{}' as default profile", "".green(), name.cyan());
}
}
Ok(())
}
async fn remove_profile(&self, name: &str) -> Result<()> {
let mut manager = ConfigManager::new()?;
if !manager.has_profile(name) {
bail!("Profile '{}' not found", name);
}
let confirm = Confirm::new()
.with_prompt(&format!("Are you sure you want to remove profile '{}'?", name))
.default(false)
.interact()?;
if !confirm {
println!("{}", "Cancelled.".yellow());
return Ok(());
}
manager.remove_profile(name)?;
manager.save()?;
println!("{} Profile '{}' removed", "".green(), name);
Ok(())
}
async fn list_profiles(&self) -> Result<()> {
let manager = ConfigManager::new()?;
let profiles = manager.list_profiles();
if profiles.is_empty() {
println!("{}", "No profiles configured.".yellow());
println!("Run {} to create one.", "quicommit profile add".cyan());
return Ok(());
}
let default = manager.default_profile_name();
println!("{}", "\nConfigured profiles:".bold());
println!("{}", "".repeat(60));
for name in profiles {
let profile = manager.get_profile(name).unwrap();
let is_default = default.map(|d| d == name).unwrap_or(false);
let marker = if is_default { "".green() } else { "".dimmed() };
let work_marker = if profile.is_work { " [work]".yellow() } else { "".normal() };
println!("{} {}{}", marker, name.cyan().bold(), work_marker);
println!(" {} <{}>", profile.user_name, profile.user_email);
if let Some(ref desc) = profile.description {
println!(" {}", desc.dimmed());
}
if profile.has_ssh() {
println!(" {} SSH configured", "🔑".to_string().dimmed());
}
if profile.has_gpg() {
println!(" {} GPG configured", "🔒".to_string().dimmed());
}
println!();
}
Ok(())
}
async fn show_profile(&self, name: Option<&str>) -> Result<()> {
let manager = ConfigManager::new()?;
let profile = if let Some(n) = name {
manager.get_profile(n)
.ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", n))?
} else {
manager.default_profile()
.ok_or_else(|| anyhow::anyhow!("No default profile set"))?
};
println!("{}", format!("\nProfile: {}", profile.name).bold());
println!("{}", "".repeat(40));
println!("User name: {}", profile.user_name);
println!("User email: {}", profile.user_email);
if let Some(ref desc) = profile.description {
println!("Description: {}", desc);
}
println!("Work profile: {}", if profile.is_work { "yes".yellow() } else { "no".normal() });
if let Some(ref org) = profile.organization {
println!("Organization: {}", org);
}
if let Some(ref ssh) = profile.ssh {
println!("\n{}", "SSH Configuration:".bold());
if let Some(ref path) = ssh.private_key_path {
println!(" Private key: {:?}", path);
}
}
if let Some(ref gpg) = profile.gpg {
println!("\n{}", "GPG Configuration:".bold());
println!(" Key ID: {}", gpg.key_id);
println!(" Program: {}", gpg.program);
println!(" Use agent: {}", if gpg.use_agent { "yes" } else { "no" });
}
Ok(())
}
async fn edit_profile(&self, name: &str) -> Result<()> {
let mut manager = ConfigManager::new()?;
let profile = manager.get_profile(name)
.ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", name))?
.clone();
println!("{}", format!("\nEditing profile: {}", name).bold());
println!("{}", "".repeat(40));
let user_name: String = Input::new()
.with_prompt("Git user name")
.default(profile.user_name.clone())
.interact_text()?;
let user_email: String = Input::new()
.with_prompt("Git user email")
.default(profile.user_email.clone())
.validate_with(|input: &String| {
crate::utils::validators::validate_email(input).map_err(|e| e.to_string())
})
.interact_text()?;
let mut new_profile = GitProfile::new(name.to_string(), user_name, user_email);
new_profile.description = profile.description;
new_profile.is_work = profile.is_work;
new_profile.organization = profile.organization;
new_profile.ssh = profile.ssh;
new_profile.gpg = profile.gpg;
manager.update_profile(name, new_profile)?;
manager.save()?;
println!("{} Profile '{}' updated", "".green(), name);
Ok(())
}
async fn set_default(&self, name: &str) -> Result<()> {
let mut manager = ConfigManager::new()?;
manager.set_default_profile(Some(name.to_string()))?;
manager.save()?;
println!("{} Set '{}' as default profile", "".green(), name.cyan());
Ok(())
}
async fn set_repo(&self, name: &str) -> Result<()> {
let mut manager = ConfigManager::new()?;
let repo = find_repo(".")?;
let repo_path = repo.path().to_string_lossy().to_string();
manager.set_repo_profile(repo_path, name.to_string())?;
manager.save()?;
println!("{} Set '{}' for current repository", "".green(), name.cyan());
Ok(())
}
async fn apply_profile(&self, name: Option<&str>, global: bool) -> Result<()> {
let manager = ConfigManager::new()?;
let profile = if let Some(n) = name {
manager.get_profile(n)
.ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", n))?
.clone()
} else {
manager.default_profile()
.ok_or_else(|| anyhow::anyhow!("No default profile set"))?
.clone()
};
if global {
profile.apply_global()?;
println!("{} Applied profile '{}' globally", "".green(), profile.name.cyan());
} else {
let repo = find_repo(".")?;
profile.apply_to_repo(repo.inner())?;
println!("{} Applied profile '{}' to current repository", "".green(), profile.name.cyan());
}
Ok(())
}
async fn switch_profile(&self) -> Result<()> {
let mut manager = ConfigManager::new()?;
let profiles: Vec<String> = manager.list_profiles()
.into_iter()
.map(|s| s.to_string())
.collect();
if profiles.is_empty() {
bail!("No profiles configured");
}
let default = manager.default_profile_name().map(|s| s.as_str());
let default_idx = default
.and_then(|d| profiles.iter().position(|p| p == d))
.unwrap_or(0);
let selection = Select::new()
.with_prompt("Select profile to switch to")
.items(&profiles)
.default(default_idx)
.interact()?;
let selected = &profiles[selection];
manager.set_default_profile(Some(selected.clone()))?;
manager.save()?;
println!("{} Switched to profile '{}'", "".green(), selected.cyan());
// Offer to apply to current repo
if find_repo(".").is_ok() {
let apply = Confirm::new()
.with_prompt("Apply to current repository?")
.default(true)
.interact()?;
if apply {
self.apply_profile(Some(selected), false).await?;
}
}
Ok(())
}
async fn copy_profile(&self, from: &str, to: &str) -> Result<()> {
let mut manager = ConfigManager::new()?;
let source = manager.get_profile(from)
.ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", from))?
.clone();
validate_profile_name(to)?;
let mut new_profile = source.clone();
new_profile.name = to.to_string();
manager.add_profile(to.to_string(), new_profile)?;
manager.save()?;
println!("{} Copied profile '{}' to '{}'", "".green(), from, to.cyan());
Ok(())
}
async fn setup_ssh_interactive(&self) -> Result<SshConfig> {
use std::path::PathBuf;
let ssh_dir = dirs::home_dir()
.map(|h| h.join(".ssh"))
.unwrap_or_else(|| PathBuf::from("~/.ssh"));
let key_path: String = Input::new()
.with_prompt("SSH private key path")
.default(ssh_dir.join("id_rsa").display().to_string())
.interact_text()?;
Ok(SshConfig {
private_key_path: Some(PathBuf::from(key_path)),
public_key_path: None,
passphrase: None,
agent_forwarding: false,
ssh_command: None,
known_hosts_file: None,
})
}
async fn setup_gpg_interactive(&self) -> Result<GpgConfig> {
let key_id: String = Input::new()
.with_prompt("GPG key ID")
.interact_text()?;
Ok(GpgConfig {
key_id,
program: "gpg".to_string(),
home_dir: None,
passphrase: None,
use_agent: true,
})
}
}

307
src/commands/tag.rs Normal file
View File

@@ -0,0 +1,307 @@
use anyhow::{bail, Context, Result};
use clap::Parser;
use colored::Colorize;
use dialoguer::{Confirm, Input, Select};
use semver::Version;
use crate::config::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,
};
/// Generate and create Git tags
#[derive(Parser)]
pub struct TagCommand {
/// Tag name
#[arg(short, long)]
name: Option<String>,
/// Semantic version bump
#[arg(short, long, value_name = "TYPE")]
bump: Option<String>,
/// Tag message
#[arg(short, long)]
message: Option<String>,
/// 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) -> Result<()> {
let repo = find_repo(".")?;
let manager = ConfigManager::new()?;
let config = manager.config();
// 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).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!("Tag creation 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).await?)
} else if !self.yes {
Some(self.input_message_interactive(&tag_name)?)
} else {
Some(format!("Release {}", tag_name))
};
// Show preview
println!("\n{}", "".repeat(60));
println!("{}", "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("Create this tag?")
.default(true)
.interact()?;
if !confirm {
println!("{}", "Tag creation cancelled.".yellow());
return Ok(());
}
}
if self.dry_run {
println!("\n{}", "Dry run - tag not created.".yellow());
return Ok(());
}
// Create tag
let builder = TagBuilder::new()
.name(&tag_name)
.message_opt(message)
.annotate(!self.lightweight)
.sign(self.sign)
.force(self.force);
builder.execute(&repo)?;
println!("{} Created tag {}", "".green(), tag_name.cyan());
// Push if requested
if self.push {
println!("{} Pushing tag to {}...", "".blue(), &self.remote);
repo.push(&self.remote, &format!("refs/tags/{}", tag_name))?;
println!("{} Pushed tag to {}", "".green(), &self.remote);
}
Ok(())
}
async fn select_version_interactive(&self, repo: &GitRepo, prefix: &str) -> Result<String> {
loop {
let latest = get_latest_version(repo, prefix)?;
println!("\n{}", "Version selection:".bold());
if let Some(ref version) = latest {
println!("Latest version: {}{}", prefix, version);
} else {
println!("No existing version tags found");
}
let options = vec![
"Auto-detect bump from commits",
"Bump major version",
"Bump minor version",
"Bump patch version",
"Enter custom version",
"Enter custom tag name",
];
let selection = Select::new()
.with_prompt("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!("Suggested bump: {:?}{}{}", bump, prefix, version);
let confirm = Confirm::new()
.with_prompt("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("Enter version (e.g., 1.2.3)")
.interact_text()?;
let version = Version::parse(&input)?;
return Ok(format!("{}{}", prefix, version));
}
5 => {
let input: String = Input::new()
.with_prompt("Enter tag name")
.interact_text()?;
return Ok(input);
}
_ => unreachable!(),
}
}
}
async fn generate_tag_message(&self, repo: &GitRepo, version: &str) -> Result<String> {
let manager = ConfigManager::new()?;
let config = manager.config();
// Get commits since last tag
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!("{} AI is generating tag message from {} commits...", "🤖", commits.len());
let generator = ContentGenerator::new(&config.llm).await?;
generator.generate_tag_message(version, &commits).await
}
fn input_message_interactive(&self, version: &str) -> Result<String> {
let default_msg = format!("Release {}", version);
let use_editor = Confirm::new()
.with_prompt("Open editor for tag message?")
.default(false)
.interact()?;
if use_editor {
crate::utils::editor::edit_content(&default_msg)
} else {
Ok(Input::new()
.with_prompt("Tag message")
.default(default_msg)
.interact_text()?)
}
}
}
// Helper trait
trait TagBuilderExt {
fn message_opt(self, message: Option<String>) -> Self;
}
impl TagBuilderExt for TagBuilder {
fn message_opt(self, message: Option<String>) -> Self {
if let Some(m) = message {
self.message(m)
} else {
self
}
}
}