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#[derive(Debug)]
16pub struct Template {
17 name: String,
19 tera: Tera,
21 #[cfg_attr(not(feature = "github"), allow(dead_code))]
23 pub variables: Vec<String>,
24}
25
26impl Template {
27 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 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 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 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 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 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 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(), 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 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 §ion.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 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 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 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 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}