@@ -2,7 +2,9 @@ use std::collections::{HashMap, HashSet};
22use std:: error:: Error as ErrorImpl ;
33
44use regex:: Regex ;
5+ use semver:: Version ;
56use serde:: Serialize ;
7+ use serde_json:: Map ;
68use tera:: { Context as TeraContext , Result as TeraResult , Tera , Value , ast} ;
79
810use 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) ]
256344mod 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}
0 commit comments