feat:(first commit)created repository and complete 0.1.0

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

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

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