#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Bucket {
Day,
Week,
Month,
}
impl Bucket {
pub fn from_name(name: &str) -> Option<Self> {
match name.trim().to_ascii_lowercase().as_str() {
"day" | "daily" => Some(Bucket::Day),
"week" | "weekly" => Some(Bucket::Week),
"month" | "monthly" => Some(Bucket::Month),
_ => None,
}
}
}
#[allow(clippy::cast_possible_truncation)]
pub fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = (if y >= 0 { y } else { y - 399 }) / 400;
let yoe = (y - era * 400) as u64;
let mp = if m > 2 { m - 3 } else { m + 9 };
let doy = (153 * mp as u64 + 2) / 5 + d as u64 - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe as i64 - 719_468
}
pub fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = (if z >= 0 { z } else { z - 146_096 }) / 146_097;
let doe = (z - era * 146_097) as u64;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
pub fn parse_date_to_unix(s: &str) -> Option<i64> {
let s = s.trim();
let mut parts = s.split('-');
let y: i64 = parts.next()?.trim().parse().ok()?;
let m: u32 = parts.next()?.trim().parse().ok()?;
let d: u32 = parts.next()?.trim().parse().ok()?;
if parts.next().is_some() {
return None;
}
if !(1..=12).contains(&m) {
return None;
}
let days = days_from_civil(y, m, d);
let (yr, mo, dy) = civil_from_days(days);
if (yr, mo, dy) != (y, m, d) {
return None;
}
Some(days * 86_400)
}
pub fn bucket_key(ts: u64, bucket: Bucket) -> String {
let days = (ts / 86_400) as i64;
let (y, m, d) = civil_from_days(days);
match bucket {
Bucket::Day | Bucket::Week => {
if matches!(bucket, Bucket::Week) {
let monday = days - (days + 3).rem_euclid(7);
let (wy, wm, wd) = civil_from_days(monday);
format!("{wy:04}-{wm:02}-{wd:02}")
} else {
format!("{y:04}-{m:02}-{d:02}")
}
}
Bucket::Month => format!("{y:04}-{m:02}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn epoch_is_zero_days() {
assert_eq!(days_from_civil(1970, 1, 1), 0);
}
#[test]
fn y2k_known_offset() {
assert_eq!(days_from_civil(2000, 1, 1), 10_957);
}
#[test]
fn civil_round_trip() {
for (y, m, d) in [
(1970, 1, 1),
(2000, 1, 1),
(1999, 12, 31),
(2024, 2, 29),
(2099, 6, 15),
(1601, 1, 1),
] {
let days = days_from_civil(y, m, d);
assert_eq!(civil_from_days(days), (y, m, d), "{y}-{m}-{d}");
}
}
#[test]
fn parse_date_valid() {
assert_eq!(parse_date_to_unix("2020-01-01"), Some(1_577_836_800));
assert_eq!(parse_date_to_unix("1970-01-01"), Some(0));
}
#[test]
fn parse_date_invalid() {
assert_eq!(parse_date_to_unix("not-a-date"), None);
assert_eq!(parse_date_to_unix("2020-13-01"), None);
assert_eq!(parse_date_to_unix("2020-00-40"), None);
assert_eq!(parse_date_to_unix("2020-02-30"), None);
assert_eq!(parse_date_to_unix("2023-04-31"), None);
assert_eq!(parse_date_to_unix("2023-02-29"), None);
assert_eq!(
parse_date_to_unix("2020-1-1"),
Some(days_from_civil(2020, 1, 1) * 86400)
);
}
#[test]
fn bucket_day_key() {
assert_eq!(bucket_key(1_577_836_800, Bucket::Day), "2020-01-01");
}
#[test]
fn bucket_month_key() {
assert_eq!(bucket_key(1_577_836_800, Bucket::Month), "2020-01");
}
#[test]
fn bucket_week_aligns_to_monday() {
let key = bucket_key(1_577_923_200, Bucket::Week); assert_eq!(key, "2019-12-30");
}
#[test]
fn bucket_from_name() {
assert_eq!(Bucket::from_name("day"), Some(Bucket::Day));
assert_eq!(Bucket::from_name("WEEKLY"), Some(Bucket::Week));
assert_eq!(Bucket::from_name("month"), Some(Bucket::Month));
assert_eq!(Bucket::from_name("hour"), None);
}
}