227 lines
7.3 KiB
Rust
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()
|
|
}
|
|
}
|
|
}
|