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, 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#[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#[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
88pub 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 pub fn today(&self) -> NaiveDate {
98 Utc::now().with_timezone(&self.tz).date_naive()
99 }
100
101 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 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 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 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
186fn 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
194pub 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 "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#[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 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 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 LocalResult::Ambiguous(a, b) => {
473 if b.offset().fix() == offset {
474 b
475 } else {
476 a
477 }
478 }
479 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
502enum Kind {
503 Day,
504 Week,
505 Month,
506 Quarter,
507 Year,
508 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 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
585fn 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
629fn 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
661pub 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
680fn 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
731pub 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 (
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 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 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 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 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 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 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 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 (
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 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 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 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 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}