git_cliff_core/
template.rs

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/// Wrapper for [`Tera`].
23#[derive(Debug)]
24pub struct Template {
25	/// Template name.
26	name:          String,
27	/// Internal Tera instance.
28	tera:          Tera,
29	/// Template variables.
30	#[cfg_attr(not(feature = "github"), allow(dead_code))]
31	pub variables: Vec<String>,
32}
33
34impl Template {
35	/// Constructs a new instance.
36	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	/// Filter for making the first character of a string uppercase.
61	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	/// Recursively finds the identifiers from the AST.
76	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 &section.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	/// Returns the variable names that are used in the template.
136	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	/// Returns `true` if the template contains one of the given variables.
147	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	/// Renders the template.
154	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}