Skip to main content

tera_contrib/
dates.rs

1use icu_calendar::{Gregorian, Iso};
2use icu_datetime::fieldsets::enums::CompositeFieldSet;
3use icu_datetime::pattern::{DateTimePattern, FixedCalendarDateTimeNames};
4use icu_locale::Locale;
5use icu_time::zone::models::AtTime;
6use icu_time::{TimeZoneInfo, ZonedDateTime};
7use jiff::fmt::temporal::{DateTimeParser, Pieces};
8use jiff::tz::TimeZone;
9use jiff::{Timestamp, Zoned};
10use jiff_icu::ConvertFrom;
11use tera::{Kwargs, Number, State, TeraResult, Value};
12use writeable::TryWriteable;
13
14static PARSER: DateTimeParser = DateTimeParser::new();
15
16/// Parse a Value (string or integer) into a Zoned datetime.
17fn parse_to_zoned(val: &Value, tz: Option<TimeZone>) -> TeraResult<Zoned> {
18    let default_tz = tz.unwrap_or(TimeZone::UTC);
19    if let Some(s) = val.as_str() {
20        PARSER
21            .parse_zoned(s)
22            .or_else(|_| {
23                PARSER.parse_timestamp(s).map(|t| {
24                    let zone = Pieces::parse(s)
25                        .ok()
26                        .and_then(|p| p.to_numeric_offset())
27                        .map_or_else(|| default_tz.clone(), TimeZone::fixed);
28                    t.to_zoned(zone)
29                })
30            })
31            .or_else(|_| {
32                PARSER
33                    .parse_datetime(s)
34                    .and_then(|d| d.to_zoned(default_tz.clone()))
35            })
36            .or_else(|_| PARSER.parse_date(s).and_then(|d| d.to_zoned(default_tz)))
37            .map_err(|e| {
38                tera::Error::message(format!(
39                    "The string {s} cannot be parsed as a valid date: {e}"
40                ))
41            })
42    } else if let Some(Number::Integer(ts)) = val.as_number() {
43        let ts = i64::try_from(ts)
44            .map_err(|_| tera::Error::message(format!("Invalid timestamp: {ts}")))?;
45        Timestamp::new(ts, 0)
46            .map(|t| t.to_zoned(default_tz))
47            .map_err(|e| tera::Error::message(format!("Invalid timestamp: {e}")))
48    } else {
49        Err(tera::Error::message(format!(
50            "Invalid value: expected a string or integer, got {}",
51            val.name()
52        )))
53    }
54}
55
56/// Returns the current datetime.
57/// You can pass an optional `timezone` name. Defaults to UTC if not provided.
58///
59/// ```text
60/// {{ now() }}
61/// {{ now(timezone="America/New_York") }}
62/// ```
63pub fn now(kwargs: Kwargs, _: &State) -> TeraResult<Value> {
64    let tz_str = kwargs.get::<&str>("timezone")?.unwrap_or("UTC");
65    let timezone = TimeZone::get(tz_str)
66        .map_err(|_| tera::Error::message(format!("Unknown timezone: {tz_str}")))?;
67    let now = Zoned::now().with_time_zone(timezone);
68    Ok(Value::from(now.to_string()))
69}
70
71/// Formats the given value using the given format if it can be parsed as a date/datetime.
72/// Takes:
73///   1. optional `format` argument, defaulting to `%Y-%m-%d`
74///   2. optional `timezone` argument, defaulting to not set
75///   3. optional `locale` argument, defaulting to not set
76///
77/// If `locale` is set, `format` is required.
78/// `format` uses 2 different formats depending on whether `locale` is set or not:
79///
80///   1. if `locale` is not set: `strftime` like format seen in [jiff documentation](https://docs.rs/jiff/latest/jiff/fmt/strtime/index.html#conversion-specifications)
81///   2. if `locale` is set: [UTS-35 datetime patterns](https://unicode.org/reports/tr35/tr35-dates.html#Date_Field_Symbol_Table)
82///
83/// ```text
84/// {{ value | date }}
85/// {{ value | date(format="%B %d, %Y") }}
86/// {{ timestamp | date(format="%Y-%m-%d %H:%M", timezone="Europe/Paris") }}
87/// {{ value | date(locale="fr", format="d MMMM y") }}
88/// {{ timestamp | date(locale="de", format="EEEE d. MMMM y, HH:mm", timezone="Europe/Berlin") }}
89/// ```
90pub fn date(val: &Value, kwargs: Kwargs, _: &State) -> TeraResult<String> {
91    let format = kwargs.get::<&str>("format")?;
92    let locale = kwargs.get::<&str>("locale")?;
93    let timezone = match kwargs.get::<&str>("timezone")? {
94        Some(t) => Some(
95            TimeZone::get(t).map_err(|_| tera::Error::message(format!("Unknown timezone: {t}")))?,
96        ),
97        None => None,
98    };
99
100    let mut zoned = parse_to_zoned(val, timezone.clone())?;
101    if let Some(tz) = timezone {
102        zoned = zoned.with_time_zone(tz);
103    }
104
105    match locale {
106        // that's a lof ot types just to format a date...
107        // https://docs.rs/icu_datetime/latest/icu_datetime/pattern/struct.FixedCalendarDateTimeNames.html#method.include_for_pattern
108        Some(locale_str) => {
109            let Some(fmt) = format else {
110                return Err(tera::Error::message(
111                    "Setting `locale` requires setting a `format` as well",
112                ));
113            };
114
115            // A `%` in the format is almost certainly a strftime format and is likely and error
116            // since we can't use strftime here.
117            // Revisit if someone actually needs the literal % in their date output but it's better
118            // to error for now IMO
119            if fmt.contains('%') {
120                return Err(tera::Error::message(format!(
121                    "Invalid date format `{fmt}`: strftime formats cannot be localized, \
122                     use a UTS-35 pattern instead (eg `d MMMM y`)"
123                )));
124            }
125            // Allow using fr-FR or fr_FR
126            let locale = Locale::try_from_str(&locale_str.replace('_', "-"))
127                .map_err(|_| tera::Error::message(format!("Invalid locale: {locale_str}")))?;
128            let pattern = DateTimePattern::try_from_pattern_str(fmt).map_err(|e| {
129                tera::Error::message(format!("Invalid UTS-35 date format `{fmt}`: {e}"))
130            })?;
131            let mut names =
132                FixedCalendarDateTimeNames::<Gregorian, CompositeFieldSet>::try_new(locale.into())
133                    .map_err(|e| {
134                        tera::Error::message(format!(
135                            "Failed to load data for locale {locale_str}: {e}"
136                        ))
137                    })?;
138            let formatter = names.include_for_pattern(&pattern).map_err(|e| {
139                tera::Error::message(format!(
140                    "Invalid date format `{fmt}` for locale {locale_str}: {e}"
141                ))
142            })?;
143
144            let iso = ZonedDateTime::<Iso, TimeZoneInfo<AtTime>>::convert_from(&zoned);
145            let zdt = ZonedDateTime {
146                date: iso.date.to_calendar(Gregorian),
147                time: iso.time,
148                zone: iso.zone,
149            };
150
151            formatter
152                .format(&zdt)
153                .try_write_to_string()
154                .map(|s| s.into_owned())
155                .map_err(|(e, _)| {
156                    tera::Error::message(format!("Failed to format date with `{fmt}`: {e}"))
157                })
158        }
159        None => {
160            let fmt = format.unwrap_or("%Y-%m-%d");
161            jiff::fmt::strtime::format(fmt, &zoned)
162                .map_err(|e| tera::Error::message(format!("Invalid date format `{fmt}`: {e}")))
163        }
164    }
165}
166
167/// Tests whether a date is before another date.
168/// Errors if one of the values cannot be parsed as a date.
169/// Takes an optional `inclusive` argument defaulting to false to make this test be `<=` instead of `<`.
170///
171/// ```text
172/// {% if date is before(other="2024-06-01") %}...{% endif %}
173/// {% if date is before(other=other_date, inclusive=true) %}...{% endif %}
174/// ```
175pub fn is_before(val: &Value, kwargs: Kwargs, _: &State) -> TeraResult<bool> {
176    let other = kwargs.must_get::<&Value>("other")?;
177    let inclusive = kwargs.get::<bool>("inclusive")?.unwrap_or(false);
178    let val_zoned = parse_to_zoned(val, None)?;
179    let other_zoned = parse_to_zoned(other, None)?;
180    if inclusive {
181        Ok(val_zoned <= other_zoned)
182    } else {
183        Ok(val_zoned < other_zoned)
184    }
185}
186
187/// Tests whether a date is after another date.
188/// Errors if one of the values cannot be parsed as a date.
189/// Takes an optional `inclusive` argument defaulting to false to make this test be `>=` instead of `>`.
190///
191/// ```text
192/// {% if date is after(other="2024-01-01") %}...{% endif %}
193/// {% if date is after(other=other_date, inclusive=true) %}...{% endif %}
194/// ```
195pub fn is_after(val: &Value, kwargs: Kwargs, _: &State) -> TeraResult<bool> {
196    let other = kwargs.must_get::<&Value>("other")?;
197    let inclusive = kwargs.get::<bool>("inclusive")?.unwrap_or(false);
198    let val_zoned = parse_to_zoned(val, None)?;
199    let other_zoned = parse_to_zoned(other, None)?;
200    if inclusive {
201        Ok(val_zoned >= other_zoned)
202    } else {
203        Ok(val_zoned > other_zoned)
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use std::sync::Arc;
211    use tera::value::Map;
212    use tera::{Context, Kwargs, State};
213
214    // copy from https://github.com/Keats/tera/blob/master/src/builtins/filters/common.rs#L326
215    #[test]
216    fn test_ok_date() {
217        let inputs = vec![
218            (Value::from(1482720453), None, None, None, "2016-12-26"),
219            (
220                Value::from(1482720453),
221                Some("%Y-%m-%d %H:%M"),
222                None,
223                None,
224                "2016-12-26 02:47",
225            ),
226            // RFC3339
227            (
228                Value::from("1985-04-12T23:20:50.52Z"),
229                None,
230                None,
231                None,
232                "1985-04-12",
233            ),
234            // timezones are preserved
235            (
236                Value::from("1996-12-19T16:39:57[-08:00]"),
237                Some("%Y-%m-%d %z"),
238                None,
239                None,
240                "1996-12-19 -0800",
241            ),
242            // a bare RFC3339 offset (no brackets) preserves the offset too
243            (
244                Value::from("1996-12-19T16:39:57-08:00"),
245                Some("%Y-%m-%d %H:%M %z"),
246                None,
247                None,
248                "1996-12-19 16:39 -0800",
249            ),
250            // simple date
251            (
252                Value::from("2017-03-05"),
253                Some("%a, %d %b %Y %H:%M:%S %z"),
254                None,
255                None,
256                "Sun, 05 Mar 2017 00:00:00 +0000",
257            ),
258            // naive datetime
259            (
260                Value::from("2017-03-05T00:00:00.602"),
261                Some("%a, %d %b %Y %H:%M:%S"),
262                None,
263                None,
264                "Sun, 05 Mar 2017 00:00:00",
265            ),
266            // with a timezone
267            (
268                Value::from("2019-09-19T01:48:44.581Z"),
269                None,
270                None,
271                Some("America/New_York"),
272                "2019-09-18",
273            ),
274            (
275                Value::from(1648252203),
276                None,
277                None,
278                Some("Europe/Berlin"),
279                "2022-03-26",
280            ),
281            // And now with locales
282            (
283                Value::from("2026-08-24"),
284                Some("d MMMM y"),
285                Some("fr"),
286                None,
287                "24 août 2026",
288            ),
289            (
290                Value::from("2026-08-24"),
291                Some("d MMMM y"),
292                Some("fr-FR"),
293                None,
294                "24 août 2026",
295            ),
296            (
297                Value::from("2026-08-24"),
298                Some("d MMMM y"),
299                Some("fr_FR"),
300                None,
301                "24 août 2026",
302            ),
303            (
304                Value::from("2026-08-24"),
305                Some("EEEE d MMMM y"),
306                Some("fr_FR"),
307                None,
308                "lundi 24 août 2026",
309            ),
310            (
311                Value::from("2026-08-24"),
312                Some("y年M月d日"),
313                Some("ja"),
314                None,
315                "2026年8月24日",
316            ),
317            (
318                Value::from(1787574924),
319                Some("d MMMM y à HH:mm"),
320                Some("fr"),
321                Some("Europe/Paris"),
322                "24 août 2026 à 14:35",
323            ),
324            (
325                Value::from("2026-08-24T14:35:00"),
326                Some("MMMM d y [zzz]"),
327                Some("it"),
328                Some("America/New_York"),
329                "agosto 24 2026 [GMT-4]",
330            ),
331        ];
332
333        for (value, format, locale, timezone, expected) in inputs {
334            let mut map = Map::new();
335            if let Some(f) = format {
336                map.insert("format".into(), f.into());
337            }
338            if let Some(tz) = timezone {
339                map.insert("timezone".into(), tz.into());
340            }
341            if let Some(l) = locale {
342                map.insert("locale".into(), l.into());
343            }
344            let kwargs = Kwargs::new(Arc::new(map));
345            let ctx = Context::new();
346            let res = date(&value, kwargs, &State::new(&ctx)).unwrap();
347            assert_eq!(expected, res);
348        }
349    }
350
351    #[test]
352    fn test_bad_date_call() {
353        let inputs = vec![
354            (Value::from(1482720453), Some("%1"), None, None),
355            (Value::from(1482720453), Some("%+S"), None, None),
356            (
357                Value::from("2019-09-19T01:48:44.581Z"),
358                Some("%+S"),
359                Some("Narnia"),
360                None,
361            ),
362            // unknown locale
363            (Value::from(1482720453), Some("MMMM"), None, Some("unknown")),
364            // missing format with locale
365            (Value::from(1482720453), None, None, Some("fr")),
366            // strftime formats error when a locale is set
367            (Value::from(1482720453), Some("%Y-%m-%d"), None, Some("fr")),
368        ];
369
370        for (value, format, timezone, locale) in inputs {
371            let mut map = Map::new();
372            if let Some(f) = format {
373                map.insert("format".into(), f.into());
374            }
375            if let Some(tz) = timezone {
376                map.insert("timezone".into(), tz.into());
377            }
378            if let Some(l) = locale {
379                map.insert("locale".into(), l.into());
380            }
381            let kwargs = Kwargs::new(Arc::new(map));
382            let ctx = Context::new();
383            let res = date(&value, kwargs, &State::new(&ctx));
384            println!("{res:?}");
385            assert!(res.is_err());
386        }
387    }
388
389    #[test]
390    fn test_register() {
391        let mut tera = tera::Tera::default();
392        tera.register_filter("date", date);
393        tera.register_test("before", is_before);
394        tera.register_test("after", is_after);
395        tera.register_function("now", now);
396    }
397
398    #[test]
399    fn test_is_before() {
400        let ctx = Context::new();
401        let state = State::new(&ctx);
402
403        // (val, other, inclusive, expected)
404        let cases: Vec<(Value, Value, bool, bool)> = vec![
405            (Value::from(500), Value::from(1000), false, true),
406            (Value::from(1000), Value::from(500), false, false),
407            (
408                Value::from("2024-01-01"),
409                Value::from("2024-06-01"),
410                false,
411                true,
412            ),
413            (
414                Value::from("2024-06-01"),
415                Value::from("2024-01-01"),
416                false,
417                false,
418            ),
419            (Value::from(1000), Value::from("2020-01-01"), false, true), // mixed formats
420            (
421                Value::from("2024-01-01"),
422                Value::from("2024-01-01"),
423                false,
424                false,
425            ), // equal
426            (
427                Value::from("2024-01-01"),
428                Value::from("2024-01-01"),
429                true,
430                true,
431            ), // equal + inclusive
432        ];
433
434        for (val, other, inclusive, expected) in cases {
435            let mut map = Map::new();
436            map.insert("other".into(), other);
437            if inclusive {
438                map.insert("inclusive".into(), Value::from(true));
439            }
440            let kwargs = Kwargs::new(Arc::new(map));
441            assert_eq!(is_before(&val, kwargs, &state).unwrap(), expected);
442        }
443    }
444
445    #[test]
446    fn test_is_after() {
447        let ctx = Context::new();
448        let state = State::new(&ctx);
449
450        // (val, other, inclusive, expected)
451        let cases: Vec<(Value, Value, bool, bool)> = vec![
452            (Value::from(1000), Value::from(500), false, true),
453            (Value::from(500), Value::from(1000), false, false),
454            (
455                Value::from("2024-06-01"),
456                Value::from("2024-01-01"),
457                false,
458                true,
459            ),
460            (
461                Value::from("2024-01-01"),
462                Value::from("2024-06-01"),
463                false,
464                false,
465            ),
466            (Value::from("2020-01-01"), Value::from(1000), false, true), // mixed formats
467            (
468                Value::from("2024-01-01"),
469                Value::from("2024-01-01"),
470                false,
471                false,
472            ), // equal
473            (
474                Value::from("2024-01-01"),
475                Value::from("2024-01-01"),
476                true,
477                true,
478            ), // equal + inclusive
479        ];
480
481        for (val, other, inclusive, expected) in cases {
482            let mut map = Map::new();
483            map.insert("other".into(), other);
484            if inclusive {
485                map.insert("inclusive".into(), Value::from(true));
486            }
487            let kwargs = Kwargs::new(Arc::new(map));
488            assert_eq!(is_after(&val, kwargs, &state).unwrap(), expected);
489        }
490    }
491}