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 { 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 { // 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 { 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 { let commit_messages: Vec = 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 { 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 { 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 { 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 { 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 { 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)> { 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)> { 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> { 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() } } }