341 lines
10 KiB
Rust
341 lines
10 KiB
Rust
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()
|
|
}
|
|
}
|
|
}
|