Skip to content

Commit ac60645

Browse files
committed
feat(templating): Implemented group_by_scope filter
1 parent 772e01d commit ac60645

2 files changed

Lines changed: 146 additions & 0 deletions

File tree

git-cliff-core/src/template.rs

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ use std::collections::{HashMap, HashSet};
22
use std::error::Error as ErrorImpl;
33

44
use regex::Regex;
5+
use semver::Version;
56
use serde::Serialize;
7+
use serde_json::Map;
68
use tera::{Context as TeraContext, Result as TeraResult, Tera, Value, ast};
79

810
use crate::config::TextProcessor;
@@ -43,6 +45,7 @@ impl Template {
4345
tera.register_filter("split_regex", Self::split_regex);
4446
tera.register_filter("replace_regex", Self::replace_regex);
4547
tera.register_filter("find_regex", Self::find_regex);
48+
tera.register_filter("group_by_scope", Self::group_by_scope);
4649

4750
Ok(Self {
4851
name: name.to_string(),
@@ -138,6 +141,41 @@ impl Template {
138141
Ok(tera::to_value(result)?)
139142
}
140143

144+
/// Groups array values by the semantic version scope of an attribute.
145+
fn group_by_scope(value: &Value, args: &HashMap<String, Value>) -> TeraResult<Value> {
146+
let releases = tera::try_get_value!("group_by_scope", "value", Vec<Value>, value);
147+
if releases.is_empty() {
148+
return Ok(Map::new().into());
149+
}
150+
151+
let attribute = match args.get("attribute") {
152+
Some(value) => tera::try_get_value!("group_by_scope", "attribute", String, value),
153+
None => String::from("version"),
154+
};
155+
let scope = VersionScope::from_args(args)?;
156+
157+
let mut grouped = Map::new();
158+
for release in releases {
159+
if let Some(key_value) = tera::dotted_pointer(&release, &attribute).cloned() {
160+
let key = match key_value.as_str() {
161+
Some(key) => key.to_owned(),
162+
None if key_value.is_null() => String::new(), // For unreleased changes
163+
None => key_value.to_string(),
164+
};
165+
let key = scoped_version(&key, scope).unwrap_or(key);
166+
167+
grouped
168+
.entry(key)
169+
.or_insert_with(|| Value::Array(Vec::new()))
170+
.as_array_mut()
171+
.unwrap()
172+
.push(release);
173+
}
174+
}
175+
176+
Ok(grouped.into())
177+
}
178+
141179
/// Recursively finds the identifiers from the AST.
142180
fn find_identifiers(node: &ast::Node, names: &mut HashSet<String>) {
143181
match node {
@@ -252,13 +290,79 @@ impl Template {
252290
}
253291
}
254292

293+
#[derive(Clone, Copy)]
294+
enum VersionScope {
295+
Major,
296+
Minor,
297+
Patch,
298+
}
299+
300+
impl VersionScope {
301+
fn from_args(args: &HashMap<String, Value>) -> TeraResult<Self> {
302+
let scope = match args.get("scope") {
303+
Some(value) => tera::try_get_value!("group_by_scope", "scope", String, value),
304+
None => String::from("minor"),
305+
};
306+
match scope.as_str() {
307+
"major" => Ok(Self::Major),
308+
"minor" => Ok(Self::Minor),
309+
"patch" => Ok(Self::Patch),
310+
_ => Err(tera::Error::msg(
311+
"Filter `group_by_scope` expected `scope` to be `major`, `minor`, or `patch`",
312+
)),
313+
}
314+
}
315+
}
316+
317+
fn scoped_version(version: &str, scope: VersionScope) -> Option<String> {
318+
if let Ok(version) = Version::parse(version) {
319+
return Some(format_scoped_version("", &version, scope));
320+
}
321+
version
322+
.char_indices()
323+
.filter(|(_, c)| c.is_ascii_digit())
324+
.find_map(|(index, _)| {
325+
let (prefix, version) = version.split_at(index);
326+
Version::parse(version)
327+
.ok()
328+
.map(|version| format_scoped_version(prefix, &version, scope))
329+
})
330+
}
331+
332+
fn format_scoped_version(prefix: &str, version: &Version, scope: VersionScope) -> String {
333+
match scope {
334+
VersionScope::Major => format!("{prefix}{}", version.major),
335+
VersionScope::Minor => format!("{prefix}{}.{}", version.major, version.minor),
336+
VersionScope::Patch => format!(
337+
"{prefix}{}.{}.{}",
338+
version.major, version.minor, version.patch
339+
),
340+
}
341+
}
342+
255343
#[cfg(test)]
256344
mod test {
257345

258346
use super::*;
259347
use crate::commit::Commit;
260348
use crate::release::Release;
261349

350+
fn release_with_commits(version: Option<&str>, commits: &[&str]) -> Release<'static> {
351+
Release {
352+
version: version.map(String::from),
353+
commits: commits
354+
.iter()
355+
.enumerate()
356+
.filter_map(|(index, message)| {
357+
let mut commit = Commit::new(index.to_string(), String::from(*message));
358+
commit.committer.timestamp = index as i64;
359+
commit.into_conventional().ok()
360+
})
361+
.collect(),
362+
..Release::default()
363+
}
364+
}
365+
262366
fn get_fake_release_data() -> Release<'static> {
263367
Release {
264368
version: Some(String::from("1.0")),
@@ -404,4 +508,25 @@ mod test {
404508
assert_eq!("[hello, world,, hello, universe]", r);
405509
Ok(())
406510
}
511+
512+
#[test]
513+
fn test_group_by_scope_filter() -> Result<()> {
514+
let releases = vec![
515+
release_with_commits(Some("v1.0.2"), &["fix(api): fix endpoint"]),
516+
release_with_commits(Some("v1.0.1"), &[
517+
"feat(api): add endpoint",
518+
"fix(ui): fix button",
519+
]),
520+
release_with_commits(Some("v0.9.0"), &["docs: update docs"]),
521+
release_with_commits(None, &["chore: unreleased change"]),
522+
];
523+
let mut context = HashMap::new();
524+
context.insert("releases", releases);
525+
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 %}"#;
526+
let template = Template::new("test", template.to_string(), true)?;
527+
let r = template.render(&get_fake_release_data(), Some(&context), &[])?;
528+
529+
assert_eq!("=1:1:chore=1,;v0.9=1:1:docs=1,;v1.0=2:3:feat=1,fix=2,;", r);
530+
Ok(())
531+
}
407532
}

website/docs/templating/syntax.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,24 @@ See the [Tera Documentation](https://keats.github.io/tera/docs/#templates) for m
4444
```jinja
4545
{{ "hello world, hello universe" | split_regex(pat=" ") }} → [hello, world,, hello, universe]
4646
```
47+
48+
- `group_by_scope`: Groups an array by the semantic version scope (`major`, `minor`, or `patch`) of an attribute.
49+
50+
```jinja
51+
{% for version, releases in releases | group_by_scope(attribute="version", scope="minor") %}
52+
{% if version %}
53+
## {{ version }}
54+
{% else %}
55+
## Unreleased
56+
{% endif %}
57+
{% set_global commits = [] %}
58+
{% for release in releases %}
59+
{% set_global commits = commits | concat(with=release.commits) %}
60+
{% endfor %}
61+
{% for group, commits in commits | group_by(attribute="group") %}
62+
### {{ group }}
63+
{% endfor %}
64+
{% endfor %}
65+
```
66+
67+
Use this in `header` or `footer`; `body` is rendered once per release and does not include the full `releases` array.

0 commit comments

Comments
 (0)