//! Changelog generation: turns changelog data (git range or IR files) into a
//! user-facing changelog by building a prompt and delegating the rewriting to a
//! user-configured AI command.
use std::fmt::{self, Write as _};
use std::path::Path;
use crate::ai::{self, AiError};
use crate::git::{self, GitError};
use crate::ir::{Ir, IrError};
/// The default AI command used when the user does not pass `--ai`.
pub const DEFAULT_AI_COMMAND: &str = "opencode run";
/// Errors produced while generating a changelog.
#[derive(Debug)]
pub enum ChangelogError {
/// Gathering git history failed.
Git(GitError),
/// Working with IR data failed.
Ir(IrError),
/// There were no commits to summarise.
NoCommits { from: String, to: String },
/// Running the AI command failed.
Ai(AiError),
}
impl fmt::Display for ChangelogError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ChangelogError::Git(e) => write!(f, "{e}"),
ChangelogError::Ir(e) => write!(f, "{e}"),
ChangelogError::NoCommits { from, to } => {
write!(f, "no commits found in range {from}..{to}")
}
ChangelogError::Ai(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for ChangelogError {}
impl From<GitError> for ChangelogError {
fn from(e: GitError) -> Self {
ChangelogError::Git(e)
}
}
impl From<IrError> for ChangelogError {
fn from(e: IrError) -> Self {
ChangelogError::Ir(e)
}
}
impl From<AiError> for ChangelogError {
fn from(e: AiError) -> Self {
ChangelogError::Ai(e)
}
}
/// Maximum number of characters of diff to include in the prompt, to avoid
/// overwhelming the AI tool with huge patches.
const MAX_DIFF_CHARS: usize = 12_000;
/// Build the prompt that instructs the AI to write a user-facing changelog.
///
/// The prompt is deterministic given an [`Ir`] and optional diff, which keeps
/// it testable.
#[must_use]
pub fn build_prompt(ir: &Ir, diff: Option<&str>) -> String {
let mut p = String::new();
p.push_str(
"You are a release-notes writer. Turn the following git history into a concise, \
user-facing changelog in Markdown.\n\n\
Rules:\n\
- Write for end users, not developers.\n\
- Group related changes under headings such as Features, Fixes, and Improvements.\n\
- Omit purely technical/internal changes (refactors, CI, tests, formatting, dependency \
bumps) unless they affect users.\n\
- Use clear, plain language and short bullet points.\n\
- Do not include commit hashes, author names, or raw commit messages verbatim.\n\
- Output only the changelog Markdown, with no preamble or explanation.\n\n",
);
let _ = write!(
p,
"Changes from `{}` to `{}` ({} commit(s)).\n\n",
ir.from,
ir.to,
ir.entries.len()
);
p.push_str("## Commits\n\n");
for e in &ir.entries {
let _ = writeln!(p, "- {} ({})", e.summary, e.short_hash);
if !e.body.is_empty() {
for line in e.body.lines() {
let line = line.trim();
if !line.is_empty() {
let _ = writeln!(p, " {line}");
}
}
}
}
if let Some(diff) = diff {
p.push_str("\n## Diff (truncated)\n\n```diff\n");
if diff.len() > MAX_DIFF_CHARS {
// Truncate on a char boundary at or before the limit.
let mut end = MAX_DIFF_CHARS;
while end > 0 && !diff.is_char_boundary(end) {
end -= 1;
}
p.push_str(&diff[..end]);
p.push_str("\n... (diff truncated) ...\n");
} else {
p.push_str(diff);
}
p.push_str("```\n");
}
p
}
/// Render a combined [`Ir`] into a changelog using the supplied AI `command`.
///
/// `diff` is optional context (present when generating straight from a git
/// range, absent when rendering from stored IR files).
pub fn render(ir: &Ir, diff: Option<&str>, ai_command: &str) -> Result<String, ChangelogError> {
if ir.entries.is_empty() {
return Err(ChangelogError::NoCommits {
from: ir.from.clone(),
to: ir.to.clone(),
});
}
let prompt = build_prompt(ir, diff);
let output = ai::run(ai_command, &prompt)?;
Ok(output.trim().to_string())
}
/// Generate a changelog directly from a git range `from..to` located at `path`,
/// using the supplied AI `command`.
pub fn generate_from_refs(
path: &Path,
from: &str,
to: &str,
ai_command: &str,
) -> Result<String, ChangelogError> {
let changes = git::collect_changes(path, from, to)?;
let ir = Ir::from_changeset(&changes);
render(&ir, Some(&changes.diff), ai_command)
}
/// Generate a changelog by combining one or more IR files and rendering them
/// with the supplied AI `command`.
pub fn generate_from_irs(irs: Vec<Ir>, ai_command: &str) -> Result<String, ChangelogError> {
let combined = Ir::combine(irs)?;
render(&combined, None, ai_command)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ir::Entry;
fn sample() -> Ir {
Ir {
from: "v1".to_string(),
to: "v2".to_string(),
entries: vec![
Entry {
short_hash: "abc12345".to_string(),
summary: "feat: add greeting".to_string(),
body: "More detail.".to_string(),
author: "Tester".to_string(),
},
Entry {
short_hash: "def67890".to_string(),
summary: "fix: correct typo".to_string(),
body: String::new(),
author: "Tester".to_string(),
},
],
}
}
#[test]
fn prompt_includes_commit_summaries() {
let p = build_prompt(&sample(), None);
assert!(p.contains("feat: add greeting"));
assert!(p.contains("fix: correct typo"));
assert!(p.contains("abc12345"));
assert!(p.contains("More detail."));
}
#[test]
fn prompt_includes_range() {
let p = build_prompt(&sample(), None);
assert!(p.contains("`v1`"));
assert!(p.contains("`v2`"));
}
#[test]
fn prompt_without_diff_has_no_diff_section() {
let p = build_prompt(&sample(), None);
assert!(!p.contains("```diff"));
}
#[test]
fn prompt_includes_diff_when_provided() {
let p = build_prompt(&sample(), Some("+added line\n-removed line\n"));
assert!(p.contains("+added line"));
assert!(p.contains("```diff"));
}
#[test]
fn prompt_truncates_large_diff() {
let big = "x".repeat(MAX_DIFF_CHARS + 5000);
let p = build_prompt(&sample(), Some(&big));
assert!(p.contains("diff truncated"));
}
#[test]
fn generate_from_refs_uses_ai_command() {
// Use `cat` as the AI command: the "changelog" is just the prompt back.
let dir = crate::testutil::make_git_repo_with_tags();
let out = generate_from_refs(dir.path(), "v1", "v2", "cat").expect("generate");
assert!(out.contains("feat: add greeting"));
assert!(out.contains("```diff"));
}
#[test]
fn generate_from_refs_empty_range_errors() {
let dir = crate::testutil::make_git_repo_with_tags();
let err = generate_from_refs(dir.path(), "v2", "v2", "cat").unwrap_err();
assert!(matches!(err, ChangelogError::NoCommits { .. }));
}
#[test]
fn generate_from_irs_combines_and_renders() {
let out = generate_from_irs(vec![sample()], "cat").expect("generate");
assert!(out.contains("feat: add greeting"));
// No diff context when rendering from IR files.
assert!(!out.contains("```diff"));
}
#[test]
fn render_empty_ir_errors() {
let empty = Ir {
from: "v1".to_string(),
to: "v2".to_string(),
entries: vec![],
};
let err = render(&empty, None, "cat").unwrap_err();
assert!(matches!(err, ChangelogError::NoCommits { .. }));
}
}