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