Skip to main content

dre_core/
dates.rs

1//! Calendar values for templates: `run.date`, `run.now`, `date()`, `datetime()` and the
2//! periods built from them. Everything is worked out at render time, so compiled SQL holds
3//! literals and a rerun with the same `DRE_RUN_DATE` renders the same text.
4//!
5//! Three types:
6//! - `Date`: a calendar day. Renders `YYYY-MM-DD`.
7//! - `DateTime`: an instant, shown in a timezone. Renders `YYYY-MM-DD HH:MM:SS[.ffffff]`.
8//! - `Period`: a run of whole days (a week, a month, ...). `start` and `end` are its first
9//!   and last instants; `start.date` and `end.date` its first and last days.
10//!
11//! A `Calendar` (the run's timezone, first day of the week and week numbering) travels with
12//! every value, so navigation and conversions agree with the project's settings.
13
14use std::fmt;
15use std::sync::Arc;
16
17use chrono::{
18    DateTime as ChronoDateTime, Datelike, Days, Duration, LocalResult, Months, NaiveDate, NaiveDateTime,
19    NaiveTime, Offset, TimeZone, Timelike, Utc, Weekday,
20};
21use chrono_tz::Tz;
22use minijinja::value::{Kwargs, Object, ObjectRepr, Value};
23use minijinja::{Environment, Error, ErrorKind, State};
24
25/// The first day of a week.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, schemars::JsonSchema)]
27#[serde(rename_all = "lowercase")]
28#[schemars(inline)]
29pub enum WeekStart {
30    Monday,
31    Sunday,
32}
33
34impl WeekStart {
35    pub fn parse(s: &str) -> Option<WeekStart> {
36        match s {
37            "monday" => Some(WeekStart::Monday),
38            "sunday" => Some(WeekStart::Sunday),
39            _ => None,
40        }
41    }
42    fn weekday(self) -> Weekday {
43        match self {
44            WeekStart::Monday => Weekday::Mon,
45            WeekStart::Sunday => Weekday::Sun,
46        }
47    }
48}
49
50/// How weeks are numbered: `iso` (week 1 holds the year's first Thursday) or `us` (week 1
51/// holds 1 January).
52#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, schemars::JsonSchema)]
53#[serde(rename_all = "lowercase")]
54#[schemars(inline)]
55pub enum WeekNumbering {
56    Iso,
57    Us,
58}
59
60impl WeekNumbering {
61    pub fn parse(s: &str) -> Option<WeekNumbering> {
62        match s {
63            "iso" => Some(WeekNumbering::Iso),
64            "us" => Some(WeekNumbering::Us),
65            _ => None,
66        }
67    }
68}
69
70/// The settings every date value carries.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub struct Calendar {
73    pub tz: Tz,
74    pub week_start: WeekStart,
75    pub numbering: WeekNumbering,
76}
77
78impl Default for Calendar {
79    fn default() -> Self {
80        Calendar {
81            tz: Tz::UTC,
82            week_start: WeekStart::Monday,
83            numbering: WeekNumbering::Iso,
84        }
85    }
86}
87
88/// Parse an IANA timezone name (`Australia/Sydney`, `UTC`).
89pub fn parse_tz(name: &str) -> Result<Tz, String> {
90    name.parse::<Tz>().map_err(|_| {
91        format!("`{name}` isn't a timezone; use an IANA name such as `UTC` or `Australia/Sydney`")
92    })
93}
94
95impl Calendar {
96    /// Today in this calendar's timezone.
97    pub fn today(&self) -> NaiveDate {
98        Utc::now().with_timezone(&self.tz).date_naive()
99    }
100
101    /// The first instant of `d` here: midnight, or the first valid time after a DST gap.
102    fn start_of(&self, d: NaiveDate) -> ChronoDateTime<Tz> {
103        let mut t = d.and_time(NaiveTime::MIN);
104        loop {
105            if let Some(x) = self.tz.from_local_datetime(&t).earliest() {
106                return x;
107            }
108            t += Duration::minutes(15);
109        }
110    }
111
112    /// The last microsecond of `d` here.
113    fn end_of(&self, d: NaiveDate) -> ChronoDateTime<Tz> {
114        self.start_of(d + Days::new(1)) - Duration::microseconds(1)
115    }
116
117    fn week_start_of(&self, d: NaiveDate) -> NaiveDate {
118        let back =
119            (7 + d.weekday().num_days_from_monday() - self.week_start.weekday().num_days_from_monday()) % 7;
120        d - Days::new(back as u64)
121    }
122
123    /// `(week_year, week)` in this calendar's numbering.
124    fn week_number(&self, d: NaiveDate) -> (i32, u32) {
125        match self.numbering {
126            WeekNumbering::Iso => (d.iso_week().year(), d.iso_week().week()),
127            WeekNumbering::Us => {
128                let first = self.week_start_of(NaiveDate::from_ymd_opt(d.year(), 1, 1).unwrap());
129                (d.year(), ((d - first).num_days() / 7) as u32 + 1)
130            }
131        }
132    }
133
134    /// The first day of week `w` of `year`, in this calendar's numbering.
135    fn week_of(&self, year: i32, w: u32) -> Result<NaiveDate, String> {
136        let bad = || format!("week_of({year}, {w}): there's no week {w} in {year}");
137        match self.numbering {
138            WeekNumbering::Iso => {
139                let monday = NaiveDate::from_isoywd_opt(year, w, Weekday::Mon).ok_or_else(bad)?;
140                Ok(self.week_start_of(monday))
141            }
142            WeekNumbering::Us => {
143                let jan1 = NaiveDate::from_ymd_opt(year, 1, 1).ok_or_else(bad)?;
144                let start = self.week_start_of(jan1) + Days::new(7 * (w.max(1) as u64 - 1));
145                if w == 0 || start.year() > year {
146                    return Err(bad());
147                }
148                Ok(start)
149            }
150        }
151    }
152}
153
154fn err(msg: impl Into<String>) -> Error {
155    Error::new(ErrorKind::InvalidOperation, msg.into())
156}
157
158fn quarter_start(d: NaiveDate) -> NaiveDate {
159    NaiveDate::from_ymd_opt(d.year(), (d.month() - 1) / 3 * 3 + 1, 1).unwrap()
160}
161
162fn month_start(d: NaiveDate) -> NaiveDate {
163    d.with_day(1).unwrap()
164}
165
166fn year_start(d: NaiveDate) -> NaiveDate {
167    NaiveDate::from_ymd_opt(d.year(), 1, 1).unwrap()
168}
169
170fn add_months(d: NaiveDate, n: i64) -> Option<NaiveDate> {
171    if n >= 0 {
172        d.checked_add_months(Months::new(n as u32))
173    } else {
174        d.checked_sub_months(Months::new(n.unsigned_abs() as u32))
175    }
176}
177
178fn add_days(d: NaiveDate, n: i64) -> Option<NaiveDate> {
179    if n >= 0 {
180        d.checked_add_days(Days::new(n as u64))
181    } else {
182        d.checked_sub_days(Days::new(n.unsigned_abs()))
183    }
184}
185
186/// `strftime` with an error instead of a panic for a bad format string.
187fn strftime(f: impl fmt::Display) -> Result<String, Error> {
188    use fmt::Write;
189    let mut out = String::new();
190    write!(out, "{f}").map_err(|_| err("invalid date format string"))?;
191    Ok(out)
192}
193
194// ---------------------------------------------------------------------------------------------
195// Date
196// ---------------------------------------------------------------------------------------------
197
198/// Every attribute of a `Date`, for `dre validate`'s check of `run.date.<name>`.
199pub const DATE_ATTRS: &[&str] = &[
200    "year",
201    "month",
202    "day",
203    "quarter",
204    "week",
205    "week_year",
206    "weekday",
207    "day_of_year",
208    "days_in_month",
209    "prev_day",
210    "next_day",
211    "week_start",
212    "week_end",
213    "month_start",
214    "month_end",
215    "quarter_start",
216    "quarter_end",
217    "year_start",
218    "year_end",
219    "this_week",
220    "prev_week",
221    "next_week",
222    "this_month",
223    "prev_month",
224    "next_month",
225    "this_quarter",
226    "prev_quarter",
227    "next_quarter",
228    "this_year",
229    "prev_year",
230    "next_year",
231    "start",
232    "end",
233    "date",
234    "unix",
235    "unix_ms",
236    "iso",
237    "yyyymmdd",
238    "yyyymm",
239    "ddmmyyyy",
240    "yyyy",
241    "mm",
242    "dd",
243    "add",
244    "format",
245    "as_period",
246];
247
248#[derive(Debug, Clone)]
249pub struct Date {
250    d: NaiveDate,
251    cal: Calendar,
252}
253
254impl Date {
255    pub fn value(d: NaiveDate, cal: Calendar) -> Value {
256        Value::from_object(Date { d, cal })
257    }
258
259    fn at(&self, d: NaiveDate) -> Value {
260        Date::value(d, self.cal)
261    }
262
263    fn period(&self, kind: Kind, first: NaiveDate) -> Value {
264        Period::value(Period::of(kind, first, self.cal))
265    }
266}
267
268impl fmt::Display for Date {
269    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
270        write!(f, "{}", self.d.format("%Y-%m-%d"))
271    }
272}
273
274impl Object for Date {
275    fn repr(self: &Arc<Self>) -> ObjectRepr {
276        ObjectRepr::Plain
277    }
278
279    fn get_value(self: &Arc<Self>, key: &Value) -> Option<Value> {
280        let d = self.d;
281        let cal = self.cal;
282        let s = |f: &str| Some(Value::from(d.format(f).to_string()));
283        Some(match key.as_str()? {
284            "year" => Value::from(d.year()),
285            "month" => Value::from(d.month()),
286            "day" => Value::from(d.day()),
287            "quarter" => Value::from((d.month() - 1) / 3 + 1),
288            "week" => Value::from(cal.week_number(d).1),
289            "week_year" => Value::from(cal.week_number(d).0),
290            "weekday" => Value::from(d.weekday().number_from_monday()),
291            "day_of_year" => Value::from(d.ordinal()),
292            "days_in_month" => {
293                Value::from((add_months(month_start(d), 1).unwrap() - month_start(d)).num_days())
294            }
295            "prev_day" => self.at(d.pred_opt()?),
296            "next_day" => self.at(d.succ_opt()?),
297            "week_start" => self.at(cal.week_start_of(d)),
298            "week_end" => self.at(cal.week_start_of(d) + Days::new(6)),
299            "month_start" => self.at(month_start(d)),
300            "month_end" => self.at(add_months(month_start(d), 1)?.pred_opt()?),
301            "quarter_start" => self.at(quarter_start(d)),
302            "quarter_end" => self.at(add_months(quarter_start(d), 3)?.pred_opt()?),
303            "year_start" => self.at(year_start(d)),
304            "year_end" => self.at(NaiveDate::from_ymd_opt(d.year(), 12, 31)?),
305            "this_week" => self.period(Kind::Week, cal.week_start_of(d)),
306            "prev_week" => self.period(Kind::Week, cal.week_start_of(d) - Days::new(7)),
307            "next_week" => self.period(Kind::Week, cal.week_start_of(d) + Days::new(7)),
308            "this_month" => self.period(Kind::Month, month_start(d)),
309            "prev_month" => self.period(Kind::Month, add_months(month_start(d), -1)?),
310            "next_month" => self.period(Kind::Month, add_months(month_start(d), 1)?),
311            "this_quarter" => self.period(Kind::Quarter, quarter_start(d)),
312            "prev_quarter" => self.period(Kind::Quarter, add_months(quarter_start(d), -3)?),
313            "next_quarter" => self.period(Kind::Quarter, add_months(quarter_start(d), 3)?),
314            "this_year" => self.period(Kind::Year, year_start(d)),
315            "prev_year" => self.period(Kind::Year, add_months(year_start(d), -12)?),
316            "next_year" => self.period(Kind::Year, add_months(year_start(d), 12)?),
317            "start" => DateTime::value(cal.start_of(d), cal),
318            "end" => DateTime::value(cal.end_of(d), cal),
319            "date" => self.at(d),
320            "unix" => Value::from(cal.start_of(d).timestamp()),
321            "unix_ms" => Value::from(cal.start_of(d).timestamp_millis()),
322            "iso" => return s("%Y-%m-%d"),
323            "yyyymmdd" => return s("%Y%m%d"),
324            "yyyymm" => return s("%Y%m"),
325            "ddmmyyyy" => return s("%d%m%Y"),
326            "yyyy" => return s("%Y"),
327            "mm" => return s("%m"),
328            "dd" => return s("%d"),
329            _ => return None,
330        })
331    }
332
333    fn call_method(
334        self: &Arc<Self>,
335        _: &mut State<'_, '_>,
336        method: &str,
337        args: &[Value],
338    ) -> Result<Value, Error> {
339        match method {
340            "format" => {
341                let (fmt,): (&str,) = minijinja::value::from_args(args)?;
342                Ok(Value::from(strftime(self.d.format(fmt))?))
343            }
344            "add" => {
345                let (kw,): (Kwargs,) = minijinja::value::from_args(args)?;
346                let get = |k: &str| -> Result<i64, Error> { Ok(kw.get::<Option<i64>>(k)?.unwrap_or(0)) };
347                let (years, months, weeks, days) =
348                    (get("years")?, get("months")?, get("weeks")?, get("days")?);
349                kw.assert_all_used()?;
350                let d = add_months(self.d, years * 12 + months)
351                    .and_then(|d| add_days(d, weeks * 7 + days))
352                    .ok_or_else(|| err("date out of range"))?;
353                Ok(self.at(d))
354            }
355            // `d.day` is the day of the month; the one-day period is `d.as_period()`.
356            "as_period" => Ok(self.period(Kind::Day, self.d)),
357            _ => Err(Error::from(ErrorKind::UnknownMethod)),
358        }
359    }
360
361    fn render(self: &Arc<Self>, f: &mut fmt::Formatter<'_>) -> fmt::Result {
362        fmt::Display::fmt(&**self, f)
363    }
364}
365
366// ---------------------------------------------------------------------------------------------
367// DateTime
368// ---------------------------------------------------------------------------------------------
369
370#[derive(Debug, Clone)]
371pub struct DateTime {
372    t: ChronoDateTime<Tz>,
373    cal: Calendar,
374}
375
376impl DateTime {
377    pub fn value(t: ChronoDateTime<Tz>, cal: Calendar) -> Value {
378        Value::from_object(DateTime { t, cal })
379    }
380
381    /// `run.now`: an instant shown in the run's timezone.
382    pub fn now(at: ChronoDateTime<Utc>, cal: Calendar) -> Value {
383        DateTime::value(at.with_timezone(&cal.tz), cal)
384    }
385
386    fn fraction(&self) -> &'static str {
387        if self.t.nanosecond() == 0 { "" } else { ".%6f" }
388    }
389}
390
391impl fmt::Display for DateTime {
392    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
393        write!(
394            f,
395            "{}",
396            self.t.format(&format!("%Y-%m-%d %H:%M:%S{}", self.fraction()))
397        )
398    }
399}
400
401impl Object for DateTime {
402    fn repr(self: &Arc<Self>) -> ObjectRepr {
403        ObjectRepr::Plain
404    }
405
406    fn get_value(self: &Arc<Self>, key: &Value) -> Option<Value> {
407        let t = self.t;
408        Some(match key.as_str()? {
409            "date" => Date::value(t.date_naive(), self.cal),
410            "time" => Value::from(t.format(&format!("%H:%M:%S{}", self.fraction())).to_string()),
411            "utc" => DateTime::value(t.with_timezone(&Tz::UTC), self.cal),
412            "iso" => {
413                let f = if t.nanosecond() == 0 {
414                    "%Y-%m-%dT%H:%M:%S%:z"
415                } else {
416                    "%Y-%m-%dT%H:%M:%S%.6f%:z"
417                };
418                Value::from(t.format(f).to_string())
419            }
420            "unix" => Value::from(t.timestamp()),
421            "unix_ms" => Value::from(t.timestamp_millis()),
422            "timezone" => Value::from(t.timezone().name()),
423            "offset" => Value::from(t.offset().fix().to_string()),
424            "year" => Value::from(t.year()),
425            "month" => Value::from(t.month()),
426            "day" => Value::from(t.day()),
427            "hour" => Value::from(t.hour()),
428            "minute" => Value::from(t.minute()),
429            "second" => Value::from(t.second()),
430            _ => return None,
431        })
432    }
433
434    fn call_method(
435        self: &Arc<Self>,
436        _: &mut State<'_, '_>,
437        method: &str,
438        args: &[Value],
439    ) -> Result<Value, Error> {
440        match method {
441            "format" => {
442                let (fmt,): (&str,) = minijinja::value::from_args(args)?;
443                Ok(Value::from(strftime(self.t.format(fmt))?))
444            }
445            "tz" => {
446                let (name,): (&str,) = minijinja::value::from_args(args)?;
447                let tz = parse_tz(name).map_err(err)?;
448                Ok(DateTime::value(self.t.with_timezone(&tz), self.cal))
449            }
450            "add" => {
451                let (kw,): (Kwargs,) = minijinja::value::from_args(args)?;
452                let get = |k: &str| -> Result<i64, Error> { Ok(kw.get::<Option<i64>>(k)?.unwrap_or(0)) };
453                let (years, months, weeks, days) =
454                    (get("years")?, get("months")?, get("weeks")?, get("days")?);
455                let (hours, minutes, seconds) = (get("hours")?, get("minutes")?, get("seconds")?);
456                kw.assert_all_used()?;
457                // Calendar parts move the wall clock (a day later is the same clock time, even
458                // across a daylight-saving change); clock parts move the instant.
459                let moved = if years == 0 && months == 0 && weeks == 0 && days == 0 {
460                    self.t
461                } else {
462                    let local = self.t.naive_local();
463                    let day = add_months(local.date(), years * 12 + months)
464                        .and_then(|d| add_days(d, weeks * 7 + days))
465                        .ok_or_else(|| err("date out of range"))?;
466                    let wall = day.and_time(local.time());
467                    let tz = self.t.timezone();
468                    let offset = self.t.offset().fix();
469                    match tz.from_local_datetime(&wall) {
470                        LocalResult::Single(t) => t,
471                        // A clock time that happens twice: keep the offset it had, if it can.
472                        LocalResult::Ambiguous(a, b) => {
473                            if b.offset().fix() == offset {
474                                b
475                            } else {
476                                a
477                            }
478                        }
479                        // A clock time the change skips: read it with the old offset, so 02:30
480                        // becomes 03:30 when 02:00 jumps to 03:00.
481                        LocalResult::None => tz.from_utc_datetime(&(wall - offset)),
482                    }
483                };
484                let t =
485                    moved + Duration::hours(hours) + Duration::minutes(minutes) + Duration::seconds(seconds);
486                Ok(DateTime::value(t, self.cal))
487            }
488            _ => Err(Error::from(ErrorKind::UnknownMethod)),
489        }
490    }
491
492    fn render(self: &Arc<Self>, f: &mut fmt::Formatter<'_>) -> fmt::Result {
493        fmt::Display::fmt(&**self, f)
494    }
495}
496
497// ---------------------------------------------------------------------------------------------
498// Period
499// ---------------------------------------------------------------------------------------------
500
501#[derive(Debug, Clone, Copy, PartialEq, Eq)]
502enum Kind {
503    Day,
504    Week,
505    Month,
506    Quarter,
507    Year,
508    /// Any run of days (`date_range()`): `next`/`prev` move by its length.
509    Days(u64),
510}
511
512#[derive(Debug, Clone)]
513pub struct Period {
514    kind: Kind,
515    first: NaiveDate,
516    last: NaiveDate,
517    cal: Calendar,
518}
519
520impl Period {
521    fn value(p: Period) -> Value {
522        Value::from_object(p)
523    }
524
525    fn of(kind: Kind, first: NaiveDate, cal: Calendar) -> Period {
526        let last = match kind {
527            Kind::Day => first,
528            Kind::Week => first + Days::new(6),
529            Kind::Month => add_months(first, 1).unwrap() - Days::new(1),
530            Kind::Quarter => add_months(first, 3).unwrap() - Days::new(1),
531            Kind::Year => add_months(first, 12).unwrap() - Days::new(1),
532            Kind::Days(n) => first + Days::new(n - 1),
533        };
534        Period {
535            kind,
536            first,
537            last,
538            cal,
539        }
540    }
541
542    fn shift(&self, forward: bool) -> Option<Period> {
543        let first = match (self.kind, forward) {
544            (Kind::Day, f) => add_days(self.first, if f { 1 } else { -1 })?,
545            (Kind::Week, f) => add_days(self.first, if f { 7 } else { -7 })?,
546            (Kind::Month, f) => add_months(self.first, if f { 1 } else { -1 })?,
547            (Kind::Quarter, f) => add_months(self.first, if f { 3 } else { -3 })?,
548            (Kind::Year, f) => add_months(self.first, if f { 12 } else { -12 })?,
549            (Kind::Days(n), f) => add_days(self.first, if f { n as i64 } else { -(n as i64) })?,
550        };
551        Some(Period::of(self.kind, first, self.cal))
552    }
553}
554
555impl Object for Period {
556    fn repr(self: &Arc<Self>) -> ObjectRepr {
557        ObjectRepr::Plain
558    }
559
560    fn get_value(self: &Arc<Self>, key: &Value) -> Option<Value> {
561        let cal = self.cal;
562        Some(match key.as_str()? {
563            "start" => DateTime::value(cal.start_of(self.first), cal),
564            "end" => DateTime::value(cal.end_of(self.last), cal),
565            "first_day" => Date::value(self.first, cal),
566            "last_day" => Date::value(self.last, cal),
567            "next" => Period::value(self.shift(true)?),
568            "prev" => Period::value(self.shift(false)?),
569            "days" => Value::from((self.last - self.first).num_days() + 1),
570            _ => return None,
571        })
572    }
573
574    /// ISO 8601 interval notation, so a Period dropped into SQL by mistake is easy to spot.
575    fn render(self: &Arc<Self>, f: &mut fmt::Formatter<'_>) -> fmt::Result {
576        write!(
577            f,
578            "{}/{}",
579            self.first.format("%Y-%m-%d"),
580            self.last.format("%Y-%m-%d")
581        )
582    }
583}
584
585// ---------------------------------------------------------------------------------------------
586// Constructors
587// ---------------------------------------------------------------------------------------------
588
589fn parse_date(s: &str) -> Option<NaiveDate> {
590    let s = s.trim();
591    NaiveDate::parse_from_str(s, "%Y-%m-%d")
592        .or_else(|_| NaiveDate::parse_from_str(s, "%Y%m%d"))
593        .ok()
594        .or_else(|| {
595            s.get(..10)
596                .and_then(|p| NaiveDate::parse_from_str(p, "%Y-%m-%d").ok())
597        })
598}
599
600fn parse_datetime(s: &str, cal: Calendar) -> Option<ChronoDateTime<Tz>> {
601    let s = s.trim();
602    if let Ok(t) = ChronoDateTime::parse_from_rfc3339(s) {
603        return Some(t.with_timezone(&cal.tz));
604    }
605    for f in [
606        "%Y-%m-%d %H:%M:%S%.f%:z",
607        "%Y-%m-%d %H:%M:%S%:z",
608        "%Y-%m-%d %H:%M%:z",
609    ] {
610        if let Ok(t) = ChronoDateTime::parse_from_str(s, f) {
611            return Some(t.with_timezone(&cal.tz));
612        }
613    }
614    for f in [
615        "%Y-%m-%d %H:%M:%S%.f",
616        "%Y-%m-%dT%H:%M:%S%.f",
617        "%Y-%m-%d %H:%M",
618        "%Y-%m-%dT%H:%M",
619    ] {
620        if let Ok(t) = NaiveDateTime::parse_from_str(s, f) {
621            return cal.tz.from_local_datetime(&t).earliest();
622        }
623    }
624    NaiveDate::parse_from_str(s, "%Y-%m-%d")
625        .ok()
626        .map(|d| cal.start_of(d))
627}
628
629/// A Date from a value: a Date, a DateTime (its day), or a string.
630fn to_date(v: &Value, cal: Calendar) -> Result<NaiveDate, Error> {
631    if let Some(d) = v.downcast_object_ref::<Date>() {
632        return Ok(d.d);
633    }
634    if let Some(t) = v.downcast_object_ref::<DateTime>() {
635        return Ok(t.t.date_naive());
636    }
637    let _ = cal;
638    v.as_str()
639        .and_then(parse_date)
640        .ok_or_else(|| err(format!("`{v}` isn't a date; use `YYYY-MM-DD`")))
641}
642
643fn to_datetime(v: &Value, cal: Calendar) -> Result<ChronoDateTime<Tz>, Error> {
644    if let Some(t) = v.downcast_object_ref::<DateTime>() {
645        return Ok(t.t);
646    }
647    if let Some(d) = v.downcast_object_ref::<Date>() {
648        return Ok(cal.start_of(d.d));
649    }
650    v.as_str().and_then(|s| parse_datetime(s, cal)).ok_or_else(|| {
651        err(format!(
652            "`{v}` isn't a date and time; use `YYYY-MM-DD HH:MM:SS`, optionally with an offset"
653        ))
654    })
655}
656
657fn ymd(y: i32, m: u32, d: u32) -> Result<NaiveDate, Error> {
658    NaiveDate::from_ymd_opt(y, m, d).ok_or_else(|| err(format!("{y}-{m:02}-{d:02} isn't a date")))
659}
660
661/// The named periods `period()` knows.
662pub const PERIOD_PRESETS: &[&str] = &[
663    "today",
664    "yesterday",
665    "this_week",
666    "last_week",
667    "this_month",
668    "last_month",
669    "mtd",
670    "this_quarter",
671    "last_quarter",
672    "qtd",
673    "this_year",
674    "last_year",
675    "ytd",
676    "last_n_days",
677    "last_n_months",
678];
679
680/// A named period relative to `as_of`: `last_month`, `mtd`, `last_n_days` (with `n`), ...
681/// `mtd`, `qtd` and `ytd` run through `as_of` itself; `last_n_days` ends the day before it.
682fn preset(name: &str, as_of: NaiveDate, n: Option<i64>, cal: Calendar) -> Result<Period, String> {
683    let d = as_of;
684    let week = cal.week_start_of(d);
685    let range = |first: NaiveDate, last: NaiveDate| {
686        Period::of(Kind::Days((last - first).num_days() as u64 + 1), first, cal)
687    };
688    let need_n = || match n {
689        Some(n) if n > 0 => Ok(n),
690        _ => Err(format!(
691            "period('{name}') needs a positive `n`, e.g. period('{name}', n=7)"
692        )),
693    };
694    let bad = || "date out of range".to_string();
695    Ok(match name {
696        "today" => Period::of(Kind::Day, d, cal),
697        "yesterday" => Period::of(Kind::Day, d.pred_opt().ok_or_else(bad)?, cal),
698        "this_week" => Period::of(Kind::Week, week, cal),
699        "last_week" => Period::of(Kind::Week, week - Days::new(7), cal),
700        "this_month" => Period::of(Kind::Month, month_start(d), cal),
701        "last_month" => Period::of(Kind::Month, add_months(month_start(d), -1).ok_or_else(bad)?, cal),
702        "mtd" => range(month_start(d), d),
703        "this_quarter" => Period::of(Kind::Quarter, quarter_start(d), cal),
704        "last_quarter" => Period::of(
705            Kind::Quarter,
706            add_months(quarter_start(d), -3).ok_or_else(bad)?,
707            cal,
708        ),
709        "qtd" => range(quarter_start(d), d),
710        "this_year" => Period::of(Kind::Year, year_start(d), cal),
711        "last_year" => Period::of(Kind::Year, add_months(year_start(d), -12).ok_or_else(bad)?, cal),
712        "ytd" => range(year_start(d), d),
713        "last_n_days" => {
714            let n = need_n()?;
715            range(add_days(d, -n).ok_or_else(bad)?, d.pred_opt().ok_or_else(bad)?)
716        }
717        "last_n_months" => {
718            let n = need_n()?;
719            let first = add_months(month_start(d), -n).ok_or_else(bad)?;
720            range(first, month_start(d).pred_opt().ok_or_else(bad)?)
721        }
722        _ => {
723            return Err(format!(
724                "period('{name}'): unknown period; use one of {}",
725                PERIOD_PRESETS.join(", ")
726            ));
727        }
728    })
729}
730
731/// Add `date()`, `datetime()`, `period()`, `date_range()`, `month_of()`, `quarter_of()`,
732/// `year_of()`, `week_of()` and the `as_date` / `as_datetime` filters. `run_date` is what
733/// `period()` counts from by default.
734pub fn register(env: &mut Environment<'static>, cal: Calendar, run_date: NaiveDate) {
735    env.add_function(
736        "period",
737        move |name: String, kwargs: Kwargs| -> Result<Value, Error> {
738            let as_of: Option<Value> = kwargs.get("as_of")?;
739            let n: Option<i64> = kwargs.get("n")?;
740            kwargs.assert_all_used()?;
741            let d = match as_of {
742                Some(v) => to_date(&v, cal)?,
743                None => run_date,
744            };
745            Ok(Period::value(preset(&name, d, n, cal).map_err(err)?))
746        },
747    );
748    env.add_function(
749        "date",
750        move |a: Value, m: Option<u32>, d: Option<u32>| -> Result<Value, Error> {
751            match (m, d) {
752                (Some(m), Some(d)) => {
753                    let y =
754                        i32::try_from(a).map_err(|_| err("date(year, month, day) takes three numbers"))?;
755                    Ok(Date::value(ymd(y, m, d)?, cal))
756                }
757                (None, None) => Ok(Date::value(to_date(&a, cal)?, cal)),
758                _ => Err(err("date() takes a `YYYY-MM-DD` string or (year, month, day)")),
759            }
760        },
761    );
762    env.add_function("datetime", move |a: Value| -> Result<Value, Error> {
763        Ok(DateTime::value(to_datetime(&a, cal)?, cal))
764    });
765    env.add_filter("as_date", move |a: Value| -> Result<Value, Error> {
766        Ok(Date::value(to_date(&a, cal)?, cal))
767    });
768    env.add_filter("as_datetime", move |a: Value| -> Result<Value, Error> {
769        Ok(DateTime::value(to_datetime(&a, cal)?, cal))
770    });
771    env.add_function("date_range", move |a: Value, b: Value| -> Result<Value, Error> {
772        let (first, last) = (to_date(&a, cal)?, to_date(&b, cal)?);
773        if last < first {
774            return Err(err(format!(
775                "date_range({first}, {last}): the end is before the start"
776            )));
777        }
778        let n = (last - first).num_days() as u64 + 1;
779        Ok(Period::value(Period::of(Kind::Days(n), first, cal)))
780    });
781    env.add_function("month_of", move |y: i32, m: u32| -> Result<Value, Error> {
782        Ok(Period::value(Period::of(Kind::Month, ymd(y, m, 1)?, cal)))
783    });
784    env.add_function("quarter_of", move |y: i32, q: u32| -> Result<Value, Error> {
785        if !(1..=4).contains(&q) {
786            return Err(err(format!("quarter_of({y}, {q}): the quarter must be 1 to 4")));
787        }
788        Ok(Period::value(Period::of(
789            Kind::Quarter,
790            ymd(y, (q - 1) * 3 + 1, 1)?,
791            cal,
792        )))
793    });
794    env.add_function("year_of", move |y: i32| -> Result<Value, Error> {
795        Ok(Period::value(Period::of(Kind::Year, ymd(y, 1, 1)?, cal)))
796    });
797    env.add_function("week_of", move |y: i32, w: u32| -> Result<Value, Error> {
798        Ok(Period::value(Period::of(
799            Kind::Week,
800            cal.week_of(y, w).map_err(err)?,
801            cal,
802        )))
803    });
804}
805
806#[cfg(test)]
807mod tests {
808    use super::*;
809
810    fn cal(tz: &str) -> Calendar {
811        Calendar {
812            tz: parse_tz(tz).unwrap(),
813            ..Calendar::default()
814        }
815    }
816
817    fn render_with(c: Calendar, today: &str, src: &str) -> String {
818        let mut env = Environment::new();
819        env.set_undefined_behavior(minijinja::UndefinedBehavior::Strict);
820        let today = parse_date(today).unwrap();
821        register(&mut env, c, today);
822        let d = Date::value(today, c);
823        env.render_str(src, minijinja::context! { d => d })
824            .unwrap_or_else(|e| format!("ERROR: {e:#}"))
825    }
826
827    fn render(today: &str, src: &str) -> String {
828        render_with(Calendar::default(), today, src)
829    }
830
831    #[test]
832    fn date_navigation_table() {
833        let cases = [
834            ("2026-03-15", "{{ d }}", "2026-03-15"),
835            (
836                "2026-03-15",
837                "{{ d.prev_day }} {{ d.next_day }}",
838                "2026-03-14 2026-03-16",
839            ),
840            ("2026-03-01", "{{ d.prev_day }}", "2026-02-28"),
841            ("2024-03-01", "{{ d.prev_day }}", "2024-02-29"),
842            ("2026-01-31", "{{ d.add(months=1) }}", "2026-02-28"),
843            ("2024-01-31", "{{ d.add(months=1) }}", "2024-02-29"),
844            (
845                "2024-02-29",
846                "{{ d.add(years=1) }} {{ d.add(years=-4) }}",
847                "2025-02-28 2020-02-29",
848            ),
849            (
850                "2026-03-15",
851                "{{ d.add(days=-15) }} {{ d.add(weeks=2) }}",
852                "2026-02-28 2026-03-29",
853            ),
854            (
855                "2026-03-15",
856                "{{ d.month_start }} {{ d.month_end }}",
857                "2026-03-01 2026-03-31",
858            ),
859            (
860                "2026-02-10",
861                "{{ d.month_end }} {{ d.days_in_month }}",
862                "2026-02-28 28",
863            ),
864            (
865                "2026-05-20",
866                "{{ d.quarter }} {{ d.quarter_start }} {{ d.quarter_end }}",
867                "2 2026-04-01 2026-06-30",
868            ),
869            (
870                "2026-05-20",
871                "{{ d.year_start }} {{ d.year_end }} {{ d.day_of_year }}",
872                "2026-01-01 2026-12-31 140",
873            ),
874            // 2026-03-15 is a Sunday.
875            (
876                "2026-03-15",
877                "{{ d.weekday }} {{ d.week_start }} {{ d.week_end }}",
878                "7 2026-03-09 2026-03-15",
879            ),
880            (
881                "2026-03-15",
882                "{{ d.year }}-{{ d.month }}-{{ d.day }}",
883                "2026-3-15",
884            ),
885            (
886                "2026-03-15",
887                "{{ d.yyyymmdd }} {{ d.ddmmyyyy }} {{ d.yyyy }}{{ d.mm }}{{ d.dd }} {{ d.iso }}",
888                "20260315 15032026 20260315 2026-03-15",
889            ),
890            ("2026-03-15", "{{ d.format('%d/%m/%Y') }}", "15/03/2026"),
891        ];
892        for (today, src, want) in cases {
893            assert_eq!(render(today, src), want, "{src} on {today}");
894        }
895    }
896
897    #[test]
898    fn periods_table() {
899        let p = |x: &str| format!("{{{{ {x}.start.date }}}}..{{{{ {x}.end.date }}}} {{{{ {x}.days }}}}");
900        let cases = [
901            ("2026-03-15", p("d.prev_month"), "2026-02-01..2026-02-28 28"),
902            ("2026-01-10", p("d.prev_month"), "2025-12-01..2025-12-31 31"),
903            ("2026-12-10", p("d.next_month"), "2027-01-01..2027-01-31 31"),
904            ("2026-03-15", p("d.this_month"), "2026-03-01..2026-03-31 31"),
905            ("2026-02-15", p("d.prev_quarter"), "2025-10-01..2025-12-31 92"),
906            ("2026-02-15", p("d.next_quarter"), "2026-04-01..2026-06-30 91"),
907            ("2026-02-15", p("d.prev_year"), "2025-01-01..2025-12-31 365"),
908            ("2024-02-15", p("d.this_year"), "2024-01-01..2024-12-31 366"),
909            ("2026-03-15", p("d.prev_week"), "2026-03-02..2026-03-08 7"),
910            ("2026-03-15", p("d.next_week"), "2026-03-16..2026-03-22 7"),
911            ("2026-03-15", p("d.as_period()"), "2026-03-15..2026-03-15 1"),
912            ("2026-03-15", p("d.prev_month.prev"), "2026-01-01..2026-01-31 31"),
913            ("2026-03-15", p("d.prev_month.next"), "2026-03-01..2026-03-31 31"),
914            ("2026-03-15", p("month_of(2024, 2)"), "2024-02-01..2024-02-29 29"),
915            (
916                "2026-03-15",
917                p("quarter_of(2026, 4)"),
918                "2026-10-01..2026-12-31 92",
919            ),
920            ("2026-03-15", p("year_of(2025)"), "2025-01-01..2025-12-31 365"),
921            (
922                "2026-03-15",
923                p("date_range('2026-01-05', '2026-01-11').next"),
924                "2026-01-12..2026-01-18 7",
925            ),
926            ("2026-03-15", "{{ d.prev_month }}".into(), "2026-02-01/2026-02-28"),
927            (
928                "2026-03-15",
929                "{{ d.prev_month.start }} {{ d.prev_month.end }}".into(),
930                "2026-02-01 00:00:00 2026-02-28 23:59:59.999999",
931            ),
932            (
933                "2026-03-15",
934                "{{ d.prev_month.next.start }}".into(),
935                "2026-03-01 00:00:00",
936            ),
937            (
938                "2026-03-15",
939                "{{ d.prev_month.first_day }} {{ d.prev_month.last_day }}".into(),
940                "2026-02-01 2026-02-28",
941            ),
942        ];
943        for (today, src, want) in cases {
944            assert_eq!(render(today, &src), want, "{src} on {today}");
945        }
946    }
947
948    #[test]
949    fn iso_and_us_week_numbers() {
950        let iso = Calendar::default();
951        let us = Calendar {
952            week_start: WeekStart::Sunday,
953            numbering: WeekNumbering::Us,
954            ..Calendar::default()
955        };
956        let w = "{{ d.week_year }}-W{{ d.week }}";
957        // ISO: 2021-01-03 (Sunday) is in 2020's week 53; 2024-12-30 is in 2025's week 1.
958        assert_eq!(render_with(iso, "2021-01-03", w), "2020-W53");
959        assert_eq!(render_with(iso, "2021-01-04", w), "2021-W1");
960        assert_eq!(render_with(iso, "2024-12-30", w), "2025-W1");
961        // US: the week holding 1 January is week 1, weeks start on Sunday.
962        assert_eq!(render_with(us, "2021-01-02", w), "2021-W1");
963        assert_eq!(render_with(us, "2021-01-03", w), "2021-W2");
964        assert_eq!(render_with(us, "2024-12-31", w), "2024-W53");
965        let wk = "{{ week_of(2026, 1).start.date }} {{ week_of(2026, 1).end.date }}";
966        assert_eq!(render_with(iso, "2026-03-15", wk), "2025-12-29 2026-01-04");
967        assert_eq!(render_with(us, "2026-03-15", wk), "2025-12-28 2026-01-03");
968        assert_eq!(
969            render_with(iso, "2026-03-15", "{{ week_of(2020, 53).start.date }}"),
970            "2020-12-28"
971        );
972        assert!(render_with(iso, "2026-03-15", "{{ week_of(2021, 53) }}").starts_with("ERROR"));
973        // Sunday weeks move week_start/week_end and week periods.
974        assert_eq!(
975            render_with(
976                us,
977                "2026-03-18",
978                "{{ d.week_start }} {{ d.week_end }} {{ d.prev_week.start.date }}"
979            ),
980            "2026-03-15 2026-03-21 2026-03-08"
981        );
982    }
983
984    #[test]
985    fn constructors_and_errors() {
986        assert_eq!(
987            render(
988                "2026-03-15",
989                "{{ date('2026-07-04') }} {{ date(2026, 7, 4).weekday }}"
990            ),
991            "2026-07-04 6"
992        );
993        assert_eq!(
994            render("2026-03-15", "{{ ('2026-07-04' | as_date).month_end }}"),
995            "2026-07-31"
996        );
997        assert_eq!(render("2026-03-15", "{{ '20260704' | as_date }}"), "2026-07-04");
998        assert!(render("2026-03-15", "{{ date('2026-02-30') }}").contains("isn't a date"));
999        assert!(render("2026-03-15", "{{ date(2026, 2, 30) }}").contains("isn't a date"));
1000        assert!(render("2026-03-15", "{{ 'soon' | as_date }}").contains("isn't a date"));
1001        assert!(render("2026-03-15", "{{ quarter_of(2026, 5) }}").contains("1 to 4"));
1002        assert!(
1003            render("2026-03-15", "{{ date_range('2026-02-01', '2026-01-01') }}").contains("before the start")
1004        );
1005        assert!(render("2026-03-15", "{{ d.add(fortnights=1) }}").starts_with("ERROR"));
1006        assert!(render("2026-03-15", "{{ d.nope }}").starts_with("ERROR"));
1007    }
1008
1009    #[test]
1010    fn datetimes_timezones_and_unix() {
1011        let syd = cal("Australia/Sydney");
1012        // Sydney is UTC+11 in March (daylight time).
1013        assert_eq!(
1014            render_with(
1015                syd,
1016                "2026-03-15",
1017                "{{ d.start }} | {{ d.start.utc }} | {{ d.start.iso }}"
1018            ),
1019            "2026-03-15 00:00:00 | 2026-03-14 13:00:00 | 2026-03-15T00:00:00+11:00"
1020        );
1021        assert_eq!(
1022            render_with(
1023                syd,
1024                "2026-03-15",
1025                "{{ d.unix }} {{ d.start.unix }} {{ d.start.utc.unix }} {{ d.unix_ms }}"
1026            ),
1027            "1773493200 1773493200 1773493200 1773493200000"
1028        );
1029        assert_eq!(render("2026-03-15", "{{ d.unix }}"), "1773532800");
1030        assert_eq!(
1031            render_with(syd, "2026-03-15", "{{ d.end.iso }}"),
1032            "2026-03-15T23:59:59.999999+11:00"
1033        );
1034        assert_eq!(
1035            render_with(
1036                syd,
1037                "2026-03-15",
1038                "{{ d.start.tz('Europe/London') }} {{ d.start.tz('Europe/London').offset }}"
1039            ),
1040            "2026-03-14 13:00:00 +00:00"
1041        );
1042        assert!(render_with(syd, "2026-03-15", "{{ d.start.tz('Mars/Base') }}").contains("isn't a timezone"));
1043        // 2026-04-05: Sydney leaves daylight time at 03:00, so the day has 25 hours.
1044        assert_eq!(
1045            render_with(
1046                syd,
1047                "2026-04-05",
1048                "{{ d.end.unix - d.start.unix + 1 }} {{ d.next_day.start.iso }}"
1049            ),
1050            "90000 2026-04-06T00:00:00+10:00"
1051        );
1052        // Datetime strings: in the run zone unless they carry an offset.
1053        assert_eq!(
1054            render_with(syd, "2026-03-15", "{{ datetime('2026-03-15 10:30:00').utc }}"),
1055            "2026-03-14 23:30:00"
1056        );
1057        assert_eq!(
1058            render_with(syd, "2026-03-15", "{{ datetime('2026-03-15T10:30:00Z') }}"),
1059            "2026-03-15 21:30:00"
1060        );
1061        assert_eq!(
1062            render_with(syd, "2026-03-15", "{{ datetime('2026-03-15 10:30:00.25').time }}"),
1063            "10:30:00.250000"
1064        );
1065        assert_eq!(
1066            render_with(
1067                syd,
1068                "2026-03-15",
1069                "{{ ('2026-03-15 10:30' | as_datetime).add(hours=2, days=1) }}"
1070            ),
1071            "2026-03-16 12:30:00"
1072        );
1073        assert_eq!(
1074            render_with(
1075                syd,
1076                "2026-03-15",
1077                "{{ datetime('2026-03-15 10:30').format('%H%M') }} {{ datetime('2026-03-15 10:30').date.prev_day }}"
1078            ),
1079            "1030 2026-03-14"
1080        );
1081        assert!(render_with(syd, "2026-03-15", "{{ datetime('later') }}").contains("isn't a date and time"));
1082    }
1083
1084    #[test]
1085    fn period_presets() {
1086        let p = |x: &str| format!("{{% set p = {x} %}}{{{{ p.start.date }}}}..{{{{ p.end.date }}}}");
1087        // 2026-03-18 is a Wednesday.
1088        let cases = [
1089            ("period('today')", "2026-03-18..2026-03-18"),
1090            ("period('yesterday')", "2026-03-17..2026-03-17"),
1091            ("period('this_week')", "2026-03-16..2026-03-22"),
1092            ("period('last_week')", "2026-03-09..2026-03-15"),
1093            ("period('this_month')", "2026-03-01..2026-03-31"),
1094            ("period('last_month')", "2026-02-01..2026-02-28"),
1095            ("period('mtd')", "2026-03-01..2026-03-18"),
1096            ("period('this_quarter')", "2026-01-01..2026-03-31"),
1097            ("period('last_quarter')", "2025-10-01..2025-12-31"),
1098            ("period('qtd')", "2026-01-01..2026-03-18"),
1099            ("period('this_year')", "2026-01-01..2026-12-31"),
1100            ("period('last_year')", "2025-01-01..2025-12-31"),
1101            ("period('ytd')", "2026-01-01..2026-03-18"),
1102            ("period('last_n_days', n=7)", "2026-03-11..2026-03-17"),
1103            ("period('last_n_months', n=3)", "2025-12-01..2026-02-28"),
1104            // Year boundary, and an explicit as_of.
1105            (
1106                "period('last_month', as_of='2026-01-10')",
1107                "2025-12-01..2025-12-31",
1108            ),
1109            ("period('ytd', as_of=date(2026, 1, 1))", "2026-01-01..2026-01-01"),
1110            (
1111                "period('last_week', as_of='2026-01-01')",
1112                "2025-12-22..2025-12-28",
1113            ),
1114        ];
1115        for (x, want) in cases {
1116            assert_eq!(render("2026-03-18", &p(x)), want, "{x}");
1117        }
1118        let e = render("2026-03-18", "{{ period('fortnight') }}");
1119        assert!(e.contains("unknown period; use one of today, yesterday"), "{e}");
1120        assert!(render("2026-03-18", "{{ period('last_n_days') }}").contains("needs a positive `n`"));
1121        assert!(render("2026-03-18", "{{ period('last_n_days', n=0) }}").contains("needs a positive `n`"));
1122    }
1123
1124    #[test]
1125    fn datetime_add_across_daylight_saving_changes() {
1126        let ny = cal("America/New_York");
1127        // 2026-03-08 02:30 doesn't exist in New York: the clocks skip from 02:00 to 03:00.
1128        assert_eq!(
1129            render_with(
1130                ny,
1131                "2026-03-07",
1132                "{{ datetime('2026-03-07 02:30').add(days=1).iso }}"
1133            ),
1134            "2026-03-08T03:30:00-04:00"
1135        );
1136        // 2026-11-01 01:30 happens twice; an hour after the first is the second.
1137        assert_eq!(
1138            render_with(
1139                ny,
1140                "2026-11-01",
1141                "{% set t = datetime('2026-11-01 01:30') %}{{ t.iso }} {{ t.add(hours=1).iso }} {{ t.add(hours=1).unix - t.unix }}"
1142            ),
1143            "2026-11-01T01:30:00-04:00 2026-11-01T01:30:00-05:00 3600"
1144        );
1145        // A day later from the second 01:30 keeps the clock time.
1146        assert_eq!(
1147            render_with(
1148                ny,
1149                "2026-11-01",
1150                "{{ datetime('2026-11-01 01:30').add(hours=1).add(days=1).iso }}"
1151            ),
1152            "2026-11-02T01:30:00-05:00"
1153        );
1154    }
1155
1156    #[test]
1157    fn dst_gap_starts_at_first_valid_instant() {
1158        // Santiago springs forward at midnight: 2026-09-06 begins at 01:00.
1159        let c = cal("America/Santiago");
1160        assert_eq!(
1161            render_with(c, "2026-09-06", "{{ d.start }}"),
1162            "2026-09-06 01:00:00"
1163        );
1164    }
1165
1166    #[test]
1167    fn now_renders_in_the_run_zone() {
1168        let at = Utc.with_ymd_and_hms(2026, 3, 14, 20, 0, 0).unwrap();
1169        let v = DateTime::now(at, cal("Australia/Sydney"));
1170        assert_eq!(v.to_string(), "2026-03-15 07:00:00");
1171    }
1172}