1use std::fmt;
41use std::str::FromStr;
42
43use chrono::{DateTime, Datelike, NaiveDate, NaiveDateTime, NaiveTime, TimeZone};
44use chrono_tz::{OffsetComponents, Tz};
45use serde::{Deserialize, Serialize};
46
47const NS: i64 = 1;
48const US: i64 = 1_000;
49const MS: i64 = 1_000_000;
50const SEC: i64 = 1_000_000_000;
51const MIN: i64 = 60 * SEC;
52const HOUR: i64 = 60 * MIN;
53const MINUTE_NS: i64 = MIN;
54
55const DAY_ANCHOR: i64 = 4;
57
58const MAX_GAP_PROBES: i64 = 180;
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
64pub enum BucketWidth {
65 Fixed(i64),
67 Days(u32),
70 Months(u32),
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
76#[error("{0}")]
77pub struct BucketParseError(pub String);
78
79impl BucketWidth {
80 #[must_use]
87 pub fn nominal_ns(self) -> i64 {
88 match self {
89 Self::Fixed(n) => n,
90 Self::Days(n) => i64::from(n).saturating_mul(24 * HOUR),
91 Self::Months(n) => i64::from(n).saturating_mul(2_629_746 * SEC),
94 }
95 }
96
97 #[must_use]
99 pub fn is_calendar(self) -> bool {
100 !matches!(self, Self::Fixed(_))
101 }
102}
103
104impl fmt::Display for BucketWidth {
105 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110 match *self {
111 Self::Fixed(n) => {
112 for (unit, suffix) in [(HOUR, "h"), (MIN, "m"), (SEC, "s"), (MS, "ms"), (US, "us")]
113 {
114 if n % unit == 0 {
115 return write!(f, "{}{suffix}", n / unit);
116 }
117 }
118 write!(f, "{n}ns")
119 }
120 Self::Days(n) if n % 7 == 0 => write!(f, "{}w", n / 7),
121 Self::Days(n) => write!(f, "{n}d"),
122 Self::Months(n) if n % 12 == 0 => write!(f, "{}y", n / 12),
123 Self::Months(n) => write!(f, "{n}mo"),
124 }
125 }
126}
127
128impl FromStr for BucketWidth {
129 type Err = BucketParseError;
130
131 fn from_str(s: &str) -> Result<Self, Self::Err> {
138 let s = s.trim();
139 if s.is_empty() {
140 return Err(BucketParseError("empty bucket width".into()));
141 }
142 let split = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len());
143 let (num_str, suffix) = s.split_at(split);
144 let n: i64 = num_str.parse().map_err(|_| {
145 BucketParseError(format!(
146 "'{s}' does not start with a number — write a width like 15m, 1h, 1d, 1mo"
147 ))
148 })?;
149 if n <= 0 {
150 return Err(BucketParseError(format!(
151 "a bucket width must be positive, got '{s}'"
152 )));
153 }
154 let suffix = suffix.trim();
155 let mul = |unit: i64| -> Result<Self, BucketParseError> {
156 n.checked_mul(unit).map(Self::Fixed).ok_or_else(|| {
157 BucketParseError(format!("bucket width '{s}' overflows the nanosecond range"))
158 })
159 };
160 let count = |per: i64| -> Result<u32, BucketParseError> {
161 u32::try_from(n.saturating_mul(per))
162 .map_err(|_| BucketParseError(format!("bucket width '{s}' is too large")))
163 };
164 match suffix {
172 "ns" | "nanosecond" | "nanoseconds" => mul(NS),
173 "us" | "µs" | "microsecond" | "microseconds" => mul(US),
174 "ms" | "millisecond" | "milliseconds" => mul(MS),
175 "s" | "sec" | "secs" | "second" | "seconds" => mul(SEC),
176 "m" | "min" | "mins" | "minute" | "minutes" => mul(MIN),
177 "h" | "hr" | "hrs" | "hour" | "hours" => mul(HOUR),
178 "d" | "day" | "days" => count(1).map(Self::Days),
179 "w" | "week" | "weeks" => count(7).map(Self::Days),
180 "mo" | "month" | "months" => count(1).map(Self::Months),
181 "y" | "yr" | "yrs" | "year" | "years" => count(12).map(Self::Months),
182 "" => Err(BucketParseError(format!(
183 "'{s}' has no unit — write a width like 15m, 1h, 1d, 1mo"
184 ))),
185 other => Err(BucketParseError(format!(
186 "unknown bucket unit '{other}'. Use ns, us, ms, s, m/min, h, d, w, mo, y — \
187 or their long forms (second, minute, hour, day, week, month, year) \
188 (the month is 'mo'; 'm' is always the minute)"
189 ))),
190 }
191 }
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198pub struct TimeBucket {
199 width: BucketWidth,
200 tz: Option<Tz>,
201 origin: Option<i64>,
204}
205
206#[derive(Serialize, Deserialize)]
217struct TimeBucketRepr {
218 width: BucketWidth,
219 timezone: Option<String>,
220 origin: Option<i64>,
223}
224
225impl Serialize for TimeBucket {
226 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
227 TimeBucketRepr {
228 width: self.width,
229 timezone: self.tz.map(|tz| tz.name().to_owned()),
230 origin: self.origin,
231 }
232 .serialize(s)
233 }
234}
235
236impl<'de> Deserialize<'de> for TimeBucket {
237 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
238 let repr = TimeBucketRepr::deserialize(d)?;
239 let tz = match repr.timezone {
240 None => None,
241 Some(name) => Some(name.parse::<Tz>().map_err(serde::de::Error::custom)?),
242 };
243 let bucket = Self {
244 width: repr.width,
245 tz,
246 origin: None,
247 };
248 match repr.origin {
252 None => Ok(bucket),
253 Some(origin) => bucket.with_origin(origin).map_err(serde::de::Error::custom),
254 }
255 }
256}
257
258impl TimeBucket {
259 #[must_use]
261 pub const fn utc(width: BucketWidth) -> Self {
262 Self {
263 width,
264 tz: None,
265 origin: None,
266 }
267 }
268
269 #[must_use]
271 pub const fn fixed_ns(ns: i64) -> Self {
272 Self::utc(BucketWidth::Fixed(ns))
273 }
274
275 #[must_use]
284 pub fn fixed(span: std::time::Duration) -> Self {
285 Self::fixed_ns(i64::try_from(span.as_nanos()).unwrap_or(i64::MAX))
286 }
287
288 #[must_use]
290 pub const fn in_zone(mut self, tz: Tz) -> Self {
291 self.tz = Some(tz);
292 self
293 }
294
295 pub fn with_origin(mut self, origin: i64) -> Result<Self, BucketParseError> {
328 if matches!(self.width, BucketWidth::Months(_)) {
329 let day = self.local_datetime(origin).map_or(1, |dt| dt.day());
330 if day > 28 {
331 return Err(BucketParseError(format!(
332 "a monthly bucket cannot start on day {day}: that day is missing from some months, so it is not a monthly boundary. Use 1 to 28."
333 )));
334 }
335 }
336 self.origin = Some(origin);
337 Ok(self)
338 }
339
340 pub fn with_origin_str(self, text: &str) -> Result<Self, BucketParseError> {
357 let text = text.trim();
358 let ns = if let Ok(dt) = DateTime::parse_from_rfc3339(text) {
359 dt.timestamp_nanos_opt()
360 } else {
361 let naive = chrono::NaiveDateTime::parse_from_str(text, "%Y-%m-%d %H:%M:%S")
362 .or_else(|_| chrono::NaiveDateTime::parse_from_str(text, "%Y-%m-%dT%H:%M:%S"))
363 .or_else(|_| {
364 NaiveDate::parse_from_str(text, "%Y-%m-%d").map(|d| d.and_time(NaiveTime::MIN))
365 })
366 .map_err(|_| {
367 BucketParseError(format!(
368 "could not read '{text}' as an origin — write it as '2024-01-15', \
369 '2024-01-15 06:00:00', or an RFC 3339 instant"
370 ))
371 })?;
372 match self.tz {
373 None => naive.and_utc().timestamp_nanos_opt(),
374 Some(tz) => match tz.from_local_datetime(&naive) {
377 chrono::LocalResult::Single(dt) => dt.timestamp_nanos_opt(),
378 chrono::LocalResult::Ambiguous(earliest, _) => earliest.timestamp_nanos_opt(),
379 chrono::LocalResult::None => {
380 return Err(BucketParseError(format!(
381 "the local time '{text}' does not exist in {} — \
382 it falls in a daylight-saving gap",
383 tz.name()
384 )));
385 }
386 },
387 }
388 };
389 let ns = ns.ok_or_else(|| {
390 BucketParseError(format!(
391 "origin '{text}' is outside the representable range"
392 ))
393 })?;
394 self.with_origin(ns)
395 }
396
397 #[must_use]
399 pub const fn origin(&self) -> Option<i64> {
400 self.origin
401 }
402
403 pub fn parse(width: &str, timezone: Option<&str>) -> Result<Self, BucketParseError> {
408 let width: BucketWidth = width.parse()?;
409 let tz = match timezone.map(str::trim).filter(|s| !s.is_empty()) {
410 None => None,
411 Some(name) => Some(name.parse::<Tz>().map_err(|_| {
412 BucketParseError(format!(
413 "unknown time zone '{name}' — use an IANA name like Europe/Berlin"
414 ))
415 })?),
416 };
417 Ok(Self {
418 width,
419 tz,
420 origin: None,
421 })
422 }
423
424 #[must_use]
426 pub const fn width(&self) -> BucketWidth {
427 self.width
428 }
429
430 #[must_use]
432 pub fn timezone(&self) -> Option<&'static str> {
433 self.tz.map(chrono_tz::Tz::name)
434 }
435
436 #[must_use]
438 pub fn nominal_ns(&self) -> i64 {
439 self.width.nominal_ns()
440 }
441
442 #[must_use]
447 pub fn is_calendar(&self) -> bool {
448 self.width.is_calendar()
449 }
450
451 #[must_use]
453 pub fn start_of(&self, ts: i64) -> i64 {
454 match self.width {
455 BucketWidth::Fixed(n) => {
456 let n = n.max(1);
457 let anchor = self.origin.unwrap_or_else(|| self.standard_offset_ns(ts));
466 ts.saturating_sub(ts.wrapping_sub(anchor).rem_euclid(n))
467 }
468 BucketWidth::Days(n) => {
469 let n = i64::from(n.max(1));
470 let Some(local) = self.local_datetime(ts) else {
471 return ts;
472 };
473 let (anchor_day, tod) = self.day_phase();
474 let mut days = days_from_epoch(local.date());
477 if local.time() < tod {
478 days -= 1;
479 }
480 let start = (days - anchor_day).div_euclid(n) * n + anchor_day;
481 self.local_at(date_from_epoch_days(start), tod, ts)
482 }
483 BucketWidth::Months(n) => {
484 let n = i64::from(n.max(1));
485 let Some(local) = self.local_datetime(ts) else {
486 return ts;
487 };
488 let (anchor_month, dom, tod) = self.month_phase();
489 let mut months = i64::from(local.year() - 1970) * 12 + i64::from(local.month0());
490 if local.day() < dom || (local.day() == dom && local.time() < tod) {
493 months -= 1;
494 }
495 let start = (months - anchor_month).div_euclid(n) * n + anchor_month;
496 self.local_at(day_of_month(start, dom), tod, ts)
497 }
498 }
499 }
500
501 #[must_use]
508 pub fn next(&self, bucket_start: i64) -> i64 {
509 match self.width {
510 BucketWidth::Fixed(n) => bucket_start.saturating_add(n.max(1)),
511 BucketWidth::Days(n) => {
512 let Some(local) = self.local_datetime(bucket_start) else {
513 return bucket_start.saturating_add(self.nominal_ns());
514 };
515 let (_, tod) = self.day_phase();
516 let next = days_from_epoch(local.date()).saturating_add(i64::from(n.max(1)));
517 self.local_at(date_from_epoch_days(next), tod, bucket_start)
518 }
519 BucketWidth::Months(n) => {
520 let Some(local) = self.local_datetime(bucket_start) else {
521 return bucket_start.saturating_add(self.nominal_ns());
522 };
523 let (_, dom, tod) = self.month_phase();
524 let months = i64::from(local.year() - 1970) * 12 + i64::from(local.month0());
525 self.local_at(
526 day_of_month(months.saturating_add(i64::from(n.max(1))), dom),
527 tod,
528 bucket_start,
529 )
530 }
531 }
532 }
533
534 fn local_datetime(&self, ts: i64) -> Option<NaiveDateTime> {
536 let utc = DateTime::from_timestamp_nanos(ts);
537 Some(match self.tz {
538 None => utc.naive_utc(),
539 Some(tz) => utc.with_timezone(&tz).naive_local(),
540 })
541 }
542
543 fn day_phase(&self) -> (i64, NaiveTime) {
549 match self.origin.and_then(|o| self.local_datetime(o)) {
550 Some(local) => (days_from_epoch(local.date()), local.time()),
551 None => (DAY_ANCHOR, NaiveTime::MIN),
552 }
553 }
554
555 fn month_phase(&self) -> (i64, u32, NaiveTime) {
563 match self.origin.and_then(|o| self.local_datetime(o)) {
564 Some(local) => (
565 i64::from(local.year() - 1970) * 12 + i64::from(local.month0()),
566 local.day().min(28),
569 local.time(),
570 ),
571 None => (0, 1, NaiveTime::MIN),
572 }
573 }
574
575 fn local_at(&self, date: Option<NaiveDate>, time: NaiveTime, fallback: i64) -> i64 {
581 let Some(naive) = date.map(|d| d.and_time(time)) else {
582 return fallback;
583 };
584 let Some(tz) = self.tz else {
585 return naive.and_utc().timestamp_nanos_opt().unwrap_or(fallback);
586 };
587 match tz.from_local_datetime(&naive) {
588 chrono::LocalResult::Single(dt) => dt.timestamp_nanos_opt().unwrap_or(fallback),
591 chrono::LocalResult::Ambiguous(earliest, _) => {
592 earliest.timestamp_nanos_opt().unwrap_or(fallback)
593 }
594 chrono::LocalResult::None => (1..=MAX_GAP_PROBES)
598 .find_map(|step| {
599 let probe = naive.checked_add_signed(chrono::TimeDelta::nanoseconds(
600 step.saturating_mul(MINUTE_NS),
601 ))?;
602 match tz.from_local_datetime(&probe) {
603 chrono::LocalResult::Single(dt) => dt.timestamp_nanos_opt(),
604 chrono::LocalResult::Ambiguous(earliest, _) => {
605 earliest.timestamp_nanos_opt()
606 }
607 chrono::LocalResult::None => None,
608 }
609 })
610 .unwrap_or(fallback),
611 }
612 }
613
614 fn standard_offset_ns(&self, ts: i64) -> i64 {
620 let Some(tz) = self.tz else { return 0 };
621 let naive = DateTime::from_timestamp_nanos(ts).naive_utc();
622 tz.offset_from_utc_datetime(&naive)
623 .base_utc_offset()
624 .num_nanoseconds()
625 .unwrap_or(0)
626 }
627}
628
629impl fmt::Display for TimeBucket {
630 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
631 match self.tz {
632 None => write!(f, "{}", self.width),
633 Some(tz) => write!(f, "{} {}", self.width, tz.name()),
634 }
635 }
636}
637
638const EPOCH_DAYS_FROM_CE: i64 = 719_163;
644
645fn days_from_epoch(date: NaiveDate) -> i64 {
647 i64::from(date.num_days_from_ce()) - EPOCH_DAYS_FROM_CE
648}
649
650fn date_from_epoch_days(days: i64) -> Option<NaiveDate> {
652 i32::try_from(days.checked_add(EPOCH_DAYS_FROM_CE)?)
653 .ok()
654 .and_then(NaiveDate::from_num_days_from_ce_opt)
655}
656
657fn day_of_month(months: i64, dom: u32) -> Option<NaiveDate> {
662 first_of_month(months)?.with_day(dom)
663}
664
665fn first_of_month(months: i64) -> Option<NaiveDate> {
667 let year = i32::try_from(months.div_euclid(12))
668 .ok()?
669 .checked_add(1970)?;
670 let month = u32::try_from(months.rem_euclid(12)).ok()?.checked_add(1)?;
671 NaiveDate::from_ymd_opt(year, month, 1)
672}
673
674#[cfg(test)]
677mod tests {
678 #![allow(clippy::unwrap_used, clippy::expect_used)]
679
680 use super::*;
681
682 fn ts(s: &str) -> i64 {
683 chrono::DateTime::parse_from_rfc3339(s)
684 .unwrap()
685 .timestamp_nanos_opt()
686 .unwrap()
687 }
688
689 fn berlin() -> Tz {
690 "Europe/Berlin".parse().unwrap()
691 }
692
693 #[test]
696 fn the_unit_decides_whether_a_width_is_a_calendar_one() {
697 use BucketWidth::{Days, Fixed, Months};
698 let cases = [
699 ("30s", Fixed(30 * SEC)),
700 ("15m", Fixed(15 * MIN)),
701 ("15min", Fixed(15 * MIN)),
702 ("36h", Fixed(36 * HOUR)),
705 ("1d", Days(1)),
706 ("1w", Days(7)),
707 ("2w", Days(14)),
708 ("1mo", Months(1)),
709 ("3mo", Months(3)),
710 ("1y", Months(12)),
711 ];
712 for (text, want) in cases {
713 assert_eq!(text.parse::<BucketWidth>().unwrap(), want, "{text}");
714 }
715 }
716
717 #[test]
722 fn a_widths_spelling_parses_back_to_the_same_width() {
723 for text in [
724 "500ns", "5us", "250ms", "30s", "15m", "6h", "1d", "1w", "3mo", "1y",
725 ] {
726 let width: BucketWidth = text.parse().unwrap();
727 let spelled = width.to_string();
728 assert_eq!(
729 spelled.parse::<BucketWidth>().unwrap(),
730 width,
731 "{text} spelled as {spelled}"
732 );
733 }
734 }
735
736 #[test]
737 fn the_month_is_mo_and_m_is_always_the_minute() {
738 assert_eq!(
739 "1m".parse::<BucketWidth>().unwrap(),
740 BucketWidth::Fixed(MIN)
741 );
742 assert_eq!(
743 "1mo".parse::<BucketWidth>().unwrap(),
744 BucketWidth::Months(1)
745 );
746 let err = "1M".parse::<BucketWidth>().unwrap_err();
750 assert!(err.0.contains("'mo'"), "{err}");
751 }
752
753 #[test]
754 fn the_long_unit_names_mean_the_same_as_the_short_ones() {
755 for (long, short) in [
760 ("1 second", "1s"),
761 ("30 seconds", "30s"),
762 ("15 minutes", "15m"),
763 ("1 minute", "1m"),
764 ("1 hour", "1h"),
765 ("24 hours", "24h"),
766 ("1 day", "1d"),
767 ("2 weeks", "2w"),
768 ("1 month", "1mo"),
769 ("3 months", "3mo"),
770 ("1 year", "1y"),
771 ] {
772 assert_eq!(
773 long.parse::<BucketWidth>().unwrap(),
774 short.parse::<BucketWidth>().unwrap(),
775 "'{long}' must mean '{short}'"
776 );
777 }
778 assert_eq!(
780 "1hour".parse::<BucketWidth>().unwrap(),
781 BucketWidth::Fixed(HOUR)
782 );
783 }
784
785 #[test]
786 fn no_long_form_reopens_the_minute_month_trap() {
787 assert_eq!(
792 "1month".parse::<BucketWidth>().unwrap(),
793 BucketWidth::Months(1)
794 );
795 assert_eq!(
796 "1minute".parse::<BucketWidth>().unwrap(),
797 BucketWidth::Fixed(MIN)
798 );
799 for ambiguous in ["1M", "1MO", "1Month", "1Min"] {
800 assert!(
801 ambiguous.parse::<BucketWidth>().is_err(),
802 "'{ambiguous}' must be refused rather than guessed at"
803 );
804 }
805 }
806
807 #[test]
808 fn a_width_without_a_unit_or_with_a_bad_one_is_refused() {
809 for bad in ["", "5", "0h", "-1h", "1fortnight", "h"] {
810 assert!(
811 bad.parse::<BucketWidth>().is_err(),
812 "'{bad}' must be refused"
813 );
814 }
815 }
816
817 #[test]
818 fn an_unknown_zone_is_refused_by_name() {
819 let err = TimeBucket::parse("1d", Some("Europe/Atlantis")).unwrap_err();
820 assert!(err.0.contains("Europe/Atlantis"), "{err}");
821 assert!(err.0.contains("IANA"), "{err}");
822 }
823
824 #[test]
829 fn a_fixed_bucket_does_not_merge_the_repeated_hour() {
830 let b = TimeBucket::utc(BucketWidth::Fixed(HOUR)).in_zone(berlin());
831 let first = b.start_of(ts("2026-10-25T00:30:00Z"));
834 let second = b.start_of(ts("2026-10-25T01:30:00Z"));
835 assert_eq!(second - first, HOUR, "hour buckets stay an hour apart");
836 assert_eq!(first, ts("2026-10-25T00:00:00Z"));
837 assert_eq!(second, ts("2026-10-25T01:00:00Z"));
838 }
839
840 #[test]
842 fn a_fall_back_day_holds_twenty_five_hourly_buckets() {
843 let b = TimeBucket::utc(BucketWidth::Fixed(HOUR)).in_zone(berlin());
844 let day_start = ts("2026-10-24T22:00:00Z"); let buckets: std::collections::BTreeSet<i64> = (0..25 * 60)
846 .map(|m| b.start_of(day_start + m * MIN))
847 .collect();
848 assert_eq!(buckets.len(), 25);
849 let mut prev: Option<i64> = None;
850 for start in buckets {
851 if let Some(p) = prev {
852 assert_eq!(start - p, HOUR, "evenly spaced");
853 }
854 prev = Some(start);
855 }
856 }
857
858 #[test]
861 fn a_half_hour_zone_buckets_on_its_own_clock() {
862 let kolkata: Tz = "Asia/Kolkata".parse().unwrap(); let b = TimeBucket::utc(BucketWidth::Fixed(HOUR)).in_zone(kolkata);
864 assert_eq!(
867 b.start_of(ts("2024-03-15T00:10:00Z")),
868 ts("2024-03-14T23:30:00Z")
869 );
870 }
871
872 #[test]
873 fn an_unzoned_fixed_bucket_truncates_utc() {
874 let b = TimeBucket::fixed_ns(HOUR);
875 assert_eq!(
876 b.start_of(ts("2024-03-15T02:34:56Z")),
877 ts("2024-03-15T02:00:00Z")
878 );
879 assert_eq!(
880 b.next(ts("2024-03-15T02:00:00Z")),
881 ts("2024-03-15T03:00:00Z")
882 );
883 }
884
885 #[test]
888 fn a_zoned_day_is_local_midnight_to_local_midnight() {
889 let ny: Tz = "America/New_York".parse().unwrap();
890 let b = TimeBucket::utc(BucketWidth::Days(1)).in_zone(ny);
891 assert_eq!(
893 b.start_of(ts("2024-03-15T02:00:00Z")),
894 ts("2024-03-14T04:00:00Z")
895 );
896 }
897
898 #[test]
900 fn a_transition_day_is_not_twenty_four_hours() {
901 let ny: Tz = "America/New_York".parse().unwrap();
902 let day = TimeBucket::utc(BucketWidth::Days(1)).in_zone(ny);
903
904 let start = day.start_of(ts("2024-03-10T20:00:00Z"));
906 assert_eq!(start, ts("2024-03-10T05:00:00Z"));
907 assert_eq!(day.next(start) - start, 23 * HOUR);
908
909 let start = day.start_of(ts("2024-11-03T20:00:00Z"));
911 assert_eq!(start, ts("2024-11-03T04:00:00Z"));
912 assert_eq!(day.next(start) - start, 25 * HOUR);
913 }
914
915 #[test]
917 fn a_month_bucket_is_a_calendar_month() {
918 let b = TimeBucket::utc(BucketWidth::Months(1)).in_zone(berlin());
919 let feb = b.start_of(ts("2024-02-17T12:00:00Z"));
920 assert_eq!(feb, ts("2024-01-31T23:00:00Z"), "1 Feb 2024, Berlin (CET)");
921 assert_eq!(b.next(feb) - feb, 29 * 24 * HOUR);
923 let feb23 = b.start_of(ts("2023-02-17T12:00:00Z"));
925 assert_eq!(b.next(feb23) - feb23, 28 * 24 * HOUR);
926 let mar = b.start_of(ts("2024-03-17T12:00:00Z"));
929 assert_eq!(b.next(mar) - mar, 31 * 24 * HOUR - HOUR);
930 }
931
932 #[test]
933 fn a_year_bucket_is_a_calendar_year() {
934 let b = TimeBucket::utc(BucketWidth::Months(12));
935 let y = b.start_of(ts("2024-07-04T00:00:00Z"));
936 assert_eq!(y, ts("2024-01-01T00:00:00Z"));
937 assert_eq!(b.next(y), ts("2025-01-01T00:00:00Z"));
938 }
939
940 #[test]
941 fn a_quarter_is_three_calendar_months_from_january() {
942 let b = TimeBucket::utc(BucketWidth::Months(3));
943 assert_eq!(
944 b.start_of(ts("2024-05-17T00:00:00Z")),
945 ts("2024-04-01T00:00:00Z")
946 );
947 assert_eq!(
948 b.start_of(ts("2024-12-31T23:59:59Z")),
949 ts("2024-10-01T00:00:00Z")
950 );
951 }
952
953 #[test]
958 fn the_epoch_is_day_zero_and_a_thursday() {
959 let epoch = NaiveDate::from_ymd_opt(1970, 1, 1).unwrap();
960 assert_eq!(days_from_epoch(epoch), 0);
961 assert_eq!(epoch.weekday(), chrono::Weekday::Thu);
962 assert_eq!(
963 date_from_epoch_days(DAY_ANCHOR).unwrap().weekday(),
964 chrono::Weekday::Mon,
965 "the multi-day anchor is a Monday"
966 );
967 }
968
969 #[test]
972 fn a_week_starts_on_monday() {
973 let b = TimeBucket::utc(BucketWidth::Days(7));
974 let start = b.start_of(ts("2024-03-15T12:00:00Z"));
976 assert_eq!(start, ts("2024-03-11T00:00:00Z"));
977 assert_eq!(
978 DateTime::from_timestamp_nanos(start).date_naive().weekday(),
979 chrono::Weekday::Mon
980 );
981 }
982
983 #[test]
986 fn buckets_tile_the_line() {
987 let specs = [
988 TimeBucket::fixed_ns(HOUR),
989 TimeBucket::utc(BucketWidth::Fixed(15 * MIN)).in_zone(berlin()),
990 TimeBucket::utc(BucketWidth::Days(1)).in_zone(berlin()),
991 TimeBucket::utc(BucketWidth::Months(1)).in_zone(berlin()),
992 TimeBucket::utc(BucketWidth::Days(7)),
993 TimeBucket::fixed_ns(HOUR)
995 .with_origin(ts("2020-01-01T00:17:00Z"))
996 .unwrap(),
997 TimeBucket::utc(BucketWidth::Days(1))
998 .in_zone(berlin())
999 .with_origin(ts("2020-01-01T05:00:00Z"))
1000 .unwrap(),
1001 TimeBucket::utc(BucketWidth::Months(1))
1002 .in_zone(berlin())
1003 .with_origin(ts("2020-01-15T00:00:00Z"))
1004 .unwrap(),
1005 TimeBucket::utc(BucketWidth::Days(7))
1006 .with_origin(ts("2020-01-01T00:00:00Z"))
1007 .unwrap(),
1008 TimeBucket::utc(BucketWidth::Days(1))
1012 .in_zone(berlin())
1013 .with_origin(ts("2020-01-15T01:30:00Z"))
1014 .unwrap(),
1015 ];
1016 let from = ts("2026-01-15T00:00:00Z");
1019 let to = ts("2027-01-15T00:00:00Z");
1020 for b in specs {
1021 let mut cursor = b.start_of(from);
1022 let mut steps = 0;
1023 while cursor < to {
1024 let next = b.next(cursor);
1025 assert!(next > cursor, "{b} did not advance at {cursor}");
1026 for probe in [cursor, cursor + (next - cursor) / 2, next - 1] {
1028 assert_eq!(b.start_of(probe), cursor, "{b} at {probe}");
1029 }
1030 assert_eq!(b.start_of(next), next, "{b} boundary at {next}");
1032 cursor = next;
1033 steps += 1;
1034 assert!(steps < 40_000, "{b} is not advancing");
1035 }
1036 }
1037 }
1038
1039 #[test]
1049 fn a_bucket_round_trips_through_the_catalog_format() {
1050 for b in [
1051 TimeBucket::fixed_ns(15 * MIN),
1052 TimeBucket::utc(BucketWidth::Days(1)),
1053 TimeBucket::utc(BucketWidth::Days(1)).in_zone(berlin()),
1054 TimeBucket::utc(BucketWidth::Months(3)).in_zone(berlin()),
1055 ] {
1056 let bytes = postcard::to_allocvec(&b).unwrap();
1057 assert_eq!(postcard::from_bytes::<TimeBucket>(&bytes).unwrap(), b);
1058 }
1059 let seq = vec![
1062 TimeBucket::fixed_ns(15 * MIN),
1063 TimeBucket::utc(BucketWidth::Months(1)).in_zone(berlin()),
1064 TimeBucket::utc(BucketWidth::Days(7)),
1065 ];
1066 let bytes = postcard::to_allocvec(&seq).unwrap();
1067 assert_eq!(
1068 postcard::from_bytes::<Vec<TimeBucket>>(&bytes).unwrap(),
1069 seq
1070 );
1071 }
1072
1073 #[test]
1075 fn a_bucket_round_trips_through_json_by_zone_name() {
1076 for b in [
1077 TimeBucket::fixed_ns(15 * MIN),
1078 TimeBucket::utc(BucketWidth::Days(1)).in_zone(berlin()),
1079 TimeBucket::utc(BucketWidth::Months(3)).in_zone(berlin()),
1080 ] {
1081 let json = serde_json::to_string(&b).unwrap();
1082 assert_eq!(
1083 serde_json::from_str::<TimeBucket>(&json).unwrap(),
1084 b,
1085 "{json}"
1086 );
1087 if let Some(name) = b.timezone() {
1088 assert!(json.contains(name), "the zone travels by name: {json}");
1089 }
1090 }
1091 }
1092
1093 #[test]
1094 fn display_round_trips_through_parse() {
1095 for (width, zone) in [("15m", None), ("1d", Some("Europe/Berlin")), ("1mo", None)] {
1096 let b = TimeBucket::parse(width, zone).unwrap();
1097 assert_eq!(b.width().to_string(), width);
1098 assert_eq!(b.timezone(), zone);
1099 }
1100 }
1101
1102 #[test]
1106 fn a_day_bucket_takes_its_boundary_from_the_origin() {
1107 let b = TimeBucket::utc(BucketWidth::Days(1))
1108 .with_origin(ts("2020-01-01T06:00:00Z"))
1109 .unwrap();
1110 assert_eq!(
1112 b.start_of(ts("2024-03-15T05:00:00Z")),
1113 ts("2024-03-14T06:00:00Z")
1114 );
1115 assert_eq!(
1117 b.start_of(ts("2024-03-15T06:00:00Z")),
1118 ts("2024-03-15T06:00:00Z")
1119 );
1120 assert_eq!(
1121 b.next(ts("2024-03-15T06:00:00Z")),
1122 ts("2024-03-16T06:00:00Z")
1123 );
1124 }
1125
1126 #[test]
1128 fn a_month_bucket_takes_its_day_from_the_origin() {
1129 let b = TimeBucket::utc(BucketWidth::Months(1))
1130 .with_origin(ts("2020-01-15T00:00:00Z"))
1131 .unwrap();
1132 assert_eq!(
1133 b.start_of(ts("2024-03-14T23:59:59Z")),
1134 ts("2024-02-15T00:00:00Z")
1135 );
1136 assert_eq!(
1137 b.start_of(ts("2024-03-15T00:00:00Z")),
1138 ts("2024-03-15T00:00:00Z")
1139 );
1140 assert_eq!(
1142 b.next(ts("2024-01-15T00:00:00Z")),
1143 ts("2024-02-15T00:00:00Z")
1144 );
1145 }
1146
1147 #[test]
1151 fn a_monthly_origin_after_the_twenty_eighth_is_refused() {
1152 for day in ["2020-01-29", "2020-01-30", "2020-01-31"] {
1153 let err = TimeBucket::utc(BucketWidth::Months(1))
1154 .with_origin(ts(&format!("{day}T00:00:00Z")))
1155 .unwrap_err();
1156 assert!(err.0.contains("missing from some months"), "{}", err.0);
1157 }
1158 assert!(
1160 TimeBucket::utc(BucketWidth::Months(1))
1161 .with_origin(ts("2020-01-28T00:00:00Z"))
1162 .is_ok()
1163 );
1164 assert!(
1166 TimeBucket::utc(BucketWidth::Days(1))
1167 .with_origin(ts("2020-01-31T00:00:00Z"))
1168 .is_ok()
1169 );
1170 }
1171
1172 #[test]
1179 fn a_multi_unit_origin_selects_which_unit_opens_a_bucket() {
1180 let jan = TimeBucket::utc(BucketWidth::Months(3))
1181 .with_origin(ts("2024-01-15T00:00:00Z"))
1182 .unwrap();
1183 let feb = TimeBucket::utc(BucketWidth::Months(3))
1184 .with_origin(ts("2024-02-15T00:00:00Z"))
1185 .unwrap();
1186 let probe = ts("2024-05-20T00:00:00Z");
1187 assert_eq!(jan.start_of(probe), ts("2024-04-15T00:00:00Z"));
1188 assert_eq!(feb.start_of(probe), ts("2024-05-15T00:00:00Z"));
1189
1190 let a = TimeBucket::utc(BucketWidth::Months(1))
1193 .with_origin(ts("2024-01-15T00:00:00Z"))
1194 .unwrap();
1195 let b = TimeBucket::utc(BucketWidth::Months(1))
1196 .with_origin(ts("2020-07-15T00:00:00Z"))
1197 .unwrap();
1198 assert_eq!(a.start_of(probe), b.start_of(probe));
1199 }
1200
1201 #[test]
1204 fn the_origin_is_read_in_the_buckets_zone() {
1205 let utc = TimeBucket::utc(BucketWidth::Days(1))
1207 .with_origin(ts("2024-01-01T04:00:00Z"))
1208 .unwrap();
1209 let berlin = TimeBucket::utc(BucketWidth::Days(1))
1210 .in_zone(berlin())
1211 .with_origin(ts("2024-01-01T04:00:00Z"))
1212 .unwrap();
1213 assert_eq!(
1214 utc.start_of(ts("2024-01-10T12:00:00Z")),
1215 ts("2024-01-10T04:00:00Z")
1216 );
1217 assert_eq!(
1219 berlin.start_of(ts("2024-01-10T12:00:00Z")),
1220 ts("2024-01-10T04:00:00Z")
1221 );
1222 assert_eq!(
1224 berlin.start_of(ts("2024-07-10T12:00:00Z")),
1225 ts("2024-07-10T03:00:00Z")
1226 );
1227 }
1228
1229 #[test]
1231 fn a_fixed_bucket_tiles_outwards_from_its_origin() {
1232 let b = TimeBucket::fixed_ns(HOUR)
1233 .with_origin(ts("2024-01-01T00:30:00Z"))
1234 .unwrap();
1235 assert_eq!(
1236 b.start_of(ts("2024-01-01T01:00:00Z")),
1237 ts("2024-01-01T00:30:00Z")
1238 );
1239 assert_eq!(
1241 b.start_of(ts("2023-12-31T23:00:00Z")),
1242 ts("2023-12-31T22:30:00Z")
1243 );
1244 }
1245
1246 #[test]
1248 fn an_origin_round_trips_through_the_catalog_format() {
1249 let b = TimeBucket::parse("1mo", Some("Europe/Berlin"))
1250 .unwrap()
1251 .with_origin(ts("2020-01-15T00:00:00Z"))
1252 .unwrap();
1253 let seq = vec![b, TimeBucket::fixed_ns(HOUR)];
1255 let bytes = postcard::to_stdvec(&seq).unwrap();
1256 let back: Vec<TimeBucket> = postcard::from_bytes(&bytes).unwrap();
1257 assert_eq!(back, seq);
1258 assert_eq!(back[0].origin(), Some(ts("2020-01-15T00:00:00Z")));
1259 }
1260
1261 #[test]
1268 fn a_deserialised_origin_goes_through_the_same_rule() {
1269 let json = r#"{"width":{"Months":1},"timezone":null,"origin":1706659200000000000}"#;
1271 let err = serde_json::from_str::<TimeBucket>(json).unwrap_err();
1272 assert!(
1273 err.to_string().contains("missing from some months"),
1274 "{err}"
1275 );
1276 let ok = r#"{"width":{"Months":1},"timezone":null,"origin":1706400000000000000}"#;
1278 assert!(serde_json::from_str::<TimeBucket>(ok).is_ok());
1279 }
1280
1281 #[test]
1284 fn a_width_of_zero_is_refused() {
1285 for spec in ["0s", "0d", "0mo", "-1h"] {
1286 assert!(
1287 spec.parse::<BucketWidth>().is_err(),
1288 "'{spec}' should be refused"
1289 );
1290 }
1291 }
1292}