1use std::collections::{HashMap, HashSet};
2use std::error::Error as ErrorImpl;
3
4use regex::Regex;
5use serde::Serialize;
6use tera::{Context as TeraContext, Result as TeraResult, Tera, Value, ast};
7
8use crate::config::TextProcessor;
9use crate::error::{Error, Result};
10
11#[derive(Debug)]
13pub struct Template {
14 name: String,
16 tera: Tera,
18 #[cfg_attr(not(feature = "github"), allow(dead_code))]
20 pub variables: Vec<String>,
21}
22
23impl Template {
24 pub fn new(name: &str, mut content: String, trim: bool) -> Result<Self> {
26 if trim {
27 content = content
28 .lines()
29 .map(str::trim)
30 .collect::<Vec<&str>>()
31 .join("\n");
32 }
33 let mut tera = Tera::default();
34 if let Err(e) = tera.add_raw_template(name, &content) {
35 return if let Some(error_source) = e.source() {
36 Err(Error::TemplateParseError(error_source.to_string()))
37 } else {
38 Err(Error::TemplateError(e))
39 };
40 }
41
42 tera.register_filter("upper_first", Self::upper_first_filter);
43 tera.register_filter("split_regex", Self::split_regex);
44 tera.register_filter("replace_regex", Self::replace_regex);
45 tera.register_filter("find_regex", Self::find_regex);
46
47 Ok(Self {
48 name: name.to_string(),
49 variables: Self::get_template_variables(name, &tera)?,
50 tera,
51 })
52 }
53
54 fn upper_first_filter(value: &Value, _: &HashMap<String, Value>) -> TeraResult<Value> {
56 let mut s = tera::try_get_value!("upper_first_filter", "value", String, value);
57 let mut c = s.chars();
58 s = match c.next() {
59 None => String::new(),
60 Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
61 };
62 Ok(tera::to_value(&s)?)
63 }
64
65 fn replace_regex(value: &Value, args: &HashMap<String, Value>) -> TeraResult<Value> {
67 let s = tera::try_get_value!("replace_regex", "value", String, value);
68 let from = match args.get("from") {
69 Some(val) => tera::try_get_value!("replace_regex", "from", String, val),
70 None => {
71 return Err(tera::Error::msg(
72 "Filter `replace_regex` expected an arg called `from`",
73 ));
74 }
75 };
76
77 let to = match args.get("to") {
78 Some(val) => tera::try_get_value!("replace_regex", "to", String, val),
79 None => {
80 return Err(tera::Error::msg(
81 "Filter `replace_regex` expected an arg called `to`",
82 ));
83 }
84 };
85
86 let re = Regex::new(&from).map_err(|e| {
87 tera::Error::msg(format!(
88 "Filter `replace_regex` received an invalid regex pattern: {e}"
89 ))
90 })?;
91 Ok(tera::to_value(re.replace_all(&s, &to))?)
92 }
93
94 fn find_regex(value: &Value, args: &HashMap<String, Value>) -> TeraResult<Value> {
96 let s = tera::try_get_value!("find_regex", "value", String, value);
97
98 let pat = match args.get("pat") {
99 Some(p) => {
100 let p = tera::try_get_value!("find_regex", "pat", String, p);
101 p.replace("\\n", "\n").replace("\\t", "\t")
102 }
103 None => {
104 return Err(tera::Error::msg(
105 "Filter `find_regex` expected an arg called `pat`",
106 ));
107 }
108 };
109 let re = Regex::new(&pat).map_err(|e| {
110 tera::Error::msg(format!(
111 "Filter `find_regex` received an invalid regex pattern: {e}"
112 ))
113 })?;
114 let result: Vec<&str> = re.find_iter(&s).map(|mat| mat.as_str()).collect();
115 Ok(tera::to_value(result)?)
116 }
117
118 fn split_regex(value: &Value, args: &HashMap<String, Value>) -> TeraResult<Value> {
120 let s = tera::try_get_value!("split_regex", "value", String, value);
121 let pat = match args.get("pat") {
122 Some(p) => {
123 let p = tera::try_get_value!("split_regex", "pat", String, p);
124 p.replace("\\n", "\n").replace("\\t", "\t")
125 }
126 None => {
127 return Err(tera::Error::msg(
128 "Filter `split_regex` expected an arg called `pat`",
129 ));
130 }
131 };
132 let re = Regex::new(&pat).map_err(|e| {
133 tera::Error::msg(format!(
134 "Filter `split_regex` received an invalid regex pattern: {e}"
135 ))
136 })?;
137 let result: Vec<&str> = re.split(&s).collect();
138 Ok(tera::to_value(result)?)
139 }
140
141 fn find_identifiers(node: &ast::Node, names: &mut HashSet<String>) {
143 match node {
144 ast::Node::Block(_, block, _) => {
145 for node in &block.body {
146 Self::find_identifiers(node, names);
147 }
148 }
149 ast::Node::VariableBlock(_, expr) => {
150 if let ast::ExprVal::Ident(v) = &expr.val {
151 names.insert(v.clone());
152 }
153 }
154 ast::Node::MacroDefinition(_, def, _) => {
155 for node in &def.body {
156 Self::find_identifiers(node, names);
157 }
158 }
159 ast::Node::FilterSection(_, section, _) => {
160 for node in §ion.body {
161 Self::find_identifiers(node, names);
162 }
163 }
164 ast::Node::Forloop(_, forloop, _) => {
165 if let ast::ExprVal::Ident(v) = &forloop.container.val {
166 names.insert(v.clone());
167 }
168 for node in &forloop.body {
169 Self::find_identifiers(node, names);
170 }
171 for node in &forloop.empty_body.clone().unwrap_or_default() {
172 Self::find_identifiers(node, names);
173 }
174 for (_, expr) in forloop.container.filters.iter().flat_map(|v| v.args.iter()) {
175 if let ast::ExprVal::String(ref v) = expr.val {
176 names.insert(v.clone());
177 }
178 }
179 }
180 ast::Node::If(cond, _) => {
181 for (_, expr, nodes) in &cond.conditions {
182 if let ast::ExprVal::Ident(v) = &expr.val {
183 names.insert(v.clone());
184 }
185 for node in nodes {
186 Self::find_identifiers(node, names);
187 }
188 }
189 if let Some((_, nodes)) = &cond.otherwise {
190 for node in nodes {
191 Self::find_identifiers(node, names);
192 }
193 }
194 }
195 _ => {}
196 }
197 }
198
199 fn get_template_variables(name: &str, tera: &Tera) -> Result<Vec<String>> {
201 let mut variables = HashSet::new();
202 let ast = &tera.get_template(name)?.ast;
203 for node in ast {
204 Self::find_identifiers(node, &mut variables);
205 }
206 tracing::trace!("Template variables for {name}: {variables:?}");
207 Ok(variables.into_iter().collect())
208 }
209
210 pub(crate) fn contains_variable(&self, variables: &[&str]) -> bool {
212 variables
213 .iter()
214 .any(|var| self.variables.iter().any(|v| v.starts_with(var)))
215 }
216
217 pub fn render<C: Serialize, T: Serialize, S: Into<String> + Clone>(
219 &self,
220 context: &C,
221 additional_context: Option<&HashMap<S, T>>,
222 postprocessors: &[TextProcessor],
223 ) -> Result<String> {
224 let mut context = TeraContext::from_serialize(context)?;
225 if let Some(additional_context) = additional_context {
226 for (key, value) in additional_context {
227 context.insert(key.clone(), &value);
228 }
229 }
230 match self.tera.render(&self.name, &context) {
231 Ok(mut v) => {
232 for postprocessor in postprocessors {
233 postprocessor.replace(&mut v, vec![])?;
234 }
235 Ok(v)
236 }
237 Err(e) => {
238 if let Some(source1) = e.source() {
239 if let Some(source2) = source1.source() {
240 Err(Error::TemplateRenderDetailedError(
241 source1.to_string(),
242 source2.to_string(),
243 ))
244 } else {
245 Err(Error::TemplateRenderError(source1.to_string()))
246 }
247 } else {
248 Err(Error::TemplateError(e))
249 }
250 }
251 }
252 }
253}
254
255#[cfg(test)]
256mod test {
257
258 use super::*;
259 use crate::commit::Commit;
260 use crate::release::Release;
261
262 fn get_fake_release_data() -> Release<'static> {
263 Release {
264 version: Some(String::from("1.0")),
265 message: None,
266 extra: None,
267 commits: vec![
268 Commit::new(String::from("123123"), String::from("feat(xyz): add xyz")),
269 Commit::new(String::from("124124"), String::from("fix(abc): fix abc")),
270 ]
271 .into_iter()
272 .filter_map(|c| c.into_conventional().ok())
273 .collect(),
274 commit_range: None,
275 commit_id: None,
276 timestamp: None,
277 previous: None,
278 repository: Some(String::from("/root/repo")),
279 submodule_commits: HashMap::new(),
280 statistics: None,
281 bump_type: None,
282 #[cfg(feature = "github")]
283 github: crate::remote::RemoteReleaseMetadata {
284 contributors: vec![],
285 },
286 #[cfg(feature = "gitlab")]
287 gitlab: crate::remote::RemoteReleaseMetadata {
288 contributors: vec![],
289 },
290 #[cfg(feature = "gitea")]
291 gitea: crate::remote::RemoteReleaseMetadata {
292 contributors: vec![],
293 },
294 #[cfg(feature = "bitbucket")]
295 bitbucket: crate::remote::RemoteReleaseMetadata {
296 contributors: vec![],
297 },
298 #[cfg(feature = "azure_devops")]
299 azure_devops: crate::remote::RemoteReleaseMetadata {
300 contributors: vec![],
301 },
302 }
303 }
304
305 #[test]
306 fn render_template() -> Result<()> {
307 let template = r"
308 ## {{ version }} - <DATE>
309 {% for commit in commits %}
310 ### {{ commit.group }}
311 - {{ commit.message | upper_first }}
312 {% endfor %}";
313 let mut template = Template::new("test", template.to_string(), false)?;
314 let release = get_fake_release_data();
315 assert_eq!(
316 "\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 \
317 abc\n\t\t",
318 template.render(&release, Option::<HashMap<&str, String>>::None.as_ref(), &[
319 TextProcessor {
320 pattern: Regex::new("<DATE>").expect("failed to compile regex"),
321 replace: Some(String::from("2023")),
322 replace_command: None,
323 }
324 ],)?
325 );
326 template.variables.sort();
327 assert_eq!(
328 vec![
329 String::from("commit.group"),
330 String::from("commit.message"),
331 String::from("commits"),
332 String::from("version"),
333 ],
334 template.variables
335 );
336 #[cfg(feature = "github")]
337 {
338 assert!(!template.contains_variable(&["commit.github"]));
339 assert!(template.contains_variable(&["commit.group"]));
340 }
341 Ok(())
342 }
343
344 #[test]
345 fn render_trimmed_template() -> Result<()> {
346 let template = r"
347 ## {{ version }}
348 ";
349 let template = Template::new("test", template.to_string(), true)?;
350 let release = get_fake_release_data();
351 assert_eq!(
352 "\n## 1.0\n",
353 template.render(&release, Option::<HashMap<&str, String>>::None.as_ref(), &[
354 ],)?
355 );
356 assert_eq!(vec![String::from("version"),], template.variables);
357 Ok(())
358 }
359
360 #[test]
361 fn test_upper_first_filter() -> Result<()> {
362 let template = "{% set hello_variable = 'hello' %}{{ hello_variable | upper_first }}";
363 let release = get_fake_release_data();
364 let template = Template::new("test", template.to_string(), true)?;
365 let r = template.render(&release, Option::<HashMap<&str, String>>::None.as_ref(), &[
366 ])?;
367 assert_eq!("Hello", r);
368 Ok(())
369 }
370
371 #[test]
372 fn test_replace_regex_filter() -> Result<()> {
373 let template = "{% set hello_variable = 'hello world' %}{{ hello_variable | \
374 replace_regex(from='o', to='a') }}";
375 let release = get_fake_release_data();
376 let template = Template::new("test", template.to_string(), true)?;
377 let r = template.render(&release, Option::<HashMap<&str, String>>::None.as_ref(), &[
378 ])?;
379 assert_eq!("hella warld", r);
380 Ok(())
381 }
382
383 #[test]
384 fn test_find_regex_filter() -> Result<()> {
385 let template = "{% set hello_variable = 'hello world, hello universe' %}{{ hello_variable \
386 | find_regex(pat='hello') }}";
387 let release = get_fake_release_data();
388 let template = Template::new("test", template.to_string(), true)?;
389 let r = template.render(&release, Option::<HashMap<&str, String>>::None.as_ref(), &[
390 ])?;
391 assert_eq!("[hello, hello]", r);
392 Ok(())
393 }
394
395 #[test]
396 fn test_split_regex_filter() -> Result<()> {
397 let template = "{% set hello_variable = 'hello world, hello universe' %}{{ hello_variable \
398 | split_regex(pat=' ') }}";
399 let release = get_fake_release_data();
400 let template = Template::new("test", template.to_string(), true)?;
401 let r = template.render(&release, Option::<HashMap<&str, String>>::None.as_ref(), &[
402 ])?;
403
404 assert_eq!("[hello, world,, hello, universe]", r);
405 Ok(())
406 }
407}