feat:(first commit)created repository and complete 0.1.0
This commit is contained in:
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal 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
39
CHANGELOG.md
Normal 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
90
Cargo.toml
Normal 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
21
LICENSE
Normal 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
91
Makefile
Normal 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
338
README.md
Normal 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.
|
||||
|
||||

|
||||

|
||||
|
||||
## 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
12
build.rs
Normal 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");
|
||||
}
|
||||
101
examples/config.example.toml
Normal file
101
examples/config.example.toml
Normal 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
337
readme_zh.md
Normal file
@@ -0,0 +1,337 @@
|
||||
# QuiCommit
|
||||
|
||||
一款强大的AI驱动的Git助手,用于生成规范化的提交信息、标签和变更日志,并支持为不同工作场景管理多个Git配置。
|
||||
|
||||

|
||||

|
||||
|
||||
## ✨ 主要功能
|
||||
|
||||
- 🤖 **AI智能生成**:使用LLM API(OpenAI、Anthropic)或本地Ollama模型生成提交信息、标签注释和变更日志
|
||||
- 📝 **规范化提交**:全面支持Conventional Commits和@commitlint格式规范
|
||||
- 👤 **多配置管理**:为不同工作场景保存和切换多个Git配置(用户信息、SSH密钥、GPG签名)
|
||||
- 🏷️ **智能标签管理**:基于语义版本的智能版本升级,自动生成发布说明
|
||||
- 📜 **变更日志生成**:自动生成Keep a Changelog或GitHub Releases格式的变更日志
|
||||
- 🔐 **安全保护**:加密存储敏感数据,如SSH密码和API密钥
|
||||
- 🎨 **交互式界面**:美观的CLI界面,支持交互式提示和预览功能
|
||||
|
||||
## 📦 安装说明
|
||||
|
||||
### 从源码安装
|
||||
|
||||
```bash
|
||||
git clone https://github.com/yourusername/quicommit.git
|
||||
cd quicommit
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
可执行文件将位于 `target/release/quicommit`。
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Rust 1.70或更高版本
|
||||
- Git 2.0或更高版本
|
||||
- AI功能可选:Ollama(本地)或OpenAI/Anthropic的API密钥
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 初始化配置
|
||||
|
||||
```bash
|
||||
quicommit init
|
||||
```
|
||||
|
||||
这将引导您完成第一个配置和LLM配置的设置。
|
||||
|
||||
### 2. 生成提交信息
|
||||
|
||||
```bash
|
||||
# AI生成提交信息(默认)
|
||||
quicommit commit
|
||||
|
||||
# 手动提交
|
||||
quicommit commit --manual -t feat -m "添加新功能"
|
||||
|
||||
# 基于日期的提交
|
||||
quicommit commit --date
|
||||
|
||||
# 暂存所有文件并提交
|
||||
quicommit commit -a
|
||||
```
|
||||
|
||||
### 3. 创建标签
|
||||
|
||||
```bash
|
||||
# 自动检测提交中的版本升级
|
||||
quicommit tag
|
||||
|
||||
# 指定版本升级
|
||||
quicommit tag --bump minor
|
||||
|
||||
# 自定义标签名
|
||||
quicommit tag -n v1.0.0
|
||||
```
|
||||
|
||||
### 4. 生成变更日志
|
||||
|
||||
```bash
|
||||
# 为未发布变更生成
|
||||
quicommit changelog
|
||||
|
||||
# 为特定版本生成
|
||||
quicommit changelog -v 1.0.0
|
||||
|
||||
# 初始化新的变更日志
|
||||
quicommit changelog --init
|
||||
```
|
||||
|
||||
## ⚙️ 配置说明
|
||||
|
||||
### 多配置管理
|
||||
|
||||
为不同场景管理多个Git身份:
|
||||
|
||||
```bash
|
||||
# 添加新配置
|
||||
quicommit profile add
|
||||
|
||||
# 查看配置列表
|
||||
quicommit profile list
|
||||
|
||||
# 切换配置
|
||||
quicommit profile switch
|
||||
|
||||
# 应用配置到当前仓库
|
||||
quicommit profile apply
|
||||
|
||||
# 为当前仓库设置配置
|
||||
quicommit profile set-repo personal
|
||||
```
|
||||
|
||||
### LLM提供商配置
|
||||
|
||||
#### Ollama(本地部署 - 推荐)
|
||||
|
||||
```bash
|
||||
# 配置Ollama
|
||||
quicommit config set-llm ollama
|
||||
|
||||
# 或使用特定设置
|
||||
quicommit config set-ollama --url http://localhost:11434 --model llama2
|
||||
```
|
||||
|
||||
#### OpenAI
|
||||
|
||||
```bash
|
||||
quicommit config set-llm openai
|
||||
quicommit config set-openai-key YOUR_API_KEY
|
||||
```
|
||||
|
||||
#### Anthropic Claude
|
||||
|
||||
```bash
|
||||
quicommit config set-llm anthropic
|
||||
quicommit config set-anthropic-key YOUR_API_KEY
|
||||
```
|
||||
|
||||
### 提交格式配置
|
||||
|
||||
```bash
|
||||
# 使用规范化提交(默认)
|
||||
quicommit config set-commit-format conventional
|
||||
|
||||
# 使用commitlint格式
|
||||
quicommit config set-commit-format commitlint
|
||||
```
|
||||
|
||||
## 📖 使用示例
|
||||
|
||||
### 交互式提交流程
|
||||
|
||||
```bash
|
||||
$ quicommit commit
|
||||
已暂存文件 (3):
|
||||
• src/main.rs
|
||||
• src/lib.rs
|
||||
• Cargo.toml
|
||||
|
||||
🤖 AI正在分析您的变更...
|
||||
|
||||
────────────────────────────────────────────────────────────
|
||||
生成的提交信息:
|
||||
────────────────────────────────────────────────────────────
|
||||
feat: 添加用户认证模块
|
||||
|
||||
实现OAuth2认证,支持GitHub和Google提供商。
|
||||
────────────────────────────────────────────────────────────
|
||||
|
||||
您希望如何操作?
|
||||
> ✓ 接受并提交
|
||||
🔄 重新生成
|
||||
✏️ 编辑
|
||||
❌ 取消
|
||||
```
|
||||
|
||||
### 配置管理
|
||||
|
||||
```bash
|
||||
# 创建工作配置
|
||||
$ quicommit profile add
|
||||
配置名称:work
|
||||
Git用户名:John Doe
|
||||
Git邮箱:john@company.com
|
||||
这是工作配置吗?yes
|
||||
组织机构:Acme Corp
|
||||
|
||||
# 为当前仓库设置
|
||||
$ quicommit profile set-repo work
|
||||
✓ 已为当前仓库设置'work'配置
|
||||
```
|
||||
|
||||
### 智能标签管理
|
||||
|
||||
```bash
|
||||
$ quicommit tag
|
||||
最新版本:v0.1.0
|
||||
|
||||
版本选择:
|
||||
> 从提交中自动检测升级
|
||||
主版本升级
|
||||
次版本升级
|
||||
修订版本升级
|
||||
|
||||
🤖 AI正在从15个提交中生成标签信息...
|
||||
|
||||
标签预览:
|
||||
名称: v0.2.0
|
||||
信息:
|
||||
## 变更内容
|
||||
|
||||
### 🚀 新功能
|
||||
- 添加用户认证
|
||||
- 实现仪表板
|
||||
|
||||
### 🐛 问题修复
|
||||
- 修复登录重定向问题
|
||||
|
||||
创建此标签?yes
|
||||
✓ 已创建标签 v0.2.0
|
||||
```
|
||||
|
||||
## 📁 配置文件
|
||||
|
||||
配置存储位置:
|
||||
|
||||
- **Linux**: `~/.config/quicommit/config.toml`
|
||||
- **macOS**: `~/Library/Application Support/quicommit/config.toml`
|
||||
- **Windows**: `%APPDATA%\quicommit\config.toml`
|
||||
|
||||
配置文件示例:
|
||||
|
||||
```toml
|
||||
version = "1"
|
||||
default_profile = "personal"
|
||||
|
||||
[profiles.personal]
|
||||
name = "personal"
|
||||
user_name = "John Doe"
|
||||
user_email = "john@example.com"
|
||||
|
||||
[profiles.work]
|
||||
name = "work"
|
||||
user_name = "John Doe"
|
||||
user_email = "john@company.com"
|
||||
is_work = true
|
||||
organization = "Acme Corp"
|
||||
|
||||
[llm]
|
||||
provider = "ollama"
|
||||
max_tokens = 500
|
||||
temperature = 0.7
|
||||
|
||||
[llm.ollama]
|
||||
url = "http://localhost:11434"
|
||||
model = "llama2"
|
||||
|
||||
[commit]
|
||||
format = "conventional"
|
||||
auto_generate = true
|
||||
max_subject_length = 100
|
||||
|
||||
[tag]
|
||||
version_prefix = "v"
|
||||
auto_generate = true
|
||||
include_changelog = true
|
||||
|
||||
[changelog]
|
||||
path = "CHANGELOG.md"
|
||||
auto_generate = true
|
||||
group_by_type = true
|
||||
```
|
||||
|
||||
## 🔧 环境变量
|
||||
|
||||
| 变量名 | 说明 |
|
||||
|--------|------|
|
||||
| `QUICOMMIT_CONFIG` | 配置文件路径 |
|
||||
| `EDITOR` | 交互式输入的默认编辑器 |
|
||||
| `NO_COLOR` | 禁用彩色输出 |
|
||||
|
||||
## 💻 Shell补全
|
||||
|
||||
### Bash
|
||||
```bash
|
||||
quicommit completions bash > /etc/bash_completion.d/quicommit
|
||||
```
|
||||
|
||||
### Zsh
|
||||
```bash
|
||||
quicommit completions zsh > /usr/local/share/zsh/site-functions/_quicommit
|
||||
```
|
||||
|
||||
### Fish
|
||||
```bash
|
||||
quicommit completions fish > ~/.config/fish/completions/quicommit.fish
|
||||
```
|
||||
|
||||
## 🔍 故障排除
|
||||
|
||||
### LLM连接问题
|
||||
|
||||
```bash
|
||||
# 测试LLM连接
|
||||
quicommit config test-llm
|
||||
|
||||
# 列出可用模型
|
||||
quicommit config list-models
|
||||
```
|
||||
|
||||
### Git操作
|
||||
|
||||
```bash
|
||||
# 查看当前配置
|
||||
quicommit profile show
|
||||
|
||||
# 应用配置修复git配置
|
||||
quicommit profile apply
|
||||
```
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
欢迎提交贡献!请随时提交Pull Request。
|
||||
|
||||
1. Fork本仓库
|
||||
2. 创建您的功能分支 (`git checkout -b feature/amazing-feature`)
|
||||
3. 提交您的变更 (`git commit -m 'feat: 添加令人惊叹的功能'`)
|
||||
4. 推送到分支 (`git push origin feature/amazing-feature`)
|
||||
5. 提交Pull Request
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
本项目采用MIT或Apache-2.0许可证。
|
||||
|
||||
## 🙏 致谢
|
||||
|
||||
- [Conventional Commits](https://www.conventionalcommits.org/) 规范
|
||||
- [Keep a Changelog](https://keepachangelog.com/) 格式
|
||||
- [Ollama](https://ollama.ai/) 本地LLM支持
|
||||
44
rustfmt.toml
Normal file
44
rustfmt.toml
Normal 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
199
src/commands/changelog.rs
Normal 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
321
src/commands/commit.rs
Normal 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
518
src/commands/config.rs
Normal 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
270
src/commands/init.rs
Normal 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
6
src/commands/mod.rs
Normal 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
492
src/commands/profile.rs
Normal 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
307
src/commands/tag.rs
Normal 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
302
src/config/manager.rs
Normal 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
532
src/config/mod.rs
Normal 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
412
src/config/profile.rs
Normal 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
340
src/generator/mod.rs
Normal 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
480
src/git/changelog.rs
Normal 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
367
src/git/commit.rs
Normal 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
590
src/git/mod.rs
Normal 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
339
src/git/tag.rs
Normal 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
227
src/llm/anthropic.rs
Normal 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
433
src/llm/mod.rs
Normal 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
206
src/llm/ollama.rs
Normal 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
345
src/llm/openai.rs
Normal 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
100
src/main.rs
Normal 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
139
src/utils/crypto.rs
Normal 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
57
src/utils/editor.rs
Normal 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
198
src/utils/formatter.rs
Normal 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
76
src/utils/mod.rs
Normal 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
270
src/utils/validators.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
64
tests/integration_tests.rs
Normal file
64
tests/integration_tests.rs
Normal 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"));
|
||||
}
|
||||
Reference in New Issue
Block a user