Skip to main content

git_cliff_core/
template.rs

1use std::collections::{HashMap, HashSet};
2use std::error::Error as ErrorImpl;
3
4use indexmap::IndexMap;
5use regex::Regex;
6use semver::Version;
7use serde::Serialize;
8use serde_json::{Map, json};
9use tera::{Context as TeraContext, Result as TeraResult, Tera, Value, ast};
10
11use crate::config::TextProcessor;
12use crate::error::{Error, Result};
13
14/// Wrapper for [`Tera`].
15#[derive(Debug)]
16pub struct Template {
17    /// Template name.
18    name: String,
19    /// Internal Tera instance.
20    tera: Tera,
21    /// Template variables.
22    #[cfg_attr(not(feature = "github"), allow(dead_code))]
23    pub variables: Vec<String>,
24}
25
26impl Template {
27    /// Constructs a new instance.
28    pub fn new(name: &str, mut content: String, trim: bool) -> Result<Self> {
29        if trim {
30            content = content
31                .lines()
32                .map(str::trim)
33                .collect::<Vec<&str>>()
34                .join("\n");
35        }
36        let mut tera = Tera::default();
37        if let Err(e) = tera.add_raw_template(name, &content) {
38            return if let Some(error_source) = e.source() {
39                Err(Error::TemplateParseError(error_source.to_string()))
40            } else {
41                Err(Error::TemplateError(e))
42            };
43        }
44
45        tera.register_filter("upper_first", Self::upper_first_filter);
46        tera.register_filter("split_regex", Self::split_regex);
47        tera.register_filter("replace_regex", Self::replace_regex);
48        tera.register_filter("find_regex", Self::find_regex);
49        tera.register_filter("commit_groups", Self::commit_groups);
50        tera.register_filter("group_by_scope", Self::group_by_scope);
51
52        Ok(Self {
53            name: name.to_string(),
54            variables: Self::get_template_variables(name, &tera)?,
55            tera,
56        })
57    }
58
59    /// Groups commits by their `group` field while preserving ordering.
60    ///
61    /// Behaves like Tera's built-in `group_by(attribute="group")` filter, but
62    /// yields entries as an array so iteration order is well-defined. Each
63    /// entry has a `group` (the group name) and `commits` (the matching
64    /// commits, in their original order).
65    ///
66    /// When the optional `groups` argument is provided (an array of group
67    /// names, typically the order of `commit_parsers` in the configuration),
68    /// the output is sorted to match that order. Any group not listed in
69    /// `groups` is appended after the listed ones, in first-appearance order.
70    /// When `groups` is omitted, the output preserves the first-appearance
71    /// order of groups in the input list (which mirrors commit chronology).
72    ///
73    /// Commits whose `group` is null or missing are skipped, matching the
74    /// behavior of the built-in `group_by` filter.
75    fn commit_groups(value: &Value, args: &HashMap<String, Value>) -> TeraResult<Value> {
76        let arr = tera::try_get_value!("commit_groups", "value", Vec<Value>, value);
77
78        let group_priority: Option<HashMap<String, usize>> = match args.get("groups") {
79            Some(val) => {
80                let groups =
81                    tera::try_get_value!("commit_groups", "groups", Vec<String>, val.clone());
82                let mut map = HashMap::with_capacity(groups.len());
83                for (idx, name) in groups.into_iter().enumerate() {
84                    map.entry(name).or_insert(idx);
85                }
86                Some(map)
87            }
88            None => None,
89        };
90
91        let mut grouped: IndexMap<String, Vec<Value>> = IndexMap::new();
92        for val in arr {
93            let key_val = match val.get("group") {
94                Some(v) if !v.is_null() => v.clone(),
95                _ => continue,
96            };
97            let str_key = match key_val.as_str() {
98                Some(k) => k.to_owned(),
99                None => format!("{key_val}"),
100            };
101            grouped.entry(str_key).or_default().push(val);
102        }
103
104        if let Some(priority) = &group_priority {
105            let next_priority = priority.len();
106            grouped.sort_by(|a_name, _, b_name, _| {
107                let a = priority.get(a_name).copied().unwrap_or(next_priority);
108                let b = priority.get(b_name).copied().unwrap_or(next_priority);
109                a.cmp(&b)
110            });
111        }
112
113        let result: Vec<Value> = grouped
114            .into_iter()
115            .map(|(group, commits)| json!({ "group": group, "commits": commits }))
116            .collect();
117        Ok(tera::to_value(result)?)
118    }
119
120    /// Filter for making the first character of a string uppercase.
121    fn upper_first_filter(value: &Value, _: &HashMap<String, Value>) -> TeraResult<Value> {
122        let mut s = tera::try_get_value!("upper_first_filter", "value", String, value);
123        let mut c = s.chars();
124        s = match c.next() {
125            None => String::new(),
126            Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
127        };
128        Ok(tera::to_value(&s)?)
129    }
130
131    /// Replaces all occurrences of a regex pattern with a string.
132    fn replace_regex(value: &Value, args: &HashMap<String, Value>) -> TeraResult<Value> {
133        let s = tera::try_get_value!("replace_regex", "value", String, value);
134        let from = match args.get("from") {
135            Some(val) => tera::try_get_value!("replace_regex", "from", String, val),
136            None => {
137                return Err(tera::Error::msg(
138                    "Filter `replace_regex` expected an arg called `from`",
139                ));
140            }
141        };
142
143        let to = match args.get("to") {
144            Some(val) => tera::try_get_value!("replace_regex", "to", String, val),
145            None => {
146                return Err(tera::Error::msg(
147                    "Filter `replace_regex` expected an arg called `to`",
148                ));
149            }
150        };
151
152        let re = Regex::new(&from).map_err(|e| {
153            tera::Error::msg(format!(
154                "Filter `replace_regex` received an invalid regex pattern: {e}"
155            ))
156        })?;
157        Ok(tera::to_value(re.replace_all(&s, &to))?)
158    }
159
160    /// Finds all occurrences of a regex pattern in a string.
161    fn find_regex(value: &Value, args: &HashMap<String, Value>) -> TeraResult<Value> {
162        let s = tera::try_get_value!("find_regex", "value", String, value);
163
164        let pat = match args.get("pat") {
165            Some(p) => {
166                let p = tera::try_get_value!("find_regex", "pat", String, p);
167                p.replace("\\n", "\n").replace("\\t", "\t")
168            }
169            None => {
170                return Err(tera::Error::msg(
171                    "Filter `find_regex` expected an arg called `pat`",
172                ));
173            }
174        };
175        let re = Regex::new(&pat).map_err(|e| {
176            tera::Error::msg(format!(
177                "Filter `find_regex` received an invalid regex pattern: {e}"
178            ))
179        })?;
180        let result: Vec<&str> = re.find_iter(&s).map(|mat| mat.as_str()).collect();
181        Ok(tera::to_value(result)?)
182    }
183
184    /// Splits a string by a regex pattern.
185    fn split_regex(value: &Value, args: &HashMap<String, Value>) -> TeraResult<Value> {
186        let s = tera::try_get_value!("split_regex", "value", String, value);
187        let pat = match args.get("pat") {
188            Some(p) => {
189                let p = tera::try_get_value!("split_regex", "pat", String, p);
190                p.replace("\\n", "\n").replace("\\t", "\t")
191            }
192            None => {
193                return Err(tera::Error::msg(
194                    "Filter `split_regex` expected an arg called `pat`",
195                ));
196            }
197        };
198        let re = Regex::new(&pat).map_err(|e| {
199            tera::Error::msg(format!(
200                "Filter `split_regex` received an invalid regex pattern: {e}"
201            ))
202        })?;
203        let result: Vec<&str> = re.split(&s).collect();
204        Ok(tera::to_value(result)?)
205    }
206
207    /// Groups releases by the semantic version scope of their `version` field.
208    fn group_by_scope(value: &Value, args: &HashMap<String, Value>) -> TeraResult<Value> {
209        let releases = tera::try_get_value!("group_by_scope", "value", Vec<Value>, value);
210        if releases.is_empty() {
211            return Ok(Map::new().into());
212        }
213
214        let scope = VersionScope::from_args(args)?;
215        let prefix = match args.get("prefix") {
216            Some(value) => tera::try_get_value!("group_by_scope", "prefix", String, value),
217            None => String::new(),
218        };
219
220        let mut grouped = Map::new();
221        for release in releases {
222            let key = match release.get("version") {
223                Some(Value::String(version)) => version.clone(),
224                Some(Value::Null) => String::new(), // For unreleased changes
225                Some(version) => version.to_string(),
226                None => continue,
227            };
228            let key = scoped_version(&key, &prefix, scope).unwrap_or(key);
229
230            let releases = grouped
231                .entry(key)
232                .or_insert_with(|| Value::Array(Vec::new()))
233                .as_array_mut()
234                .ok_or_else(|| {
235                    tera::Error::msg("Filter `group_by_scope` expected grouped values to be arrays")
236                })?;
237            releases.push(release);
238        }
239
240        Ok(grouped.into())
241    }
242
243    /// Recursively finds the identifiers from the AST.
244    fn find_identifiers(node: &ast::Node, names: &mut HashSet<String>) {
245        match node {
246            ast::Node::Block(_, block, _) => {
247                for node in &block.body {
248                    Self::find_identifiers(node, names);
249                }
250            }
251            ast::Node::VariableBlock(_, expr) => {
252                if let ast::ExprVal::Ident(v) = &expr.val {
253                    names.insert(v.clone());
254                }
255            }
256            ast::Node::MacroDefinition(_, def, _) => {
257                for node in &def.body {
258                    Self::find_identifiers(node, names);
259                }
260            }
261            ast::Node::FilterSection(_, section, _) => {
262                for node in &section.body {
263                    Self::find_identifiers(node, names);
264                }
265            }
266            ast::Node::Forloop(_, forloop, _) => {
267                if let ast::ExprVal::Ident(v) = &forloop.container.val {
268                    names.insert(v.clone());
269                }
270                for node in &forloop.body {
271                    Self::find_identifiers(node, names);
272                }
273                for node in &forloop.empty_body.clone().unwrap_or_default() {
274                    Self::find_identifiers(node, names);
275                }
276                for (_, expr) in forloop.container.filters.iter().flat_map(|v| v.args.iter()) {
277                    if let ast::ExprVal::String(ref v) = expr.val {
278                        names.insert(v.clone());
279                    }
280                }
281            }
282            ast::Node::If(cond, _) => {
283                for (_, expr, nodes) in &cond.conditions {
284                    if let ast::ExprVal::Ident(v) = &expr.val {
285                        names.insert(v.clone());
286                    }
287                    for node in nodes {
288                        Self::find_identifiers(node, names);
289                    }
290                }
291                if let Some((_, nodes)) = &cond.otherwise {
292                    for node in nodes {
293                        Self::find_identifiers(node, names);
294                    }
295                }
296            }
297            _ => {}
298        }
299    }
300
301    /// Returns the variable names that are used in the template.
302    fn get_template_variables(name: &str, tera: &Tera) -> Result<Vec<String>> {
303        let mut variables = HashSet::new();
304        let ast = &tera.get_template(name)?.ast;
305        for node in ast {
306            Self::find_identifiers(node, &mut variables);
307        }
308        tracing::trace!("Template variables for {name}: {variables:?}");
309        Ok(variables.into_iter().collect())
310    }
311
312    /// Returns `true` if the template contains one of the given variables.
313    pub(crate) fn contains_variable(&self, variables: &[&str]) -> bool {
314        variables
315            .iter()
316            .any(|var| self.variables.iter().any(|v| v.starts_with(var)))
317    }
318
319    /// Renders the template.
320    pub fn render<C: Serialize, T: Serialize, S: Into<String> + Clone>(
321        &self,
322        context: &C,
323        additional_context: Option<&HashMap<S, T>>,
324        postprocessors: &[TextProcessor],
325    ) -> Result<String> {
326        let mut context = TeraContext::from_serialize(context)?;
327        if let Some(additional_context) = additional_context {
328            for (key, value) in additional_context {
329                context.insert(key.clone(), &value);
330            }
331        }
332        match self.tera.render(&self.name, &context) {
333            Ok(mut v) => {
334                for postprocessor in postprocessors {
335                    postprocessor.replace(&mut v, vec![])?;
336                }
337                Ok(v)
338            }
339            Err(e) => {
340                if let Some(source1) = e.source() {
341                    if let Some(source2) = source1.source() {
342                        Err(Error::TemplateRenderDetailedError(
343                            source1.to_string(),
344                            source2.to_string(),
345                        ))
346                    } else {
347                        Err(Error::TemplateRenderError(source1.to_string()))
348                    }
349                } else {
350                    Err(Error::TemplateError(e))
351                }
352            }
353        }
354    }
355}
356
357#[derive(Clone, Copy)]
358enum VersionScope {
359    Major,
360    Minor,
361    Patch,
362}
363
364impl VersionScope {
365    fn from_args(args: &HashMap<String, Value>) -> TeraResult<Self> {
366        let scope = match args.get("scope") {
367            Some(value) => tera::try_get_value!("group_by_scope", "scope", String, value),
368            None => String::from("minor"),
369        };
370        match scope.as_str() {
371            "major" => Ok(Self::Major),
372            "minor" => Ok(Self::Minor),
373            "patch" => Ok(Self::Patch),
374            _ => Err(tera::Error::msg(
375                "Filter `group_by_scope` expected `scope` to be `major`, `minor`, or `patch`",
376            )),
377        }
378    }
379}
380
381fn scoped_version(version: &str, prefix: &str, scope: VersionScope) -> Option<String> {
382    let version = version.strip_prefix(prefix)?;
383    let version = Version::parse(version).ok()?;
384    Some(format_scoped_version(prefix, &version, scope))
385}
386
387fn format_scoped_version(prefix: &str, version: &Version, scope: VersionScope) -> String {
388    match scope {
389        VersionScope::Major => format!("{prefix}{}", version.major),
390        VersionScope::Minor => format!("{prefix}{}.{}", version.major, version.minor),
391        VersionScope::Patch => format!(
392            "{prefix}{}.{}.{}",
393            version.major, version.minor, version.patch
394        ),
395    }
396}
397
398#[cfg(test)]
399mod test {
400
401    use super::*;
402    use crate::commit::Commit;
403    use crate::release::Release;
404
405    fn release_with_commits(version: Option<&str>, commits: &[&str]) -> Release<'static> {
406        Release {
407            version: version.map(String::from),
408            commits: commits
409                .iter()
410                .enumerate()
411                .filter_map(|(index, message)| {
412                    let mut commit = Commit::new(index.to_string(), String::from(*message));
413                    commit.committer.timestamp = index as i64;
414                    commit.into_conventional().ok()
415                })
416                .collect(),
417            ..Release::default()
418        }
419    }
420
421    fn get_fake_release_data() -> Release<'static> {
422        Release {
423            version: Some(String::from("1.0")),
424            message: None,
425            extra: None,
426            commits: vec![
427                Commit::new(String::from("123123"), String::from("feat(xyz): add xyz")),
428                Commit::new(String::from("124124"), String::from("fix(abc): fix abc")),
429            ]
430            .into_iter()
431            .filter_map(|c| c.into_conventional().ok())
432            .collect(),
433            commit_range: None,
434            commit_id: None,
435            timestamp: None,
436            previous: None,
437            repository: Some(String::from("/root/repo")),
438            submodule_commits: HashMap::new(),
439            statistics: None,
440            bump_type: None,
441            #[cfg(feature = "github")]
442            github: crate::remote::RemoteReleaseMetadata {
443                contributors: vec![],
444            },
445            #[cfg(feature = "gitlab")]
446            gitlab: crate::remote::RemoteReleaseMetadata {
447                contributors: vec![],
448            },
449            #[cfg(feature = "gitea")]
450            gitea: crate::remote::RemoteReleaseMetadata {
451                contributors: vec![],
452            },
453            #[cfg(feature = "bitbucket")]
454            bitbucket: crate::remote::RemoteReleaseMetadata {
455                contributors: vec![],
456            },
457            #[cfg(feature = "azure_devops")]
458            azure_devops: crate::remote::RemoteReleaseMetadata {
459                contributors: vec![],
460            },
461        }
462    }
463
464    #[test]
465    fn render_template() -> Result<()> {
466        let template = r"
467		## {{ version }} - <DATE>
468		{% for commit in commits %}
469		### {{ commit.group }}
470		- {{ commit.message | upper_first }}
471		{% endfor %}";
472        let mut template = Template::new("test", template.to_string(), false)?;
473        let release = get_fake_release_data();
474        assert_eq!(
475            "\n\t\t## 1.0 - 2023\n\t\t\n\t\t### feat\n\t\t- Add xyz\n\t\t\n\t\t### fix\n\t\t- Fix \
476             abc\n\t\t",
477            template.render(&release, Option::<HashMap<&str, String>>::None.as_ref(), &[
478                TextProcessor {
479                    pattern: Regex::new("<DATE>").expect("failed to compile regex"),
480                    replace: Some(String::from("2023")),
481                    replace_command: None,
482                }
483            ],)?
484        );
485        template.variables.sort();
486        assert_eq!(
487            vec![
488                String::from("commit.group"),
489                String::from("commit.message"),
490                String::from("commits"),
491                String::from("version"),
492            ],
493            template.variables
494        );
495        #[cfg(feature = "github")]
496        {
497            assert!(!template.contains_variable(&["commit.github"]));
498            assert!(template.contains_variable(&["commit.group"]));
499        }
500        Ok(())
501    }
502
503    #[test]
504    fn render_trimmed_template() -> Result<()> {
505        let template = r"
506		##  {{ version }}
507		";
508        let template = Template::new("test", template.to_string(), true)?;
509        let release = get_fake_release_data();
510        assert_eq!(
511            "\n##  1.0\n",
512            template.render(&release, Option::<HashMap<&str, String>>::None.as_ref(), &[
513            ],)?
514        );
515        assert_eq!(vec![String::from("version"),], template.variables);
516        Ok(())
517    }
518
519    #[test]
520    fn test_upper_first_filter() -> Result<()> {
521        let template = "{% set hello_variable = 'hello' %}{{ hello_variable | upper_first }}";
522        let release = get_fake_release_data();
523        let template = Template::new("test", template.to_string(), true)?;
524        let r = template.render(&release, Option::<HashMap<&str, String>>::None.as_ref(), &[
525        ])?;
526        assert_eq!("Hello", r);
527        Ok(())
528    }
529
530    #[test]
531    fn test_replace_regex_filter() -> Result<()> {
532        let template = "{% set hello_variable = 'hello world' %}{{ hello_variable | \
533                        replace_regex(from='o', to='a') }}";
534        let release = get_fake_release_data();
535        let template = Template::new("test", template.to_string(), true)?;
536        let r = template.render(&release, Option::<HashMap<&str, String>>::None.as_ref(), &[
537        ])?;
538        assert_eq!("hella warld", r);
539        Ok(())
540    }
541
542    #[test]
543    fn test_find_regex_filter() -> Result<()> {
544        let template = "{% set hello_variable = 'hello world, hello universe' %}{{ hello_variable \
545                        | find_regex(pat='hello') }}";
546        let release = get_fake_release_data();
547        let template = Template::new("test", template.to_string(), true)?;
548        let r = template.render(&release, Option::<HashMap<&str, String>>::None.as_ref(), &[
549        ])?;
550        assert_eq!("[hello, hello]", r);
551        Ok(())
552    }
553
554    #[test]
555    fn test_split_regex_filter() -> Result<()> {
556        let template = "{% set hello_variable = 'hello world, hello universe' %}{{ hello_variable \
557                        | split_regex(pat=' ') }}";
558        let release = get_fake_release_data();
559        let template = Template::new("test", template.to_string(), true)?;
560        let r = template.render(&release, Option::<HashMap<&str, String>>::None.as_ref(), &[
561        ])?;
562
563        assert_eq!("[hello, world,, hello, universe]", r);
564        Ok(())
565    }
566
567    /// Builds a release whose commits would be sorted alphabetically by the
568    /// built-in `group_by` filter. Reproduces the scenario from
569    /// <https://github.com/orhun/git-cliff/issues/9>.
570    fn release_with_emoji_groups() -> Release<'static> {
571        let mut release = get_fake_release_data();
572        release.commits = vec![
573            {
574                let mut c = Commit::new(String::from("000001"), String::from("perf: speed"));
575                c.group = Some(String::from("\u{26A1} Performance"));
576                c
577            },
578            {
579                let mut c = Commit::new(String::from("000002"), String::from("fix: bug"));
580                c.group = Some(String::from("\u{1F41B} Bug Fixes"));
581                c
582            },
583            {
584                let mut c = Commit::new(String::from("000003"), String::from("feat: new"));
585                c.group = Some(String::from("\u{1F680} Features"));
586                c
587            },
588            {
589                let mut c = Commit::new(String::from("000004"), String::from("feat: another"));
590                c.group = Some(String::from("\u{1F680} Features"));
591                c
592            },
593        ];
594        release
595    }
596
597    #[test]
598    fn test_commit_groups_filter_preserves_first_appearance_when_no_groups() -> Result<()> {
599        let template = "{% for entry in commits | commit_groups %}{{ entry.group }}|{{ \
600                        entry.commits | length }};{% endfor %}";
601        let template = Template::new("test", template.to_string(), true)?;
602        let release = release_with_emoji_groups();
603        let r = template.render(&release, Option::<HashMap<&str, String>>::None.as_ref(), &[
604        ])?;
605        assert_eq!(
606            "\u{26A1} Performance|1;\u{1F41B} Bug Fixes|1;\u{1F680} Features|2;",
607            r
608        );
609        Ok(())
610    }
611
612    #[test]
613    fn test_commit_groups_filter_uses_groups_argument() -> Result<()> {
614        let template = "{% for entry in commits | commit_groups(groups=order) %}{{ entry.group \
615                        }}|{{ entry.commits | length }};{% endfor %}";
616        let template = Template::new("test", template.to_string(), true)?;
617        let release = release_with_emoji_groups();
618        let mut additional: HashMap<&str, Vec<&str>> = HashMap::new();
619        additional.insert("order", vec![
620            "\u{1F680} Features",
621            "\u{1F41B} Bug Fixes",
622            "\u{26A1} Performance",
623        ]);
624        let r = template.render(&release, Some(&additional), &[])?;
625        assert_eq!(
626            "\u{1F680} Features|2;\u{1F41B} Bug Fixes|1;\u{26A1} Performance|1;",
627            r
628        );
629        Ok(())
630    }
631
632    #[test]
633    fn test_commit_groups_filter_appends_unknown_groups() -> Result<()> {
634        let template = "{% for entry in commits | commit_groups(groups=order) %}{{ entry.group \
635                        }};{% endfor %}";
636        let template = Template::new("test", template.to_string(), true)?;
637        let release = release_with_emoji_groups();
638        let mut additional: HashMap<&str, Vec<&str>> = HashMap::new();
639        additional.insert("order", vec!["\u{1F680} Features"]);
640        let r = template.render(&release, Some(&additional), &[])?;
641        assert_eq!(
642            "\u{1F680} Features;\u{26A1} Performance;\u{1F41B} Bug Fixes;",
643            r
644        );
645        Ok(())
646    }
647
648    #[test]
649    fn test_commit_groups_filter_skips_null_groups() -> Result<()> {
650        let template = "{% for entry in commits | commit_groups %}{{ entry.group }}|{{ \
651                        entry.commits | length }};{% endfor %}";
652        let template = Template::new("test", template.to_string(), true)?;
653        let mut release = get_fake_release_data();
654        release.commits = vec![
655            {
656                let mut c = Commit::new(String::from("a"), String::from("a"));
657                c.group = Some(String::from("kept"));
658                c
659            },
660            Commit::new(String::from("b"), String::from("b")),
661        ];
662        let r = template.render(&release, Option::<HashMap<&str, String>>::None.as_ref(), &[
663        ])?;
664        assert_eq!("kept|1;", r);
665        Ok(())
666    }
667
668    #[test]
669    fn test_group_by_scope_filter() -> Result<()> {
670        let releases = vec![
671            release_with_commits(Some("v1.0.2"), &["fix(api): fix endpoint"]),
672            release_with_commits(Some("v1.0.1"), &[
673                "feat(api): add endpoint",
674                "fix(ui): fix button",
675            ]),
676            release_with_commits(Some("v0.9.0"), &["docs: update docs"]),
677            release_with_commits(None, &["chore: unreleased change"]),
678        ];
679        let mut context = HashMap::new();
680        context.insert("releases", releases);
681        let template = r#"{% for version, releases in releases | group_by_scope(prefix="v") %}{{ 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 %}"#;
682        let template = Template::new("test", template.to_string(), true)?;
683        let r = template.render(&get_fake_release_data(), Some(&context), &[])?;
684
685        assert_eq!("=1:1:chore=1,;v0.9=1:1:docs=1,;v1.0=2:3:feat=1,fix=2,;", r);
686        Ok(())
687    }
688}