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
}
}
}

302
src/config/manager.rs Normal file
View File

@@ -0,0 +1,302 @@
use super::{AppConfig, GitProfile};
use anyhow::{bail, Context, Result};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
/// Configuration manager
pub struct ConfigManager {
config: AppConfig,
config_path: PathBuf,
modified: bool,
}
impl ConfigManager {
/// Create new config manager with default path
pub fn new() -> Result<Self> {
let config_path = AppConfig::default_path()?;
Self::with_path(&config_path)
}
/// Create config manager with specific path
pub fn with_path(path: &Path) -> Result<Self> {
let config = AppConfig::load(path)?;
Ok(Self {
config,
config_path: path.to_path_buf(),
modified: false,
})
}
/// Get configuration reference
pub fn config(&self) -> &AppConfig {
&self.config
}
/// Get mutable configuration reference
pub fn config_mut(&mut self) -> &mut AppConfig {
self.modified = true;
&mut self.config
}
/// Save configuration if modified
pub fn save(&mut self) -> Result<()> {
if self.modified {
self.config.save(&self.config_path)?;
self.modified = false;
}
Ok(())
}
/// Force save configuration
pub fn force_save(&self) -> Result<()> {
self.config.save(&self.config_path)
}
/// Get configuration file path
pub fn path(&self) -> &Path {
&self.config_path
}
// Profile management
/// Add a new profile
pub fn add_profile(&mut self, name: String, profile: GitProfile) -> Result<()> {
if self.config.profiles.contains_key(&name) {
bail!("Profile '{}' already exists", name);
}
self.config.profiles.insert(name, profile);
self.modified = true;
Ok(())
}
/// Remove a profile
pub fn remove_profile(&mut self, name: &str) -> Result<()> {
if !self.config.profiles.contains_key(name) {
bail!("Profile '{}' does not exist", name);
}
// Check if it's the default profile
if self.config.default_profile.as_ref() == Some(&name.to_string()) {
self.config.default_profile = None;
}
// Remove from repo mappings
self.config.repo_profiles.retain(|_, v| v != name);
self.config.profiles.remove(name);
self.modified = true;
Ok(())
}
/// Update a profile
pub fn update_profile(&mut self, name: &str, profile: GitProfile) -> Result<()> {
if !self.config.profiles.contains_key(name) {
bail!("Profile '{}' does not exist", name);
}
self.config.profiles.insert(name.to_string(), profile);
self.modified = true;
Ok(())
}
/// Get a profile
pub fn get_profile(&self, name: &str) -> Option<&GitProfile> {
self.config.profiles.get(name)
}
/// Get mutable profile
pub fn get_profile_mut(&mut self, name: &str) -> Option<&mut GitProfile> {
self.modified = true;
self.config.profiles.get_mut(name)
}
/// List all profile names
pub fn list_profiles(&self) -> Vec<&String> {
self.config.profiles.keys().collect()
}
/// Check if profile exists
pub fn has_profile(&self, name: &str) -> bool {
self.config.profiles.contains_key(name)
}
/// Set default profile
pub fn set_default_profile(&mut self, name: Option<String>) -> Result<()> {
if let Some(ref n) = name {
if !self.config.profiles.contains_key(n) {
bail!("Profile '{}' does not exist", n);
}
}
self.config.default_profile = name;
self.modified = true;
Ok(())
}
/// Get default profile
pub fn default_profile(&self) -> Option<&GitProfile> {
self.config
.default_profile
.as_ref()
.and_then(|name| self.config.profiles.get(name))
}
/// Get default profile name
pub fn default_profile_name(&self) -> Option<&String> {
self.config.default_profile.as_ref()
}
// Repository profile management
/// Get profile for repository
pub fn get_repo_profile(&self, repo_path: &str) -> Option<&GitProfile> {
self.config
.repo_profiles
.get(repo_path)
.and_then(|name| self.config.profiles.get(name))
}
/// Set profile for repository
pub fn set_repo_profile(&mut self, repo_path: String, profile_name: String) -> Result<()> {
if !self.config.profiles.contains_key(&profile_name) {
bail!("Profile '{}' does not exist", profile_name);
}
self.config.repo_profiles.insert(repo_path, profile_name);
self.modified = true;
Ok(())
}
/// Remove repository profile mapping
pub fn remove_repo_profile(&mut self, repo_path: &str) {
self.config.repo_profiles.remove(repo_path);
self.modified = true;
}
/// List repository profile mappings
pub fn list_repo_profiles(&self) -> &HashMap<String, String> {
&self.config.repo_profiles
}
/// Get effective profile for a repository (repo-specific -> default)
pub fn get_effective_profile(&self, repo_path: Option<&str>) -> Option<&GitProfile> {
if let Some(path) = repo_path {
if let Some(profile) = self.get_repo_profile(path) {
return Some(profile);
}
}
self.default_profile()
}
// LLM configuration
/// Get LLM provider
pub fn llm_provider(&self) -> &str {
&self.config.llm.provider
}
/// Set LLM provider
pub fn set_llm_provider(&mut self, provider: String) {
self.config.llm.provider = provider;
self.modified = true;
}
/// Get OpenAI API key
pub fn openai_api_key(&self) -> Option<&String> {
self.config.llm.openai.api_key.as_ref()
}
/// Set OpenAI API key
pub fn set_openai_api_key(&mut self, key: String) {
self.config.llm.openai.api_key = Some(key);
self.modified = true;
}
/// Get Anthropic API key
pub fn anthropic_api_key(&self) -> Option<&String> {
self.config.llm.anthropic.api_key.as_ref()
}
/// Set Anthropic API key
pub fn set_anthropic_api_key(&mut self, key: String) {
self.config.llm.anthropic.api_key = Some(key);
self.modified = true;
}
// Commit configuration
/// Get commit format
pub fn commit_format(&self) -> super::CommitFormat {
self.config.commit.format
}
/// Set commit format
pub fn set_commit_format(&mut self, format: super::CommitFormat) {
self.config.commit.format = format;
self.modified = true;
}
/// Check if auto-generate is enabled
pub fn auto_generate_commits(&self) -> bool {
self.config.commit.auto_generate
}
/// Set auto-generate commits
pub fn set_auto_generate_commits(&mut self, enabled: bool) {
self.config.commit.auto_generate = enabled;
self.modified = true;
}
// Tag configuration
/// Get version prefix
pub fn version_prefix(&self) -> &str {
&self.config.tag.version_prefix
}
/// Set version prefix
pub fn set_version_prefix(&mut self, prefix: String) {
self.config.tag.version_prefix = prefix;
self.modified = true;
}
// Changelog configuration
/// Get changelog path
pub fn changelog_path(&self) -> &str {
&self.config.changelog.path
}
/// Set changelog path
pub fn set_changelog_path(&mut self, path: String) {
self.config.changelog.path = path;
self.modified = true;
}
/// Export configuration to TOML string
pub fn export(&self) -> Result<String> {
toml::to_string_pretty(&self.config)
.context("Failed to serialize config")
}
/// Import configuration from TOML string
pub fn import(&mut self, toml_str: &str) -> Result<()> {
self.config = toml::from_str(toml_str)
.context("Failed to parse config")?;
self.modified = true;
Ok(())
}
/// Reset to default configuration
pub fn reset(&mut self) {
self.config = AppConfig::default();
self.modified = true;
}
}
impl Default for ConfigManager {
fn default() -> Self {
Self {
config: AppConfig::default(),
config_path: PathBuf::new(),
modified: false,
}
}
}

532
src/config/mod.rs Normal file
View File

@@ -0,0 +1,532 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
pub mod manager;
pub mod profile;
pub use manager::ConfigManager;
pub use profile::{GitProfile, ProfileSettings};
/// Application configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
/// Configuration version for migration
#[serde(default = "default_version")]
pub version: String,
/// Default profile name
pub default_profile: Option<String>,
/// All configured profiles
#[serde(default)]
pub profiles: HashMap<String, GitProfile>,
/// LLM configuration
#[serde(default)]
pub llm: LlmConfig,
/// Commit configuration
#[serde(default)]
pub commit: CommitConfig,
/// Tag configuration
#[serde(default)]
pub tag: TagConfig,
/// Changelog configuration
#[serde(default)]
pub changelog: ChangelogConfig,
/// Repository-specific profile mappings
#[serde(default)]
pub repo_profiles: HashMap<String, String>,
/// Whether to encrypt sensitive data
#[serde(default = "default_true")]
pub encrypt_sensitive: bool,
/// Theme settings
#[serde(default)]
pub theme: ThemeConfig,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
version: default_version(),
default_profile: None,
profiles: HashMap::new(),
llm: LlmConfig::default(),
commit: CommitConfig::default(),
tag: TagConfig::default(),
changelog: ChangelogConfig::default(),
repo_profiles: HashMap::new(),
encrypt_sensitive: true,
theme: ThemeConfig::default(),
}
}
}
/// LLM configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LlmConfig {
/// Default LLM provider
#[serde(default = "default_llm_provider")]
pub provider: String,
/// OpenAI configuration
#[serde(default)]
pub openai: OpenAiConfig,
/// Ollama configuration
#[serde(default)]
pub ollama: OllamaConfig,
/// Anthropic Claude configuration
#[serde(default)]
pub anthropic: AnthropicConfig,
/// Custom API configuration
#[serde(default)]
pub custom: Option<CustomLlmConfig>,
/// Maximum tokens for generation
#[serde(default = "default_max_tokens")]
pub max_tokens: u32,
/// Temperature for generation
#[serde(default = "default_temperature")]
pub temperature: f32,
/// Timeout in seconds
#[serde(default = "default_timeout")]
pub timeout: u64,
}
impl Default for LlmConfig {
fn default() -> Self {
Self {
provider: default_llm_provider(),
openai: OpenAiConfig::default(),
ollama: OllamaConfig::default(),
anthropic: AnthropicConfig::default(),
custom: None,
max_tokens: default_max_tokens(),
temperature: default_temperature(),
timeout: default_timeout(),
}
}
}
/// OpenAI API configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenAiConfig {
/// API key
pub api_key: Option<String>,
/// Model to use
#[serde(default = "default_openai_model")]
pub model: String,
/// API base URL (for custom endpoints)
#[serde(default = "default_openai_base_url")]
pub base_url: String,
}
impl Default for OpenAiConfig {
fn default() -> Self {
Self {
api_key: None,
model: default_openai_model(),
base_url: default_openai_base_url(),
}
}
}
/// Ollama configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OllamaConfig {
/// Ollama server URL
#[serde(default = "default_ollama_url")]
pub url: String,
/// Model to use
#[serde(default = "default_ollama_model")]
pub model: String,
}
impl Default for OllamaConfig {
fn default() -> Self {
Self {
url: default_ollama_url(),
model: default_ollama_model(),
}
}
}
/// Anthropic Claude configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnthropicConfig {
/// API key
pub api_key: Option<String>,
/// Model to use
#[serde(default = "default_anthropic_model")]
pub model: String,
}
impl Default for AnthropicConfig {
fn default() -> Self {
Self {
api_key: None,
model: default_anthropic_model(),
}
}
}
/// Custom LLM API configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomLlmConfig {
/// API endpoint URL
pub url: String,
/// API key (optional)
pub api_key: Option<String>,
/// Model name
pub model: String,
/// Request format template (JSON)
pub request_template: String,
/// Response path to extract content (e.g., "choices.0.message.content")
pub response_path: String,
}
/// Commit configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommitConfig {
/// Default commit format
#[serde(default = "default_commit_format")]
pub format: CommitFormat,
/// Enable AI generation by default
#[serde(default = "default_true")]
pub auto_generate: bool,
/// Allow empty commits
#[serde(default)]
pub allow_empty: bool,
/// Sign commits with GPG
#[serde(default)]
pub gpg_sign: bool,
/// Default scope (optional)
pub default_scope: Option<String>,
/// Maximum subject length
#[serde(default = "default_max_subject_length")]
pub max_subject_length: usize,
/// Require scope
#[serde(default)]
pub require_scope: bool,
/// Require body for certain types
#[serde(default)]
pub require_body: bool,
/// Types that require body
#[serde(default = "default_body_required_types")]
pub body_required_types: Vec<String>,
}
impl Default for CommitConfig {
fn default() -> Self {
Self {
format: default_commit_format(),
auto_generate: true,
allow_empty: false,
gpg_sign: false,
default_scope: None,
max_subject_length: default_max_subject_length(),
require_scope: false,
require_body: false,
body_required_types: default_body_required_types(),
}
}
}
/// Commit format
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum CommitFormat {
Conventional,
Commitlint,
}
impl std::fmt::Display for CommitFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CommitFormat::Conventional => write!(f, "conventional"),
CommitFormat::Commitlint => write!(f, "commitlint"),
}
}
}
/// Tag configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TagConfig {
/// Default version prefix (e.g., "v")
#[serde(default = "default_version_prefix")]
pub version_prefix: String,
/// Enable AI generation for tag messages
#[serde(default = "default_true")]
pub auto_generate: bool,
/// Sign tags with GPG
#[serde(default)]
pub gpg_sign: bool,
/// Include changelog in annotated tags
#[serde(default = "default_true")]
pub include_changelog: bool,
/// Default annotation template
#[serde(default)]
pub annotation_template: Option<String>,
}
impl Default for TagConfig {
fn default() -> Self {
Self {
version_prefix: default_version_prefix(),
auto_generate: true,
gpg_sign: false,
include_changelog: true,
annotation_template: None,
}
}
}
/// Changelog configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChangelogConfig {
/// Changelog file path
#[serde(default = "default_changelog_path")]
pub path: String,
/// Enable AI generation for changelog entries
#[serde(default = "default_true")]
pub auto_generate: bool,
/// Changelog format
#[serde(default = "default_changelog_format")]
pub format: ChangelogFormat,
/// Include commit hashes
#[serde(default)]
pub include_hashes: bool,
/// Include authors
#[serde(default)]
pub include_authors: bool,
/// Group by type
#[serde(default = "default_true")]
pub group_by_type: bool,
/// Custom categories
#[serde(default)]
pub custom_categories: Vec<ChangelogCategory>,
}
impl Default for ChangelogConfig {
fn default() -> Self {
Self {
path: default_changelog_path(),
auto_generate: true,
format: default_changelog_format(),
include_hashes: false,
include_authors: false,
group_by_type: true,
custom_categories: vec![],
}
}
}
/// Changelog format
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum ChangelogFormat {
KeepAChangelog,
GitHubReleases,
Custom,
}
/// Changelog category mapping
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChangelogCategory {
/// Category title
pub title: String,
/// Commit types included in this category
pub types: Vec<String>,
}
/// Theme configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThemeConfig {
/// Enable colors
#[serde(default = "default_true")]
pub colors: bool,
/// Enable icons
#[serde(default = "default_true")]
pub icons: bool,
/// Preferred date format
#[serde(default = "default_date_format")]
pub date_format: String,
}
impl Default for ThemeConfig {
fn default() -> Self {
Self {
colors: true,
icons: true,
date_format: default_date_format(),
}
}
}
// Default value functions
fn default_version() -> String {
"1".to_string()
}
fn default_true() -> bool {
true
}
fn default_llm_provider() -> String {
"ollama".to_string()
}
fn default_max_tokens() -> u32 {
500
}
fn default_temperature() -> f32 {
0.7
}
fn default_timeout() -> u64 {
30
}
fn default_openai_model() -> String {
"gpt-4".to_string()
}
fn default_openai_base_url() -> String {
"https://api.openai.com/v1".to_string()
}
fn default_ollama_url() -> String {
"http://localhost:11434".to_string()
}
fn default_ollama_model() -> String {
"llama2".to_string()
}
fn default_anthropic_model() -> String {
"claude-3-sonnet-20240229".to_string()
}
fn default_commit_format() -> CommitFormat {
CommitFormat::Conventional
}
fn default_max_subject_length() -> usize {
100
}
fn default_body_required_types() -> Vec<String> {
vec!["feat".to_string(), "fix".to_string()]
}
fn default_version_prefix() -> String {
"v".to_string()
}
fn default_changelog_path() -> String {
"CHANGELOG.md".to_string()
}
fn default_changelog_format() -> ChangelogFormat {
ChangelogFormat::KeepAChangelog
}
fn default_date_format() -> String {
"%Y-%m-%d".to_string()
}
impl AppConfig {
/// Load configuration from file
pub fn load(path: &Path) -> Result<Self> {
if path.exists() {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read config file: {:?}", path))?;
let config: AppConfig = toml::from_str(&content)
.with_context(|| format!("Failed to parse config file: {:?}", path))?;
Ok(config)
} else {
Ok(Self::default())
}
}
/// Save configuration to file
pub fn save(&self, path: &Path) -> Result<()> {
let content = toml::to_string_pretty(self)
.context("Failed to serialize config")?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create config directory: {:?}", parent))?;
}
fs::write(path, content)
.with_context(|| format!("Failed to write config file: {:?}", path))?;
Ok(())
}
/// Get default config path
pub fn default_path() -> Result<PathBuf> {
let config_dir = dirs::config_dir()
.context("Could not find config directory")?;
Ok(config_dir.join("quicommit").join("config.toml"))
}
/// Get profile for a repository
pub fn get_profile_for_repo(&self, repo_path: &str) -> Option<&GitProfile> {
let profile_name = self.repo_profiles.get(repo_path)?;
self.profiles.get(profile_name)
}
/// Set profile for a repository
pub fn set_profile_for_repo(&mut self, repo_path: String, profile_name: String) -> Result<()> {
if !self.profiles.contains_key(&profile_name) {
anyhow::bail!("Profile '{}' does not exist", profile_name);
}
self.repo_profiles.insert(repo_path, profile_name);
Ok(())
}
}

412
src/config/profile.rs Normal file
View File

@@ -0,0 +1,412 @@
use anyhow::{bail, Result};
use serde::{Deserialize, Serialize};
/// Git profile containing user identity and authentication settings
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitProfile {
/// Profile display name
pub name: String,
/// Git user name
pub user_name: String,
/// Git user email
pub user_email: String,
/// Profile settings
#[serde(default)]
pub settings: ProfileSettings,
/// SSH configuration
#[serde(default)]
pub ssh: Option<SshConfig>,
/// GPG configuration
#[serde(default)]
pub gpg: Option<GpgConfig>,
/// Signing key (for commit/tag signing)
#[serde(default)]
pub signing_key: Option<String>,
/// Profile description
#[serde(default)]
pub description: Option<String>,
/// Is this a work profile
#[serde(default)]
pub is_work: bool,
/// Company/Organization name (for work profiles)
#[serde(default)]
pub organization: Option<String>,
}
impl GitProfile {
/// Create a new basic profile
pub fn new(name: String, user_name: String, user_email: String) -> Self {
Self {
name,
user_name,
user_email,
settings: ProfileSettings::default(),
ssh: None,
gpg: None,
signing_key: None,
description: None,
is_work: false,
organization: None,
}
}
/// Create a builder for fluent API
pub fn builder() -> GitProfileBuilder {
GitProfileBuilder::default()
}
/// Validate the profile
pub fn validate(&self) -> Result<()> {
if self.user_name.is_empty() {
bail!("User name cannot be empty");
}
if self.user_email.is_empty() {
bail!("User email cannot be empty");
}
crate::utils::validators::validate_email(&self.user_email)?;
if let Some(ref ssh) = self.ssh {
ssh.validate()?;
}
if let Some(ref gpg) = self.gpg {
gpg.validate()?;
}
Ok(())
}
/// Check if profile has SSH configured
pub fn has_ssh(&self) -> bool {
self.ssh.is_some()
}
/// Check if profile has GPG configured
pub fn has_gpg(&self) -> bool {
self.gpg.is_some() || self.signing_key.is_some()
}
/// Get signing key (from GPG config or direct)
pub fn signing_key(&self) -> Option<&str> {
self.signing_key
.as_ref()
.map(|s| s.as_str())
.or_else(|| self.gpg.as_ref().map(|g| g.key_id.as_str()))
}
/// Apply this profile to a git repository
pub fn apply_to_repo(&self, repo: &git2::Repository) -> Result<()> {
let mut config = repo.config()?;
// Set user info
config.set_str("user.name", &self.user_name)?;
config.set_str("user.email", &self.user_email)?;
// Set signing key if available
if let Some(key) = self.signing_key() {
config.set_str("user.signingkey", key)?;
if self.settings.auto_sign_commits {
config.set_bool("commit.gpgsign", true)?;
}
if self.settings.auto_sign_tags {
config.set_bool("tag.gpgsign", true)?;
}
}
// Set SSH if configured
if let Some(ref ssh) = self.ssh {
if let Some(ref key_path) = ssh.private_key_path {
config.set_str("core.sshCommand",
&format!("ssh -i {}", key_path.display()))?;
}
}
Ok(())
}
/// Apply this profile globally
pub fn apply_global(&self) -> Result<()> {
let mut config = git2::Config::open_default()?;
config.set_str("user.name", &self.user_name)?;
config.set_str("user.email", &self.user_email)?;
if let Some(key) = self.signing_key() {
config.set_str("user.signingkey", key)?;
}
Ok(())
}
}
/// Profile settings
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileSettings {
/// Automatically sign commits
#[serde(default)]
pub auto_sign_commits: bool,
/// Automatically sign tags
#[serde(default)]
pub auto_sign_tags: bool,
/// Default commit format for this profile
#[serde(default)]
pub default_commit_format: Option<super::CommitFormat>,
/// Use this profile for specific repositories (path patterns)
#[serde(default)]
pub repo_patterns: Vec<String>,
/// Preferred LLM provider for this profile
#[serde(default)]
pub llm_provider: Option<String>,
/// Custom commit message template
#[serde(default)]
pub commit_template: Option<String>,
}
impl Default for ProfileSettings {
fn default() -> Self {
Self {
auto_sign_commits: false,
auto_sign_tags: false,
default_commit_format: None,
repo_patterns: vec![],
llm_provider: None,
commit_template: None,
}
}
}
/// SSH configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SshConfig {
/// SSH private key path
pub private_key_path: Option<std::path::PathBuf>,
/// SSH public key path
pub public_key_path: Option<std::path::PathBuf>,
/// SSH key passphrase (encrypted)
#[serde(skip_serializing_if = "Option::is_none")]
pub passphrase: Option<String>,
/// SSH agent forwarding
#[serde(default)]
pub agent_forwarding: bool,
/// Custom SSH command
#[serde(default)]
pub ssh_command: Option<String>,
/// Known hosts file
#[serde(default)]
pub known_hosts_file: Option<std::path::PathBuf>,
}
impl SshConfig {
/// Validate SSH configuration
pub fn validate(&self) -> Result<()> {
if let Some(ref path) = self.private_key_path {
if !path.exists() {
bail!("SSH private key does not exist: {:?}", path);
}
}
if let Some(ref path) = self.public_key_path {
if !path.exists() {
bail!("SSH public key does not exist: {:?}", path);
}
}
Ok(())
}
/// Get SSH command for git
pub fn git_ssh_command(&self) -> Option<String> {
if let Some(ref cmd) = self.ssh_command {
Some(cmd.clone())
} else if let Some(ref key_path) = self.private_key_path {
Some(format!("ssh -i '{}'", key_path.display()))
} else {
None
}
}
}
/// GPG configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GpgConfig {
/// GPG key ID
pub key_id: String,
/// GPG executable path
#[serde(default = "default_gpg_program")]
pub program: String,
/// GPG home directory
#[serde(default)]
pub home_dir: Option<std::path::PathBuf>,
/// Key passphrase (encrypted)
#[serde(skip_serializing_if = "Option::is_none")]
pub passphrase: Option<String>,
/// Use GPG agent
#[serde(default = "default_true")]
pub use_agent: bool,
}
impl GpgConfig {
/// Validate GPG configuration
pub fn validate(&self) -> Result<()> {
crate::utils::validators::validate_gpg_key_id(&self.key_id)?;
Ok(())
}
/// Get GPG program path
pub fn program(&self) -> &str {
&self.program
}
}
fn default_gpg_program() -> String {
"gpg".to_string()
}
fn default_true() -> bool {
true
}
/// Git profile builder
#[derive(Default)]
pub struct GitProfileBuilder {
name: Option<String>,
user_name: Option<String>,
user_email: Option<String>,
settings: ProfileSettings,
ssh: Option<SshConfig>,
gpg: Option<GpgConfig>,
signing_key: Option<String>,
description: Option<String>,
is_work: bool,
organization: Option<String>,
}
impl GitProfileBuilder {
pub fn name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn user_name(mut self, user_name: impl Into<String>) -> Self {
self.user_name = Some(user_name.into());
self
}
pub fn user_email(mut self, user_email: impl Into<String>) -> Self {
self.user_email = Some(user_email.into());
self
}
pub fn settings(mut self, settings: ProfileSettings) -> Self {
self.settings = settings;
self
}
pub fn ssh(mut self, ssh: SshConfig) -> Self {
self.ssh = Some(ssh);
self
}
pub fn gpg(mut self, gpg: GpgConfig) -> Self {
self.gpg = Some(gpg);
self
}
pub fn signing_key(mut self, key: impl Into<String>) -> Self {
self.signing_key = Some(key.into());
self
}
pub fn description(mut self, desc: impl Into<String>) -> Self {
self.description = Some(desc.into());
self
}
pub fn work(mut self, is_work: bool) -> Self {
self.is_work = is_work;
self
}
pub fn organization(mut self, org: impl Into<String>) -> Self {
self.organization = Some(org.into());
self
}
pub fn build(self) -> Result<GitProfile> {
let name = self.name.ok_or_else(|| anyhow::anyhow!("Name is required"))?;
let user_name = self.user_name.ok_or_else(|| anyhow::anyhow!("User name is required"))?;
let user_email = self.user_email.ok_or_else(|| anyhow::anyhow!("User email is required"))?;
Ok(GitProfile {
name,
user_name,
user_email,
settings: self.settings,
ssh: self.ssh,
gpg: self.gpg,
signing_key: self.signing_key,
description: self.description,
is_work: self.is_work,
organization: self.organization,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_profile_builder() {
let profile = GitProfile::builder()
.name("personal")
.user_name("John Doe")
.user_email("john@example.com")
.description("Personal profile")
.build()
.unwrap();
assert_eq!(profile.name, "personal");
assert_eq!(profile.user_name, "John Doe");
assert_eq!(profile.user_email, "john@example.com");
assert!(profile.validate().is_ok());
}
#[test]
fn test_profile_validation() {
let profile = GitProfile::new(
"test".to_string(),
"".to_string(),
"invalid-email".to_string(),
);
assert!(profile.validate().is_err());
}
}

340
src/generator/mod.rs Normal file
View File

@@ -0,0 +1,340 @@
use crate::config::{CommitFormat, LlmConfig};
use crate::git::{CommitInfo, GitRepo};
use crate::llm::{GeneratedCommit, LlmClient};
use anyhow::{Context, Result};
use chrono::Utc;
/// Content generator using LLM
pub struct ContentGenerator {
llm_client: LlmClient,
}
impl ContentGenerator {
/// Create new content generator
pub async fn new(config: &LlmConfig) -> Result<Self> {
let llm_client = LlmClient::from_config(config).await?;
// Check if provider is available
if !llm_client.is_available().await {
anyhow::bail!("LLM provider '{}' is not available", config.provider);
}
Ok(Self { llm_client })
}
/// Generate commit message from diff
pub async fn generate_commit_message(
&self,
diff: &str,
format: CommitFormat,
) -> Result<GeneratedCommit> {
// Truncate diff if too long
let max_diff_len = 4000;
let truncated_diff = if diff.len() > max_diff_len {
format!("{}\n... (truncated)", &diff[..max_diff_len])
} else {
diff.to_string()
};
self.llm_client.generate_commit_message(&truncated_diff, format).await
}
/// Generate commit message from repository changes
pub async fn generate_commit_from_repo(
&self,
repo: &GitRepo,
format: CommitFormat,
) -> Result<GeneratedCommit> {
let diff = repo.get_staged_diff()
.context("Failed to get staged diff")?;
if diff.is_empty() {
anyhow::bail!("No staged changes to generate commit from");
}
self.generate_commit_message(&diff, format).await
}
/// Generate tag message
pub async fn generate_tag_message(
&self,
version: &str,
commits: &[CommitInfo],
) -> Result<String> {
let commit_messages: Vec<String> = commits
.iter()
.map(|c| c.subject().to_string())
.collect();
self.llm_client.generate_tag_message(version, &commit_messages).await
}
/// Generate changelog entry
pub async fn generate_changelog_entry(
&self,
version: &str,
commits: &[CommitInfo],
) -> Result<String> {
let typed_commits: Vec<(String, String)> = commits
.iter()
.map(|c| {
let commit_type = c.commit_type().unwrap_or_else(|| "other".to_string());
(commit_type, c.subject().to_string())
})
.collect();
self.llm_client.generate_changelog_entry(version, &typed_commits).await
}
/// Generate changelog from repository
pub async fn generate_changelog_from_repo(
&self,
repo: &GitRepo,
version: &str,
from_tag: Option<&str>,
) -> Result<String> {
let commits = if let Some(tag) = from_tag {
repo.get_commits_between(tag, "HEAD")?
} else {
repo.get_commits(50)?
};
self.generate_changelog_entry(version, &commits).await
}
/// Interactive commit generation with user feedback
pub async fn generate_commit_interactive(
&self,
repo: &GitRepo,
format: CommitFormat,
) -> Result<GeneratedCommit> {
use dialoguer::{Confirm, Select};
use console::Term;
let diff = repo.get_staged_diff()?;
if diff.is_empty() {
anyhow::bail!("No staged changes");
}
// Show diff summary
let files = repo.get_staged_files()?;
println!("\nStaged files ({}):", files.len());
for file in &files {
println!("{}", file);
}
// Generate initial commit
println!("\nGenerating commit message...");
let mut generated = self.generate_commit_message(&diff, format).await?;
loop {
println!("\n{}", "".repeat(60));
println!("Generated commit message:");
println!("{}", "".repeat(60));
println!("{}", generated.to_conventional());
println!("{}", "".repeat(60));
let options = vec![
"✓ Accept and commit",
"🔄 Regenerate",
"✏️ Edit",
"📋 Copy to clipboard",
"❌ Cancel",
];
let selection = Select::new()
.with_prompt("What would you like to do?")
.items(&options)
.default(0)
.interact()?;
match selection {
0 => return Ok(generated),
1 => {
println!("Regenerating...");
generated = self.generate_commit_message(&diff, format).await?;
}
2 => {
let edited = crate::utils::editor::edit_content(&generated.to_conventional())?;
generated = self.parse_edited_commit(&edited, format)?;
}
3 => {
#[cfg(feature = "clipboard")]
{
arboard::Clipboard::new()?.set_text(generated.to_conventional())?;
println!("Copied to clipboard!");
}
#[cfg(not(feature = "clipboard"))]
{
println!("Clipboard feature not enabled");
}
}
4 => anyhow::bail!("Cancelled by user"),
_ => {}
}
}
}
fn parse_edited_commit(&self, edited: &str, format: CommitFormat) -> Result<GeneratedCommit> {
let parsed = crate::git::commit::parse_commit_message(edited);
Ok(GeneratedCommit {
commit_type: parsed.commit_type.unwrap_or_else(|| "chore".to_string()),
scope: parsed.scope,
description: parsed.description.unwrap_or_else(|| "update".to_string()),
body: parsed.body,
footer: parsed.footer,
breaking: parsed.breaking,
})
}
}
/// Batch generator for multiple operations
pub struct BatchGenerator {
generator: ContentGenerator,
}
impl BatchGenerator {
/// Create new batch generator
pub async fn new(config: &LlmConfig) -> Result<Self> {
let generator = ContentGenerator::new(config).await?;
Ok(Self { generator })
}
/// Generate commits for multiple repositories
pub async fn generate_commits_batch<'a>(
&self,
repos: &[&'a GitRepo],
format: CommitFormat,
) -> Vec<(&'a str, Result<GeneratedCommit>)> {
let mut results = vec![];
for repo in repos {
let result = self.generator.generate_commit_from_repo(repo, format).await;
results.push((repo.path().to_str().unwrap_or("unknown"), result));
}
results
}
/// Generate changelog for multiple versions
pub async fn generate_changelog_batch(
&self,
repo: &GitRepo,
versions: &[String],
) -> Vec<(String, Result<String>)> {
let mut results = vec![];
// Get all tags
let tags = repo.get_tags().unwrap_or_default();
for (i, version) in versions.iter().enumerate() {
let from_tag = if i + 1 < tags.len() {
tags.get(i + 1).map(|t| t.name.as_str())
} else {
None
};
let result = self.generator.generate_changelog_from_repo(repo, version, from_tag).await;
results.push((version.clone(), result));
}
results
}
}
/// Generator options
#[derive(Debug, Clone)]
pub struct GeneratorOptions {
pub auto_commit: bool,
pub auto_push: bool,
pub interactive: bool,
pub dry_run: bool,
}
impl Default for GeneratorOptions {
fn default() -> Self {
Self {
auto_commit: false,
auto_push: false,
interactive: true,
dry_run: false,
}
}
}
/// Generate with options
pub async fn generate_with_options(
repo: &GitRepo,
config: &LlmConfig,
format: CommitFormat,
options: GeneratorOptions,
) -> Result<Option<GeneratedCommit>> {
let generator = ContentGenerator::new(config).await?;
let generated = if options.interactive {
generator.generate_commit_interactive(repo, format).await?
} else {
generator.generate_commit_from_repo(repo, format).await?
};
if options.dry_run {
println!("{}", generated.to_conventional());
return Ok(Some(generated));
}
if options.auto_commit {
let message = generated.to_conventional();
repo.commit(&message, false)?;
if options.auto_push {
repo.push("origin", "HEAD")?;
}
}
Ok(Some(generated))
}
/// Fallback generators when LLM is not available
pub mod fallback {
use super::*;
use crate::git::commit::create_date_commit_message;
/// Generate simple commit message without LLM
pub fn generate_simple_commit(files: &[String]) -> String {
if files.len() == 1 {
format!("chore: update {}", files[0])
} else if files.len() <= 3 {
format!("chore: update {}", files.join(", "))
} else {
format!("chore: update {} files", files.len())
}
}
/// Generate date-based commit
pub fn generate_date_commit() -> String {
create_date_commit_message(None)
}
/// Generate commit based on file types
pub fn generate_by_file_types(files: &[String]) -> String {
let has_code = files.iter().any(|f| {
f.ends_with(".rs") || f.ends_with(".py") || f.ends_with(".js") || f.ends_with(".ts")
});
let has_docs = files.iter().any(|f| f.ends_with(".md") || f.contains("README"));
let has_tests = files.iter().any(|f| f.contains("test") || f.contains("spec"));
if has_tests {
"test: update tests".to_string()
} else if has_docs {
"docs: update documentation".to_string()
} else if has_code {
"refactor: update code".to_string()
} else {
"chore: update files".to_string()
}
}
}

480
src/git/changelog.rs Normal file
View File

@@ -0,0 +1,480 @@
use super::{CommitInfo, GitRepo};
use anyhow::{Context, Result};
use chrono::{DateTime, TimeZone, Utc};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
/// Changelog generator
pub struct ChangelogGenerator {
format: ChangelogFormat,
include_hashes: bool,
include_authors: bool,
group_by_type: bool,
custom_categories: Vec<ChangelogCategory>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChangelogFormat {
KeepAChangelog,
GitHubReleases,
Custom,
}
#[derive(Debug, Clone)]
pub struct ChangelogCategory {
pub title: String,
pub types: Vec<String>,
}
impl ChangelogGenerator {
/// Create new changelog generator
pub fn new() -> Self {
Self {
format: ChangelogFormat::KeepAChangelog,
include_hashes: false,
include_authors: false,
group_by_type: true,
custom_categories: vec![],
}
}
/// Set format
pub fn format(mut self, format: ChangelogFormat) -> Self {
self.format = format;
self
}
/// Include commit hashes
pub fn include_hashes(mut self, include: bool) -> Self {
self.include_hashes = include;
self
}
/// Include authors
pub fn include_authors(mut self, include: bool) -> Self {
self.include_authors = include;
self
}
/// Group by type
pub fn group_by_type(mut self, group: bool) -> Self {
self.group_by_type = group;
self
}
/// Add custom category
pub fn add_category(mut self, title: impl Into<String>, types: Vec<String>) -> Self {
self.custom_categories.push(ChangelogCategory {
title: title.into(),
types,
});
self
}
/// Generate changelog for version
pub fn generate(
&self,
version: &str,
date: DateTime<Utc>,
commits: &[CommitInfo],
) -> Result<String> {
match self.format {
ChangelogFormat::KeepAChangelog => {
self.generate_keep_a_changelog(version, date, commits)
}
ChangelogFormat::GitHubReleases => {
self.generate_github_releases(version, date, commits)
}
ChangelogFormat::Custom => {
self.generate_custom(version, date, commits)
}
}
}
/// Generate changelog entry and prepend to file
pub fn generate_and_prepend(
&self,
changelog_path: &Path,
version: &str,
date: DateTime<Utc>,
commits: &[CommitInfo],
) -> Result<()> {
let entry = self.generate(version, date, commits)?;
let existing = if changelog_path.exists() {
fs::read_to_string(changelog_path)?
} else {
String::new()
};
let new_content = if existing.is_empty() {
format!("# Changelog\n\n{}", entry)
} else {
// Find position after header
let lines: Vec<&str> = existing.lines().collect();
let mut header_end = 0;
for (i, line) in lines.iter().enumerate() {
if i == 0 && line.starts_with('#') {
header_end = i + 1;
} else if line.trim().is_empty() {
header_end = i + 1;
} else {
break;
}
}
let header = lines[..header_end].join("\n");
let rest = lines[header_end..].join("\n");
format!("{}\n{}\n{}", header, entry, rest)
};
fs::write(changelog_path, new_content)
.with_context(|| format!("Failed to write changelog: {:?}", changelog_path))?;
Ok(())
}
fn generate_keep_a_changelog(
&self,
version: &str,
date: DateTime<Utc>,
commits: &[CommitInfo],
) -> Result<String> {
let date_str = date.format("%Y-%m-%d").to_string();
let mut output = format!("## [{}] - {}\n\n", version, date_str);
if self.group_by_type {
let grouped = self.group_commits(commits);
// Standard categories
let categories = vec![
("Added", vec!["feat"]),
("Changed", vec!["refactor", "perf"]),
("Deprecated", vec![]),
("Removed", vec!["remove"]),
("Fixed", vec!["fix"]),
("Security", vec!["security"]),
];
for (title, types) in &categories {
let items: Vec<&CommitInfo> = commits
.iter()
.filter(|c| {
if let Some(ref t) = c.commit_type() {
types.contains(&t.as_str())
} else {
false
}
})
.collect();
if !items.is_empty() {
output.push_str(&format!("### {}\n\n", title));
for commit in items {
output.push_str(&self.format_commit(commit));
output.push('\n');
}
output.push('\n');
}
}
// Other changes
let categorized: Vec<String> = categories
.iter()
.flat_map(|(_, types)| types.iter().map(|s| s.to_string()))
.collect();
let other: Vec<&CommitInfo> = commits
.iter()
.filter(|c| {
if let Some(ref t) = c.commit_type() {
!categorized.contains(t)
} else {
true
}
})
.collect();
if !other.is_empty() {
output.push_str("### Other\n\n");
for commit in other {
output.push_str(&self.format_commit(commit));
output.push('\n');
}
output.push('\n');
}
} else {
for commit in commits {
output.push_str(&self.format_commit(commit));
output.push('\n');
}
}
Ok(output)
}
fn generate_github_releases(
&self,
version: &str,
_date: DateTime<Utc>,
commits: &[CommitInfo],
) -> Result<String> {
let mut output = format!("## What's Changed\n\n");
// Group by type
let mut features = vec![];
let mut fixes = vec![];
let mut docs = vec![];
let mut other = vec![];
let mut breaking = vec![];
for commit in commits {
let msg = commit.subject();
if commit.message.contains("BREAKING CHANGE") {
breaking.push(commit);
}
if let Some(ref t) = commit.commit_type() {
match t.as_str() {
"feat" => features.push(commit),
"fix" => fixes.push(commit),
"docs" => docs.push(commit),
_ => other.push(commit),
}
} else {
other.push(commit);
}
}
if !breaking.is_empty() {
output.push_str("### ⚠ Breaking Changes\n\n");
for commit in breaking {
output.push_str(&self.format_commit_github(commit));
}
output.push('\n');
}
if !features.is_empty() {
output.push_str("### 🚀 Features\n\n");
for commit in features {
output.push_str(&self.format_commit_github(commit));
}
output.push('\n');
}
if !fixes.is_empty() {
output.push_str("### 🐛 Bug Fixes\n\n");
for commit in fixes {
output.push_str(&self.format_commit_github(commit));
}
output.push('\n');
}
if !docs.is_empty() {
output.push_str("### 📚 Documentation\n\n");
for commit in docs {
output.push_str(&self.format_commit_github(commit));
}
output.push('\n');
}
if !other.is_empty() {
output.push_str("### Other Changes\n\n");
for commit in other {
output.push_str(&self.format_commit_github(commit));
}
}
Ok(output)
}
fn generate_custom(
&self,
version: &str,
date: DateTime<Utc>,
commits: &[CommitInfo],
) -> Result<String> {
// Use custom categories if defined
if !self.custom_categories.is_empty() {
let date_str = date.format("%Y-%m-%d").to_string();
let mut output = format!("## [{}] - {}\n\n", version, date_str);
for category in &self.custom_categories {
let items: Vec<&CommitInfo> = commits
.iter()
.filter(|c| {
if let Some(ref t) = c.commit_type() {
category.types.contains(t)
} else {
false
}
})
.collect();
if !items.is_empty() {
output.push_str(&format!("### {}\n\n", category.title));
for commit in items {
output.push_str(&self.format_commit(commit));
output.push('\n');
}
output.push('\n');
}
}
Ok(output)
} else {
// Fall back to keep-a-changelog
self.generate_keep_a_changelog(version, date, commits)
}
}
fn format_commit(&self, commit: &CommitInfo) -> String {
let mut line = format!("- {}", commit.subject());
if self.include_hashes {
line.push_str(&format!(" ({})", &commit.short_id));
}
if self.include_authors {
line.push_str(&format!(" - @{}", commit.author));
}
line
}
fn format_commit_github(&self, commit: &CommitInfo) -> String {
format!("- {} by @{} in {}\n", commit.subject(), commit.author, &commit.short_id)
}
fn group_commits<'a>(&self, commits: &'a [CommitInfo]) -> HashMap<String, Vec<&'a CommitInfo>> {
let mut groups: HashMap<String, Vec<&'a CommitInfo>> = HashMap::new();
for commit in commits {
let commit_type = commit.commit_type().unwrap_or_else(|| "other".to_string());
groups.entry(commit_type).or_default().push(commit);
}
groups
}
}
impl Default for ChangelogGenerator {
fn default() -> Self {
Self::new()
}
}
/// Read existing changelog
pub fn read_changelog(path: &Path) -> Result<String> {
fs::read_to_string(path)
.with_context(|| format!("Failed to read changelog: {:?}", path))
}
/// Initialize new changelog file
pub fn init_changelog(path: &Path) -> Result<()> {
if path.exists() {
anyhow::bail!("Changelog already exists at {:?}", path);
}
let content = r#"# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
"#;
fs::write(path, content)
.with_context(|| format!("Failed to create changelog: {:?}", path))?;
Ok(())
}
/// Generate changelog from git history
pub fn generate_from_history(
repo: &GitRepo,
from_tag: Option<&str>,
to_ref: Option<&str>,
) -> Result<Vec<CommitInfo>> {
let to_ref = to_ref.unwrap_or("HEAD");
if let Some(from) = from_tag {
repo.get_commits_between(from, to_ref)
} else {
// Get last 50 commits if no tag specified
repo.get_commits(50)
}
}
/// Update version links in changelog
pub fn update_version_links(
changelog: &str,
version: &str,
compare_url: &str,
) -> String {
// Add version link at the end of changelog
format!("{}\n[{}]: {}\n", changelog, version, compare_url)
}
/// Parse changelog to extract versions
pub fn parse_versions(changelog: &str) -> Vec<(String, String)> {
let mut versions = vec![];
for line in changelog.lines() {
if line.starts_with("## [") {
if let Some(start) = line.find('[') {
if let Some(end) = line.find(']') {
let version = &line[start + 1..end];
if version != "Unreleased" {
if let Some(date_start) = line.find(" - ") {
let date = &line[date_start + 3..].trim();
versions.push((version.to_string(), date.to_string()));
}
}
}
}
}
}
versions
}
/// Get unreleased changes
pub fn get_unreleased_changes(repo: &GitRepo) -> Result<Vec<CommitInfo>> {
let tags = repo.get_tags()?;
if let Some(latest_tag) = tags.first() {
repo.get_commits_between(&latest_tag.name, "HEAD")
} else {
repo.get_commits(50)
}
}
/// Changelog entry for a specific version
pub struct ChangelogEntry {
pub version: String,
pub date: DateTime<Utc>,
pub commits: Vec<CommitInfo>,
}
impl ChangelogEntry {
/// Create new entry
pub fn new(version: impl Into<String>, commits: Vec<CommitInfo>) -> Self {
Self {
version: version.into(),
date: Utc::now(),
commits,
}
}
/// Set date
pub fn with_date(mut self, date: DateTime<Utc>) -> Self {
self.date = date;
self
}
}

367
src/git/commit.rs Normal file
View File

@@ -0,0 +1,367 @@
use super::GitRepo;
use anyhow::{bail, Context, Result};
use chrono::Local;
/// Commit builder for creating commits
pub struct CommitBuilder {
commit_type: Option<String>,
scope: Option<String>,
description: Option<String>,
body: Option<String>,
footer: Option<String>,
breaking: bool,
sign: bool,
amend: bool,
no_verify: bool,
dry_run: bool,
format: crate::config::CommitFormat,
}
impl CommitBuilder {
/// Create new commit builder
pub fn new() -> Self {
Self {
commit_type: None,
scope: None,
description: None,
body: None,
footer: None,
breaking: false,
sign: false,
amend: false,
no_verify: false,
dry_run: false,
format: crate::config::CommitFormat::Conventional,
}
}
/// Set commit type
pub fn commit_type(mut self, commit_type: impl Into<String>) -> Self {
self.commit_type = Some(commit_type.into());
self
}
/// Set scope
pub fn scope(mut self, scope: impl Into<String>) -> Self {
self.scope = Some(scope.into());
self
}
/// Set description
pub fn description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
/// Set body
pub fn body(mut self, body: impl Into<String>) -> Self {
self.body = Some(body.into());
self
}
/// Set footer
pub fn footer(mut self, footer: impl Into<String>) -> Self {
self.footer = Some(footer.into());
self
}
/// Mark as breaking change
pub fn breaking(mut self, breaking: bool) -> Self {
self.breaking = breaking;
self
}
/// Sign the commit
pub fn sign(mut self, sign: bool) -> Self {
self.sign = sign;
self
}
/// Amend previous commit
pub fn amend(mut self, amend: bool) -> Self {
self.amend = amend;
self
}
/// Skip pre-commit hooks
pub fn no_verify(mut self, no_verify: bool) -> Self {
self.no_verify = no_verify;
self
}
/// Dry run (don't actually commit)
pub fn dry_run(mut self, dry_run: bool) -> Self {
self.dry_run = dry_run;
self
}
/// Set commit format
pub fn format(mut self, format: crate::config::CommitFormat) -> Self {
self.format = format;
self
}
/// Build commit message
pub fn build_message(&self) -> Result<String> {
let commit_type = self.commit_type.as_ref()
.ok_or_else(|| anyhow::anyhow!("Commit type is required"))?;
let description = self.description.as_ref()
.ok_or_else(|| anyhow::anyhow!("Description is required"))?;
let message = match self.format {
crate::config::CommitFormat::Conventional => {
crate::utils::formatter::format_conventional_commit(
commit_type,
self.scope.as_deref(),
description,
self.body.as_deref(),
self.footer.as_deref(),
self.breaking,
)
}
crate::config::CommitFormat::Commitlint => {
crate::utils::formatter::format_commitlint_commit(
commit_type,
self.scope.as_deref(),
description,
self.body.as_deref(),
self.footer.as_deref(),
None,
)
}
};
Ok(message)
}
/// Execute commit
pub fn execute(&self, repo: &GitRepo) -> Result<Option<String>> {
let message = self.build_message()?;
if self.dry_run {
return Ok(Some(message));
}
// Check if there are staged changes
let staged_files = repo.get_staged_files()?;
if staged_files.is_empty() && !self.amend {
bail!("No staged changes to commit. Use 'git add' to stage files first.");
}
// Validate message
match self.format {
crate::config::CommitFormat::Conventional => {
crate::utils::validators::validate_conventional_commit(&message)?;
}
crate::config::CommitFormat::Commitlint => {
crate::utils::validators::validate_commitlint_commit(&message)?;
}
}
if self.amend {
self.amend_commit(repo, &message)?;
} else {
repo.commit(&message, self.sign)?;
}
Ok(None)
}
fn amend_commit(&self, repo: &GitRepo, message: &str) -> Result<()> {
use std::process::Command;
let mut args = vec!["commit", "--amend"];
if self.no_verify {
args.push("--no-verify");
}
args.push("-m");
args.push(message);
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(())
}
}
impl Default for CommitBuilder {
fn default() -> Self {
Self::new()
}
}
/// Create a date-based commit message
pub fn create_date_commit_message(prefix: Option<&str>) -> String {
let now = Local::now();
let date_str = now.format("%Y-%m-%d").to_string();
match prefix {
Some(p) => format!("{}: {}", p, date_str),
None => format!("chore: update {}", date_str),
}
}
/// Commit type suggestions based on diff
pub fn suggest_commit_type(diff: &str) -> Vec<&'static str> {
let mut suggestions = vec![];
// Check for test files
if diff.contains("test") || diff.contains("spec") || diff.contains("__tests__") {
suggestions.push("test");
}
// Check for documentation
if diff.contains("README") || diff.contains(".md") || diff.contains("docs/") {
suggestions.push("docs");
}
// Check for configuration files
if diff.contains("config") || diff.contains(".json") || diff.contains(".yaml") || diff.contains(".toml") {
suggestions.push("chore");
}
// Check for dependencies
if diff.contains("Cargo.toml") || diff.contains("package.json") || diff.contains("requirements.txt") {
suggestions.push("build");
}
// Check for CI
if diff.contains(".github/") || diff.contains(".gitlab-") || diff.contains("Jenkinsfile") {
suggestions.push("ci");
}
// Default suggestions
if suggestions.is_empty() {
suggestions.extend(&["feat", "fix", "refactor"]);
}
suggestions
}
/// Parse existing commit message
pub fn parse_commit_message(message: &str) -> ParsedCommit {
let lines: Vec<&str> = message.lines().collect();
if lines.is_empty() {
return ParsedCommit::default();
}
let first_line = lines[0];
// Try to parse as conventional commit
if let Some(colon_pos) = first_line.find(':') {
let type_part = &first_line[..colon_pos];
let description = first_line[colon_pos + 1..].trim();
let breaking = type_part.ends_with('!');
let type_part = type_part.trim_end_matches('!');
let (commit_type, scope) = if let Some(open) = type_part.find('(') {
if let Some(close) = type_part.find(')') {
let t = &type_part[..open];
let s = &type_part[open + 1..close];
(Some(t.to_string()), Some(s.to_string()))
} else {
(Some(type_part.to_string()), None)
}
} else {
(Some(type_part.to_string()), None)
};
// Extract body and footer
let mut body_lines = vec![];
let mut footer_lines = vec![];
let mut in_footer = false;
for line in &lines[1..] {
if line.trim().is_empty() {
continue;
}
if line.starts_with("BREAKING CHANGE:") ||
line.starts_with("Closes") ||
line.starts_with("Fixes") ||
line.starts_with("Refs") ||
line.starts_with("Co-authored-by:") {
in_footer = true;
}
if in_footer {
footer_lines.push(line.to_string());
} else {
body_lines.push(line.to_string());
}
}
return ParsedCommit {
commit_type,
scope,
description: Some(description.to_string()),
body: if body_lines.is_empty() { None } else { Some(body_lines.join("\n")) },
footer: if footer_lines.is_empty() { None } else { Some(footer_lines.join("\n")) },
breaking,
};
}
// Non-conventional commit
ParsedCommit {
description: Some(first_line.to_string()),
..Default::default()
}
}
/// Parsed commit structure
#[derive(Debug, Clone, Default)]
pub struct ParsedCommit {
pub commit_type: Option<String>,
pub scope: Option<String>,
pub description: Option<String>,
pub body: Option<String>,
pub footer: Option<String>,
pub breaking: bool,
}
impl ParsedCommit {
/// Convert back to commit message
pub fn to_message(&self, format: crate::config::CommitFormat) -> String {
let commit_type = self.commit_type.as_deref().unwrap_or("chore");
let description = self.description.as_deref().unwrap_or("update");
match format {
crate::config::CommitFormat::Conventional => {
crate::utils::formatter::format_conventional_commit(
commit_type,
self.scope.as_deref(),
description,
self.body.as_deref(),
self.footer.as_deref(),
self.breaking,
)
}
crate::config::CommitFormat::Commitlint => {
crate::utils::formatter::format_commitlint_commit(
commit_type,
self.scope.as_deref(),
description,
self.body.as_deref(),
self.footer.as_deref(),
None,
)
}
}
}
}

590
src/git/mod.rs Normal file
View File

@@ -0,0 +1,590 @@
use anyhow::{bail, Context, Result};
use git2::{Repository, Signature, StatusOptions, DiffOptions};
use std::path::Path;
pub mod changelog;
pub mod commit;
pub mod tag;
pub use changelog::ChangelogGenerator;
pub use commit::CommitBuilder;
pub use tag::TagBuilder;
/// Git repository wrapper
pub struct GitRepo {
repo: Repository,
path: std::path::PathBuf,
}
impl GitRepo {
/// Open a git repository
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
let path = path.as_ref().canonicalize()
.unwrap_or_else(|_| path.as_ref().to_path_buf());
let repo = Repository::open(&path)
.with_context(|| format!("Failed to open git repository: {:?}", path))?;
Ok(Self { repo, path })
}
/// Get repository path
pub fn path(&self) -> &Path {
&self.path
}
/// Get internal git2 repository
pub fn inner(&self) -> &Repository {
&self.repo
}
/// Check if this is a valid git repository
pub fn is_valid(&self) -> bool {
!self.repo.is_bare()
}
/// Check if there are uncommitted changes
pub fn has_changes(&self) -> Result<bool> {
let statuses = self.repo.statuses(Some(
StatusOptions::new()
.include_untracked(true)
.renames_head_to_index(true)
.renames_index_to_workdir(true),
))?;
Ok(!statuses.is_empty())
}
/// Get staged diff
pub fn get_staged_diff(&self) -> Result<String> {
let head = self.repo.head().ok();
let head_tree = head.as_ref()
.and_then(|h| h.peel_to_tree().ok());
let mut index = self.repo.index()?;
let index_tree = index.write_tree()?;
let index_tree = self.repo.find_tree(index_tree)?;
let diff = if let Some(head) = head_tree {
self.repo.diff_tree_to_index(Some(&head), Some(&index), None)?
} else {
self.repo.diff_tree_to_index(None, Some(&index), None)?
};
let mut diff_text = String::new();
diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
if let Ok(content) = std::str::from_utf8(line.content()) {
diff_text.push_str(content);
}
true
})?;
Ok(diff_text)
}
/// Get unstaged diff
pub fn get_unstaged_diff(&self) -> Result<String> {
let diff = self.repo.diff_index_to_workdir(None, None)?;
let mut diff_text = String::new();
diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
if let Ok(content) = std::str::from_utf8(line.content()) {
diff_text.push_str(content);
}
true
})?;
Ok(diff_text)
}
/// Get complete diff (staged + unstaged)
pub fn get_full_diff(&self) -> Result<String> {
let staged = self.get_staged_diff().unwrap_or_default();
let unstaged = self.get_unstaged_diff().unwrap_or_default();
Ok(format!("{}{}", staged, unstaged))
}
/// Get list of changed files
pub fn get_changed_files(&self) -> Result<Vec<String>> {
let statuses = self.repo.statuses(Some(
StatusOptions::new()
.include_untracked(true)
.renames_head_to_index(true)
.renames_index_to_workdir(true),
))?;
let mut files = vec![];
for entry in statuses.iter() {
if let Some(path) = entry.path() {
files.push(path.to_string());
}
}
Ok(files)
}
/// Get list of staged files
pub fn get_staged_files(&self) -> Result<Vec<String>> {
let statuses = self.repo.statuses(Some(
StatusOptions::new()
.include_untracked(false),
))?;
let mut files = vec![];
for entry in statuses.iter() {
let status = entry.status();
if status.is_index_new() || status.is_index_modified() || status.is_index_deleted() || status.is_index_renamed() || status.is_index_typechange() {
if let Some(path) = entry.path() {
files.push(path.to_string());
}
}
}
Ok(files)
}
/// Stage files
pub fn stage_files<P: AsRef<Path>>(&self, paths: &[P]) -> Result<()> {
let mut index = self.repo.index()?;
for path in paths {
index.add_path(path.as_ref())?;
}
index.write()?;
Ok(())
}
/// Stage all changes
pub fn stage_all(&self) -> Result<()> {
let mut index = self.repo.index()?;
// Get list of all files in working directory
let mut paths = Vec::new();
for entry in std::fs::read_dir(".")? {
let entry = entry?;
let path = entry.path();
if path.is_file() {
paths.push(path.to_path_buf());
}
}
for path_buf in paths {
if let Ok(_) = index.add_path(&path_buf) {
// File added successfully
}
}
index.write()?;
Ok(())
}
/// Unstage files
pub fn unstage_files<P: AsRef<Path>>(&self, paths: &[P]) -> Result<()> {
let head = self.repo.head()?;
let head_commit = head.peel_to_commit()?;
let head_tree = head_commit.tree()?;
let mut index = self.repo.index()?;
for path in paths {
// For now, just reset the index to HEAD
// This removes all staged changes
index.clear()?;
}
index.write()?;
Ok(())
}
/// Create a commit
pub fn commit(&self, message: &str, sign: bool) -> Result<git2::Oid> {
let signature = self.repo.signature()?;
let head = self.repo.head().ok();
let parents = if let Some(ref head) = head {
vec![head.peel_to_commit()?]
} else {
vec![]
};
let parent_refs: Vec<&git2::Commit> = parents.iter().collect();
let oid = if sign {
// For GPG signing, we need to use the command-line git
self.commit_signed(message, &signature)?
} else {
let tree_id = self.repo.index()?.write_tree()?;
let tree = self.repo.find_tree(tree_id)?;
self.repo.commit(
Some("HEAD"),
&signature,
&signature,
message,
&tree,
&parent_refs,
)?
};
Ok(oid)
}
/// Create a signed commit using git command
fn commit_signed(&self, message: &str, _signature: &git2::Signature) -> Result<git2::Oid> {
use std::process::Command;
// Write message to temp file
let temp_file = tempfile::NamedTempFile::new()?;
std::fs::write(temp_file.path(), message)?;
// Use git CLI for signed commit
let output = Command::new("git")
.args(&["commit", "-S", "-F", temp_file.path().to_str().unwrap()])
.current_dir(&self.path)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to create signed commit: {}", stderr);
}
// Get the new HEAD
let head = self.repo.head()?;
Ok(head.target().unwrap())
}
/// Get current branch name
pub fn current_branch(&self) -> Result<String> {
let head = self.repo.head()?;
if head.is_branch() {
let name = head.shorthand()
.ok_or_else(|| anyhow::anyhow!("Invalid branch name"))?;
Ok(name.to_string())
} else {
bail!("HEAD is not pointing to a branch")
}
}
/// Get current commit hash (short)
pub fn current_commit_short(&self) -> Result<String> {
let head = self.repo.head()?;
let oid = head.target()
.ok_or_else(|| anyhow::anyhow!("No target for HEAD"))?;
Ok(oid.to_string()[..8].to_string())
}
/// Get current commit hash (full)
pub fn current_commit(&self) -> Result<String> {
let head = self.repo.head()?;
let oid = head.target()
.ok_or_else(|| anyhow::anyhow!("No target for HEAD"))?;
Ok(oid.to_string())
}
/// Get commit history
pub fn get_commits(&self, count: usize) -> Result<Vec<CommitInfo>> {
let mut revwalk = self.repo.revwalk()?;
revwalk.push_head()?;
let mut commits = vec![];
for (i, oid) in revwalk.enumerate() {
if i >= count {
break;
}
let oid = oid?;
let commit = self.repo.find_commit(oid)?;
commits.push(CommitInfo {
id: oid.to_string(),
short_id: oid.to_string()[..8].to_string(),
message: commit.message().unwrap_or("").to_string(),
author: commit.author().name().unwrap_or("").to_string(),
email: commit.author().email().unwrap_or("").to_string(),
time: commit.time().seconds(),
});
}
Ok(commits)
}
/// Get commits between two references
pub fn get_commits_between(&self, from: &str, to: &str) -> Result<Vec<CommitInfo>> {
let from_obj = self.repo.revparse_single(from)?;
let to_obj = self.repo.revparse_single(to)?;
let from_commit = from_obj.peel_to_commit()?;
let to_commit = to_obj.peel_to_commit()?;
let mut revwalk = self.repo.revwalk()?;
revwalk.push(to_commit.id())?;
revwalk.hide(from_commit.id())?;
let mut commits = vec![];
for oid in revwalk {
let oid = oid?;
let commit = self.repo.find_commit(oid)?;
commits.push(CommitInfo {
id: oid.to_string(),
short_id: oid.to_string()[..8].to_string(),
message: commit.message().unwrap_or("").to_string(),
author: commit.author().name().unwrap_or("").to_string(),
email: commit.author().email().unwrap_or("").to_string(),
time: commit.time().seconds(),
});
}
Ok(commits)
}
/// Get tags
pub fn get_tags(&self) -> Result<Vec<TagInfo>> {
let mut tags = vec![];
self.repo.tag_foreach(|oid, name| {
let name = String::from_utf8_lossy(name);
let name = name.strip_prefix("refs/tags/").unwrap_or(&name);
if let Ok(commit) = self.repo.find_commit(oid) {
tags.push(TagInfo {
name: name.to_string(),
target: oid.to_string(),
message: commit.message().unwrap_or("").to_string(),
});
}
true
})?;
Ok(tags)
}
/// Create a tag
pub fn create_tag(&self, name: &str, message: Option<&str>, sign: bool) -> Result<()> {
let head = self.repo.head()?;
let target = head.peel_to_commit()?;
if let Some(msg) = message {
// Annotated tag
let sig = self.repo.signature()?;
if sign {
// Use git CLI for signed tags
self.create_signed_tag(name, msg)?;
} else {
self.repo.tag(
name,
target.as_object(),
&sig,
msg,
false,
)?;
}
} else {
// Lightweight tag
self.repo.tag(
name,
target.as_object(),
&self.repo.signature()?,
"",
false,
)?;
}
Ok(())
}
/// Create signed tag using git CLI
fn create_signed_tag(&self, name: &str, message: &str) -> Result<()> {
use std::process::Command;
let output = Command::new("git")
.args(&["tag", "-s", name, "-m", message])
.current_dir(&self.path)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to create signed tag: {}", stderr);
}
Ok(())
}
/// Delete a tag
pub fn delete_tag(&self, name: &str) -> Result<()> {
self.repo.tag_delete(name)?;
Ok(())
}
/// Push to remote
pub fn push(&self, remote: &str, refspec: &str) -> Result<()> {
use std::process::Command;
let output = Command::new("git")
.args(&["push", remote, refspec])
.current_dir(&self.path)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Push failed: {}", stderr);
}
Ok(())
}
/// Get remote URL
pub fn get_remote_url(&self, remote: &str) -> Result<String> {
let remote = self.repo.find_remote(remote)?;
let url = remote.url()
.ok_or_else(|| anyhow::anyhow!("Remote has no URL"))?;
Ok(url.to_string())
}
/// Check if working directory is clean
pub fn is_clean(&self) -> Result<bool> {
Ok(!self.has_changes()?)
}
/// Get repository status summary
pub fn status_summary(&self) -> Result<StatusSummary> {
let statuses = self.repo.statuses(Some(StatusOptions::new().include_untracked(true)))?;
let mut staged = 0;
let mut unstaged = 0;
let mut untracked = 0;
let mut conflicted = 0;
for entry in statuses.iter() {
let status = entry.status();
if status.is_index_new() || status.is_index_modified() ||
status.is_index_deleted() || status.is_index_renamed() ||
status.is_index_typechange() {
staged += 1;
}
if status.is_wt_modified() || status.is_wt_deleted() ||
status.is_wt_renamed() || status.is_wt_typechange() {
unstaged += 1;
}
if status.is_wt_new() {
untracked += 1;
}
if status.is_conflicted() {
conflicted += 1;
}
}
Ok(StatusSummary {
staged,
unstaged,
untracked,
conflicted,
clean: staged == 0 && unstaged == 0 && untracked == 0 && conflicted == 0,
})
}
}
/// Commit information
#[derive(Debug, Clone)]
pub struct CommitInfo {
pub id: String,
pub short_id: String,
pub message: String,
pub author: String,
pub email: String,
pub time: i64,
}
impl CommitInfo {
/// Get commit message subject (first line)
pub fn subject(&self) -> &str {
self.message.lines().next().unwrap_or("")
}
/// Get commit type from conventional commit
pub fn commit_type(&self) -> Option<String> {
let subject = self.subject();
if let Some(colon) = subject.find(':') {
let type_part = &subject[..colon];
let type_name = type_part.split('(').next()?;
Some(type_name.to_string())
} else {
None
}
}
}
/// Tag information
#[derive(Debug, Clone)]
pub struct TagInfo {
pub name: String,
pub target: String,
pub message: String,
}
/// Repository status summary
#[derive(Debug, Clone)]
pub struct StatusSummary {
pub staged: usize,
pub unstaged: usize,
pub untracked: usize,
pub conflicted: usize,
pub clean: bool,
}
impl StatusSummary {
/// Format as human-readable string
pub fn format(&self) -> String {
if self.clean {
"working tree clean".to_string()
} else {
let mut parts = vec![];
if self.staged > 0 {
parts.push(format!("{} staged", self.staged));
}
if self.unstaged > 0 {
parts.push(format!("{} unstaged", self.unstaged));
}
if self.untracked > 0 {
parts.push(format!("{} untracked", self.untracked));
}
if self.conflicted > 0 {
parts.push(format!("{} conflicted", self.conflicted));
}
parts.join(", ")
}
}
}
/// Find git repository starting from path and walking up
pub fn find_repo<P: AsRef<Path>>(start_path: P) -> Result<GitRepo> {
let start_path = start_path.as_ref();
if let Ok(repo) = GitRepo::open(start_path) {
return Ok(repo);
}
let mut current = start_path;
while let Some(parent) = current.parent() {
if let Ok(repo) = GitRepo::open(parent) {
return Ok(repo);
}
current = parent;
}
bail!("No git repository found starting from {:?}", start_path)
}
/// Check if path is inside a git repository
pub fn is_git_repo<P: AsRef<Path>>(path: P) -> bool {
find_repo(path).is_ok()
}

339
src/git/tag.rs Normal file
View File

@@ -0,0 +1,339 @@
use super::GitRepo;
use anyhow::{bail, Context, Result};
use semver::Version;
/// Tag builder for creating tags
pub struct TagBuilder {
name: Option<String>,
message: Option<String>,
annotate: bool,
sign: bool,
force: bool,
dry_run: 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,
dry_run: false,
version_prefix: "v".to_string(),
}
}
/// Set tag name
pub fn name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
/// Set tag message
pub fn message(mut self, message: impl Into<String>) -> 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
}
/// Dry run (don't actually create tag)
pub fn dry_run(mut self, dry_run: bool) -> Self {
self.dry_run = dry_run;
self
}
/// Set version prefix
pub fn version_prefix(mut self, prefix: impl Into<String>) -> 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<String> {
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.dry_run {
println!("Would create tag: {}", name);
if self.annotate {
println!("Message: {}", self.build_message()?);
}
return Ok(());
}
// Check if tag already exists
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<Self> {
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<Option<Version>> {
let tags = repo.get_tags()?;
let mut versions: Vec<Version> = 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<usize>,
) -> Result<Vec<super::TagInfo>> {
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)
}
}

227
src/llm/anthropic.rs Normal file
View File

@@ -0,0 +1,227 @@
use super::{create_http_client, LlmProvider};
use anyhow::{bail, Context, Result};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::time::Duration;
/// Anthropic Claude API client
pub struct AnthropicClient {
api_key: String,
model: String,
client: reqwest::Client,
}
#[derive(Debug, Serialize)]
struct MessagesRequest {
model: String,
max_tokens: u32,
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f32>,
messages: Vec<AnthropicMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
system: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct AnthropicMessage {
role: String,
content: String,
}
#[derive(Debug, Deserialize)]
struct MessagesResponse {
content: Vec<ContentBlock>,
}
#[derive(Debug, Deserialize)]
struct ContentBlock {
#[serde(rename = "type")]
content_type: String,
text: String,
}
#[derive(Debug, Deserialize)]
struct ErrorResponse {
error: AnthropicError,
}
#[derive(Debug, Deserialize)]
struct AnthropicError {
#[serde(rename = "type")]
error_type: String,
message: String,
}
impl AnthropicClient {
/// Create new Anthropic client
pub fn new(api_key: &str, model: &str) -> Result<Self> {
let client = create_http_client(Duration::from_secs(60))?;
Ok(Self {
api_key: api_key.to_string(),
model: model.to_string(),
client,
})
}
/// Set timeout
pub fn with_timeout(mut self, timeout: Duration) -> Result<Self> {
self.client = create_http_client(timeout)?;
Ok(self)
}
/// Validate API key
pub async fn validate_key(&self) -> Result<bool> {
let url = "https://api.anthropic.com/v1/messages";
let request = MessagesRequest {
model: self.model.clone(),
max_tokens: 5,
temperature: Some(0.0),
messages: vec![AnthropicMessage {
role: "user".to_string(),
content: "Hi".to_string(),
}],
system: None,
};
let response = self.client
.post(url)
.header("x-api-key", &self.api_key)
.header("anthropic-version", "2023-06-01")
.header("Content-Type", "application/json")
.json(&request)
.send()
.await;
match response {
Ok(resp) => {
if resp.status().is_success() {
Ok(true)
} else {
let status = resp.status();
if status.as_u16() == 401 {
Ok(false)
} else {
let text = resp.text().await.unwrap_or_default();
bail!("Anthropic API error: {} - {}", status, text)
}
}
}
Err(e) => Err(e.into()),
}
}
}
#[async_trait]
impl LlmProvider for AnthropicClient {
async fn generate(&self, prompt: &str) -> Result<String> {
let messages = vec![AnthropicMessage {
role: "user".to_string(),
content: prompt.to_string(),
}];
self.messages_request(messages, None).await
}
async fn generate_with_system(&self, system: &str, user: &str) -> Result<String> {
let messages = vec![AnthropicMessage {
role: "user".to_string(),
content: user.to_string(),
}];
let system = if system.is_empty() {
None
} else {
Some(system.to_string())
};
self.messages_request(messages, system).await
}
async fn is_available(&self) -> bool {
self.validate_key().await.unwrap_or(false)
}
fn name(&self) -> &str {
"anthropic"
}
}
impl AnthropicClient {
async fn messages_request(
&self,
messages: Vec<AnthropicMessage>,
system: Option<String>,
) -> Result<String> {
let url = "https://api.anthropic.com/v1/messages";
let request = MessagesRequest {
model: self.model.clone(),
max_tokens: 500,
temperature: Some(0.7),
messages,
system,
};
let response = self.client
.post(url)
.header("x-api-key", &self.api_key)
.header("anthropic-version", "2023-06-01")
.header("Content-Type", "application/json")
.json(&request)
.send()
.await
.context("Failed to send request to Anthropic")?;
let status = response.status();
if !status.is_success() {
let text = response.text().await.unwrap_or_default();
// Try to parse error
if let Ok(error) = serde_json::from_str::<ErrorResponse>(&text) {
bail!("Anthropic API error: {} ({})", error.error.message, error.error.error_type);
}
bail!("Anthropic API error: {} - {}", status, text);
}
let result: MessagesResponse = response
.json()
.await
.context("Failed to parse Anthropic response")?;
result.content
.into_iter()
.find(|c| c.content_type == "text")
.map(|c| c.text.trim().to_string())
.ok_or_else(|| anyhow::anyhow!("No text response from Anthropic"))
}
}
/// Available Anthropic models
pub const ANTHROPIC_MODELS: &[&str] = &[
"claude-3-opus-20240229",
"claude-3-sonnet-20240229",
"claude-3-haiku-20240307",
"claude-2.1",
"claude-2.0",
"claude-instant-1.2",
];
/// Check if a model name is valid
pub fn is_valid_model(model: &str) -> bool {
ANTHROPIC_MODELS.contains(&model)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_model_validation() {
assert!(is_valid_model("claude-3-sonnet-20240229"));
assert!(!is_valid_model("invalid-model"));
}
}

433
src/llm/mod.rs Normal file
View File

@@ -0,0 +1,433 @@
use anyhow::{bail, Context, Result};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::time::Duration;
pub mod ollama;
pub mod openai;
pub mod anthropic;
pub use ollama::OllamaClient;
pub use openai::OpenAiClient;
pub use anthropic::AnthropicClient;
/// LLM provider trait
#[async_trait]
pub trait LlmProvider: Send + Sync {
/// Generate text from prompt
async fn generate(&self, prompt: &str) -> Result<String>;
/// Generate with system prompt
async fn generate_with_system(&self, system: &str, user: &str) -> Result<String>;
/// Check if provider is available
async fn is_available(&self) -> bool;
/// Get provider name
fn name(&self) -> &str;
}
/// LLM client that wraps different providers
pub struct LlmClient {
provider: Box<dyn LlmProvider>,
config: LlmClientConfig,
}
#[derive(Debug, Clone)]
pub struct LlmClientConfig {
pub max_tokens: u32,
pub temperature: f32,
pub timeout: Duration,
}
impl Default for LlmClientConfig {
fn default() -> Self {
Self {
max_tokens: 500,
temperature: 0.7,
timeout: Duration::from_secs(30),
}
}
}
impl LlmClient {
/// Create LLM client from configuration
pub async fn from_config(config: &crate::config::LlmConfig) -> Result<Self> {
let client_config = LlmClientConfig {
max_tokens: config.max_tokens,
temperature: config.temperature,
timeout: Duration::from_secs(config.timeout),
};
let provider: Box<dyn LlmProvider> = match config.provider.as_str() {
"ollama" => {
Box::new(OllamaClient::new(&config.ollama.url, &config.ollama.model))
}
"openai" => {
let api_key = config.openai.api_key.as_ref()
.ok_or_else(|| anyhow::anyhow!("OpenAI API key not configured"))?;
Box::new(OpenAiClient::new(
&config.openai.base_url,
api_key,
&config.openai.model,
)?)
}
"anthropic" => {
let api_key = config.anthropic.api_key.as_ref()
.ok_or_else(|| anyhow::anyhow!("Anthropic API key not configured"))?;
Box::new(AnthropicClient::new(api_key, &config.anthropic.model)?)
}
_ => bail!("Unknown LLM provider: {}", config.provider),
};
Ok(Self {
provider,
config: client_config,
})
}
/// Create with specific provider
pub fn with_provider(provider: Box<dyn LlmProvider>) -> Self {
Self {
provider,
config: LlmClientConfig::default(),
}
}
/// Generate commit message from git diff
pub async fn generate_commit_message(
&self,
diff: &str,
format: crate::config::CommitFormat,
) -> Result<GeneratedCommit> {
let system_prompt = match format {
crate::config::CommitFormat::Conventional => {
CONVENTIONAL_COMMIT_SYSTEM_PROMPT
}
crate::config::CommitFormat::Commitlint => {
COMMITLINT_SYSTEM_PROMPT
}
};
let prompt = format!("{}", diff);
let response = self.provider.generate_with_system(system_prompt, &prompt).await?;
self.parse_commit_response(&response, format)
}
/// Generate tag message from commits
pub async fn generate_tag_message(
&self,
version: &str,
commits: &[String],
) -> Result<String> {
let system_prompt = TAG_MESSAGE_SYSTEM_PROMPT;
let commits_text = commits.join("\n");
let prompt = format!("Version: {}\n\nCommits:\n{}", version, commits_text);
self.provider.generate_with_system(system_prompt, &prompt).await
}
/// Generate changelog entry
pub async fn generate_changelog_entry(
&self,
version: &str,
commits: &[(String, String)], // (type, message)
) -> Result<String> {
let system_prompt = CHANGELOG_SYSTEM_PROMPT;
let commits_text = commits
.iter()
.map(|(t, m)| format!("- [{}] {}", t, m))
.collect::<Vec<_>>()
.join("\n");
let prompt = format!("Version: {}\n\nCommits:\n{}", version, commits_text);
self.provider.generate_with_system(system_prompt, &prompt).await
}
/// Check if provider is available
pub async fn is_available(&self) -> bool {
self.provider.is_available().await
}
/// Parse commit response from LLM
fn parse_commit_response(&self, response: &str, format: crate::config::CommitFormat) -> Result<GeneratedCommit> {
let lines: Vec<&str> = response.lines().collect();
if lines.is_empty() {
bail!("Empty response from LLM");
}
let first_line = lines[0];
// Parse based on format
match format {
crate::config::CommitFormat::Conventional => {
self.parse_conventional_commit(first_line, lines)
}
crate::config::CommitFormat::Commitlint => {
self.parse_commitlint_commit(first_line, lines)
}
}
}
fn parse_conventional_commit(
&self,
first_line: &str,
lines: Vec<&str>,
) -> Result<GeneratedCommit> {
// Parse: type(scope)!: description
let parts: Vec<&str> = first_line.splitn(2, ':').collect();
if parts.len() != 2 {
bail!("Invalid conventional commit format: missing colon");
}
let type_part = parts[0];
let description = parts[1].trim();
// Extract type, scope, and breaking indicator
let breaking = type_part.ends_with('!');
let type_part = type_part.trim_end_matches('!');
let (commit_type, scope) = if let Some(start) = type_part.find('(') {
if let Some(end) = type_part.find(')') {
let t = &type_part[..start];
let s = &type_part[start + 1..end];
(t.to_string(), Some(s.to_string()))
} else {
bail!("Invalid scope format: missing closing parenthesis");
}
} else {
(type_part.to_string(), None)
};
// Extract body and footer
let (body, footer) = self.extract_body_footer(&lines);
Ok(GeneratedCommit {
commit_type,
scope,
description: description.to_string(),
body,
footer,
breaking,
})
}
fn parse_commitlint_commit(
&self,
first_line: &str,
lines: Vec<&str>,
) -> Result<GeneratedCommit> {
// Similar parsing but with commitlint rules
let parts: Vec<&str> = first_line.splitn(2, ':').collect();
if parts.len() != 2 {
bail!("Invalid commit format: missing colon");
}
let type_part = parts[0];
let subject = parts[1].trim();
let (commit_type, scope) = if let Some(start) = type_part.find('(') {
if let Some(end) = type_part.find(')') {
let t = &type_part[..start];
let s = &type_part[start + 1..end];
(t.to_string(), Some(s.to_string()))
} else {
(type_part.to_string(), None)
}
} else {
(type_part.to_string(), None)
};
let (body, footer) = self.extract_body_footer(&lines);
Ok(GeneratedCommit {
commit_type,
scope,
description: subject.to_string(),
body,
footer,
breaking: false,
})
}
fn extract_body_footer(&self, lines: &[&str]) -> (Option<String>, Option<String>) {
if lines.len() <= 1 {
return (None, None);
}
let rest: Vec<&str> = lines[1..]
.iter()
.skip_while(|l| l.trim().is_empty())
.copied()
.collect();
if rest.is_empty() {
return (None, None);
}
// Look for footer markers
let footer_markers = ["BREAKING CHANGE:", "Closes", "Fixes", "Refs", "Co-authored-by:"];
let mut body_lines = vec![];
let mut footer_lines = vec![];
let mut in_footer = false;
for line in &rest {
if footer_markers.iter().any(|m| line.starts_with(m)) {
in_footer = true;
}
if in_footer {
footer_lines.push(*line);
} else {
body_lines.push(*line);
}
}
let body = if body_lines.is_empty() {
None
} else {
Some(body_lines.join("\n"))
};
let footer = if footer_lines.is_empty() {
None
} else {
Some(footer_lines.join("\n"))
};
(body, footer)
}
}
/// Generated commit structure
#[derive(Debug, Clone)]
pub struct GeneratedCommit {
pub commit_type: String,
pub scope: Option<String>,
pub description: String,
pub body: Option<String>,
pub footer: Option<String>,
pub breaking: bool,
}
impl GeneratedCommit {
/// Format as conventional commit
pub fn to_conventional(&self) -> String {
crate::utils::formatter::format_conventional_commit(
&self.commit_type,
self.scope.as_deref(),
&self.description,
self.body.as_deref(),
self.footer.as_deref(),
self.breaking,
)
}
/// Format as commitlint commit
pub fn to_commitlint(&self) -> String {
crate::utils::formatter::format_commitlint_commit(
&self.commit_type,
self.scope.as_deref(),
&self.description,
self.body.as_deref(),
self.footer.as_deref(),
None,
)
}
}
// System prompts for LLM
const CONVENTIONAL_COMMIT_SYSTEM_PROMPT: &str = r#"You are a helpful assistant that generates conventional commit messages.
Analyze the git diff provided and generate a commit message following the Conventional Commits specification.
Format: <type>[optional scope]: <description>
Types:
- feat: A new feature
- fix: A bug fix
- docs: Documentation only changes
- style: Changes that don't affect code meaning (formatting, semicolons, etc.)
- refactor: Code change that neither fixes a bug nor adds a feature
- perf: Code change that improves performance
- test: Adding or correcting tests
- build: Changes to build system or dependencies
- ci: Changes to CI configuration
- chore: Other changes that don't modify src or test files
- revert: Reverts a previous commit
Rules:
1. Use lowercase for type and scope
2. Keep description under 100 characters
3. Use imperative mood ("add" not "added")
4. Don't capitalize first letter
5. No period at the end
6. Include scope if the change is specific to a module/component
Output ONLY the commit message, nothing else.
"#;
const COMMITLINT_SYSTEM_PROMPT: &str = r#"You are a helpful assistant that generates commit messages following @commitlint/config-conventional.
Analyze the git diff and generate a commit message.
Format: <type>[optional scope]: <subject>
Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
Rules:
1. Subject should not start with uppercase
2. Subject should not end with period
3. Subject should be 4-100 characters
4. Use imperative mood
5. Be concise but descriptive
Output ONLY the commit message, nothing else.
"#;
const TAG_MESSAGE_SYSTEM_PROMPT: &str = r#"You are a helpful assistant that generates git tag annotation messages.
Given a version number and a list of commits, generate a concise but informative tag message.
The message should:
1. Start with a brief summary of the release
2. Group changes by type (features, fixes, etc.)
3. Be suitable for a git annotated tag
Format:
<version> Release
Summary of changes...
Changes:
- Feature: description
- Fix: description
...
"#;
const CHANGELOG_SYSTEM_PROMPT: &str = r#"You are a helpful assistant that generates changelog entries.
Given a version and a list of commits, generate a well-formatted changelog section.
Group commits by:
- Features (feat)
- Bug Fixes (fix)
- Documentation (docs)
- Other Changes
Format in markdown with proper headings and bullet points.
"#;
/// HTTP client helper
pub(crate) fn create_http_client(timeout: Duration) -> Result<reqwest::Client> {
reqwest::Client::builder()
.timeout(timeout)
.build()
.context("Failed to create HTTP client")
}

206
src/llm/ollama.rs Normal file
View File

@@ -0,0 +1,206 @@
use super::{create_http_client, LlmProvider};
use anyhow::{Context, Result};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::time::Duration;
/// Ollama API client
pub struct OllamaClient {
base_url: String,
model: String,
client: reqwest::Client,
}
#[derive(Debug, Serialize)]
struct GenerateRequest {
model: String,
prompt: String,
system: Option<String>,
stream: bool,
options: GenerationOptions,
}
#[derive(Debug, Serialize, Default)]
struct GenerationOptions {
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
num_predict: Option<u32>,
}
#[derive(Debug, Deserialize)]
struct GenerateResponse {
response: String,
done: bool,
}
#[derive(Debug, Deserialize)]
struct ListModelsResponse {
models: Vec<ModelInfo>,
}
#[derive(Debug, Deserialize)]
struct ModelInfo {
name: String,
}
impl OllamaClient {
/// Create new Ollama client
pub fn new(base_url: &str, model: &str) -> Self {
let client = create_http_client(Duration::from_secs(120))
.expect("Failed to create HTTP client");
Self {
base_url: base_url.trim_end_matches('/').to_string(),
model: model.to_string(),
client,
}
}
/// Set timeout
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.client = create_http_client(timeout)
.expect("Failed to create HTTP client");
self
}
/// List available models
pub async fn list_models(&self) -> Result<Vec<String>> {
let url = format!("{}/api/tags", self.base_url);
let response = self.client
.get(&url)
.send()
.await
.context("Failed to list Ollama models")?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
anyhow::bail!("Ollama API error: {} - {}", status, text);
}
let result: ListModelsResponse = response
.json()
.await
.context("Failed to parse Ollama response")?;
Ok(result.models.into_iter().map(|m| m.name).collect())
}
/// Pull a model
pub async fn pull_model(&self, model: &str) -> Result<()> {
let url = format!("{}/api/pull", self.base_url);
let request = serde_json::json!({
"name": model,
"stream": false,
});
let response = self.client
.post(&url)
.json(&request)
.send()
.await
.context("Failed to pull Ollama model")?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
anyhow::bail!("Ollama pull error: {} - {}", status, text);
}
Ok(())
}
/// Check if model exists
pub async fn model_exists(&self, model: &str) -> bool {
match self.list_models().await {
Ok(models) => models.contains(&model.to_string()),
Err(_) => false,
}
}
}
#[async_trait]
impl LlmProvider for OllamaClient {
async fn generate(&self, prompt: &str) -> Result<String> {
self.generate_with_system("", prompt).await
}
async fn generate_with_system(&self, system: &str, user: &str) -> Result<String> {
let url = format!("{}/api/generate", self.base_url);
let system = if system.is_empty() {
None
} else {
Some(system.to_string())
};
let request = GenerateRequest {
model: self.model.clone(),
prompt: user.to_string(),
system,
stream: false,
options: GenerationOptions {
temperature: Some(0.7),
num_predict: Some(500),
},
};
let response = self.client
.post(&url)
.json(&request)
.send()
.await
.context("Failed to send request to Ollama")?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
anyhow::bail!("Ollama API error: {} - {}", status, text);
}
let result: GenerateResponse = response
.json()
.await
.context("Failed to parse Ollama response")?;
Ok(result.response.trim().to_string())
}
async fn is_available(&self) -> bool {
let url = format!("{}/api/tags", self.base_url);
match self.client.get(&url).send().await {
Ok(response) => response.status().is_success(),
Err(_) => false,
}
}
fn name(&self) -> &str {
"ollama"
}
}
#[cfg(test)]
mod tests {
use super::*;
// These tests require a running Ollama server
#[tokio::test]
#[ignore]
async fn test_ollama_connection() {
let client = OllamaClient::new("http://localhost:11434", "llama2");
assert!(client.is_available().await);
}
#[tokio::test]
#[ignore]
async fn test_ollama_generate() {
let client = OllamaClient::new("http://localhost:11434", "llama2");
let response = client.generate("Hello, how are you?").await;
assert!(response.is_ok());
println!("Response: {}", response.unwrap());
}
}

345
src/llm/openai.rs Normal file
View File

@@ -0,0 +1,345 @@
use super::{create_http_client, LlmProvider};
use anyhow::{bail, Context, Result};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::time::Duration;
/// OpenAI API client
pub struct OpenAiClient {
base_url: String,
api_key: String,
model: String,
client: reqwest::Client,
}
#[derive(Debug, Serialize)]
struct ChatCompletionRequest {
model: String,
messages: Vec<Message>,
#[serde(skip_serializing_if = "Option::is_none")]
max_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f32>,
stream: bool,
}
#[derive(Debug, Serialize, Deserialize)]
struct Message {
role: String,
content: String,
}
#[derive(Debug, Deserialize)]
struct ChatCompletionResponse {
choices: Vec<Choice>,
}
#[derive(Debug, Deserialize)]
struct Choice {
message: Message,
}
#[derive(Debug, Deserialize)]
struct ErrorResponse {
error: ApiError,
}
#[derive(Debug, Deserialize)]
struct ApiError {
message: String,
#[serde(rename = "type")]
error_type: String,
}
impl OpenAiClient {
/// Create new OpenAI client
pub fn new(base_url: &str, api_key: &str, model: &str) -> Result<Self> {
let client = create_http_client(Duration::from_secs(60))?;
Ok(Self {
base_url: base_url.trim_end_matches('/').to_string(),
api_key: api_key.to_string(),
model: model.to_string(),
client,
})
}
/// Set timeout
pub fn with_timeout(mut self, timeout: Duration) -> Result<Self> {
self.client = create_http_client(timeout)?;
Ok(self)
}
/// List available models
pub async fn list_models(&self) -> Result<Vec<String>> {
let url = format!("{}/models", self.base_url);
let response = self.client
.get(&url)
.header("Authorization", format!("Bearer {}", self.api_key))
.send()
.await
.context("Failed to list OpenAI models")?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
bail!("OpenAI API error: {} - {}", status, text);
}
#[derive(Deserialize)]
struct ModelsResponse {
data: Vec<Model>,
}
#[derive(Deserialize)]
struct Model {
id: String,
}
let result: ModelsResponse = response
.json()
.await
.context("Failed to parse OpenAI response")?;
Ok(result.data.into_iter().map(|m| m.id).collect())
}
/// Validate API key
pub async fn validate_key(&self) -> Result<bool> {
match self.list_models().await {
Ok(_) => Ok(true),
Err(e) => {
let err_str = e.to_string();
if err_str.contains("401") || err_str.contains("Unauthorized") {
Ok(false)
} else {
Err(e)
}
}
}
}
}
#[async_trait]
impl LlmProvider for OpenAiClient {
async fn generate(&self, prompt: &str) -> Result<String> {
let messages = vec![
Message {
role: "user".to_string(),
content: prompt.to_string(),
},
];
self.chat_completion(messages).await
}
async fn generate_with_system(&self, system: &str, user: &str) -> Result<String> {
let mut messages = vec![];
if !system.is_empty() {
messages.push(Message {
role: "system".to_string(),
content: system.to_string(),
});
}
messages.push(Message {
role: "user".to_string(),
content: user.to_string(),
});
self.chat_completion(messages).await
}
async fn is_available(&self) -> bool {
self.validate_key().await.unwrap_or(false)
}
fn name(&self) -> &str {
"openai"
}
}
impl OpenAiClient {
async fn chat_completion(&self, messages: Vec<Message>) -> Result<String> {
let url = format!("{}/chat/completions", self.base_url);
let request = ChatCompletionRequest {
model: self.model.clone(),
messages,
max_tokens: Some(500),
temperature: Some(0.7),
stream: false,
};
let response = self.client
.post(&url)
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.json(&request)
.send()
.await
.context("Failed to send request to OpenAI")?;
let status = response.status();
if !status.is_success() {
let text = response.text().await.unwrap_or_default();
// Try to parse error
if let Ok(error) = serde_json::from_str::<ErrorResponse>(&text) {
bail!("OpenAI API error: {} ({})", error.error.message, error.error.error_type);
}
bail!("OpenAI API error: {} - {}", status, text);
}
let result: ChatCompletionResponse = response
.json()
.await
.context("Failed to parse OpenAI response")?;
result.choices
.into_iter()
.next()
.map(|c| c.message.content.trim().to_string())
.ok_or_else(|| anyhow::anyhow!("No response from OpenAI"))
}
}
/// Azure OpenAI client (extends OpenAI with Azure-specific config)
pub struct AzureOpenAiClient {
endpoint: String,
api_key: String,
deployment: String,
api_version: String,
client: reqwest::Client,
}
impl AzureOpenAiClient {
/// Create new Azure OpenAI client
pub fn new(
endpoint: &str,
api_key: &str,
deployment: &str,
api_version: &str,
) -> Result<Self> {
let client = create_http_client(Duration::from_secs(60))?;
Ok(Self {
endpoint: endpoint.trim_end_matches('/').to_string(),
api_key: api_key.to_string(),
deployment: deployment.to_string(),
api_version: api_version.to_string(),
client,
})
}
async fn chat_completion(&self, messages: Vec<Message>) -> Result<String> {
let url = format!(
"{}/openai/deployments/{}/chat/completions?api-version={}",
self.endpoint, self.deployment, self.api_version
);
let request = ChatCompletionRequest {
model: self.deployment.clone(),
messages,
max_tokens: Some(500),
temperature: Some(0.7),
stream: false,
};
let response = self.client
.post(&url)
.header("api-key", &self.api_key)
.header("Content-Type", "application/json")
.json(&request)
.send()
.await
.context("Failed to send request to Azure OpenAI")?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
bail!("Azure OpenAI API error: {} - {}", status, text);
}
let result: ChatCompletionResponse = response
.json()
.await
.context("Failed to parse Azure OpenAI response")?;
result.choices
.into_iter()
.next()
.map(|c| c.message.content.trim().to_string())
.ok_or_else(|| anyhow::anyhow!("No response from Azure OpenAI"))
}
}
#[async_trait]
impl LlmProvider for AzureOpenAiClient {
async fn generate(&self, prompt: &str) -> Result<String> {
let messages = vec![
Message {
role: "user".to_string(),
content: prompt.to_string(),
},
];
self.chat_completion(messages).await
}
async fn generate_with_system(&self, system: &str, user: &str) -> Result<String> {
let mut messages = vec![];
if !system.is_empty() {
messages.push(Message {
role: "system".to_string(),
content: system.to_string(),
});
}
messages.push(Message {
role: "user".to_string(),
content: user.to_string(),
});
self.chat_completion(messages).await
}
async fn is_available(&self) -> bool {
// Simple check - try to make a minimal request
let url = format!(
"{}/openai/deployments/{}/chat/completions?api-version={}",
self.endpoint, self.deployment, self.api_version
);
let request = ChatCompletionRequest {
model: self.deployment.clone(),
messages: vec![Message {
role: "user".to_string(),
content: "Hi".to_string(),
}],
max_tokens: Some(5),
temperature: Some(0.0),
stream: false,
};
match self.client
.post(&url)
.header("api-key", &self.api_key)
.json(&request)
.send()
.await
{
Ok(response) => response.status().is_success(),
Err(_) => false,
}
}
fn name(&self) -> &str {
"azure-openai"
}
}

100
src/main.rs Normal file
View File

@@ -0,0 +1,100 @@
use anyhow::Result;
use clap::{Parser, Subcommand, ValueEnum};
use tracing::{debug, info};
mod commands;
mod config;
mod generator;
mod git;
mod llm;
mod utils;
use commands::{
changelog::ChangelogCommand, commit::CommitCommand, config::ConfigCommand,
init::InitCommand, profile::ProfileCommand, tag::TagCommand,
};
/// QuicCommit - AI-powered Git assistant
///
/// A powerful tool that helps you generate conventional commits, tags, and changelogs
/// using AI (LLM APIs or local Ollama models). Manage multiple Git profiles for different
/// work contexts seamlessly.
#[derive(Parser)]
#[command(name = "quicommit")]
#[command(about = "AI-powered Git assistant for conventional commits, tags, and changelogs")]
#[command(version = env!("CARGO_PKG_VERSION"))]
#[command(propagate_version = true)]
#[command(arg_required_else_help = true)]
struct Cli {
/// Enable verbose output
#[arg(short, long, global = true, action = clap::ArgAction::Count)]
verbose: u8,
/// Configuration file path
#[arg(short, long, global = true, env = "QUICOMMIT_CONFIG")]
config: Option<String>,
/// Disable colored output
#[arg(long, global = true, env = "NO_COLOR")]
no_color: bool,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Initialize quicommit configuration
#[command(alias = "i")]
Init(InitCommand),
/// Generate and execute conventional commits
#[command(alias = "c")]
Commit(CommitCommand),
/// Generate and create Git tags
#[command(alias = "t")]
Tag(TagCommand),
/// Generate changelog
#[command(alias = "cl")]
Changelog(ChangelogCommand),
/// Manage Git profiles (user info, SSH, GPG)
#[command(alias = "p")]
Profile(ProfileCommand),
/// Manage configuration settings
#[command(alias = "cfg")]
Config(ConfigCommand),
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
// Initialize logging
let log_level = match cli.verbose {
0 => "warn",
1 => "info",
2 => "debug",
_ => "trace",
};
tracing_subscriber::fmt()
.with_env_filter(log_level)
.with_target(false)
.init();
debug!("Starting quicommit v{}", env!("CARGO_PKG_VERSION"));
// Execute command
match cli.command {
Commands::Init(cmd) => cmd.execute().await,
Commands::Commit(cmd) => cmd.execute().await,
Commands::Tag(cmd) => cmd.execute().await,
Commands::Changelog(cmd) => cmd.execute().await,
Commands::Profile(cmd) => cmd.execute().await,
Commands::Config(cmd) => cmd.execute().await,
}
}

139
src/utils/crypto.rs Normal file
View File

@@ -0,0 +1,139 @@
use aes_gcm::{
aead::{Aead, KeyInit},
Aes256Gcm, Nonce,
};
use anyhow::{Context, Result};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use rand::Rng;
use std::fs;
use std::path::Path;
const KEY_LEN: usize = 32;
const NONCE_LEN: usize = 12;
const SALT_LEN: usize = 32;
/// Encrypt data with password
pub fn encrypt(data: &[u8], password: &str) -> Result<String> {
let mut salt = [0u8; SALT_LEN];
rand::thread_rng().fill(&mut salt);
let mut nonce_bytes = [0u8; NONCE_LEN];
rand::thread_rng().fill(&mut nonce_bytes);
let key = derive_key(password, &salt)?;
let cipher = Aes256Gcm::new_from_slice(&key)
.context("Failed to create cipher")?;
let nonce = Nonce::from_slice(&nonce_bytes);
let encrypted = cipher
.encrypt(nonce, data)
.map_err(|e| anyhow::anyhow!("Encryption failed: {:?}", e))?;
// Combine salt + nonce + encrypted data
let mut result = Vec::with_capacity(SALT_LEN + NONCE_LEN + encrypted.len());
result.extend_from_slice(&salt);
result.extend_from_slice(&nonce_bytes);
result.extend_from_slice(&encrypted);
Ok(BASE64.encode(&result))
}
/// Decrypt data with password
pub fn decrypt(encrypted_data: &str, password: &str) -> Result<Vec<u8>> {
let data = BASE64.decode(encrypted_data)
.context("Invalid base64 encoding")?;
if data.len() < SALT_LEN + NONCE_LEN {
anyhow::bail!("Invalid encrypted data format");
}
let salt = &data[..SALT_LEN];
let nonce_bytes = &data[SALT_LEN..SALT_LEN + NONCE_LEN];
let encrypted = &data[SALT_LEN + NONCE_LEN..];
let key = derive_key(password, salt)?;
let cipher = Aes256Gcm::new_from_slice(&key)
.context("Failed to create cipher")?;
let nonce = Nonce::from_slice(nonce_bytes);
let decrypted = cipher
.decrypt(nonce, encrypted)
.map_err(|e| anyhow::anyhow!("Decryption failed: {:?}", e))?;
Ok(decrypted)
}
/// Derive key from password using simple method
fn derive_key(password: &str, salt: &[u8]) -> Result<[u8; KEY_LEN]> {
use sha2::{Sha256, Digest};
let mut hasher = Sha256::new();
hasher.update(salt);
hasher.update(password.as_bytes());
hasher.update(b"quicommit_key_derivation_v1");
let hash = hasher.finalize();
let mut key = [0u8; KEY_LEN];
key.copy_from_slice(&hash[..KEY_LEN]);
Ok(key)
}
/// Encrypt and save to file
pub fn encrypt_to_file(data: &[u8], password: &str, path: &Path) -> Result<()> {
let encrypted = encrypt(data, password)?;
fs::write(path, encrypted)
.with_context(|| format!("Failed to write encrypted file: {:?}", path))?;
Ok(())
}
/// Decrypt from file
pub fn decrypt_from_file(path: &Path, password: &str) -> Result<Vec<u8>> {
let encrypted = fs::read_to_string(path)
.with_context(|| format!("Failed to read encrypted file: {:?}", path))?;
decrypt(&encrypted, password)
}
/// Generate a secure random token
pub fn generate_token(length: usize) -> String {
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let mut rng = rand::thread_rng();
(0..length)
.map(|_| {
let idx = rng.gen_range(0..CHARSET.len());
CHARSET[idx] as char
})
.collect()
}
/// Hash data using SHA-256
pub fn sha256(data: &[u8]) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(data);
hex::encode(hasher.finalize())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_encrypt_decrypt() {
let data = b"Hello, World!";
let password = "my_secret_password";
let encrypted = encrypt(data, password).unwrap();
let decrypted = decrypt(&encrypted, password).unwrap();
assert_eq!(data.to_vec(), decrypted);
}
#[test]
fn test_wrong_password() {
let data = b"Hello, World!";
let encrypted = encrypt(data, "correct_password").unwrap();
assert!(decrypt(&encrypted, "wrong_password").is_err());
}
}

57
src/utils/editor.rs Normal file
View File

@@ -0,0 +1,57 @@
use anyhow::{Context, Result};
use std::fs;
use std::path::Path;
/// Open editor for user to input/edit content
pub fn edit_content(initial_content: &str) -> Result<String> {
edit::edit(initial_content).context("Failed to open editor")
}
/// Edit file in user's default editor
pub fn edit_file(path: &Path) -> Result<String> {
let content = fs::read_to_string(path)
.unwrap_or_default();
let edited = edit::edit(&content)
.context("Failed to open editor")?;
fs::write(path, &edited)
.with_context(|| format!("Failed to write file: {:?}", path))?;
Ok(edited)
}
/// Create temporary file and open in editor
pub fn edit_temp(initial_content: &str, extension: &str) -> Result<String> {
let temp_file = tempfile::Builder::new()
.suffix(&format!(".{}", extension))
.tempfile()
.context("Failed to create temp file")?;
let path = temp_file.path();
fs::write(path, initial_content)
.context("Failed to write temp file")?;
edit_file(path)
}
/// Get editor command from environment
pub fn get_editor() -> String {
std::env::var("EDITOR")
.or_else(|_| std::env::var("VISUAL"))
.unwrap_or_else(|_| {
if cfg!(target_os = "windows") {
"notepad".to_string()
} else {
"vi".to_string()
}
})
}
/// Check if editor is available
pub fn check_editor() -> Result<()> {
let editor = get_editor();
which::which(&editor)
.with_context(|| format!("Editor '{}' not found in PATH", editor))?;
Ok(())
}

198
src/utils/formatter.rs Normal file
View File

@@ -0,0 +1,198 @@
use chrono::{DateTime, Local, Utc};
use regex::Regex;
/// Format commit message with conventional commit format
pub fn format_conventional_commit(
commit_type: &str,
scope: Option<&str>,
description: &str,
body: Option<&str>,
footer: Option<&str>,
breaking: bool,
) -> String {
let mut message = String::new();
// Type and scope
message.push_str(commit_type);
if let Some(s) = scope {
message.push_str(&format!("({})", s));
}
if breaking {
message.push('!');
}
message.push_str(&format!(": {}", description));
// Body
if let Some(b) = body {
message.push_str(&format!("\n\n{}", b));
}
// Footer
if let Some(f) = footer {
message.push_str(&format!("\n\n{}", f));
}
message
}
/// Format commit with @commitlint format
pub fn format_commitlint_commit(
commit_type: &str,
scope: Option<&str>,
subject: &str,
body: Option<&str>,
footer: Option<&str>,
references: Option<&[&str]>,
) -> String {
let mut message = String::new();
// Header
message.push_str(commit_type);
if let Some(s) = scope {
message.push_str(&format!("({})", s));
}
message.push_str(&format!(": {}", subject));
// References
if let Some(refs) = references {
for reference in refs {
message.push_str(&format!(" #{}", reference));
}
}
// Body
if let Some(b) = body {
message.push_str(&format!("\n\n{}", b));
}
// Footer
if let Some(f) = footer {
message.push_str(&format!("\n\n{}", f));
}
message
}
/// Format date for commit message
pub fn format_commit_date(date: &DateTime<Local>) -> String {
date.format("%Y-%m-%d %H:%M:%S").to_string()
}
/// Format date for changelog
pub fn format_changelog_date(date: &DateTime<Utc>) -> String {
date.format("%Y-%m-%d").to_string()
}
/// Format tag name with version
pub fn format_tag_name(version: &str, prefix: Option<&str>) -> String {
match prefix {
Some(p) => format!("{}{}", p, version),
None => version.to_string(),
}
}
/// Wrap text at specified width
pub fn wrap_text(text: &str, width: usize) -> String {
textwrap::fill(text, width)
}
/// Truncate text with ellipsis
pub fn truncate(text: &str, max_len: usize) -> String {
if text.len() <= max_len {
text.to_string()
} else {
format!("{}...", &text[..max_len.saturating_sub(3)])
}
}
/// Clean commit message (remove comments, extra whitespace)
pub fn clean_message(message: &str) -> String {
let comment_regex = Regex::new(r"^#.*$").unwrap();
message
.lines()
.filter(|line| !comment_regex.is_match(line.trim()))
.collect::<Vec<_>>()
.join("\n")
.trim()
.to_string()
}
/// Format list as markdown bullet points
pub fn format_markdown_list(items: &[String]) -> String {
items
.iter()
.map(|item| format!("- {}", item))
.collect::<Vec<_>>()
.join("\n")
}
/// Format changelog section
pub fn format_changelog_section(
version: &str,
date: &str,
changes: &[(String, Vec<String>)],
) -> String {
let mut section = format!("## [{}] - {}\n\n", version, date);
for (category, items) in changes {
if !items.is_empty() {
section.push_str(&format!("### {}\n\n", category));
for item in items {
section.push_str(&format!("- {}\n", item));
}
section.push('\n');
}
}
section
}
/// Format git config key
pub fn format_git_config_key(section: &str, subsection: Option<&str>, key: &str) -> String {
match subsection {
Some(sub) => format!("{}.{}.{}", section, sub, key),
None => format!("{}.{}", section, key),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_conventional_commit() {
let msg = format_conventional_commit(
"feat",
Some("auth"),
"add login functionality",
Some("This adds OAuth2 login support."),
Some("Closes #123"),
false,
);
assert!(msg.contains("feat(auth): add login functionality"));
assert!(msg.contains("This adds OAuth2 login support."));
assert!(msg.contains("Closes #123"));
}
#[test]
fn test_format_conventional_commit_breaking() {
let msg = format_conventional_commit(
"feat",
None,
"change API response format",
None,
Some("BREAKING CHANGE: response format changed"),
true,
);
assert!(msg.starts_with("feat!: change API response format"));
}
#[test]
fn test_truncate() {
assert_eq!(truncate("hello", 10), "hello");
assert_eq!(truncate("hello world", 8), "hello...");
}
}

76
src/utils/mod.rs Normal file
View File

@@ -0,0 +1,76 @@
pub mod crypto;
pub mod editor;
pub mod formatter;
pub mod validators;
use anyhow::{Context, Result};
use colored::Colorize;
use std::io::{self, Write};
/// Print success message
pub fn print_success(msg: &str) {
println!("{} {}", "".green().bold(), msg);
}
/// Print error message
pub fn print_error(msg: &str) {
eprintln!("{} {}", "".red().bold(), msg);
}
/// Print warning message
pub fn print_warning(msg: &str) {
println!("{} {}", "".yellow().bold(), msg);
}
/// Print info message
pub fn print_info(msg: &str) {
println!("{} {}", "".blue().bold(), msg);
}
/// Confirm action with user
pub fn confirm(prompt: &str) -> Result<bool> {
print!("{} [y/N] ", prompt);
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
Ok(input.trim().to_lowercase().starts_with('y'))
}
/// Get user input
pub fn input(prompt: &str) -> Result<String> {
print!("{}: ", prompt);
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
Ok(input.trim().to_string())
}
/// Get password input (hidden)
pub fn password_input(prompt: &str) -> Result<String> {
use dialoguer::Password;
Password::new()
.with_prompt(prompt)
.interact()
.context("Failed to read password")
}
/// Check if running in a terminal
pub fn is_terminal() -> bool {
atty::is(atty::Stream::Stdout)
}
/// Format duration in human-readable format
pub fn format_duration(secs: u64) -> String {
if secs < 60 {
format!("{}s", secs)
} else if secs < 3600 {
format!("{}m {}s", secs / 60, secs % 60)
} else {
format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
}
}

270
src/utils/validators.rs Normal file
View File

@@ -0,0 +1,270 @@
use anyhow::{bail, Result};
use lazy_static::lazy_static;
use regex::Regex;
/// Conventional commit types
pub const CONVENTIONAL_TYPES: &[&str] = &[
"feat", // A new feature
"fix", // A bug fix
"docs", // Documentation only changes
"style", // Changes that do not affect the meaning of the code
"refactor", // A code change that neither fixes a bug nor adds a feature
"perf", // A code change that improves performance
"test", // Adding missing tests or correcting existing tests
"build", // Changes that affect the build system or external dependencies
"ci", // Changes to CI configuration files and scripts
"chore", // Other changes that don't modify src or test files
"revert", // Reverts a previous commit
];
/// Commitlint configuration types (extends conventional)
pub const COMMITLINT_TYPES: &[&str] = &[
"feat", // A new feature
"fix", // A bug fix
"docs", // Documentation only changes
"style", // Changes that do not affect the meaning of the code
"refactor", // A code change that neither fixes a bug nor adds a feature
"perf", // A code change that improves performance
"test", // Adding missing tests or correcting existing tests
"build", // Changes that affect the build system or external dependencies
"ci", // Changes to CI configuration files and scripts
"chore", // Other changes that don't modify src or test files
"revert", // Reverts a previous commit
"wip", // Work in progress
"init", // Initial commit
"update", // Update existing functionality
"remove", // Remove functionality
"security", // Security-related changes
];
lazy_static! {
/// Regex for conventional commit format
static ref CONVENTIONAL_COMMIT_REGEX: Regex = Regex::new(
r"^(?P<type>feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?: (?P<description>.+)$"
).unwrap();
/// Regex for scope validation
static ref SCOPE_REGEX: Regex = Regex::new(
r"^[a-z0-9-]+$"
).unwrap();
/// Regex for version tag validation (semver)
static ref SEMVER_REGEX: Regex = Regex::new(
r"^v?(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$"
).unwrap();
/// Regex for email validation
static ref EMAIL_REGEX: Regex = Regex::new(
r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
).unwrap();
/// Regex for SSH key validation (basic)
static ref SSH_KEY_REGEX: Regex = Regex::new(
r"^(ssh-rsa|ssh-ed25519|ecdsa-sha2-nistp256|ecdsa-sha2-nistp384|ecdsa-sha2-nistp521)\s+[A-Za-z0-9+/]+={0,2}\s+.*$"
).unwrap();
/// Regex for GPG key ID validation
static ref GPG_KEY_ID_REGEX: Regex = Regex::new(
r"^[A-F0-9]{16,40}$"
).unwrap();
}
/// Validate conventional commit message
pub fn validate_conventional_commit(message: &str) -> Result<()> {
let first_line = message.lines().next().unwrap_or("");
if !CONVENTIONAL_COMMIT_REGEX.is_match(first_line) {
bail!(
"Invalid conventional commit format. Expected: <type>[optional scope]: <description>\n\
Valid types: {}",
CONVENTIONAL_TYPES.join(", ")
);
}
// Check description length (max 100 chars for first line)
if first_line.len() > 100 {
bail!("Commit subject too long (max 100 characters)");
}
Ok(())
}
/// Validate @commitlint commit message
pub fn validate_commitlint_commit(message: &str) -> Result<()> {
let first_line = message.lines().next().unwrap_or("");
// Commitlint is more lenient but still requires type prefix
let parts: Vec<&str> = first_line.splitn(2, ':').collect();
if parts.len() != 2 {
bail!("Invalid commit format. Expected: <type>[optional scope]: <subject>");
}
let type_part = parts[0];
let subject = parts[1].trim();
// Extract type (handle scope and breaking indicator)
let commit_type = type_part
.split('(')
.next()
.unwrap_or("")
.trim_end_matches('!');
if !COMMITLINT_TYPES.contains(&commit_type) {
bail!(
"Invalid commit type: '{}'. Valid types: {}",
commit_type,
COMMITLINT_TYPES.join(", ")
);
}
// Validate subject
if subject.is_empty() {
bail!("Commit subject cannot be empty");
}
if subject.len() < 4 {
bail!("Commit subject too short (min 4 characters)");
}
if subject.len() > 100 {
bail!("Commit subject too long (max 100 characters)");
}
// Subject should not start with uppercase
if subject.chars().next().map(|c| c.is_uppercase()).unwrap_or(false) {
bail!("Commit subject should not start with uppercase letter");
}
// Subject should not end with period
if subject.ends_with('.') {
bail!("Commit subject should not end with a period");
}
Ok(())
}
/// Validate scope name
pub fn validate_scope(scope: &str) -> Result<()> {
if scope.is_empty() {
bail!("Scope cannot be empty");
}
if !SCOPE_REGEX.is_match(scope) {
bail!("Invalid scope format. Use lowercase letters, numbers, and hyphens only");
}
Ok(())
}
/// Validate semantic version tag
pub fn validate_semver(version: &str) -> Result<()> {
let version = version.trim_start_matches('v');
if !SEMVER_REGEX.is_match(version) {
bail!(
"Invalid semantic version format. Expected: MAJOR.MINOR.PATCH[-prerelease][+build]\n\
Examples: 1.0.0, 1.2.3-beta, v2.0.0+build123"
);
}
Ok(())
}
/// Validate email address
pub fn validate_email(email: &str) -> Result<()> {
if !EMAIL_REGEX.is_match(email) {
bail!("Invalid email address format");
}
Ok(())
}
/// Validate SSH key format
pub fn validate_ssh_key(key: &str) -> Result<()> {
if !SSH_KEY_REGEX.is_match(key.trim()) {
bail!("Invalid SSH public key format");
}
Ok(())
}
/// Validate GPG key ID
pub fn validate_gpg_key_id(key_id: &str) -> Result<()> {
if !GPG_KEY_ID_REGEX.is_match(key_id) {
bail!("Invalid GPG key ID format. Expected 16-40 hexadecimal characters");
}
Ok(())
}
/// Validate profile name
pub fn validate_profile_name(name: &str) -> Result<()> {
if name.is_empty() {
bail!("Profile name cannot be empty");
}
if name.len() > 50 {
bail!("Profile name too long (max 50 characters)");
}
if !name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
bail!("Profile name can only contain letters, numbers, hyphens, and underscores");
}
Ok(())
}
/// Check if commit type is valid
pub fn is_valid_commit_type(commit_type: &str, use_commitlint: bool) -> bool {
let types = if use_commitlint {
COMMITLINT_TYPES
} else {
CONVENTIONAL_TYPES
};
types.contains(&commit_type)
}
/// Get available commit types
pub fn get_commit_types(use_commitlint: bool) -> &'static [&'static str] {
if use_commitlint {
COMMITLINT_TYPES
} else {
CONVENTIONAL_TYPES
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_conventional_commit() {
assert!(validate_conventional_commit("feat: add new feature").is_ok());
assert!(validate_conventional_commit("fix(auth): fix login bug").is_ok());
assert!(validate_conventional_commit("feat!: breaking change").is_ok());
assert!(validate_conventional_commit("invalid: message").is_err());
}
#[test]
fn test_validate_semver() {
assert!(validate_semver("1.0.0").is_ok());
assert!(validate_semver("v1.2.3").is_ok());
assert!(validate_semver("2.0.0-beta.1").is_ok());
assert!(validate_semver("invalid").is_err());
}
#[test]
fn test_validate_email() {
assert!(validate_email("test@example.com").is_ok());
assert!(validate_email("invalid-email").is_err());
}
#[test]
fn test_validate_profile_name() {
assert!(validate_profile_name("personal").is_ok());
assert!(validate_profile_name("work-company").is_ok());
assert!(validate_profile_name("").is_err());
assert!(validate_profile_name("invalid name").is_err());
}
}