Skip to main content

tera_contrib/
dates.rs

1use jiff::fmt::temporal::{DateTimeParser, Pieces};
2use jiff::tz::TimeZone;
3use jiff::{Timestamp, Zoned};
4use tera::{Kwargs, Number, State, TeraResult, Value};
5
6static PARSER: DateTimeParser = DateTimeParser::new();
7
8/// Parse a Value (string or integer) into a Zoned datetime.
9fn parse_to_zoned(val: &Value, tz: Option<TimeZone>) -> TeraResult<Zoned> {
10    let default_tz = tz.unwrap_or(TimeZone::UTC);
11    if let Some(s) = val.as_str() {
12        PARSER
13            .parse_zoned(s)
14            .or_else(|_| {
15                PARSER.parse_timestamp(s).map(|t| {
16                    let zone = Pieces::parse(s)
17                        .ok()
18                        .and_then(|p| p.to_numeric_offset())
19                        .map_or_else(|| default_tz.clone(), TimeZone::fixed);
20                    t.to_zoned(zone)
21                })
22            })
23            .or_else(|_| {
24                PARSER
25                    .parse_datetime(s)
26                    .and_then(|d| d.to_zoned(default_tz.clone()))
27            })
28            .or_else(|_| PARSER.parse_date(s).and_then(|d| d.to_zoned(default_tz)))
29            .map_err(|e| {
30                tera::Error::message(format!(
31                    "The string {s} cannot be parsed as a valid date: {e}"
32                ))
33            })
34    } else if let Some(Number::Integer(ts)) = val.as_number() {
35        let ts = i64::try_from(ts)
36            .map_err(|_| tera::Error::message(format!("Invalid timestamp: {ts}")))?;
37        Timestamp::new(ts, 0)
38            .map(|t| t.to_zoned(default_tz))
39            .map_err(|e| tera::Error::message(format!("Invalid timestamp: {e}")))
40    } else {
41        Err(tera::Error::message(format!(
42            "Invalid value: expected a string or integer, got {}",
43            val.name()
44        )))
45    }
46}
47
48/// Returns the current datetime.
49/// You can pass an optional `timezone` name. Defaults to UTC if not provided.
50///
51/// ```text
52/// {{ now() }}
53/// {{ now(timezone="America/New_York") }}
54/// ```
55pub fn now(kwargs: Kwargs, _: &State) -> TeraResult<Value> {
56    let tz_str = kwargs.get::<&str>("timezone")?.unwrap_or("UTC");
57    let timezone = TimeZone::get(tz_str)
58        .map_err(|_| tera::Error::message(format!("Unknown timezone: {tz_str}")))?;
59    let now = Zoned::now().with_time_zone(timezone);
60    Ok(Value::from(now.to_string()))
61}
62
63/// Formats the given value using the given format if it can be parsed as a date/datetime.
64/// Takes:
65///   1. optional `format` argument, defaulting to `%Y-%m-%d`
66///   2. optional `timezone` argument, defaulting to not set
67///
68/// ```text
69/// {{ value | date }}
70/// {{ value | date(format="%B %d, %Y") }}
71/// {{ timestamp | date(format="%Y-%m-%d %H:%M", timezone="Europe/Paris") }}
72/// ```
73pub fn date(val: &Value, kwargs: Kwargs, _: &State) -> TeraResult<String> {
74    let format = kwargs.get::<&str>("format")?.unwrap_or("%Y-%m-%d");
75    let timezone = match kwargs.get::<&str>("timezone")? {
76        Some(t) => Some(
77            TimeZone::get(t).map_err(|_| tera::Error::message(format!("Unknown timezone: {t}")))?,
78        ),
79        None => None,
80    };
81
82    let mut zoned = parse_to_zoned(val, timezone.clone())?;
83    if let Some(tz) = timezone {
84        zoned = zoned.with_time_zone(tz);
85    }
86
87    jiff::fmt::strtime::format(format, &zoned)
88        .map_err(|e| tera::Error::message(format!("Invalid date format `{format}`: {e}")))
89}
90
91/// Tests whether a date is before another date.
92/// Errors if one of the values cannot be parsed as a date.
93/// Takes an optional `inclusive` argument defaulting to false to make this test be `<=` instead of `<`.
94///
95/// ```text
96/// {% if date is before(other="2024-06-01") %}...{% endif %}
97/// {% if date is before(other=other_date, inclusive=true) %}...{% endif %}
98/// ```
99pub fn is_before(val: &Value, kwargs: Kwargs, _: &State) -> TeraResult<bool> {
100    let other = kwargs.must_get::<&Value>("other")?;
101    let inclusive = kwargs.get::<bool>("inclusive")?.unwrap_or(false);
102    let val_zoned = parse_to_zoned(val, None)?;
103    let other_zoned = parse_to_zoned(other, None)?;
104    if inclusive {
105        Ok(val_zoned <= other_zoned)
106    } else {
107        Ok(val_zoned < other_zoned)
108    }
109}
110
111/// Tests whether a date is after another date.
112/// Errors if one of the values cannot be parsed as a date.
113/// Takes an optional `inclusive` argument defaulting to false to make this test be `>=` instead of `>`.
114///
115/// ```text
116/// {% if date is after(other="2024-01-01") %}...{% endif %}
117/// {% if date is after(other=other_date, inclusive=true) %}...{% endif %}
118/// ```
119pub fn is_after(val: &Value, kwargs: Kwargs, _: &State) -> TeraResult<bool> {
120    let other = kwargs.must_get::<&Value>("other")?;
121    let inclusive = kwargs.get::<bool>("inclusive")?.unwrap_or(false);
122    let val_zoned = parse_to_zoned(val, None)?;
123    let other_zoned = parse_to_zoned(other, None)?;
124    if inclusive {
125        Ok(val_zoned >= other_zoned)
126    } else {
127        Ok(val_zoned > other_zoned)
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use std::sync::Arc;
135    use tera::value::Map;
136    use tera::{Context, Kwargs, State};
137
138    // copy from https://github.com/Keats/tera/blob/master/src/builtins/filters/common.rs#L326
139    #[test]
140    fn test_ok_date() {
141        let inputs = vec![
142            (Value::from(1482720453), None, None, "2016-12-26"),
143            (
144                Value::from(1482720453),
145                Some("%Y-%m-%d %H:%M"),
146                None,
147                "2016-12-26 02:47",
148            ),
149            // RFC3339
150            (
151                Value::from("1985-04-12T23:20:50.52Z"),
152                None,
153                None,
154                "1985-04-12",
155            ),
156            // timezones are preserved
157            (
158                Value::from("1996-12-19T16:39:57[-08:00]"),
159                Some("%Y-%m-%d %z"),
160                None,
161                "1996-12-19 -0800",
162            ),
163            // a bare RFC3339 offset (no brackets) preserves the offset too
164            (
165                Value::from("1996-12-19T16:39:57-08:00"),
166                Some("%Y-%m-%d %H:%M %z"),
167                None,
168                "1996-12-19 16:39 -0800",
169            ),
170            // simple date
171            (
172                Value::from("2017-03-05"),
173                Some("%a, %d %b %Y %H:%M:%S %z"),
174                None,
175                "Sun, 05 Mar 2017 00:00:00 +0000",
176            ),
177            // naive datetime
178            (
179                Value::from("2017-03-05T00:00:00.602"),
180                Some("%a, %d %b %Y %H:%M:%S"),
181                None,
182                "Sun, 05 Mar 2017 00:00:00",
183            ),
184            // with a timezone
185            (
186                Value::from("2019-09-19T01:48:44.581Z"),
187                None,
188                Some("America/New_York"),
189                "2019-09-18",
190            ),
191            (
192                Value::from(1648252203),
193                None,
194                Some("Europe/Berlin"),
195                "2022-03-26",
196            ),
197        ];
198
199        for (value, format, timezone, expected) in inputs {
200            let mut map = Map::new();
201            if let Some(f) = format {
202                map.insert("format".into(), f.into());
203            }
204            if let Some(tz) = timezone {
205                map.insert("timezone".into(), tz.into());
206            }
207            let kwargs = Kwargs::new(Arc::new(map));
208            let ctx = Context::new();
209            let res = date(&value, kwargs, &State::new(&ctx)).unwrap();
210            assert_eq!(expected, res);
211        }
212    }
213
214    #[test]
215    fn test_bad_date_call() {
216        let inputs = vec![
217            (Value::from(1482720453), Some("%1"), None),
218            (Value::from(1482720453), Some("%+S"), None),
219            (
220                Value::from("2019-09-19T01:48:44.581Z"),
221                Some("%+S"),
222                Some("Narnia"),
223            ),
224        ];
225
226        for (value, format, timezone) in inputs {
227            let mut map = Map::new();
228            if let Some(f) = format {
229                map.insert("format".into(), f.into());
230            }
231            if let Some(tz) = timezone {
232                map.insert("timezone".into(), tz.into());
233            }
234            let kwargs = Kwargs::new(Arc::new(map));
235            let ctx = Context::new();
236            let res = date(&value, kwargs, &State::new(&ctx));
237            println!("{res:?}");
238            assert!(res.is_err());
239        }
240    }
241
242    #[test]
243    fn test_register() {
244        let mut tera = tera::Tera::default();
245        tera.register_filter("date", date);
246        tera.register_test("before", is_before);
247        tera.register_test("after", is_after);
248        tera.register_function("now", now);
249    }
250
251    #[test]
252    fn test_is_before() {
253        let ctx = Context::new();
254        let state = State::new(&ctx);
255
256        // (val, other, inclusive, expected)
257        let cases: Vec<(Value, Value, bool, bool)> = vec![
258            (Value::from(500), Value::from(1000), false, true),
259            (Value::from(1000), Value::from(500), false, false),
260            (
261                Value::from("2024-01-01"),
262                Value::from("2024-06-01"),
263                false,
264                true,
265            ),
266            (
267                Value::from("2024-06-01"),
268                Value::from("2024-01-01"),
269                false,
270                false,
271            ),
272            (Value::from(1000), Value::from("2020-01-01"), false, true), // mixed formats
273            (
274                Value::from("2024-01-01"),
275                Value::from("2024-01-01"),
276                false,
277                false,
278            ), // equal
279            (
280                Value::from("2024-01-01"),
281                Value::from("2024-01-01"),
282                true,
283                true,
284            ), // equal + inclusive
285        ];
286
287        for (val, other, inclusive, expected) in cases {
288            let mut map = Map::new();
289            map.insert("other".into(), other);
290            if inclusive {
291                map.insert("inclusive".into(), Value::from(true));
292            }
293            let kwargs = Kwargs::new(Arc::new(map));
294            assert_eq!(is_before(&val, kwargs, &state).unwrap(), expected);
295        }
296    }
297
298    #[test]
299    fn test_is_after() {
300        let ctx = Context::new();
301        let state = State::new(&ctx);
302
303        // (val, other, inclusive, expected)
304        let cases: Vec<(Value, Value, bool, bool)> = vec![
305            (Value::from(1000), Value::from(500), false, true),
306            (Value::from(500), Value::from(1000), false, false),
307            (
308                Value::from("2024-06-01"),
309                Value::from("2024-01-01"),
310                false,
311                true,
312            ),
313            (
314                Value::from("2024-01-01"),
315                Value::from("2024-06-01"),
316                false,
317                false,
318            ),
319            (Value::from("2020-01-01"), Value::from(1000), false, true), // mixed formats
320            (
321                Value::from("2024-01-01"),
322                Value::from("2024-01-01"),
323                false,
324                false,
325            ), // equal
326            (
327                Value::from("2024-01-01"),
328                Value::from("2024-01-01"),
329                true,
330                true,
331            ), // equal + inclusive
332        ];
333
334        for (val, other, inclusive, expected) in cases {
335            let mut map = Map::new();
336            map.insert("other".into(), other);
337            if inclusive {
338                map.insert("inclusive".into(), Value::from(true));
339            }
340            let kwargs = Kwargs::new(Arc::new(map));
341            assert_eq!(is_after(&val, kwargs, &state).unwrap(), expected);
342        }
343    }
344}