feat:(first commit)created repository and complete 0.1.0

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

23
.gitignore vendored Normal file
View File

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

39
CHANGELOG.md Normal file
View File

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

90
Cargo.toml Normal file
View File

@@ -0,0 +1,90 @@
[package]
name = "quicommit"
version = "0.1.0"
edition = "2024"
authors = ["Your Name <your.email@example.com>"]
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

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Sidney Zhang<zly@lyzhang.me>
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.

91
Makefile Normal file
View File

@@ -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"

338
README.md Normal file
View File

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

12
build.rs Normal file
View File

@@ -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");
}

View File

@@ -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"

337
readme_zh.md Normal file
View File

@@ -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 APIOpenAI、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支持

44
rustfmt.toml Normal file
View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -0,0 +1,307 @@
use anyhow::{bail, Context, Result};
use clap::Parser;
use colored::Colorize;
use dialoguer::{Confirm, Input, Select};
use semver::Version;
use crate::config::manager::ConfigManager;
use crate::git::{find_repo, GitRepo};
use crate::generator::ContentGenerator;
use crate::git::tag::{
bump_version, get_latest_version, suggest_version_bump, TagBuilder, VersionBump,
};
/// Generate and create Git tags
#[derive(Parser)]
pub struct TagCommand {
/// Tag name
#[arg(short, long)]
name: Option<String>,
/// Semantic version bump
#[arg(short, long, value_name = "TYPE")]
bump: Option<String>,
/// Tag message
#[arg(short, long)]
message: Option<String>,
/// Generate message with AI
#[arg(short, long)]
generate: bool,
/// Sign the tag
#[arg(short = 'S', long)]
sign: bool,
/// Create lightweight tag (no annotation)
#[arg(short, long)]
lightweight: bool,
/// Force overwrite existing tag
#[arg(short, long)]
force: bool,
/// Push tag to remote after creation
#[arg(short, long)]
push: bool,
/// Remote to push to
#[arg(short, long, default_value = "origin")]
remote: String,
/// Dry run
#[arg(long)]
dry_run: bool,
/// Skip interactive prompts
#[arg(short = 'y', long)]
yes: bool,
}
impl TagCommand {
pub async fn execute(&self) -> Result<()> {
let repo = find_repo(".")?;
let manager = ConfigManager::new()?;
let config = manager.config();
// Determine tag name
let tag_name = if let Some(name) = &self.name {
name.clone()
} else if let Some(bump_str) = &self.bump {
// Calculate bumped version
let prefix = &config.tag.version_prefix;
let latest = get_latest_version(&repo, prefix)?
.unwrap_or_else(|| Version::new(0, 0, 0));
let bump = VersionBump::from_str(bump_str)?;
let new_version = bump_version(&latest, bump, None);
format!("{}{}", prefix, new_version)
} else {
// Interactive mode
self.select_version_interactive(&repo, &config.tag.version_prefix).await?
};
// Validate tag name (if it looks like a version)
if tag_name.starts_with('v') || tag_name.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) {
let version_str = tag_name.trim_start_matches('v');
if let Err(e) = crate::utils::validators::validate_semver(version_str) {
println!("{}: {}", "Warning".yellow(), e);
if !self.yes {
let proceed = Confirm::new()
.with_prompt("Proceed with this tag name anyway?")
.default(true)
.interact()?;
if !proceed {
bail!("Tag creation cancelled");
}
}
}
}
// Generate or get tag message
let message = if self.lightweight {
None
} else if let Some(msg) = &self.message {
Some(msg.clone())
} else if self.generate || (config.tag.auto_generate && !self.yes) {
Some(self.generate_tag_message(&repo, &tag_name).await?)
} else if !self.yes {
Some(self.input_message_interactive(&tag_name)?)
} else {
Some(format!("Release {}", tag_name))
};
// Show preview
println!("\n{}", "".repeat(60));
println!("{}", "Tag preview:".bold());
println!("{}", "".repeat(60));
println!("Name: {}", tag_name.cyan());
if let Some(ref msg) = message {
println!("Message:\n{}", msg);
} else {
println!("Type: {}", "lightweight".yellow());
}
println!("{}", "".repeat(60));
if !self.yes {
let confirm = Confirm::new()
.with_prompt("Create this tag?")
.default(true)
.interact()?;
if !confirm {
println!("{}", "Tag creation cancelled.".yellow());
return Ok(());
}
}
if self.dry_run {
println!("\n{}", "Dry run - tag not created.".yellow());
return Ok(());
}
// Create tag
let builder = TagBuilder::new()
.name(&tag_name)
.message_opt(message)
.annotate(!self.lightweight)
.sign(self.sign)
.force(self.force);
builder.execute(&repo)?;
println!("{} Created tag {}", "".green(), tag_name.cyan());
// Push if requested
if self.push {
println!("{} Pushing tag to {}...", "".blue(), &self.remote);
repo.push(&self.remote, &format!("refs/tags/{}", tag_name))?;
println!("{} Pushed tag to {}", "".green(), &self.remote);
}
Ok(())
}
async fn select_version_interactive(&self, repo: &GitRepo, prefix: &str) -> Result<String> {
loop {
let latest = get_latest_version(repo, prefix)?;
println!("\n{}", "Version selection:".bold());
if let Some(ref version) = latest {
println!("Latest version: {}{}", prefix, version);
} else {
println!("No existing version tags found");
}
let options = vec![
"Auto-detect bump from commits",
"Bump major version",
"Bump minor version",
"Bump patch version",
"Enter custom version",
"Enter custom tag name",
];
let selection = Select::new()
.with_prompt("Select option")
.items(&options)
.default(0)
.interact()?;
match selection {
0 => {
// Auto-detect
let commits = repo.get_commits(50)?;
let bump = suggest_version_bump(&commits);
let version = latest.as_ref()
.map(|v| bump_version(v, bump, None))
.unwrap_or_else(|| Version::new(0, 1, 0));
println!("Suggested bump: {:?}{}{}", bump, prefix, version);
let confirm = Confirm::new()
.with_prompt("Use this version?")
.default(true)
.interact()?;
if confirm {
return Ok(format!("{}{}", prefix, version));
}
// User rejected, continue the loop
}
1 => {
let version = latest.as_ref()
.map(|v| bump_version(v, VersionBump::Major, None))
.unwrap_or_else(|| Version::new(1, 0, 0));
return Ok(format!("{}{}", prefix, version));
}
2 => {
let version = latest.as_ref()
.map(|v| bump_version(v, VersionBump::Minor, None))
.unwrap_or_else(|| Version::new(0, 1, 0));
return Ok(format!("{}{}", prefix, version));
}
3 => {
let version = latest.as_ref()
.map(|v| bump_version(v, VersionBump::Patch, None))
.unwrap_or_else(|| Version::new(0, 0, 1));
return Ok(format!("{}{}", prefix, version));
}
4 => {
let input: String = Input::new()
.with_prompt("Enter version (e.g., 1.2.3)")
.interact_text()?;
let version = Version::parse(&input)?;
return Ok(format!("{}{}", prefix, version));
}
5 => {
let input: String = Input::new()
.with_prompt("Enter tag name")
.interact_text()?;
return Ok(input);
}
_ => unreachable!(),
}
}
}
async fn generate_tag_message(&self, repo: &GitRepo, version: &str) -> Result<String> {
let manager = ConfigManager::new()?;
let config = manager.config();
// Get commits since last tag
let tags = repo.get_tags()?;
let commits = if let Some(latest_tag) = tags.first() {
repo.get_commits_between(&latest_tag.name, "HEAD")?
} else {
repo.get_commits(50)?
};
if commits.is_empty() {
return Ok(format!("Release {}", version));
}
println!("{} AI is generating tag message from {} commits...", "🤖", commits.len());
let generator = ContentGenerator::new(&config.llm).await?;
generator.generate_tag_message(version, &commits).await
}
fn input_message_interactive(&self, version: &str) -> Result<String> {
let default_msg = format!("Release {}", version);
let use_editor = Confirm::new()
.with_prompt("Open editor for tag message?")
.default(false)
.interact()?;
if use_editor {
crate::utils::editor::edit_content(&default_msg)
} else {
Ok(Input::new()
.with_prompt("Tag message")
.default(default_msg)
.interact_text()?)
}
}
}
// Helper trait
trait TagBuilderExt {
fn message_opt(self, message: Option<String>) -> Self;
}
impl TagBuilderExt for TagBuilder {
fn message_opt(self, message: Option<String>) -> Self {
if let Some(m) = message {
self.message(m)
} else {
self
}
}
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -0,0 +1,339 @@
use super::GitRepo;
use anyhow::{bail, Context, Result};
use semver::Version;
/// Tag builder for creating tags
pub struct TagBuilder {
name: Option<String>,
message: Option<String>,
annotate: bool,
sign: bool,
force: bool,
dry_run: bool,
version_prefix: String,
}
impl TagBuilder {
/// Create new tag builder
pub fn new() -> Self {
Self {
name: None,
message: None,
annotate: true,
sign: false,
force: false,
dry_run: false,
version_prefix: "v".to_string(),
}
}
/// Set tag name
pub fn name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
/// Set tag message
pub fn message(mut self, message: impl Into<String>) -> Self {
self.message = Some(message.into());
self
}
/// Create annotated tag
pub fn annotate(mut self, annotate: bool) -> Self {
self.annotate = annotate;
self
}
/// Sign the tag
pub fn sign(mut self, sign: bool) -> Self {
self.sign = sign;
self
}
/// Force overwrite existing tag
pub fn force(mut self, force: bool) -> Self {
self.force = force;
self
}
/// Dry run (don't actually create tag)
pub fn dry_run(mut self, dry_run: bool) -> Self {
self.dry_run = dry_run;
self
}
/// Set version prefix
pub fn version_prefix(mut self, prefix: impl Into<String>) -> Self {
self.version_prefix = prefix.into();
self
}
/// Set semantic version
pub fn version(mut self, version: &Version) -> Self {
self.name = Some(format!("{}{}", self.version_prefix, version));
self
}
/// Build tag message
pub fn build_message(&self) -> Result<String> {
let message = self.message.as_ref()
.cloned()
.unwrap_or_else(|| {
let name = self.name.as_deref().unwrap_or("unknown");
format!("Release {}", name)
});
Ok(message)
}
/// Execute tag creation
pub fn execute(&self, repo: &GitRepo) -> Result<()> {
let name = self.name.as_ref()
.ok_or_else(|| anyhow::anyhow!("Tag name is required"))?;
if self.dry_run {
println!("Would create tag: {}", name);
if self.annotate {
println!("Message: {}", self.build_message()?);
}
return Ok(());
}
// Check if tag already exists
if !self.force {
let existing_tags = repo.get_tags()?;
if existing_tags.iter().any(|t| t.name == *name) {
bail!("Tag '{}' already exists. Use --force to overwrite.", name);
}
}
let message = if self.annotate {
Some(self.build_message()?.as_str().to_string())
} else {
None
};
repo.create_tag(name, message.as_deref(), self.sign)?;
Ok(())
}
/// Execute and push tag
pub fn execute_and_push(&self, repo: &GitRepo, remote: &str) -> Result<()> {
self.execute(repo)?;
let name = self.name.as_ref().unwrap();
repo.push(remote, &format!("refs/tags/{}", name))?;
Ok(())
}
}
impl Default for TagBuilder {
fn default() -> Self {
Self::new()
}
}
/// Semantic version bump types
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VersionBump {
Major,
Minor,
Patch,
Prerelease,
}
impl VersionBump {
/// Parse from string
pub fn from_str(s: &str) -> Result<Self> {
match s.to_lowercase().as_str() {
"major" => Ok(Self::Major),
"minor" => Ok(Self::Minor),
"patch" => Ok(Self::Patch),
"prerelease" | "pre" => Ok(Self::Prerelease),
_ => bail!("Invalid version bump: {}. Use: major, minor, patch, prerelease", s),
}
}
/// Get all variants
pub fn variants() -> &'static [&'static str] {
&["major", "minor", "patch", "prerelease"]
}
}
/// Get latest version tag from repository
pub fn get_latest_version(repo: &GitRepo, prefix: &str) -> Result<Option<Version>> {
let tags = repo.get_tags()?;
let mut versions: Vec<Version> = tags
.iter()
.filter_map(|t| {
let name = &t.name;
let version_str = name.strip_prefix(prefix).unwrap_or(name);
Version::parse(version_str).ok()
})
.collect();
versions.sort_by(|a, b| b.cmp(a)); // Descending order
Ok(versions.into_iter().next())
}
/// Bump version
pub fn bump_version(version: &Version, bump: VersionBump, prerelease_id: Option<&str>) -> Version {
match bump {
VersionBump::Major => Version::new(version.major + 1, 0, 0),
VersionBump::Minor => Version::new(version.major, version.minor + 1, 0),
VersionBump::Patch => Version::new(version.major, version.minor, version.patch + 1),
VersionBump::Prerelease => {
let pre = prerelease_id.unwrap_or("alpha");
let pre_id = format!("{}.0", pre);
Version::parse(&format!("{}-{}", version, pre_id)).unwrap_or_else(|_| version.clone())
}
}
}
/// Suggest next version based on commits
pub fn suggest_version_bump(commits: &[super::CommitInfo]) -> VersionBump {
let mut has_breaking = false;
let mut has_feature = false;
let mut has_fix = false;
for commit in commits {
let msg = commit.message.to_lowercase();
if msg.contains("breaking change") || msg.contains("breaking-change") || msg.contains("breaking_change") {
has_breaking = true;
}
if let Some(commit_type) = commit.commit_type() {
match commit_type.as_str() {
"feat" => has_feature = true,
"fix" => has_fix = true,
_ => {}
}
}
}
if has_breaking {
VersionBump::Major
} else if has_feature {
VersionBump::Minor
} else if has_fix {
VersionBump::Patch
} else {
VersionBump::Patch
}
}
/// Generate tag message from commits
pub fn generate_tag_message(version: &str, commits: &[super::CommitInfo]) -> String {
let mut message = format!("Release {}\n\n", version);
// Group commits by type
let mut features = vec![];
let mut fixes = vec![];
let mut other = vec![];
let mut breaking = vec![];
for commit in commits {
let subject = commit.subject();
if commit.message.contains("BREAKING CHANGE") {
breaking.push(subject.to_string());
}
if let Some(commit_type) = commit.commit_type() {
match commit_type.as_str() {
"feat" => features.push(subject.to_string()),
"fix" => fixes.push(subject.to_string()),
_ => other.push(subject.to_string()),
}
} else {
other.push(subject.to_string());
}
}
// Build message
if !breaking.is_empty() {
message.push_str("## Breaking Changes\n\n");
for item in &breaking {
message.push_str(&format!("- {}\n", item));
}
message.push('\n');
}
if !features.is_empty() {
message.push_str("## Features\n\n");
for item in &features {
message.push_str(&format!("- {}\n", item));
}
message.push('\n');
}
if !fixes.is_empty() {
message.push_str("## Bug Fixes\n\n");
for item in &fixes {
message.push_str(&format!("- {}\n", item));
}
message.push('\n');
}
if !other.is_empty() {
message.push_str("## Other Changes\n\n");
for item in &other {
message.push_str(&format!("- {}\n", item));
}
}
message
}
/// Tag deletion helper
pub fn delete_tag(repo: &GitRepo, name: &str, remote: Option<&str>) -> Result<()> {
repo.delete_tag(name)?;
if let Some(remote) = remote {
use std::process::Command;
let output = Command::new("git")
.args(&["push", remote, ":refs/tags/{}"])
.current_dir(repo.path())
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to delete remote tag: {}", stderr);
}
}
Ok(())
}
/// List tags with filtering
pub fn list_tags(
repo: &GitRepo,
pattern: Option<&str>,
limit: Option<usize>,
) -> Result<Vec<super::TagInfo>> {
let tags = repo.get_tags()?;
let filtered: Vec<_> = tags
.into_iter()
.filter(|t| {
if let Some(p) = pattern {
t.name.contains(p)
} else {
true
}
})
.collect();
if let Some(limit) = limit {
Ok(filtered.into_iter().take(limit).collect())
} else {
Ok(filtered)
}
}

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

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

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

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

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

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

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

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

100
src/main.rs Normal file
View File

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -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"));
}