lib/render/
engine.rs

1//! Defines the interface to the templating engine.
2
3use 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/// Templating engine interface.
14#[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    /// Registers a template into the engine.
27    ///
28    /// # Arguments
29    ///
30    /// * `name` - The template's name.
31    /// * `contents` - The templates's contents.
32    ///
33    /// # Errors
34    ///
35    /// Will return `Err` if the templates contains any errors.
36    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    /// Renders a template with a context.
43    ///
44    /// # Arguments
45    ///
46    /// * `name` - The template's name.
47    /// * `context` - The templates's context.
48    ///
49    /// # Errors
50    ///
51    /// Will return `Err` if:
52    /// * The template doesn't exist.
53    /// * [`serde_json`][serde-json] encounters any errors.
54    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    /// Renders a one-off template string with a context.
65    ///
66    /// # Arguments
67    ///
68    /// * `template` - The template's contents.
69    /// * `context` - The templates's context.
70    ///
71    /// # Errors
72    ///
73    /// Will return `Err` if:
74    /// * The templates contains any errors.
75    /// * [`serde_json`][serde-json] encounters any errors.
76    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    /// Registers custom template filters.
87    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/// This is a partial reimplementation of `Tera`'s `date` filter that handles empty dates strings.
95///
96/// Some date fields in the source data might be blank. Instead of throwing a 'type' error (`Tera`s
97/// default behaviuor), this function returns a blank string if an empty date is passed to the
98/// `date` filter.
99///
100/// Additionally, this only handles [`DateTime`]'s default serialize format: RFC 3339. As we're
101/// using [`DateTime`]s default [`Serialize`] implementation, we can use its default [`FromStr`]
102/// to deserialize it.
103#[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    // This should be safe as we're providing the input string. It's a serialized `DateTime<Utc>`
135    // object. An error here would be critical and should fail.
136    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/// Wraps the `strip` function to interface with the templating engine.
144#[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/// Wraps the `to_slug` function to interface with the templating engine.
162#[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}