git_cliff_core/
template.rs

1use std::collections::{HashMap, HashSet};
2use std::error::Error as ErrorImpl;
3
4use serde::Serialize;
5use tera::{Context as TeraContext, Result as TeraResult, Tera, Value, ast};
6
7use crate::config::TextProcessor;
8use crate::error::{Error, Result};
9
10/// Wrapper for [`Tera`].
11#[derive(Debug)]
12pub struct Template {
13    /// Template name.
14    name: String,
15    /// Internal Tera instance.
16    tera: Tera,
17    /// Template variables.
18    #[cfg_attr(not(feature = "github"), allow(dead_code))]
19    pub variables: Vec<String>,
20}
21
22impl Template {
23    /// Constructs a new instance.
24    pub fn new(name: &str, mut content: String, trim: bool) -> Result<Self> {
25        if trim {
26            content = content
27                .lines()
28                .map(|v| v.trim())
29                .collect::<Vec<&str>>()
30                .join("\n");
31        }
32        let mut tera = Tera::default();
33        if let Err(e) = tera.add_raw_template(name, &content) {
34            return if let Some(error_source) = e.source() {
35                Err(Error::TemplateParseError(error_source.to_string()))
36            } else {
37                Err(Error::TemplateError(e))
38            };
39        }
40        tera.register_filter("upper_first", Self::upper_first_filter);
41        Ok(Self {
42            name: name.to_string(),
43            variables: Self::get_template_variables(name, &tera)?,
44            tera,
45        })
46    }
47
48    /// Filter for making the first character of a string uppercase.
49    fn upper_first_filter(value: &Value, _: &HashMap<String, Value>) -> TeraResult<Value> {
50        let mut s = tera::try_get_value!("upper_first_filter", "value", String, value);
51        let mut c = s.chars();
52        s = match c.next() {
53            None => String::new(),
54            Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
55        };
56        Ok(tera::to_value(&s)?)
57    }
58
59    /// Recursively finds the identifiers from the AST.
60    fn find_identifiers(node: &ast::Node, names: &mut HashSet<String>) {
61        match node {
62            ast::Node::Block(_, block, _) => {
63                for node in &block.body {
64                    Self::find_identifiers(node, names);
65                }
66            }
67            ast::Node::VariableBlock(_, expr) => {
68                if let ast::ExprVal::Ident(v) = &expr.val {
69                    names.insert(v.clone());
70                }
71            }
72            ast::Node::MacroDefinition(_, def, _) => {
73                for node in &def.body {
74                    Self::find_identifiers(node, names);
75                }
76            }
77            ast::Node::FilterSection(_, section, _) => {
78                for node in &section.body {
79                    Self::find_identifiers(node, names);
80                }
81            }
82            ast::Node::Forloop(_, forloop, _) => {
83                if let ast::ExprVal::Ident(v) = &forloop.container.val {
84                    names.insert(v.clone());
85                }
86                for node in &forloop.body {
87                    Self::find_identifiers(node, names);
88                }
89                for node in &forloop.empty_body.clone().unwrap_or_default() {
90                    Self::find_identifiers(node, names);
91                }
92                for (_, expr) in forloop.container.filters.iter().flat_map(|v| v.args.iter()) {
93                    if let ast::ExprVal::String(ref v) = expr.val {
94                        names.insert(v.clone());
95                    }
96                }
97            }
98            ast::Node::If(cond, _) => {
99                for (_, expr, nodes) in &cond.conditions {
100                    if let ast::ExprVal::Ident(v) = &expr.val {
101                        names.insert(v.clone());
102                    }
103                    for node in nodes {
104                        Self::find_identifiers(node, names);
105                    }
106                }
107                if let Some((_, nodes)) = &cond.otherwise {
108                    for node in nodes {
109                        Self::find_identifiers(node, names);
110                    }
111                }
112            }
113            _ => {}
114        }
115    }
116
117    /// Returns the variable names that are used in the template.
118    fn get_template_variables(name: &str, tera: &Tera) -> Result<Vec<String>> {
119        let mut variables = HashSet::new();
120        let ast = &tera.get_template(name)?.ast;
121        for node in ast {
122            Self::find_identifiers(node, &mut variables);
123        }
124        trace!("Template variables for {name}: {variables:?}");
125        Ok(variables.into_iter().collect())
126    }
127
128    /// Returns `true` if the template contains one of the given variables.
129    pub(crate) fn contains_variable(&self, variables: &[&str]) -> bool {
130        variables
131            .iter()
132            .any(|var| self.variables.iter().any(|v| v.starts_with(var)))
133    }
134
135    /// Renders the template.
136    pub fn render<C: Serialize, T: Serialize, S: Into<String> + Clone>(
137        &self,
138        context: &C,
139        additional_context: Option<&HashMap<S, T>>,
140        postprocessors: &[TextProcessor],
141    ) -> Result<String> {
142        let mut context = TeraContext::from_serialize(context)?;
143        if let Some(additional_context) = additional_context {
144            for (key, value) in additional_context {
145                context.insert(key.clone(), &value);
146            }
147        }
148        match self.tera.render(&self.name, &context) {
149            Ok(mut v) => {
150                for postprocessor in postprocessors {
151                    postprocessor.replace(&mut v, vec![])?;
152                }
153                Ok(v)
154            }
155            Err(e) => {
156                if let Some(source1) = e.source() {
157                    if let Some(source2) = source1.source() {
158                        Err(Error::TemplateRenderDetailedError(
159                            source1.to_string(),
160                            source2.to_string(),
161                        ))
162                    } else {
163                        Err(Error::TemplateRenderError(source1.to_string()))
164                    }
165                } else {
166                    Err(Error::TemplateError(e))
167                }
168            }
169        }
170    }
171}
172
173#[cfg(test)]
174mod test {
175    use regex::Regex;
176
177    use super::*;
178    use crate::commit::Commit;
179    use crate::release::Release;
180
181    fn get_fake_release_data() -> Release<'static> {
182        Release {
183            version: Some(String::from("1.0")),
184            message: None,
185            extra: None,
186            commits: vec![
187                Commit::new(String::from("123123"), String::from("feat(xyz): add xyz")),
188                Commit::new(String::from("124124"), String::from("fix(abc): fix abc")),
189            ]
190            .into_iter()
191            .filter_map(|c| c.into_conventional().ok())
192            .collect(),
193            commit_range: None,
194            commit_id: None,
195            timestamp: None,
196            previous: None,
197            repository: Some(String::from("/root/repo")),
198            submodule_commits: HashMap::new(),
199            statistics: None,
200            #[cfg(feature = "github")]
201            github: crate::remote::RemoteReleaseMetadata {
202                contributors: vec![],
203            },
204            #[cfg(feature = "gitlab")]
205            gitlab: crate::remote::RemoteReleaseMetadata {
206                contributors: vec![],
207            },
208            #[cfg(feature = "gitea")]
209            gitea: crate::remote::RemoteReleaseMetadata {
210                contributors: vec![],
211            },
212            #[cfg(feature = "bitbucket")]
213            bitbucket: crate::remote::RemoteReleaseMetadata {
214                contributors: vec![],
215            },
216        }
217    }
218
219    #[test]
220    fn render_template() -> Result<()> {
221        let template = r#"
222		## {{ version }} - <DATE>
223		{% for commit in commits %}
224		### {{ commit.group }}
225		- {{ commit.message | upper_first }}
226		{% endfor %}"#;
227        let mut template = Template::new("test", template.to_string(), false)?;
228        let release = get_fake_release_data();
229        assert_eq!(
230            "\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 \
231             abc\n\t\t",
232            template.render(&release, Option::<HashMap<&str, String>>::None.as_ref(), &[
233                TextProcessor {
234                    pattern: Regex::new("<DATE>").expect("failed to compile regex"),
235                    replace: Some(String::from("2023")),
236                    replace_command: None,
237                }
238            ],)?
239        );
240        template.variables.sort();
241        assert_eq!(
242            vec![
243                String::from("commit.group"),
244                String::from("commit.message"),
245                String::from("commits"),
246                String::from("version"),
247            ],
248            template.variables
249        );
250        #[cfg(feature = "github")]
251        {
252            assert!(!template.contains_variable(&["commit.github"]));
253            assert!(template.contains_variable(&["commit.group"]));
254        }
255        Ok(())
256    }
257
258    #[test]
259    fn render_trimmed_template() -> Result<()> {
260        let template = r#"
261		##  {{ version }}
262		"#;
263        let template = Template::new("test", template.to_string(), true)?;
264        let release = get_fake_release_data();
265        assert_eq!(
266            "\n##  1.0\n",
267            template.render(&release, Option::<HashMap<&str, String>>::None.as_ref(), &[
268            ],)?
269        );
270        assert_eq!(vec![String::from("version"),], template.variables);
271        Ok(())
272    }
273
274    #[test]
275    fn test_upper_first_filter() -> Result<()> {
276        let template = "{% set hello_variable = 'hello' %}{{ hello_variable | upper_first }}";
277        let release = get_fake_release_data();
278        let template = Template::new("test", template.to_string(), true)?;
279        let r = template.render(&release, Option::<HashMap<&str, String>>::None.as_ref(), &[
280        ])?;
281        assert_eq!("Hello", r);
282        Ok(())
283    }
284}