verctl 0.3.16

Stack-agnostic version PRs from Changesets-format fragments
use anyhow::{Context, Result};
use minijinja::{Environment, context};
use serde::Serialize;

const RELEASE_TEMPLATE: &str = include_str!("../templates/changelog.jinja");
const DEPENDENCY_TEMPLATE: &str = include_str!("../templates/dependency-changelog.jinja");

#[derive(Debug, Clone, Serialize)]
pub struct PullRequest {
    pub number: u64,
    pub url: String,
    pub user: Option<String>,
    pub user_url: Option<String>,
    pub external_author: bool,
}

#[derive(Debug, Clone, Serialize)]
pub struct Commit {
    pub short: String,
    pub url: String,
}

#[derive(Debug, Clone, Serialize)]
pub struct Dependency {
    pub name: String,
    pub new_version: String,
}

#[derive(Debug, Clone)]
pub struct ReleaseInput {
    pub summary: String,
    pub continuations: Vec<String>,
    pub pull_request: Option<PullRequest>,
    pub commit: Option<Commit>,
}

impl ReleaseInput {
    #[must_use]
    pub fn with_author_filter(mut self, internal_authors: &[String]) -> Self {
        if let Some(pr) = &mut self.pull_request {
            let login = pr.user.as_deref().unwrap_or("");
            pr.external_author =
                !login.is_empty() && !internal_authors.iter().any(|name| name == login);
        }
        self
    }
}

#[must_use]
pub fn summary_has_terminal(summary: &str) -> bool {
    summary
        .trim_end()
        .chars()
        .next_back()
        .is_some_and(|ch| matches!(ch, '.' | '!' | '?'))
}

/// The summary a reference follows: one trailing period removed, so the entry
/// ends `… ([#12](url)).` with a single period, never `…. ([#12](url)).`. An
/// ellipsis (`...`) stays whole, since dropping one dot would leave `..`.
#[must_use]
pub fn summary_body(summary: &str) -> &str {
    let summary = summary.trim_end();
    if summary.ends_with("..") {
        return summary;
    }
    summary.strip_suffix('.').unwrap_or(summary)
}

pub fn render_release(input: &ReleaseInput) -> Result<String> {
    render_release_template(RELEASE_TEMPLATE, input)
}

pub fn render_release_template(template: &str, input: &ReleaseInput) -> Result<String> {
    let mut env = Environment::new();
    env.add_template("changelog", template)
        .context("parse changelog template")?;
    let tmpl = env.get_template("changelog")?;
    let rendered = tmpl
        .render(context! {
            summary => input.summary,
            continuations => input.continuations,
            pull_request => input.pull_request,
            commit => input.commit,
            summary_body => summary_body(&input.summary),
            summary_has_terminal => summary_has_terminal(&input.summary),
        })
        .context("render changelog")?;
    Ok(rendered)
}

pub fn render_dependencies(dependencies: &[Dependency]) -> Result<String> {
    let mut env = Environment::new();
    env.add_template("deps", DEPENDENCY_TEMPLATE)
        .context("parse dependency template")?;
    let tmpl = env.get_template("deps")?;
    tmpl.render(context! { dependencies => dependencies })
        .context("render dependency changelog")
}