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