Skip to main content

git_cliff_core/
template.rs

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/// Wrapper for [`Tera`].
12#[derive(Debug)]
13pub struct Template {
14    /// Template name.
15    name: String,
16    /// Internal Tera instance.
17    tera: Tera,
18    /// Template variables.
19    #[cfg_attr(not(feature = "github"), allow(dead_code))]
20    pub variables: Vec<String>,
21}
22
23impl Template {
24    /// Constructs a new instance.
25    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    /// Filter for making the first character of a string uppercase.
55    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    /// Replaces all occurrences of a regex pattern with a string.
66    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    /// Finds all occurrences of a regex pattern in a string.
95    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    /// Splits a string by a regex pattern.
119    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    /// Recursively finds the identifiers from the AST.
142    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 &section.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    /// Returns the variable names that are used in the template.
200    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    /// Returns `true` if the template contains one of the given variables.
211    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    /// Renders the template.
218    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}