diff --git a/git-cliff-core/src/template.rs b/git-cliff-core/src/template.rs index f8fdd4e67a..09a46e9d6e 100644 --- a/git-cliff-core/src/template.rs +++ b/git-cliff-core/src/template.rs @@ -2,7 +2,9 @@ use std::collections::{HashMap, HashSet}; use std::error::Error as ErrorImpl; use regex::Regex; +use semver::Version; use serde::Serialize; +use serde_json::Map; use tera::{Context as TeraContext, Result as TeraResult, Tera, Value, ast}; use crate::config::TextProcessor; @@ -43,6 +45,7 @@ impl Template { tera.register_filter("split_regex", Self::split_regex); tera.register_filter("replace_regex", Self::replace_regex); tera.register_filter("find_regex", Self::find_regex); + tera.register_filter("group_by_scope", Self::group_by_scope); Ok(Self { name: name.to_string(), @@ -138,6 +141,45 @@ impl Template { Ok(tera::to_value(result)?) } + /// Groups array values by the semantic version scope of an attribute. + fn group_by_scope(value: &Value, args: &HashMap) -> TeraResult { + let releases = tera::try_get_value!("group_by_scope", "value", Vec, value); + if releases.is_empty() { + return Ok(Map::new().into()); + } + + let attribute = match args.get("attribute") { + Some(value) => tera::try_get_value!("group_by_scope", "attribute", String, value), + None => String::from("version"), + }; + let scope = VersionScope::from_args(args)?; + + let mut grouped = Map::new(); + for release in releases { + if let Some(key_value) = tera::dotted_pointer(&release, &attribute).cloned() { + let key = match key_value.as_str() { + Some(key) => key.to_owned(), + None if key_value.is_null() => String::new(), // For unreleased changes + None => key_value.to_string(), + }; + let key = scoped_version(&key, scope).unwrap_or(key); + + let releases = grouped + .entry(key) + .or_insert_with(|| Value::Array(Vec::new())) + .as_array_mut() + .ok_or_else(|| { + tera::Error::msg( + "Filter `group_by_scope` expected grouped values to be arrays", + ) + })?; + releases.push(release); + } + } + + Ok(grouped.into()) + } + /// Recursively finds the identifiers from the AST. fn find_identifiers(node: &ast::Node, names: &mut HashSet) { match node { @@ -252,6 +294,56 @@ impl Template { } } +#[derive(Clone, Copy)] +enum VersionScope { + Major, + Minor, + Patch, +} + +impl VersionScope { + fn from_args(args: &HashMap) -> TeraResult { + let scope = match args.get("scope") { + Some(value) => tera::try_get_value!("group_by_scope", "scope", String, value), + None => String::from("minor"), + }; + match scope.as_str() { + "major" => Ok(Self::Major), + "minor" => Ok(Self::Minor), + "patch" => Ok(Self::Patch), + _ => Err(tera::Error::msg( + "Filter `group_by_scope` expected `scope` to be `major`, `minor`, or `patch`", + )), + } + } +} + +fn scoped_version(version: &str, scope: VersionScope) -> Option { + if let Ok(version) = Version::parse(version) { + return Some(format_scoped_version("", &version, scope)); + } + version + .char_indices() + .filter(|(_, c)| c.is_ascii_digit()) + .find_map(|(index, _)| { + let (prefix, version) = version.split_at(index); + Version::parse(version) + .ok() + .map(|version| format_scoped_version(prefix, &version, scope)) + }) +} + +fn format_scoped_version(prefix: &str, version: &Version, scope: VersionScope) -> String { + match scope { + VersionScope::Major => format!("{prefix}{}", version.major), + VersionScope::Minor => format!("{prefix}{}.{}", version.major, version.minor), + VersionScope::Patch => format!( + "{prefix}{}.{}.{}", + version.major, version.minor, version.patch + ), + } +} + #[cfg(test)] mod test { @@ -259,6 +351,22 @@ mod test { use crate::commit::Commit; use crate::release::Release; + fn release_with_commits(version: Option<&str>, commits: &[&str]) -> Release<'static> { + Release { + version: version.map(String::from), + commits: commits + .iter() + .enumerate() + .filter_map(|(index, message)| { + let mut commit = Commit::new(index.to_string(), String::from(*message)); + commit.committer.timestamp = index as i64; + commit.into_conventional().ok() + }) + .collect(), + ..Release::default() + } + } + fn get_fake_release_data() -> Release<'static> { Release { version: Some(String::from("1.0")), @@ -404,4 +512,25 @@ mod test { assert_eq!("[hello, world,, hello, universe]", r); Ok(()) } + + #[test] + fn test_group_by_scope_filter() -> Result<()> { + let releases = vec![ + release_with_commits(Some("v1.0.2"), &["fix(api): fix endpoint"]), + release_with_commits(Some("v1.0.1"), &[ + "feat(api): add endpoint", + "fix(ui): fix button", + ]), + release_with_commits(Some("v0.9.0"), &["docs: update docs"]), + release_with_commits(None, &["chore: unreleased change"]), + ]; + let mut context = HashMap::new(); + context.insert("releases", releases); + let template = r#"{% for version, releases in releases | group_by_scope %}{{ version }}={{ releases | length }}:{% set_global commits = [] %}{% for release in releases %}{% set_global commits = commits | concat(with=release.commits) %}{% endfor %}{{ commits | length }}:{% for group, commits in commits | group_by(attribute="group") %}{{ group }}={{ commits | length }},{% endfor %};{% endfor %}"#; + let template = Template::new("test", template.to_string(), true)?; + let r = template.render(&get_fake_release_data(), Some(&context), &[])?; + + assert_eq!("=1:1:chore=1,;v0.9=1:1:docs=1,;v1.0=2:3:feat=1,fix=2,;", r); + Ok(()) + } } diff --git a/website/docs/templating/syntax.md b/website/docs/templating/syntax.md index 7889a380b9..ac3c1c44f5 100644 --- a/website/docs/templating/syntax.md +++ b/website/docs/templating/syntax.md @@ -44,3 +44,24 @@ See the [Tera Documentation](https://keats.github.io/tera/docs/#templates) for m ```jinja {{ "hello world, hello universe" | split_regex(pat=" ") }} → [hello, world,, hello, universe] ``` + +- `group_by_scope`: Groups an array by the semantic version scope (`major`, `minor`, or `patch`) of an attribute. + + ```jinja + {% for version, releases in releases | group_by_scope(attribute="version", scope="minor") %} + {% if version %} + ## {{ version }} + {% else %} + ## Unreleased + {% endif %} + {% set_global commits = [] %} + {% for release in releases %} + {% set_global commits = commits | concat(with=release.commits) %} + {% endfor %} + {% for group, commits in commits | group_by(attribute="group") %} + ### {{ group }} + {% endfor %} + {% endfor %} + ``` + + Use this in `header` or `footer`; `body` is rendered once per release and does not include the full `releases` array.