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