commit 5d4156e5e037f669a75272c7b2e02c2279c2ea5e Author: SidneyZhang Date: Fri Jan 30 14:18:32 2026 +0800 feat:(first commit)created repository and complete 0.1.0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd0bb53 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Rust +target/ +Cargo.lock +**/*.rs.bk +*.pdb + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Test artifacts +*.log +test_output/ + +# Config (for development) +config.toml diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3dc51b9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,39 @@ +# 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). + +## [Unreleased] + +### Added + +- Initial release of QuicCommit +- AI-powered commit message generation using LLM APIs (OpenAI, Anthropic) or local Ollama +- Support for Conventional Commits and @commitlint formats +- Multiple Git profile management with SSH and GPG support +- Smart tag generation with semantic version bumping +- Automatic changelog generation +- Interactive CLI with beautiful prompts and previews +- Encrypted storage for sensitive data +- Cross-platform support (Linux, macOS, Windows) + +### Features + +- **Commit Generation**: Automatically generate conventional commit messages from git diffs +- **Profile Management**: Switch between multiple Git identities for different contexts +- **Tag Management**: Create annotated tags with AI-generated release notes +- **Changelog**: Generate and maintain changelog in Keep a Changelog format +- **Security**: Encrypt SSH passphrases and API keys +- **Interactive UI**: Beautiful CLI with prompts and previews + +## [0.1.0] - 2024-01-01 + +### Added + +- Initial project structure +- Core functionality for git operations +- LLM integration +- Configuration management +- CLI interface diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..74d39a7 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,90 @@ +[package] +name = "quicommit" +version = "0.1.0" +edition = "2024" +authors = ["Your Name "] +description = "A powerful Git assistant tool with AI-powered commit/tag/changelog generation" +license = "MIT" +repository = "https://github.com/yourusername/quicommit" +keywords = ["git", "commit", "ai", "cli", "automation"] +categories = ["command-line-utilities", "development-tools"] + + +[[bin]] +name = "quicommit" +path = "src/main.rs" + +[dependencies] +# CLI and argument parsing +clap = { version = "4.5", features = ["derive", "env", "wrap_help"] } +clap_complete = "4.5" +dialoguer = "0.11" +console = "0.15" +indicatif = "0.17" + +# Configuration management +config = "0.14" +serde = { version = "1.0", features = ["derive"] } +toml = "0.8" +dirs = "5.0" + +# Git operations +git2 = "0.18" +which = "6.0" + +# HTTP client for LLM APIs +reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } +tokio = { version = "1.35", features = ["full"] } + +# Error handling +thiserror = "1.0" +anyhow = "1.0" + +# Logging and tracing +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } + +# Utilities +chrono = { version = "0.4", features = ["serde"] } +regex = "1.10" +lazy_static = "1.4" +colored = "2.1" +handlebars = "5.1" +semver = "1.0" +walkdir = "2.4" +tempfile = "3.9" +sha2 = "0.10" +hex = "0.4" +textwrap = "0.16" +async-trait = "0.1" +serde_json = "1.0" +atty = "0.2" + +# Encryption for sensitive data (SSH keys, GPG, etc.) +aes-gcm = "0.10" +argon2 = "0.5" +rand = "0.8" +base64 = "0.22" + +# Interactive editor +edit = "0.1" + +# Shell completion generation +shell-words = "1.1" + +[dev-dependencies] +assert_cmd = "2.0" +predicates = "3.1" +tempfile = "3.9" +mockall = "0.12" +wiremock = "0.6" + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 +strip = true + +[profile.dev] +opt-level = 0 +debug = true diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3e07081 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sidney Zhang + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b0c3d7f --- /dev/null +++ b/Makefile @@ -0,0 +1,91 @@ +.PHONY: build release test clean install fmt lint check doc + +# Default target +all: build + +# Build debug version +build: + cargo build + +# Build release version +release: + cargo build --release + +# Run tests +test: + cargo test + +# Run integration tests +test-integration: + cargo test --test integration_tests + +# Clean build artifacts +clean: + cargo clean + +# Install locally (requires cargo-install) +install: + cargo install --path . + +# Format code +fmt: + cargo fmt + +# Check formatting +check-fmt: + cargo fmt -- --check + +# Run clippy linter +lint: + cargo clippy -- -D warnings + +# Run all checks +check: check-fmt lint test + +# Generate documentation +doc: + cargo doc --no-deps --open + +# Run with debug logging +debug: + RUST_LOG=debug cargo run -- $(ARGS) + +# Build and run +run: build + ./target/debug/quicommit $(ARGS) + +# Build release and run +run-release: release + ./target/release/quicommit $(ARGS) + +# Generate shell completions +completions: release + mkdir -p completions + ./target/release/quicommit completions bash > completions/quicommit.bash + ./target/release/quicommit completions zsh > completions/_quicommit + ./target/release/quicommit completions fish > completions/quicommit.fish + ./target/release/quicommit completions powershell > completions/_quicommit.ps1 + +# Development helpers +dev-setup: + rustup component add rustfmt clippy + +# CI pipeline +ci: check-fmt lint test + @echo "All CI checks passed!" + +# Help +help: + @echo "Available targets:" + @echo " build - Build debug version" + @echo " release - Build release version" + @echo " test - Run all tests" + @echo " clean - Clean build artifacts" + @echo " install - Install locally" + @echo " fmt - Format code" + @echo " lint - Run clippy linter" + @echo " check - Run all checks (fmt, lint, test)" + @echo " doc - Generate documentation" + @echo " run - Build and run (use ARGS='...' for arguments)" + @echo " completions - Generate shell completions" + @echo " ci - Run CI pipeline" diff --git a/README.md b/README.md new file mode 100644 index 0000000..dfc02d8 --- /dev/null +++ b/README.md @@ -0,0 +1,338 @@ +# QuiCommit + +A powerful AI-powered Git assistant for generating conventional commits, tags, and changelogs. Manage multiple Git profiles for different work contexts seamlessly. + +![Rust](https://img.shields.io/badge/rust-%23000000.svg?style=for-the-badge&logo=rust&logoColor=white) +![License](https://img.shields.io/badge/license-MIT%2FApache--2.0-blue.svg) + +## Features + +- 🤖 **AI-Powered Generation**: Generate commit messages, tag annotations, and changelog entries using LLM APIs (OpenAI, Anthropic) or local Ollama models +- 📝 **Conventional Commits**: Full support for Conventional Commits and @commitlint formats +- 👤 **Profile Management**: Save and switch between multiple Git profiles (user info, SSH keys, GPG signing) +- 🏷️ **Smart Tagging**: Semantic version bumping with auto-generated release notes +- 📜 **Changelog Generation**: Automatic changelog generation in Keep a Changelog or GitHub Releases format +- 🔐 **Security**: Encrypt sensitive data like SSH passphrases and API keys +- 🎨 **Interactive UI**: Beautiful CLI with interactive prompts and previews + +## Installation + +### From Source + +```bash +git clone https://github.com/yourusername/quicommit.git +cd quicommit +cargo build --release +``` + +The binary will be available at `target/release/quicommit`. + +### Prerequisites + +- Rust 1.70 or later +- Git 2.0 or later +- For AI features: Ollama (local) or API keys for OpenAI/Anthropic + +## Quick Start + +### 1. Initialize Configuration + +```bash +quicommit init +``` + +This will guide you through setting up your first profile and LLM configuration. + +### 2. Generate a Commit + +```bash +# AI-generated commit (default) +quicommit commit + +# Manual commit +quicommit commit --manual -t feat -m "add new feature" + +# Date-based commit +quicommit commit --date + +# Stage all and commit +quicommit commit -a +``` + +### 3. Create a Tag + +```bash +# Auto-detect version bump from commits +quicommit tag + +# Bump specific version +quicommit tag --bump minor + +# Custom tag name +quicommit tag -n v1.0.0 +``` + +### 4. Generate Changelog + +```bash +# Generate for unreleased changes +quicommit changelog + +# Generate for specific version +quicommit changelog -v 1.0.0 + +# Initialize new changelog +quicommit changelog --init +``` + +## Configuration + +### Profiles + +Manage multiple Git identities for different contexts: + +```bash +# Add a new profile +quicommit profile add + +# List profiles +quicommit profile list + +# Switch profile +quicommit profile switch + +# Apply profile to current repo +quicommit profile apply + +# Set profile for current repo +quicommit profile set-repo personal +``` + +### LLM Providers + +#### Ollama (Local - Recommended) + +```bash +# Configure Ollama +quicommit config set-llm ollama + +# Or with specific settings +quicommit config set-ollama --url http://localhost:11434 --model llama2 +``` + +#### OpenAI + +```bash +quicommit config set-llm openai +quicommit config set-openai-key YOUR_API_KEY +``` + +#### Anthropic Claude + +```bash +quicommit config set-llm anthropic +quicommit config set-anthropic-key YOUR_API_KEY +``` + +### Commit Format + +```bash +# Use conventional commits (default) +quicommit config set-commit-format conventional + +# Use commitlint format +quicommit config set-commit-format commitlint +``` + +## Usage Examples + +### Interactive Commit Flow + +```bash +$ quicommit commit +Staged files (3): + • src/main.rs + • src/lib.rs + • Cargo.toml + +🤖 AI is analyzing your changes... + +──────────────────────────────────────────────────────────── +Generated commit message: +──────────────────────────────────────────────────────────── +feat: add user authentication module + +Implement OAuth2 authentication with support for GitHub +and Google providers. +──────────────────────────────────────────────────────────── + +What would you like to do? +> ✓ Accept and commit + 🔄 Regenerate + ✏️ Edit + ❌ Cancel +``` + +### Profile Management + +```bash +# Create work profile +$ quicommit profile add +Profile name: work +Git user name: John Doe +Git user email: john@company.com +Is this a work profile? yes +Organization: Acme Corp + +# Set for current repository +$ quicommit profile set-repo work +✓ Set 'work' for current repository +``` + +### Smart Tagging + +```bash +$ quicommit tag +Latest version: v0.1.0 + +Version selection: +> Auto-detect bump from commits + Bump major version + Bump minor version + Bump patch version + +🤖 AI is generating tag message from 15 commits... + +Tag preview: +Name: v0.2.0 +Message: +## What's Changed + +### 🚀 Features +- Add user authentication +- Implement dashboard + +### 🐛 Bug Fixes +- Fix login redirect issue + +Create this tag? yes +✓ Created tag v0.2.0 +``` + +## Configuration File + +Configuration is stored at: + +- **Linux**: `~/.config/quicommit/config.toml` +- **macOS**: `~/Library/Application Support/quicommit/config.toml` +- **Windows**: `%APPDATA%\quicommit\config.toml` + +Example configuration: + +```toml +version = "1" +default_profile = "personal" + +[profiles.personal] +name = "personal" +user_name = "John Doe" +user_email = "john@example.com" + +[profiles.work] +name = "work" +user_name = "John Doe" +user_email = "john@company.com" +is_work = true +organization = "Acme Corp" + +[llm] +provider = "ollama" +max_tokens = 500 +temperature = 0.7 + +[llm.ollama] +url = "http://localhost:11434" +model = "llama2" + +[commit] +format = "conventional" +auto_generate = true +max_subject_length = 100 + +[tag] +version_prefix = "v" +auto_generate = true +include_changelog = true + +[changelog] +path = "CHANGELOG.md" +auto_generate = true +group_by_type = true +``` + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `QUICOMMIT_CONFIG` | Path to configuration file | +| `EDITOR` | Default editor for interactive input | +| `NO_COLOR` | Disable colored output | + +## Shell Completions + +### Bash +```bash +quicommit completions bash > /etc/bash_completion.d/quicommit +``` + +### Zsh +```bash +quicommit completions zsh > /usr/local/share/zsh/site-functions/_quicommit +``` + +### Fish +```bash +quicommit completions fish > ~/.config/fish/completions/quicommit.fish +``` + +## Troubleshooting + +### LLM Connection Issues + +```bash +# Test LLM connection +quicommit config test-llm + +# List available models +quicommit config list-models +``` + +### Git Operations + +```bash +# Check current profile +quicommit profile show + +# Apply profile to fix git config +quicommit profile apply +``` + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +1. Fork the repository +2. Create your feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'feat: add amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +## License + +This project is licensed under the MIT OR Apache-2.0 license. + +## Acknowledgments + +- [Conventional Commits](https://www.conventionalcommits.org/) specification +- [Keep a Changelog](https://keepachangelog.com/) format +- [Ollama](https://ollama.ai/) for local LLM support diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..9ee9ebc --- /dev/null +++ b/build.rs @@ -0,0 +1,12 @@ +use std::env; + + +fn main() { + // Only generate completions when explicitly requested + if env::var("GENERATE_COMPLETIONS").is_ok() { + println!("cargo:warning=To generate shell completions, run: cargo run --bin quicommit -- completions"); + } + + // Rerun if build.rs changes + println!("cargo:rerun-if-changed=build.rs"); +} diff --git a/examples/config.example.toml b/examples/config.example.toml new file mode 100644 index 0000000..9ccf4ca --- /dev/null +++ b/examples/config.example.toml @@ -0,0 +1,101 @@ +# QuicCommit Configuration Example +# Copy this file to your config directory and modify as needed: +# - Linux: ~/.config/quicommit/config.toml +# - macOS: ~/Library/Application Support/quicommit/config.toml +# - Windows: %APPDATA%\quicommit\config.toml + +# Configuration version (for migration) +version = "1" + +# Default profile to use when no repo-specific profile is set +default_profile = "personal" + +# Profile definitions +[profiles.personal] +name = "personal" +user_name = "Your Name" +user_email = "your.email@example.com" +description = "Personal projects" +is_work = false + +[profiles.work] +name = "work" +user_name = "Your Name" +user_email = "your.name@company.com" +description = "Work projects" +is_work = true +organization = "Your Company" + +# SSH configuration for work profile +[profiles.work.ssh] +private_key_path = "/home/user/.ssh/id_rsa_work" +agent_forwarding = true + +# GPG configuration for work profile +[profiles.work.gpg] +key_id = "YOUR_GPG_KEY_ID" +program = "gpg" +use_agent = true + +# LLM Configuration +[llm] +# Provider: ollama, openai, or anthropic +provider = "ollama" +max_tokens = 500 +temperature = 0.7 +timeout = 30 + +# Ollama settings (local LLM) +[llm.ollama] +url = "http://localhost:11434" +model = "llama2" + +# OpenAI settings +[llm.openai] +# api_key = "sk-..." # Set via: quicommit config set-openai-key +model = "gpt-4" +base_url = "https://api.openai.com/v1" + +# Anthropic settings +[llm.anthropic] +# api_key = "sk-ant-..." # Set via: quicommit config set-anthropic-key +model = "claude-3-sonnet-20240229" + +# Commit settings +[commit] +# Format: conventional or commitlint +format = "conventional" +auto_generate = true +allow_empty = false +gpg_sign = false +max_subject_length = 100 +require_scope = false +require_body = false +body_required_types = ["feat", "fix"] + +# Tag settings +[tag] +version_prefix = "v" +auto_generate = true +gpg_sign = false +include_changelog = true + +# Changelog settings +[changelog] +path = "CHANGELOG.md" +auto_generate = true +format = "keep-a-changelog" # or "github-releases" +include_hashes = false +include_authors = false +group_by_type = true + +# Theme settings +[theme] +colors = true +icons = true +date_format = "%Y-%m-%d" + +# Repository-specific profile mappings +# [repo_profiles] +# "/path/to/work/project" = "work" +# "/path/to/personal/project" = "personal" diff --git a/readme_zh.md b/readme_zh.md new file mode 100644 index 0000000..a7b74ae --- /dev/null +++ b/readme_zh.md @@ -0,0 +1,337 @@ +# QuiCommit + +一款强大的AI驱动的Git助手,用于生成规范化的提交信息、标签和变更日志,并支持为不同工作场景管理多个Git配置。 + +![Rust](https://img.shields.io/badge/rust-%23000000.svg?style=for-the-badge&logo=rust&logoColor=white) +![LICENSE](https://img.shields.io/badge/license-MIT-blue.svg) + +## ✨ 主要功能 + +- 🤖 **AI智能生成**:使用LLM API(OpenAI、Anthropic)或本地Ollama模型生成提交信息、标签注释和变更日志 +- 📝 **规范化提交**:全面支持Conventional Commits和@commitlint格式规范 +- 👤 **多配置管理**:为不同工作场景保存和切换多个Git配置(用户信息、SSH密钥、GPG签名) +- 🏷️ **智能标签管理**:基于语义版本的智能版本升级,自动生成发布说明 +- 📜 **变更日志生成**:自动生成Keep a Changelog或GitHub Releases格式的变更日志 +- 🔐 **安全保护**:加密存储敏感数据,如SSH密码和API密钥 +- 🎨 **交互式界面**:美观的CLI界面,支持交互式提示和预览功能 + +## 📦 安装说明 + +### 从源码安装 + +```bash +git clone https://github.com/yourusername/quicommit.git +cd quicommit +cargo build --release +``` + +可执行文件将位于 `target/release/quicommit`。 + +### 环境要求 + +- Rust 1.70或更高版本 +- Git 2.0或更高版本 +- AI功能可选:Ollama(本地)或OpenAI/Anthropic的API密钥 + +## 🚀 快速开始 + +### 1. 初始化配置 + +```bash +quicommit init +``` + +这将引导您完成第一个配置和LLM配置的设置。 + +### 2. 生成提交信息 + +```bash +# AI生成提交信息(默认) +quicommit commit + +# 手动提交 +quicommit commit --manual -t feat -m "添加新功能" + +# 基于日期的提交 +quicommit commit --date + +# 暂存所有文件并提交 +quicommit commit -a +``` + +### 3. 创建标签 + +```bash +# 自动检测提交中的版本升级 +quicommit tag + +# 指定版本升级 +quicommit tag --bump minor + +# 自定义标签名 +quicommit tag -n v1.0.0 +``` + +### 4. 生成变更日志 + +```bash +# 为未发布变更生成 +quicommit changelog + +# 为特定版本生成 +quicommit changelog -v 1.0.0 + +# 初始化新的变更日志 +quicommit changelog --init +``` + +## ⚙️ 配置说明 + +### 多配置管理 + +为不同场景管理多个Git身份: + +```bash +# 添加新配置 +quicommit profile add + +# 查看配置列表 +quicommit profile list + +# 切换配置 +quicommit profile switch + +# 应用配置到当前仓库 +quicommit profile apply + +# 为当前仓库设置配置 +quicommit profile set-repo personal +``` + +### LLM提供商配置 + +#### Ollama(本地部署 - 推荐) + +```bash +# 配置Ollama +quicommit config set-llm ollama + +# 或使用特定设置 +quicommit config set-ollama --url http://localhost:11434 --model llama2 +``` + +#### OpenAI + +```bash +quicommit config set-llm openai +quicommit config set-openai-key YOUR_API_KEY +``` + +#### Anthropic Claude + +```bash +quicommit config set-llm anthropic +quicommit config set-anthropic-key YOUR_API_KEY +``` + +### 提交格式配置 + +```bash +# 使用规范化提交(默认) +quicommit config set-commit-format conventional + +# 使用commitlint格式 +quicommit config set-commit-format commitlint +``` + +## 📖 使用示例 + +### 交互式提交流程 + +```bash +$ quicommit commit +已暂存文件 (3): + • src/main.rs + • src/lib.rs + • Cargo.toml + +🤖 AI正在分析您的变更... + +──────────────────────────────────────────────────────────── +生成的提交信息: +──────────────────────────────────────────────────────────── +feat: 添加用户认证模块 + +实现OAuth2认证,支持GitHub和Google提供商。 +──────────────────────────────────────────────────────────── + +您希望如何操作? +> ✓ 接受并提交 + 🔄 重新生成 + ✏️ 编辑 + ❌ 取消 +``` + +### 配置管理 + +```bash +# 创建工作配置 +$ quicommit profile add +配置名称:work +Git用户名:John Doe +Git邮箱:john@company.com +这是工作配置吗?yes +组织机构:Acme Corp + +# 为当前仓库设置 +$ quicommit profile set-repo work +✓ 已为当前仓库设置'work'配置 +``` + +### 智能标签管理 + +```bash +$ quicommit tag +最新版本:v0.1.0 + +版本选择: +> 从提交中自动检测升级 + 主版本升级 + 次版本升级 + 修订版本升级 + +🤖 AI正在从15个提交中生成标签信息... + +标签预览: +名称: v0.2.0 +信息: +## 变更内容 + +### 🚀 新功能 +- 添加用户认证 +- 实现仪表板 + +### 🐛 问题修复 +- 修复登录重定向问题 + +创建此标签?yes +✓ 已创建标签 v0.2.0 +``` + +## 📁 配置文件 + +配置存储位置: + +- **Linux**: `~/.config/quicommit/config.toml` +- **macOS**: `~/Library/Application Support/quicommit/config.toml` +- **Windows**: `%APPDATA%\quicommit\config.toml` + +配置文件示例: + +```toml +version = "1" +default_profile = "personal" + +[profiles.personal] +name = "personal" +user_name = "John Doe" +user_email = "john@example.com" + +[profiles.work] +name = "work" +user_name = "John Doe" +user_email = "john@company.com" +is_work = true +organization = "Acme Corp" + +[llm] +provider = "ollama" +max_tokens = 500 +temperature = 0.7 + +[llm.ollama] +url = "http://localhost:11434" +model = "llama2" + +[commit] +format = "conventional" +auto_generate = true +max_subject_length = 100 + +[tag] +version_prefix = "v" +auto_generate = true +include_changelog = true + +[changelog] +path = "CHANGELOG.md" +auto_generate = true +group_by_type = true +``` + +## 🔧 环境变量 + +| 变量名 | 说明 | +|--------|------| +| `QUICOMMIT_CONFIG` | 配置文件路径 | +| `EDITOR` | 交互式输入的默认编辑器 | +| `NO_COLOR` | 禁用彩色输出 | + +## 💻 Shell补全 + +### Bash +```bash +quicommit completions bash > /etc/bash_completion.d/quicommit +``` + +### Zsh +```bash +quicommit completions zsh > /usr/local/share/zsh/site-functions/_quicommit +``` + +### Fish +```bash +quicommit completions fish > ~/.config/fish/completions/quicommit.fish +``` + +## 🔍 故障排除 + +### LLM连接问题 + +```bash +# 测试LLM连接 +quicommit config test-llm + +# 列出可用模型 +quicommit config list-models +``` + +### Git操作 + +```bash +# 查看当前配置 +quicommit profile show + +# 应用配置修复git配置 +quicommit profile apply +``` + +## 🤝 贡献指南 + +欢迎提交贡献!请随时提交Pull Request。 + +1. Fork本仓库 +2. 创建您的功能分支 (`git checkout -b feature/amazing-feature`) +3. 提交您的变更 (`git commit -m 'feat: 添加令人惊叹的功能'`) +4. 推送到分支 (`git push origin feature/amazing-feature`) +5. 提交Pull Request + +## 📄 许可证 + +本项目采用MIT或Apache-2.0许可证。 + +## 🙏 致谢 + +- [Conventional Commits](https://www.conventionalcommits.org/) 规范 +- [Keep a Changelog](https://keepachangelog.com/) 格式 +- [Ollama](https://ollama.ai/) 本地LLM支持 \ No newline at end of file diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..8c0c342 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,44 @@ +# Rustfmt configuration +# See: https://rust-lang.github.io/rustfmt/ + +# Maximum width of each line +max_width = 100 + +# Number of spaces per tab +tab_spaces = 4 + +# Use field initialization shorthand if possible +use_field_init_shorthand = true + +# Reorder import statements alphabetically +reorder_imports = true + +# Reorder module statements alphabetically +reorder_modules = true + +# Remove nested parentheses +remove_nested_parens = true + +# Edition to use for parsing +edition = "2021" + +# Merge multiple imports into a single nested import +imports_granularity = "Crate" + +# Group imports by std, external crates, and local modules +group_imports = "StdExternalCrate" + +# Format code in doc comments +format_code_in_doc_comments = true + +# Format strings +format_strings = true + +# Wrap comments +wrap_comments = true + +# Normalize comments +normalize_comments = true + +# Normalize doc attributes +normalize_doc_attributes = true diff --git a/src/commands/changelog.rs b/src/commands/changelog.rs new file mode 100644 index 0000000..645c9eb --- /dev/null +++ b/src/commands/changelog.rs @@ -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, + + /// Version to generate changelog for + #[arg(short, long)] + version: Option, + + /// Generate from specific tag + #[arg(short, long)] + from: Option, + + /// 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, + + /// 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::>().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 { + 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 { + let generator = ChangelogGenerator::new() + .format(format) + .include_hashes(self.include_hashes) + .include_authors(self.include_authors); + + generator.generate(version, Utc::now(), commits) + } +} diff --git a/src/commands/commit.rs b/src/commands/commit.rs new file mode 100644 index 0000000..bd26b5c --- /dev/null +++ b/src/commands/commit.rs @@ -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, + + /// Commit scope + #[arg(short, long)] + scope: Option, + + /// Commit description/subject + #[arg(short, long)] + message: Option, + + /// Commit body + #[arg(long)] + body: Option, + + /// 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 { + let commit_type = self.commit_type.clone() + .ok_or_else(|| anyhow::anyhow!("Commit type required for manual commit. Use -t "))?; + + let description = self.message.clone() + .ok_or_else(|| anyhow::anyhow!("Description required for manual commit. Use -m "))?; + + 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 { + 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 { + 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) -> Self; + fn body_opt(self, body: Option) -> Self; +} + +impl CommitBuilderExt for CommitBuilder { + fn scope_opt(self, scope: Option) -> Self { + if let Some(s) = scope { + self.scope(s) + } else { + self + } + } + + fn body_opt(self, body: Option) -> Self { + if let Some(b) = body { + self.body(b) + } else { + self + } + } +} diff --git a/src/commands/config.rs b/src/commands/config.rs new file mode 100644 index 0000000..015bffb --- /dev/null +++ b/src/commands/config.rs @@ -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, +} + +#[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, + }, + + /// 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, + /// Model name + #[arg(short, long)] + model: Option, + }, + + /// 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, + }, + + /// 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(()) + } +} diff --git a/src/commands/init.rs b/src/commands/init.rs new file mode 100644 index 0000000..6eab1c9 --- /dev/null +++ b/src/commands/init.rs @@ -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 { + 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 { + 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, + }) + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..a14be75 --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1,6 @@ +pub mod changelog; +pub mod commit; +pub mod config; +pub mod init; +pub mod profile; +pub mod tag; diff --git a/src/commands/profile.rs b/src/commands/profile.rs new file mode 100644 index 0000000..c935fbb --- /dev/null +++ b/src/commands/profile.rs @@ -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, +} + +#[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, + }, + + /// 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, + + /// 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 = 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 { + 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 { + 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, + }) + } +} diff --git a/src/commands/tag.rs b/src/commands/tag.rs new file mode 100644 index 0000000..ecdd62b --- /dev/null +++ b/src/commands/tag.rs @@ -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, + + /// Semantic version bump + #[arg(short, long, value_name = "TYPE")] + bump: Option, + + /// Tag message + #[arg(short, long)] + message: Option, + + /// Generate message with AI + #[arg(short, long)] + generate: bool, + + /// Sign the tag + #[arg(short = 'S', long)] + sign: bool, + + /// Create lightweight tag (no annotation) + #[arg(short, long)] + lightweight: bool, + + /// Force overwrite existing tag + #[arg(short, long)] + force: bool, + + /// Push tag to remote after creation + #[arg(short, long)] + push: bool, + + /// Remote to push to + #[arg(short, long, default_value = "origin")] + remote: String, + + /// Dry run + #[arg(long)] + dry_run: bool, + + /// Skip interactive prompts + #[arg(short = 'y', long)] + yes: bool, +} + +impl TagCommand { + pub async fn execute(&self) -> 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 { + 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 { + 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 { + 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) -> Self; +} + +impl TagBuilderExt for TagBuilder { + fn message_opt(self, message: Option) -> Self { + if let Some(m) = message { + self.message(m) + } else { + self + } + } +} diff --git a/src/config/manager.rs b/src/config/manager.rs new file mode 100644 index 0000000..8d02aa7 --- /dev/null +++ b/src/config/manager.rs @@ -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 { + let config_path = AppConfig::default_path()?; + Self::with_path(&config_path) + } + + /// Create config manager with specific path + pub fn with_path(path: &Path) -> Result { + 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) -> 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 { + &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 { + 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, + } + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..0bf49c7 --- /dev/null +++ b/src/config/mod.rs @@ -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, + + /// All configured profiles + #[serde(default)] + pub profiles: HashMap, + + /// 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, + + /// 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, + + /// 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, + + /// 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, + + /// 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, + + /// 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, + + /// 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, +} + +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, +} + +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, +} + +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, +} + +/// 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 { + 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 { + 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 { + 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(()) + } +} diff --git a/src/config/profile.rs b/src/config/profile.rs new file mode 100644 index 0000000..7afdbe8 --- /dev/null +++ b/src/config/profile.rs @@ -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, + + /// GPG configuration + #[serde(default)] + pub gpg: Option, + + /// Signing key (for commit/tag signing) + #[serde(default)] + pub signing_key: Option, + + /// Profile description + #[serde(default)] + pub description: Option, + + /// Is this a work profile + #[serde(default)] + pub is_work: bool, + + /// Company/Organization name (for work profiles) + #[serde(default)] + pub organization: Option, +} + +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, + + /// Use this profile for specific repositories (path patterns) + #[serde(default)] + pub repo_patterns: Vec, + + /// Preferred LLM provider for this profile + #[serde(default)] + pub llm_provider: Option, + + /// Custom commit message template + #[serde(default)] + pub commit_template: Option, +} + +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, + + /// SSH public key path + pub public_key_path: Option, + + /// SSH key passphrase (encrypted) + #[serde(skip_serializing_if = "Option::is_none")] + pub passphrase: Option, + + /// SSH agent forwarding + #[serde(default)] + pub agent_forwarding: bool, + + /// Custom SSH command + #[serde(default)] + pub ssh_command: Option, + + /// Known hosts file + #[serde(default)] + pub known_hosts_file: Option, +} + +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 { + 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, + + /// Key passphrase (encrypted) + #[serde(skip_serializing_if = "Option::is_none")] + pub passphrase: Option, + + /// 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, + user_name: Option, + user_email: Option, + settings: ProfileSettings, + ssh: Option, + gpg: Option, + signing_key: Option, + description: Option, + is_work: bool, + organization: Option, +} + +impl GitProfileBuilder { + pub fn name(mut self, name: impl Into) -> Self { + self.name = Some(name.into()); + self + } + + pub fn user_name(mut self, user_name: impl Into) -> Self { + self.user_name = Some(user_name.into()); + self + } + + pub fn user_email(mut self, user_email: impl Into) -> 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) -> Self { + self.signing_key = Some(key.into()); + self + } + + pub fn description(mut self, desc: impl Into) -> 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) -> Self { + self.organization = Some(org.into()); + self + } + + pub fn build(self) -> Result { + 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()); + } +} diff --git a/src/generator/mod.rs b/src/generator/mod.rs new file mode 100644 index 0000000..fe900b0 --- /dev/null +++ b/src/generator/mod.rs @@ -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 { + 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 { + // 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 { + 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 { + let commit_messages: Vec = 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 { + 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 { + 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 { + 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 { + 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 { + 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)> { + 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)> { + 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> { + 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() + } + } +} diff --git a/src/git/changelog.rs b/src/git/changelog.rs new file mode 100644 index 0000000..ead0f78 --- /dev/null +++ b/src/git/changelog.rs @@ -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, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChangelogFormat { + KeepAChangelog, + GitHubReleases, + Custom, +} + +#[derive(Debug, Clone)] +pub struct ChangelogCategory { + pub title: String, + pub types: Vec, +} + +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, types: Vec) -> Self { + self.custom_categories.push(ChangelogCategory { + title: title.into(), + types, + }); + self + } + + /// Generate changelog for version + pub fn generate( + &self, + version: &str, + date: DateTime, + commits: &[CommitInfo], + ) -> Result { + 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, + 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, + commits: &[CommitInfo], + ) -> Result { + 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 = 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, + commits: &[CommitInfo], + ) -> Result { + 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, + commits: &[CommitInfo], + ) -> Result { + // 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> { + let mut groups: HashMap> = 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 { + 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> { + 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> { + 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, + pub commits: Vec, +} + +impl ChangelogEntry { + /// Create new entry + pub fn new(version: impl Into, commits: Vec) -> Self { + Self { + version: version.into(), + date: Utc::now(), + commits, + } + } + + /// Set date + pub fn with_date(mut self, date: DateTime) -> Self { + self.date = date; + self + } +} diff --git a/src/git/commit.rs b/src/git/commit.rs new file mode 100644 index 0000000..31987f5 --- /dev/null +++ b/src/git/commit.rs @@ -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, + scope: Option, + description: Option, + body: Option, + footer: Option, + 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) -> Self { + self.commit_type = Some(commit_type.into()); + self + } + + /// Set scope + pub fn scope(mut self, scope: impl Into) -> Self { + self.scope = Some(scope.into()); + self + } + + /// Set description + pub fn description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + /// Set body + pub fn body(mut self, body: impl Into) -> Self { + self.body = Some(body.into()); + self + } + + /// Set footer + pub fn footer(mut self, footer: impl Into) -> 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 { + 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> { + 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, + pub scope: Option, + pub description: Option, + pub body: Option, + pub footer: Option, + 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, + ) + } + } + } +} diff --git a/src/git/mod.rs b/src/git/mod.rs new file mode 100644 index 0000000..107aa66 --- /dev/null +++ b/src/git/mod.rs @@ -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>(path: P) -> Result { + 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 { + 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 { + 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 { + 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 { + 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> { + 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> { + 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>(&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>(&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 { + 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 { + 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 { + 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 { + 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 { + 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> { + 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> { + 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> { + 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 { + 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 { + Ok(!self.has_changes()?) + } + + /// Get repository status summary + pub fn status_summary(&self) -> Result { + 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 { + 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>(start_path: P) -> Result { + 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>(path: P) -> bool { + find_repo(path).is_ok() +} diff --git a/src/git/tag.rs b/src/git/tag.rs new file mode 100644 index 0000000..28d6e02 --- /dev/null +++ b/src/git/tag.rs @@ -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, + message: Option, + 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) -> Self { + self.name = Some(name.into()); + self + } + + /// Set tag message + pub fn message(mut self, message: impl Into) -> Self { + self.message = Some(message.into()); + self + } + + /// Create annotated tag + pub fn annotate(mut self, annotate: bool) -> Self { + self.annotate = annotate; + self + } + + /// Sign the tag + pub fn sign(mut self, sign: bool) -> Self { + self.sign = sign; + self + } + + /// Force overwrite existing tag + pub fn force(mut self, force: bool) -> Self { + self.force = force; + self + } + + /// 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) -> Self { + self.version_prefix = prefix.into(); + self + } + + /// Set semantic version + pub fn version(mut self, version: &Version) -> Self { + self.name = Some(format!("{}{}", self.version_prefix, version)); + self + } + + /// Build tag message + pub fn build_message(&self) -> Result { + let message = self.message.as_ref() + .cloned() + .unwrap_or_else(|| { + let name = self.name.as_deref().unwrap_or("unknown"); + format!("Release {}", name) + }); + + Ok(message) + } + + /// Execute tag creation + pub fn execute(&self, repo: &GitRepo) -> Result<()> { + let name = self.name.as_ref() + .ok_or_else(|| anyhow::anyhow!("Tag name is required"))?; + + if self.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 { + match s.to_lowercase().as_str() { + "major" => Ok(Self::Major), + "minor" => Ok(Self::Minor), + "patch" => Ok(Self::Patch), + "prerelease" | "pre" => Ok(Self::Prerelease), + _ => bail!("Invalid version bump: {}. Use: major, minor, patch, prerelease", s), + } + } + + /// Get all variants + pub fn variants() -> &'static [&'static str] { + &["major", "minor", "patch", "prerelease"] + } +} + +/// Get latest version tag from repository +pub fn get_latest_version(repo: &GitRepo, prefix: &str) -> Result> { + let tags = repo.get_tags()?; + + let mut versions: Vec = tags + .iter() + .filter_map(|t| { + let name = &t.name; + let version_str = name.strip_prefix(prefix).unwrap_or(name); + Version::parse(version_str).ok() + }) + .collect(); + + versions.sort_by(|a, b| b.cmp(a)); // Descending order + + Ok(versions.into_iter().next()) +} + +/// Bump version +pub fn bump_version(version: &Version, bump: VersionBump, prerelease_id: Option<&str>) -> Version { + match bump { + VersionBump::Major => Version::new(version.major + 1, 0, 0), + VersionBump::Minor => Version::new(version.major, version.minor + 1, 0), + VersionBump::Patch => Version::new(version.major, version.minor, version.patch + 1), + VersionBump::Prerelease => { + let pre = prerelease_id.unwrap_or("alpha"); + let pre_id = format!("{}.0", pre); + Version::parse(&format!("{}-{}", version, pre_id)).unwrap_or_else(|_| version.clone()) + } + } +} + +/// Suggest next version based on commits +pub fn suggest_version_bump(commits: &[super::CommitInfo]) -> VersionBump { + let mut has_breaking = false; + let mut has_feature = false; + let mut has_fix = false; + + for commit in commits { + let msg = commit.message.to_lowercase(); + + if msg.contains("breaking change") || msg.contains("breaking-change") || msg.contains("breaking_change") { + has_breaking = true; + } + + if let Some(commit_type) = commit.commit_type() { + match commit_type.as_str() { + "feat" => has_feature = true, + "fix" => has_fix = true, + _ => {} + } + } + } + + if has_breaking { + VersionBump::Major + } else if has_feature { + VersionBump::Minor + } else if has_fix { + VersionBump::Patch + } else { + VersionBump::Patch + } +} + +/// Generate tag message from commits +pub fn generate_tag_message(version: &str, commits: &[super::CommitInfo]) -> String { + let mut message = format!("Release {}\n\n", version); + + // Group commits by type + let mut features = vec![]; + let mut fixes = vec![]; + let mut other = vec![]; + let mut breaking = vec![]; + + for commit in commits { + let subject = commit.subject(); + + if commit.message.contains("BREAKING CHANGE") { + breaking.push(subject.to_string()); + } + + if let Some(commit_type) = commit.commit_type() { + match commit_type.as_str() { + "feat" => features.push(subject.to_string()), + "fix" => fixes.push(subject.to_string()), + _ => other.push(subject.to_string()), + } + } else { + other.push(subject.to_string()); + } + } + + // Build message + if !breaking.is_empty() { + message.push_str("## Breaking Changes\n\n"); + for item in &breaking { + message.push_str(&format!("- {}\n", item)); + } + message.push('\n'); + } + + if !features.is_empty() { + message.push_str("## Features\n\n"); + for item in &features { + message.push_str(&format!("- {}\n", item)); + } + message.push('\n'); + } + + if !fixes.is_empty() { + message.push_str("## Bug Fixes\n\n"); + for item in &fixes { + message.push_str(&format!("- {}\n", item)); + } + message.push('\n'); + } + + if !other.is_empty() { + message.push_str("## Other Changes\n\n"); + for item in &other { + message.push_str(&format!("- {}\n", item)); + } + } + + message +} + +/// Tag deletion helper +pub fn delete_tag(repo: &GitRepo, name: &str, remote: Option<&str>) -> Result<()> { + repo.delete_tag(name)?; + + if let Some(remote) = remote { + use std::process::Command; + + let output = Command::new("git") + .args(&["push", remote, ":refs/tags/{}"]) + .current_dir(repo.path()) + .output()?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!("Failed to delete remote tag: {}", stderr); + } + } + + Ok(()) +} + +/// List tags with filtering +pub fn list_tags( + repo: &GitRepo, + pattern: Option<&str>, + limit: Option, +) -> Result> { + let tags = repo.get_tags()?; + + let filtered: Vec<_> = tags + .into_iter() + .filter(|t| { + if let Some(p) = pattern { + t.name.contains(p) + } else { + true + } + }) + .collect(); + + if let Some(limit) = limit { + Ok(filtered.into_iter().take(limit).collect()) + } else { + Ok(filtered) + } +} diff --git a/src/llm/anthropic.rs b/src/llm/anthropic.rs new file mode 100644 index 0000000..9e6a511 --- /dev/null +++ b/src/llm/anthropic.rs @@ -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, + messages: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + system: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct AnthropicMessage { + role: String, + content: String, +} + +#[derive(Debug, Deserialize)] +struct MessagesResponse { + content: Vec, +} + +#[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 { + 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.client = create_http_client(timeout)?; + Ok(self) + } + + /// Validate API key + pub async fn validate_key(&self) -> Result { + 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 { + 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 { + 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, + system: Option, + ) -> Result { + 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::(&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")); + } +} diff --git a/src/llm/mod.rs b/src/llm/mod.rs new file mode 100644 index 0000000..0d3598e --- /dev/null +++ b/src/llm/mod.rs @@ -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; + + /// Generate with system prompt + async fn generate_with_system(&self, system: &str, user: &str) -> Result; + + /// 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, + 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 { + let client_config = LlmClientConfig { + max_tokens: config.max_tokens, + temperature: config.temperature, + timeout: Duration::from_secs(config.timeout), + }; + + let provider: Box = 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) -> 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 { + 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 { + 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 { + let system_prompt = CHANGELOG_SYSTEM_PROMPT; + + let commits_text = commits + .iter() + .map(|(t, m)| format!("- [{}] {}", t, m)) + .collect::>() + .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 { + 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 { + // 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 { + // 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, Option) { + 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, + pub description: String, + pub body: Option, + pub footer: Option, + 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: [optional scope]: + +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: [optional scope]: + +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: + 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::builder() + .timeout(timeout) + .build() + .context("Failed to create HTTP client") +} diff --git a/src/llm/ollama.rs b/src/llm/ollama.rs new file mode 100644 index 0000000..372b18f --- /dev/null +++ b/src/llm/ollama.rs @@ -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, + stream: bool, + options: GenerationOptions, +} + +#[derive(Debug, Serialize, Default)] +struct GenerationOptions { + #[serde(skip_serializing_if = "Option::is_none")] + temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + num_predict: Option, +} + +#[derive(Debug, Deserialize)] +struct GenerateResponse { + response: String, + done: bool, +} + +#[derive(Debug, Deserialize)] +struct ListModelsResponse { + models: Vec, +} + +#[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> { + 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 { + self.generate_with_system("", prompt).await + } + + async fn generate_with_system(&self, system: &str, user: &str) -> Result { + 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()); + } +} diff --git a/src/llm/openai.rs b/src/llm/openai.rs new file mode 100644 index 0000000..3f85c40 --- /dev/null +++ b/src/llm/openai.rs @@ -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, + #[serde(skip_serializing_if = "Option::is_none")] + max_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + temperature: Option, + stream: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +struct Message { + role: String, + content: String, +} + +#[derive(Debug, Deserialize)] +struct ChatCompletionResponse { + choices: Vec, +} + +#[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 { + 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.client = create_http_client(timeout)?; + Ok(self) + } + + /// List available models + pub async fn list_models(&self) -> Result> { + 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, + } + + #[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 { + 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 { + 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 { + 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) -> Result { + 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::(&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 { + 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) -> Result { + 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 { + 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 { + 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" + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..efec89f --- /dev/null +++ b/src/main.rs @@ -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, + + /// 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, + } +} diff --git a/src/utils/crypto.rs b/src/utils/crypto.rs new file mode 100644 index 0000000..2b55eae --- /dev/null +++ b/src/utils/crypto.rs @@ -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 { + 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> { + 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> { + 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()); + } +} diff --git a/src/utils/editor.rs b/src/utils/editor.rs new file mode 100644 index 0000000..7009696 --- /dev/null +++ b/src/utils/editor.rs @@ -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 { + edit::edit(initial_content).context("Failed to open editor") +} + +/// Edit file in user's default editor +pub fn edit_file(path: &Path) -> Result { + 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 { + 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(()) +} diff --git a/src/utils/formatter.rs b/src/utils/formatter.rs new file mode 100644 index 0000000..7594b5c --- /dev/null +++ b/src/utils/formatter.rs @@ -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) -> String { + date.format("%Y-%m-%d %H:%M:%S").to_string() +} + +/// Format date for changelog +pub fn format_changelog_date(date: &DateTime) -> 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::>() + .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::>() + .join("\n") +} + +/// Format changelog section +pub fn format_changelog_section( + version: &str, + date: &str, + changes: &[(String, Vec)], +) -> 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..."); + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..0e1cc9c --- /dev/null +++ b/src/utils/mod.rs @@ -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 { + 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 { + 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 { + 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) + } +} diff --git a/src/utils/validators.rs b/src/utils/validators.rs new file mode 100644 index 0000000..e3e3b5a --- /dev/null +++ b/src/utils/validators.rs @@ -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"^(?Pfeat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(?:\((?P[^)]+)\))?(?P!)?: (?P.+)$" + ).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?(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)(?:-(?P(?: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[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: [optional scope]: \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: [optional scope]: "); + } + + 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()); + } +} diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs new file mode 100644 index 0000000..9674fd5 --- /dev/null +++ b/tests/integration_tests.rs @@ -0,0 +1,64 @@ +use assert_cmd::Command; +use predicates::prelude::*; +use std::fs; +use tempfile::TempDir; + +#[test] +fn test_cli_help() { + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.arg("--help"); + cmd.assert() + .success() + .stdout(predicate::str::contains("QuicCommit")); +} + +#[test] +fn test_version() { + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.arg("--version"); + cmd.assert() + .success() + .stdout(predicate::str::contains("0.1.0")); +} + +#[test] +fn test_config_show() { + let temp_dir = TempDir::new().unwrap(); + let config_dir = temp_dir.path().join("config"); + fs::create_dir(&config_dir).unwrap(); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.env("QUICOMMIT_CONFIG", config_dir.join("config.toml")) + .arg("config") + .arg("show"); + + cmd.assert().success(); +} + +#[test] +fn test_profile_list_empty() { + let temp_dir = TempDir::new().unwrap(); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.env("QUICOMMIT_CONFIG", temp_dir.path().join("config.toml")) + .arg("profile") + .arg("list"); + + cmd.assert() + .success() + .stdout(predicate::str::contains("No profiles configured")); +} + +#[test] +fn test_init_quick() { + let temp_dir = TempDir::new().unwrap(); + + let mut cmd = Command::cargo_bin("quicommit").unwrap(); + cmd.env("QUICOMMIT_CONFIG", temp_dir.path().join("config.toml")) + .arg("init") + .arg("--yes"); + + cmd.assert() + .success() + .stdout(predicate::str::contains("initialized successfully")); +}