1use std::collections::HashMap;
4
5use chrono::format::{Item, StrftimeItems};
6use chrono::{DateTime, Utc};
7use serde::Serialize;
8use tera::{try_get_value, Tera};
9
10use crate::result::Result;
11use crate::strings;
12
13#[derive(Debug)]
15pub struct RenderEngine(Tera);
16
17impl Default for RenderEngine {
18 fn default() -> Self {
19 let mut inst = Self(Tera::default());
20 inst.register_custom_filters();
21 inst
22 }
23}
24
25impl RenderEngine {
26 pub fn register_template(&mut self, name: &str, content: &str) -> Result<()> {
37 self.0.add_raw_template(name, content)?;
38
39 Ok(())
40 }
41
42 pub fn render<C>(&self, name: &str, context: C) -> Result<String>
55 where
56 C: Serialize,
57 {
58 let context = &tera::Context::from_serialize(context)?;
59 let string = self.0.render(name, context)?;
60
61 Ok(string)
62 }
63
64 pub fn render_str<C>(&mut self, template: &str, context: C) -> Result<String>
77 where
78 C: Serialize,
79 {
80 let context = &tera::Context::from_serialize(context)?;
81 let string = self.0.render_str(template, context)?;
82
83 Ok(string)
84 }
85
86 fn register_custom_filters(&mut self) {
88 self.0.register_filter("date", filter_date);
89 self.0.register_filter("strip", filter_strip);
90 self.0.register_filter("slugify", filter_slugify);
91 }
92}
93
94#[allow(clippy::implicit_hasher)]
104#[allow(clippy::missing_errors_doc)]
105#[allow(clippy::missing_panics_doc)]
106pub fn filter_date(
107 value: &tera::Value,
108 args: &HashMap<String, tera::Value>,
109) -> tera::Result<tera::Value> {
110 if value.is_null() || value.as_str() == Some("") {
111 return Ok(tera::Value::String(String::new()));
112 }
113
114 let format = match args.get("format") {
115 Some(val) => try_get_value!("date", "format", String, val),
116 None => crate::defaults::DATE_FORMAT_TEMPLATE.to_string(),
117 };
118
119 let errors: Vec<Item<'_>> = StrftimeItems::new(&format)
120 .filter(|item| matches!(item, Item::Error))
121 .collect();
122
123 if !errors.is_empty() {
124 return Err(tera::Error::msg(format!("Invalid date format `{format}`",)));
125 }
126
127 let tera::Value::String(date_str) = value else {
128 return Err(tera::Error::msg(format!(
129 "Filter `date` received an incorrect type for arg `value`: \
130 got `{value:?}` but expected String",
131 )));
132 };
133
134 let date = date_str.parse::<DateTime<Utc>>().unwrap();
137
138 let formatted = date.format(&format).to_string();
139
140 Ok(tera::Value::String(formatted))
141}
142
143#[allow(clippy::implicit_hasher)]
145fn filter_strip(
146 value: &tera::Value,
147 args: &HashMap<String, tera::Value>,
148) -> tera::Result<tera::Value> {
149 let input = value
150 .as_str()
151 .ok_or("Expected input value to be a string")?;
152
153 let chars = args
154 .get("chars")
155 .and_then(tera::Value::as_str)
156 .unwrap_or(" ");
157
158 Ok(tera::Value::String(strings::strip(input, chars)))
159}
160
161#[allow(clippy::implicit_hasher)]
163fn filter_slugify(
164 value: &tera::Value,
165 args: &HashMap<String, tera::Value>,
166) -> tera::Result<tera::Value> {
167 let input = value
168 .as_str()
169 .ok_or("Expected input value to be a string")?;
170
171 let lowercase = args
172 .get("lowercase")
173 .and_then(tera::Value::as_bool)
174 .unwrap_or(true);
175
176 let replaced = strings::to_slug(input, lowercase);
177
178 Ok(tera::Value::String(replaced))
179}
180
181#[cfg(test)]
182mod test {
183
184 use super::*;
185
186 use crate::defaults::test::TemplatesDirectory;
187 use crate::utils;
188
189 use std::collections::BTreeMap;
190
191 #[derive(Default, Serialize)]
192 struct EmptyContext(BTreeMap<String, String>);
193
194 fn render_test_template(directory: TemplatesDirectory, filename: &str) {
195 let mut engine = RenderEngine::default();
196 let template = utils::testing::load_template_str(directory, filename);
197 engine.register_template(filename, &template).unwrap();
198 engine.render(filename, EmptyContext::default()).unwrap();
199 }
200
201 mod valid_filter {
202
203 use super::*;
204
205 #[test]
206 fn strip() {
207 render_test_template(TemplatesDirectory::ValidFilter, "valid-strip.txt");
208 }
209
210 #[test]
211 fn slugify() {
212 render_test_template(TemplatesDirectory::ValidFilter, "valid-slugify.txt");
213 }
214
215 #[test]
216 fn date() {
217 render_test_template(TemplatesDirectory::ValidFilter, "valid-date.txt");
218 }
219 }
220
221 mod invalid_filter {
222
223 use super::*;
224
225 #[test]
226 #[should_panic(expected = "Failed to parse 'invalid-strip-01.txt'")]
227 fn strip_01() {
228 render_test_template(TemplatesDirectory::InvalidFilter, "invalid-strip-01.txt");
229 }
230
231 #[test]
232 #[should_panic(expected = "Failed to parse 'invalid-strip-02.txt'")]
233 fn strip_02() {
234 render_test_template(TemplatesDirectory::InvalidFilter, "invalid-strip-02.txt");
235 }
236
237 #[test]
238 #[should_panic(expected = "Failed to parse 'invalid-slugify.txt'")]
239 fn slugify() {
240 render_test_template(TemplatesDirectory::InvalidFilter, "invalid-slugify.txt");
241 }
242
243 #[test]
244 #[should_panic(
245 expected = "called `Result::unwrap()` on an `Err` value: ParseError(TooShort)"
246 )]
247 fn date() {
248 render_test_template(TemplatesDirectory::InvalidFilter, "invalid-date.txt");
249 }
250 }
251}