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