Files
QuiCommit/src/generator/mod.rs

227 lines
7.3 KiB
Rust

use crate::config::{CommitFormat, Language};
use crate::config::manager::ConfigManager;
use crate::git::{CommitInfo, GitRepo};
use crate::llm::{GeneratedCommit, LlmClient};
use anyhow::{Context, Result};
/// Content generator using LLM
pub struct ContentGenerator {
llm_client: LlmClient,
}
impl ContentGenerator {
/// Create new content generator
pub async fn new(manager: &ConfigManager) -> Result<Self> {
let llm_client = LlmClient::from_config(manager).await?;
if !llm_client.is_available().await {
anyhow::bail!("LLM provider '{}' is not available", manager.llm_provider());
}
Ok(Self { llm_client })
}
/// Generate commit message from diff
pub async fn generate_commit_message(
&self,
diff: &str,
format: CommitFormat,
language: Language,
) -> Result<GeneratedCommit> {
// Truncate diff if too long
let max_diff_len = 4000;
let truncated_diff = if diff.len() > max_diff_len {
let boundary = diff.floor_char_boundary(max_diff_len);
format!("{}\n... (truncated)", &diff[..boundary])
} else {
diff.to_string()
};
self.llm_client.generate_commit_message(&truncated_diff, format, language).await
}
/// Generate commit message from repository changes
pub async fn generate_commit_from_repo(
&self,
repo: &GitRepo,
format: CommitFormat,
language: Language,
) -> Result<GeneratedCommit> {
let diff = repo.get_staged_diff_sorted()
.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, language).await
}
/// Generate tag message
pub async fn generate_tag_message(
&self,
version: &str,
commits: &[CommitInfo],
language: Language,
) -> 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, language).await
}
/// Generate changelog entry
pub async fn generate_changelog_entry(
&self,
version: &str,
commits: &[CommitInfo],
language: Language,
) -> 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, language).await
}
/// Generate changelog from repository
pub async fn generate_changelog_from_repo(
&self,
repo: &GitRepo,
version: &str,
from_tag: Option<&str>,
language: Language,
) -> 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, language).await
}
/// Interactive commit generation with user feedback
pub async fn generate_commit_interactive(
&self,
repo: &GitRepo,
format: CommitFormat,
language: Language,
) -> Result<GeneratedCommit> {
use dialoguer::Select;
let diff = repo.get_staged_diff_sorted()?;
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, language).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",
"❌ 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, language).await?;
}
2 => {
let edited = crate::utils::editor::edit_content(&generated.to_conventional())?;
generated = self.parse_edited_commit(&edited, format)?;
}
3 => 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,
})
}
}
/// Fallback generators when LLM is not available
pub mod fallback {
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()
}
}
}