From 5d4156e5e037f669a75272c7b2e02c2279c2ea5e Mon Sep 17 00:00:00 2001 From: SidneyZhang Date: Fri, 30 Jan 2026 14:18:32 +0800 Subject: [PATCH] feat:(first commit)created repository and complete 0.1.0 --- .gitignore | 23 ++ CHANGELOG.md | 39 +++ Cargo.toml | 90 ++++++ LICENSE | 21 ++ Makefile | 91 ++++++ README.md | 338 ++++++++++++++++++++ build.rs | 12 + examples/config.example.toml | 101 ++++++ readme_zh.md | 337 ++++++++++++++++++++ rustfmt.toml | 44 +++ src/commands/changelog.rs | 199 ++++++++++++ src/commands/commit.rs | 321 +++++++++++++++++++ src/commands/config.rs | 518 ++++++++++++++++++++++++++++++ src/commands/init.rs | 270 ++++++++++++++++ src/commands/mod.rs | 6 + src/commands/profile.rs | 492 +++++++++++++++++++++++++++++ src/commands/tag.rs | 307 ++++++++++++++++++ src/config/manager.rs | 302 ++++++++++++++++++ src/config/mod.rs | 532 +++++++++++++++++++++++++++++++ src/config/profile.rs | 412 ++++++++++++++++++++++++ src/generator/mod.rs | 340 ++++++++++++++++++++ src/git/changelog.rs | 480 ++++++++++++++++++++++++++++ src/git/commit.rs | 367 ++++++++++++++++++++++ src/git/mod.rs | 590 +++++++++++++++++++++++++++++++++++ src/git/tag.rs | 339 ++++++++++++++++++++ src/llm/anthropic.rs | 227 ++++++++++++++ src/llm/mod.rs | 433 +++++++++++++++++++++++++ src/llm/ollama.rs | 206 ++++++++++++ src/llm/openai.rs | 345 ++++++++++++++++++++ src/main.rs | 100 ++++++ src/utils/crypto.rs | 139 +++++++++ src/utils/editor.rs | 57 ++++ src/utils/formatter.rs | 198 ++++++++++++ src/utils/mod.rs | 76 +++++ src/utils/validators.rs | 270 ++++++++++++++++ tests/integration_tests.rs | 64 ++++ 36 files changed, 8686 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 build.rs create mode 100644 examples/config.example.toml create mode 100644 readme_zh.md create mode 100644 rustfmt.toml create mode 100644 src/commands/changelog.rs create mode 100644 src/commands/commit.rs create mode 100644 src/commands/config.rs create mode 100644 src/commands/init.rs create mode 100644 src/commands/mod.rs create mode 100644 src/commands/profile.rs create mode 100644 src/commands/tag.rs create mode 100644 src/config/manager.rs create mode 100644 src/config/mod.rs create mode 100644 src/config/profile.rs create mode 100644 src/generator/mod.rs create mode 100644 src/git/changelog.rs create mode 100644 src/git/commit.rs create mode 100644 src/git/mod.rs create mode 100644 src/git/tag.rs create mode 100644 src/llm/anthropic.rs create mode 100644 src/llm/mod.rs create mode 100644 src/llm/ollama.rs create mode 100644 src/llm/openai.rs create mode 100644 src/main.rs create mode 100644 src/utils/crypto.rs create mode 100644 src/utils/editor.rs create mode 100644 src/utils/formatter.rs create mode 100644 src/utils/mod.rs create mode 100644 src/utils/validators.rs create mode 100644 tests/integration_tests.rs 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")); +}