Skip to content
//! 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 { .. }));
    }
}