use std::fmt;
use std::str::FromStr;
use chrono::{DateTime, Datelike, NaiveDate, NaiveDateTime, NaiveTime, TimeZone};
use chrono_tz::{OffsetComponents, Tz};
use serde::{Deserialize, Serialize};
const NS: i64 = 1;
const US: i64 = 1_000;
const MS: i64 = 1_000_000;
const SEC: i64 = 1_000_000_000;
const MIN: i64 = 60 * SEC;
const HOUR: i64 = 60 * MIN;
const MINUTE_NS: i64 = MIN;
const DAY_ANCHOR: i64 = 4;
const MAX_GAP_PROBES: i64 = 180;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum BucketWidth {
Fixed(i64),
Days(u32),
Months(u32),
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{0}")]
pub struct BucketParseError(pub String);
impl BucketWidth {
#[must_use]
pub fn nominal_ns(self) -> i64 {
match self {
Self::Fixed(n) => n,
Self::Days(n) => i64::from(n).saturating_mul(24 * HOUR),
Self::Months(n) => i64::from(n).saturating_mul(2_629_746 * SEC),
}
}
#[must_use]
pub fn is_calendar(self) -> bool {
!matches!(self, Self::Fixed(_))
}
}
impl fmt::Display for BucketWidth {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match *self {
Self::Fixed(n) => {
for (unit, suffix) in [(HOUR, "h"), (MIN, "m"), (SEC, "s"), (MS, "ms"), (US, "us")]
{
if n % unit == 0 {
return write!(f, "{}{suffix}", n / unit);
}
}
write!(f, "{n}ns")
}
Self::Days(n) if n % 7 == 0 => write!(f, "{}w", n / 7),
Self::Days(n) => write!(f, "{n}d"),
Self::Months(n) if n % 12 == 0 => write!(f, "{}y", n / 12),
Self::Months(n) => write!(f, "{n}mo"),
}
}
}
impl FromStr for BucketWidth {
type Err = BucketParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let s = s.trim();
if s.is_empty() {
return Err(BucketParseError("empty bucket width".into()));
}
let split = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len());
let (num_str, suffix) = s.split_at(split);
let n: i64 = num_str.parse().map_err(|_| {
BucketParseError(format!(
"'{s}' does not start with a number — write a width like 15m, 1h, 1d, 1mo"
))
})?;
if n <= 0 {
return Err(BucketParseError(format!(
"a bucket width must be positive, got '{s}'"
)));
}
let suffix = suffix.trim();
let mul = |unit: i64| -> Result<Self, BucketParseError> {
n.checked_mul(unit).map(Self::Fixed).ok_or_else(|| {
BucketParseError(format!("bucket width '{s}' overflows the nanosecond range"))
})
};
let count = |per: i64| -> Result<u32, BucketParseError> {
u32::try_from(n.saturating_mul(per))
.map_err(|_| BucketParseError(format!("bucket width '{s}' is too large")))
};
match suffix {
"ns" | "nanosecond" | "nanoseconds" => mul(NS),
"us" | "µs" | "microsecond" | "microseconds" => mul(US),
"ms" | "millisecond" | "milliseconds" => mul(MS),
"s" | "sec" | "secs" | "second" | "seconds" => mul(SEC),
"m" | "min" | "mins" | "minute" | "minutes" => mul(MIN),
"h" | "hr" | "hrs" | "hour" | "hours" => mul(HOUR),
"d" | "day" | "days" => count(1).map(Self::Days),
"w" | "week" | "weeks" => count(7).map(Self::Days),
"mo" | "month" | "months" => count(1).map(Self::Months),
"y" | "yr" | "yrs" | "year" | "years" => count(12).map(Self::Months),
"" => Err(BucketParseError(format!(
"'{s}' has no unit — write a width like 15m, 1h, 1d, 1mo"
))),
other => Err(BucketParseError(format!(
"unknown bucket unit '{other}'. Use ns, us, ms, s, m/min, h, d, w, mo, y — \
or their long forms (second, minute, hour, day, week, month, year) \
(the month is 'mo'; 'm' is always the minute)"
))),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TimeBucket {
width: BucketWidth,
tz: Option<Tz>,
origin: Option<i64>,
}
#[derive(Serialize, Deserialize)]
struct TimeBucketRepr {
width: BucketWidth,
timezone: Option<String>,
origin: Option<i64>,
}
impl Serialize for TimeBucket {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
TimeBucketRepr {
width: self.width,
timezone: self.tz.map(|tz| tz.name().to_owned()),
origin: self.origin,
}
.serialize(s)
}
}
impl<'de> Deserialize<'de> for TimeBucket {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let repr = TimeBucketRepr::deserialize(d)?;
let tz = match repr.timezone {
None => None,
Some(name) => Some(name.parse::<Tz>().map_err(serde::de::Error::custom)?),
};
let bucket = Self {
width: repr.width,
tz,
origin: None,
};
match repr.origin {
None => Ok(bucket),
Some(origin) => bucket.with_origin(origin).map_err(serde::de::Error::custom),
}
}
}
impl TimeBucket {
#[must_use]
pub const fn utc(width: BucketWidth) -> Self {
Self {
width,
tz: None,
origin: None,
}
}
#[must_use]
pub const fn fixed_ns(ns: i64) -> Self {
Self::utc(BucketWidth::Fixed(ns))
}
#[must_use]
pub fn fixed(span: std::time::Duration) -> Self {
Self::fixed_ns(i64::try_from(span.as_nanos()).unwrap_or(i64::MAX))
}
#[must_use]
pub const fn in_zone(mut self, tz: Tz) -> Self {
self.tz = Some(tz);
self
}
pub fn with_origin(mut self, origin: i64) -> Result<Self, BucketParseError> {
if matches!(self.width, BucketWidth::Months(_)) {
let day = self.local_datetime(origin).map_or(1, |dt| dt.day());
if day > 28 {
return Err(BucketParseError(format!(
"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."
)));
}
}
self.origin = Some(origin);
Ok(self)
}
pub fn with_origin_str(self, text: &str) -> Result<Self, BucketParseError> {
let text = text.trim();
let ns = if let Ok(dt) = DateTime::parse_from_rfc3339(text) {
dt.timestamp_nanos_opt()
} else {
let naive = chrono::NaiveDateTime::parse_from_str(text, "%Y-%m-%d %H:%M:%S")
.or_else(|_| chrono::NaiveDateTime::parse_from_str(text, "%Y-%m-%dT%H:%M:%S"))
.or_else(|_| {
NaiveDate::parse_from_str(text, "%Y-%m-%d").map(|d| d.and_time(NaiveTime::MIN))
})
.map_err(|_| {
BucketParseError(format!(
"could not read '{text}' as an origin — write it as '2024-01-15', \
'2024-01-15 06:00:00', or an RFC 3339 instant"
))
})?;
match self.tz {
None => naive.and_utc().timestamp_nanos_opt(),
Some(tz) => match tz.from_local_datetime(&naive) {
chrono::LocalResult::Single(dt) => dt.timestamp_nanos_opt(),
chrono::LocalResult::Ambiguous(earliest, _) => earliest.timestamp_nanos_opt(),
chrono::LocalResult::None => {
return Err(BucketParseError(format!(
"the local time '{text}' does not exist in {} — \
it falls in a daylight-saving gap",
tz.name()
)));
}
},
}
};
let ns = ns.ok_or_else(|| {
BucketParseError(format!(
"origin '{text}' is outside the representable range"
))
})?;
self.with_origin(ns)
}
#[must_use]
pub const fn origin(&self) -> Option<i64> {
self.origin
}
pub fn parse(width: &str, timezone: Option<&str>) -> Result<Self, BucketParseError> {
let width: BucketWidth = width.parse()?;
let tz = match timezone.map(str::trim).filter(|s| !s.is_empty()) {
None => None,
Some(name) => Some(name.parse::<Tz>().map_err(|_| {
BucketParseError(format!(
"unknown time zone '{name}' — use an IANA name like Europe/Berlin"
))
})?),
};
Ok(Self {
width,
tz,
origin: None,
})
}
#[must_use]
pub const fn width(&self) -> BucketWidth {
self.width
}
#[must_use]
pub fn timezone(&self) -> Option<&'static str> {
self.tz.map(chrono_tz::Tz::name)
}
#[must_use]
pub fn nominal_ns(&self) -> i64 {
self.width.nominal_ns()
}
#[must_use]
pub fn is_calendar(&self) -> bool {
self.width.is_calendar()
}
#[must_use]
pub fn start_of(&self, ts: i64) -> i64 {
match self.width {
BucketWidth::Fixed(n) => {
let n = n.max(1);
let anchor = self.origin.unwrap_or_else(|| self.standard_offset_ns(ts));
ts.saturating_sub(ts.wrapping_sub(anchor).rem_euclid(n))
}
BucketWidth::Days(n) => {
let n = i64::from(n.max(1));
let Some(local) = self.local_datetime(ts) else {
return ts;
};
let (anchor_day, tod) = self.day_phase();
let mut days = days_from_epoch(local.date());
if local.time() < tod {
days -= 1;
}
let start = (days - anchor_day).div_euclid(n) * n + anchor_day;
self.local_at(date_from_epoch_days(start), tod, ts)
}
BucketWidth::Months(n) => {
let n = i64::from(n.max(1));
let Some(local) = self.local_datetime(ts) else {
return ts;
};
let (anchor_month, dom, tod) = self.month_phase();
let mut months = i64::from(local.year() - 1970) * 12 + i64::from(local.month0());
if local.day() < dom || (local.day() == dom && local.time() < tod) {
months -= 1;
}
let start = (months - anchor_month).div_euclid(n) * n + anchor_month;
self.local_at(day_of_month(start, dom), tod, ts)
}
}
}
#[must_use]
pub fn next(&self, bucket_start: i64) -> i64 {
match self.width {
BucketWidth::Fixed(n) => bucket_start.saturating_add(n.max(1)),
BucketWidth::Days(n) => {
let Some(local) = self.local_datetime(bucket_start) else {
return bucket_start.saturating_add(self.nominal_ns());
};
let (_, tod) = self.day_phase();
let next = days_from_epoch(local.date()).saturating_add(i64::from(n.max(1)));
self.local_at(date_from_epoch_days(next), tod, bucket_start)
}
BucketWidth::Months(n) => {
let Some(local) = self.local_datetime(bucket_start) else {
return bucket_start.saturating_add(self.nominal_ns());
};
let (_, dom, tod) = self.month_phase();
let months = i64::from(local.year() - 1970) * 12 + i64::from(local.month0());
self.local_at(
day_of_month(months.saturating_add(i64::from(n.max(1))), dom),
tod,
bucket_start,
)
}
}
}
fn local_datetime(&self, ts: i64) -> Option<NaiveDateTime> {
let utc = DateTime::from_timestamp_nanos(ts);
Some(match self.tz {
None => utc.naive_utc(),
Some(tz) => utc.with_timezone(&tz).naive_local(),
})
}
fn day_phase(&self) -> (i64, NaiveTime) {
match self.origin.and_then(|o| self.local_datetime(o)) {
Some(local) => (days_from_epoch(local.date()), local.time()),
None => (DAY_ANCHOR, NaiveTime::MIN),
}
}
fn month_phase(&self) -> (i64, u32, NaiveTime) {
match self.origin.and_then(|o| self.local_datetime(o)) {
Some(local) => (
i64::from(local.year() - 1970) * 12 + i64::from(local.month0()),
local.day().min(28),
local.time(),
),
None => (0, 1, NaiveTime::MIN),
}
}
fn local_at(&self, date: Option<NaiveDate>, time: NaiveTime, fallback: i64) -> i64 {
let Some(naive) = date.map(|d| d.and_time(time)) else {
return fallback;
};
let Some(tz) = self.tz else {
return naive.and_utc().timestamp_nanos_opt().unwrap_or(fallback);
};
match tz.from_local_datetime(&naive) {
chrono::LocalResult::Single(dt) => dt.timestamp_nanos_opt().unwrap_or(fallback),
chrono::LocalResult::Ambiguous(earliest, _) => {
earliest.timestamp_nanos_opt().unwrap_or(fallback)
}
chrono::LocalResult::None => (1..=MAX_GAP_PROBES)
.find_map(|step| {
let probe = naive.checked_add_signed(chrono::TimeDelta::nanoseconds(
step.saturating_mul(MINUTE_NS),
))?;
match tz.from_local_datetime(&probe) {
chrono::LocalResult::Single(dt) => dt.timestamp_nanos_opt(),
chrono::LocalResult::Ambiguous(earliest, _) => {
earliest.timestamp_nanos_opt()
}
chrono::LocalResult::None => None,
}
})
.unwrap_or(fallback),
}
}
fn standard_offset_ns(&self, ts: i64) -> i64 {
let Some(tz) = self.tz else { return 0 };
let naive = DateTime::from_timestamp_nanos(ts).naive_utc();
tz.offset_from_utc_datetime(&naive)
.base_utc_offset()
.num_nanoseconds()
.unwrap_or(0)
}
}
impl fmt::Display for TimeBucket {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.tz {
None => write!(f, "{}", self.width),
Some(tz) => write!(f, "{} {}", self.width, tz.name()),
}
}
}
const EPOCH_DAYS_FROM_CE: i64 = 719_163;
fn days_from_epoch(date: NaiveDate) -> i64 {
i64::from(date.num_days_from_ce()) - EPOCH_DAYS_FROM_CE
}
fn date_from_epoch_days(days: i64) -> Option<NaiveDate> {
i32::try_from(days.checked_add(EPOCH_DAYS_FROM_CE)?)
.ok()
.and_then(NaiveDate::from_num_days_from_ce_opt)
}
fn day_of_month(months: i64, dom: u32) -> Option<NaiveDate> {
first_of_month(months)?.with_day(dom)
}
fn first_of_month(months: i64) -> Option<NaiveDate> {
let year = i32::try_from(months.div_euclid(12))
.ok()?
.checked_add(1970)?;
let month = u32::try_from(months.rem_euclid(12)).ok()?.checked_add(1)?;
NaiveDate::from_ymd_opt(year, month, 1)
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
fn ts(s: &str) -> i64 {
chrono::DateTime::parse_from_rfc3339(s)
.unwrap()
.timestamp_nanos_opt()
.unwrap()
}
fn berlin() -> Tz {
"Europe/Berlin".parse().unwrap()
}
#[test]
fn the_unit_decides_whether_a_width_is_a_calendar_one() {
use BucketWidth::{Days, Fixed, Months};
let cases = [
("30s", Fixed(30 * SEC)),
("15m", Fixed(15 * MIN)),
("15min", Fixed(15 * MIN)),
("36h", Fixed(36 * HOUR)),
("1d", Days(1)),
("1w", Days(7)),
("2w", Days(14)),
("1mo", Months(1)),
("3mo", Months(3)),
("1y", Months(12)),
];
for (text, want) in cases {
assert_eq!(text.parse::<BucketWidth>().unwrap(), want, "{text}");
}
}
#[test]
fn a_widths_spelling_parses_back_to_the_same_width() {
for text in [
"500ns", "5us", "250ms", "30s", "15m", "6h", "1d", "1w", "3mo", "1y",
] {
let width: BucketWidth = text.parse().unwrap();
let spelled = width.to_string();
assert_eq!(
spelled.parse::<BucketWidth>().unwrap(),
width,
"{text} spelled as {spelled}"
);
}
}
#[test]
fn the_month_is_mo_and_m_is_always_the_minute() {
assert_eq!(
"1m".parse::<BucketWidth>().unwrap(),
BucketWidth::Fixed(MIN)
);
assert_eq!(
"1mo".parse::<BucketWidth>().unwrap(),
BucketWidth::Months(1)
);
let err = "1M".parse::<BucketWidth>().unwrap_err();
assert!(err.0.contains("'mo'"), "{err}");
}
#[test]
fn the_long_unit_names_mean_the_same_as_the_short_ones() {
for (long, short) in [
("1 second", "1s"),
("30 seconds", "30s"),
("15 minutes", "15m"),
("1 minute", "1m"),
("1 hour", "1h"),
("24 hours", "24h"),
("1 day", "1d"),
("2 weeks", "2w"),
("1 month", "1mo"),
("3 months", "3mo"),
("1 year", "1y"),
] {
assert_eq!(
long.parse::<BucketWidth>().unwrap(),
short.parse::<BucketWidth>().unwrap(),
"'{long}' must mean '{short}'"
);
}
assert_eq!(
"1hour".parse::<BucketWidth>().unwrap(),
BucketWidth::Fixed(HOUR)
);
}
#[test]
fn no_long_form_reopens_the_minute_month_trap() {
assert_eq!(
"1month".parse::<BucketWidth>().unwrap(),
BucketWidth::Months(1)
);
assert_eq!(
"1minute".parse::<BucketWidth>().unwrap(),
BucketWidth::Fixed(MIN)
);
for ambiguous in ["1M", "1MO", "1Month", "1Min"] {
assert!(
ambiguous.parse::<BucketWidth>().is_err(),
"'{ambiguous}' must be refused rather than guessed at"
);
}
}
#[test]
fn a_width_without_a_unit_or_with_a_bad_one_is_refused() {
for bad in ["", "5", "0h", "-1h", "1fortnight", "h"] {
assert!(
bad.parse::<BucketWidth>().is_err(),
"'{bad}' must be refused"
);
}
}
#[test]
fn an_unknown_zone_is_refused_by_name() {
let err = TimeBucket::parse("1d", Some("Europe/Atlantis")).unwrap_err();
assert!(err.0.contains("Europe/Atlantis"), "{err}");
assert!(err.0.contains("IANA"), "{err}");
}
#[test]
fn a_fixed_bucket_does_not_merge_the_repeated_hour() {
let b = TimeBucket::utc(BucketWidth::Fixed(HOUR)).in_zone(berlin());
let first = b.start_of(ts("2026-10-25T00:30:00Z"));
let second = b.start_of(ts("2026-10-25T01:30:00Z"));
assert_eq!(second - first, HOUR, "hour buckets stay an hour apart");
assert_eq!(first, ts("2026-10-25T00:00:00Z"));
assert_eq!(second, ts("2026-10-25T01:00:00Z"));
}
#[test]
fn a_fall_back_day_holds_twenty_five_hourly_buckets() {
let b = TimeBucket::utc(BucketWidth::Fixed(HOUR)).in_zone(berlin());
let day_start = ts("2026-10-24T22:00:00Z"); let buckets: std::collections::BTreeSet<i64> = (0..25 * 60)
.map(|m| b.start_of(day_start + m * MIN))
.collect();
assert_eq!(buckets.len(), 25);
let mut prev: Option<i64> = None;
for start in buckets {
if let Some(p) = prev {
assert_eq!(start - p, HOUR, "evenly spaced");
}
prev = Some(start);
}
}
#[test]
fn a_half_hour_zone_buckets_on_its_own_clock() {
let kolkata: Tz = "Asia/Kolkata".parse().unwrap(); let b = TimeBucket::utc(BucketWidth::Fixed(HOUR)).in_zone(kolkata);
assert_eq!(
b.start_of(ts("2024-03-15T00:10:00Z")),
ts("2024-03-14T23:30:00Z")
);
}
#[test]
fn an_unzoned_fixed_bucket_truncates_utc() {
let b = TimeBucket::fixed_ns(HOUR);
assert_eq!(
b.start_of(ts("2024-03-15T02:34:56Z")),
ts("2024-03-15T02:00:00Z")
);
assert_eq!(
b.next(ts("2024-03-15T02:00:00Z")),
ts("2024-03-15T03:00:00Z")
);
}
#[test]
fn a_zoned_day_is_local_midnight_to_local_midnight() {
let ny: Tz = "America/New_York".parse().unwrap();
let b = TimeBucket::utc(BucketWidth::Days(1)).in_zone(ny);
assert_eq!(
b.start_of(ts("2024-03-15T02:00:00Z")),
ts("2024-03-14T04:00:00Z")
);
}
#[test]
fn a_transition_day_is_not_twenty_four_hours() {
let ny: Tz = "America/New_York".parse().unwrap();
let day = TimeBucket::utc(BucketWidth::Days(1)).in_zone(ny);
let start = day.start_of(ts("2024-03-10T20:00:00Z"));
assert_eq!(start, ts("2024-03-10T05:00:00Z"));
assert_eq!(day.next(start) - start, 23 * HOUR);
let start = day.start_of(ts("2024-11-03T20:00:00Z"));
assert_eq!(start, ts("2024-11-03T04:00:00Z"));
assert_eq!(day.next(start) - start, 25 * HOUR);
}
#[test]
fn a_month_bucket_is_a_calendar_month() {
let b = TimeBucket::utc(BucketWidth::Months(1)).in_zone(berlin());
let feb = b.start_of(ts("2024-02-17T12:00:00Z"));
assert_eq!(feb, ts("2024-01-31T23:00:00Z"), "1 Feb 2024, Berlin (CET)");
assert_eq!(b.next(feb) - feb, 29 * 24 * HOUR);
let feb23 = b.start_of(ts("2023-02-17T12:00:00Z"));
assert_eq!(b.next(feb23) - feb23, 28 * 24 * HOUR);
let mar = b.start_of(ts("2024-03-17T12:00:00Z"));
assert_eq!(b.next(mar) - mar, 31 * 24 * HOUR - HOUR);
}
#[test]
fn a_year_bucket_is_a_calendar_year() {
let b = TimeBucket::utc(BucketWidth::Months(12));
let y = b.start_of(ts("2024-07-04T00:00:00Z"));
assert_eq!(y, ts("2024-01-01T00:00:00Z"));
assert_eq!(b.next(y), ts("2025-01-01T00:00:00Z"));
}
#[test]
fn a_quarter_is_three_calendar_months_from_january() {
let b = TimeBucket::utc(BucketWidth::Months(3));
assert_eq!(
b.start_of(ts("2024-05-17T00:00:00Z")),
ts("2024-04-01T00:00:00Z")
);
assert_eq!(
b.start_of(ts("2024-12-31T23:59:59Z")),
ts("2024-10-01T00:00:00Z")
);
}
#[test]
fn the_epoch_is_day_zero_and_a_thursday() {
let epoch = NaiveDate::from_ymd_opt(1970, 1, 1).unwrap();
assert_eq!(days_from_epoch(epoch), 0);
assert_eq!(epoch.weekday(), chrono::Weekday::Thu);
assert_eq!(
date_from_epoch_days(DAY_ANCHOR).unwrap().weekday(),
chrono::Weekday::Mon,
"the multi-day anchor is a Monday"
);
}
#[test]
fn a_week_starts_on_monday() {
let b = TimeBucket::utc(BucketWidth::Days(7));
let start = b.start_of(ts("2024-03-15T12:00:00Z"));
assert_eq!(start, ts("2024-03-11T00:00:00Z"));
assert_eq!(
DateTime::from_timestamp_nanos(start).date_naive().weekday(),
chrono::Weekday::Mon
);
}
#[test]
fn buckets_tile_the_line() {
let specs = [
TimeBucket::fixed_ns(HOUR),
TimeBucket::utc(BucketWidth::Fixed(15 * MIN)).in_zone(berlin()),
TimeBucket::utc(BucketWidth::Days(1)).in_zone(berlin()),
TimeBucket::utc(BucketWidth::Months(1)).in_zone(berlin()),
TimeBucket::utc(BucketWidth::Days(7)),
TimeBucket::fixed_ns(HOUR)
.with_origin(ts("2020-01-01T00:17:00Z"))
.unwrap(),
TimeBucket::utc(BucketWidth::Days(1))
.in_zone(berlin())
.with_origin(ts("2020-01-01T05:00:00Z"))
.unwrap(),
TimeBucket::utc(BucketWidth::Months(1))
.in_zone(berlin())
.with_origin(ts("2020-01-15T00:00:00Z"))
.unwrap(),
TimeBucket::utc(BucketWidth::Days(7))
.with_origin(ts("2020-01-01T00:00:00Z"))
.unwrap(),
TimeBucket::utc(BucketWidth::Days(1))
.in_zone(berlin())
.with_origin(ts("2020-01-15T01:30:00Z"))
.unwrap(),
];
let from = ts("2026-01-15T00:00:00Z");
let to = ts("2027-01-15T00:00:00Z");
for b in specs {
let mut cursor = b.start_of(from);
let mut steps = 0;
while cursor < to {
let next = b.next(cursor);
assert!(next > cursor, "{b} did not advance at {cursor}");
for probe in [cursor, cursor + (next - cursor) / 2, next - 1] {
assert_eq!(b.start_of(probe), cursor, "{b} at {probe}");
}
assert_eq!(b.start_of(next), next, "{b} boundary at {next}");
cursor = next;
steps += 1;
assert!(steps < 40_000, "{b} is not advancing");
}
}
}
#[test]
fn a_bucket_round_trips_through_the_catalog_format() {
for b in [
TimeBucket::fixed_ns(15 * MIN),
TimeBucket::utc(BucketWidth::Days(1)),
TimeBucket::utc(BucketWidth::Days(1)).in_zone(berlin()),
TimeBucket::utc(BucketWidth::Months(3)).in_zone(berlin()),
] {
let bytes = postcard::to_allocvec(&b).unwrap();
assert_eq!(postcard::from_bytes::<TimeBucket>(&bytes).unwrap(), b);
}
let seq = vec![
TimeBucket::fixed_ns(15 * MIN),
TimeBucket::utc(BucketWidth::Months(1)).in_zone(berlin()),
TimeBucket::utc(BucketWidth::Days(7)),
];
let bytes = postcard::to_allocvec(&seq).unwrap();
assert_eq!(
postcard::from_bytes::<Vec<TimeBucket>>(&bytes).unwrap(),
seq
);
}
#[test]
fn a_bucket_round_trips_through_json_by_zone_name() {
for b in [
TimeBucket::fixed_ns(15 * MIN),
TimeBucket::utc(BucketWidth::Days(1)).in_zone(berlin()),
TimeBucket::utc(BucketWidth::Months(3)).in_zone(berlin()),
] {
let json = serde_json::to_string(&b).unwrap();
assert_eq!(
serde_json::from_str::<TimeBucket>(&json).unwrap(),
b,
"{json}"
);
if let Some(name) = b.timezone() {
assert!(json.contains(name), "the zone travels by name: {json}");
}
}
}
#[test]
fn display_round_trips_through_parse() {
for (width, zone) in [("15m", None), ("1d", Some("Europe/Berlin")), ("1mo", None)] {
let b = TimeBucket::parse(width, zone).unwrap();
assert_eq!(b.width().to_string(), width);
assert_eq!(b.timezone(), zone);
}
}
#[test]
fn a_day_bucket_takes_its_boundary_from_the_origin() {
let b = TimeBucket::utc(BucketWidth::Days(1))
.with_origin(ts("2020-01-01T06:00:00Z"))
.unwrap();
assert_eq!(
b.start_of(ts("2024-03-15T05:00:00Z")),
ts("2024-03-14T06:00:00Z")
);
assert_eq!(
b.start_of(ts("2024-03-15T06:00:00Z")),
ts("2024-03-15T06:00:00Z")
);
assert_eq!(
b.next(ts("2024-03-15T06:00:00Z")),
ts("2024-03-16T06:00:00Z")
);
}
#[test]
fn a_month_bucket_takes_its_day_from_the_origin() {
let b = TimeBucket::utc(BucketWidth::Months(1))
.with_origin(ts("2020-01-15T00:00:00Z"))
.unwrap();
assert_eq!(
b.start_of(ts("2024-03-14T23:59:59Z")),
ts("2024-02-15T00:00:00Z")
);
assert_eq!(
b.start_of(ts("2024-03-15T00:00:00Z")),
ts("2024-03-15T00:00:00Z")
);
assert_eq!(
b.next(ts("2024-01-15T00:00:00Z")),
ts("2024-02-15T00:00:00Z")
);
}
#[test]
fn a_monthly_origin_after_the_twenty_eighth_is_refused() {
for day in ["2020-01-29", "2020-01-30", "2020-01-31"] {
let err = TimeBucket::utc(BucketWidth::Months(1))
.with_origin(ts(&format!("{day}T00:00:00Z")))
.unwrap_err();
assert!(err.0.contains("missing from some months"), "{}", err.0);
}
assert!(
TimeBucket::utc(BucketWidth::Months(1))
.with_origin(ts("2020-01-28T00:00:00Z"))
.is_ok()
);
assert!(
TimeBucket::utc(BucketWidth::Days(1))
.with_origin(ts("2020-01-31T00:00:00Z"))
.is_ok()
);
}
#[test]
fn a_multi_unit_origin_selects_which_unit_opens_a_bucket() {
let jan = TimeBucket::utc(BucketWidth::Months(3))
.with_origin(ts("2024-01-15T00:00:00Z"))
.unwrap();
let feb = TimeBucket::utc(BucketWidth::Months(3))
.with_origin(ts("2024-02-15T00:00:00Z"))
.unwrap();
let probe = ts("2024-05-20T00:00:00Z");
assert_eq!(jan.start_of(probe), ts("2024-04-15T00:00:00Z"));
assert_eq!(feb.start_of(probe), ts("2024-05-15T00:00:00Z"));
let a = TimeBucket::utc(BucketWidth::Months(1))
.with_origin(ts("2024-01-15T00:00:00Z"))
.unwrap();
let b = TimeBucket::utc(BucketWidth::Months(1))
.with_origin(ts("2020-07-15T00:00:00Z"))
.unwrap();
assert_eq!(a.start_of(probe), b.start_of(probe));
}
#[test]
fn the_origin_is_read_in_the_buckets_zone() {
let utc = TimeBucket::utc(BucketWidth::Days(1))
.with_origin(ts("2024-01-01T04:00:00Z"))
.unwrap();
let berlin = TimeBucket::utc(BucketWidth::Days(1))
.in_zone(berlin())
.with_origin(ts("2024-01-01T04:00:00Z"))
.unwrap();
assert_eq!(
utc.start_of(ts("2024-01-10T12:00:00Z")),
ts("2024-01-10T04:00:00Z")
);
assert_eq!(
berlin.start_of(ts("2024-01-10T12:00:00Z")),
ts("2024-01-10T04:00:00Z")
);
assert_eq!(
berlin.start_of(ts("2024-07-10T12:00:00Z")),
ts("2024-07-10T03:00:00Z")
);
}
#[test]
fn a_fixed_bucket_tiles_outwards_from_its_origin() {
let b = TimeBucket::fixed_ns(HOUR)
.with_origin(ts("2024-01-01T00:30:00Z"))
.unwrap();
assert_eq!(
b.start_of(ts("2024-01-01T01:00:00Z")),
ts("2024-01-01T00:30:00Z")
);
assert_eq!(
b.start_of(ts("2023-12-31T23:00:00Z")),
ts("2023-12-31T22:30:00Z")
);
}
#[test]
fn an_origin_round_trips_through_the_catalog_format() {
let b = TimeBucket::parse("1mo", Some("Europe/Berlin"))
.unwrap()
.with_origin(ts("2020-01-15T00:00:00Z"))
.unwrap();
let seq = vec![b, TimeBucket::fixed_ns(HOUR)];
let bytes = postcard::to_stdvec(&seq).unwrap();
let back: Vec<TimeBucket> = postcard::from_bytes(&bytes).unwrap();
assert_eq!(back, seq);
assert_eq!(back[0].origin(), Some(ts("2020-01-15T00:00:00Z")));
}
#[test]
fn a_deserialised_origin_goes_through_the_same_rule() {
let json = r#"{"width":{"Months":1},"timezone":null,"origin":1706659200000000000}"#;
let err = serde_json::from_str::<TimeBucket>(json).unwrap_err();
assert!(
err.to_string().contains("missing from some months"),
"{err}"
);
let ok = r#"{"width":{"Months":1},"timezone":null,"origin":1706400000000000000}"#;
assert!(serde_json::from_str::<TimeBucket>(ok).is_ok());
}
#[test]
fn a_width_of_zero_is_refused() {
for spec in ["0s", "0d", "0mo", "-1h"] {
assert!(
spec.parse::<BucketWidth>().is_err(),
"'{spec}' should be refused"
);
}
}
}