use chrono::{Local, MappedLocalTime, TimeZone, Timelike};
const MAX_TIME_VALUE: i64 = 8_640_000_000_000_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Components {
pub(crate) year: i64,
pub(crate) month: i64,
pub(crate) day: i64,
pub(crate) hour: i64,
pub(crate) minute: i64,
pub(crate) second: i64,
pub(crate) millisecond: i64,
pub(crate) offset_minutes: Option<i64>,
}
impl Components {
#[cfg(test)]
pub(crate) fn date(year: i64, month: i64, day: i64) -> Self {
Self {
year,
month,
day,
hour: 0,
minute: 0,
second: 0,
millisecond: 0,
offset_minutes: None,
}
}
pub(crate) fn to_timestamp(self) -> Option<i64> {
let days = days_from_civil(self.year, self.month, self.day)?;
let time =
self.hour * 3_600_000 + self.minute * 60_000 + self.second * 1_000 + self.millisecond;
let naive = days.checked_mul(86_400_000)?.checked_add(time)?;
let utc = match self.offset_minutes {
Some(offset) => naive.checked_sub(offset.checked_mul(60_000)?)?,
None => local_to_utc(naive)?,
};
(utc.abs() <= MAX_TIME_VALUE).then_some(utc)
}
}
fn days_from_civil(year: i64, month: i64, day: i64) -> Option<i64> {
let zero_based = month.checked_sub(1)?;
let year = year.checked_add(zero_based.div_euclid(12))?;
let month = zero_based.rem_euclid(12) + 1;
let shifted_year = if month <= 2 { year - 1 } else { year };
let era = shifted_year.div_euclid(400);
let year_of_era = shifted_year - era * 400;
let day_of_year = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5;
let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
let first_of_month = era * 146_097 + day_of_era - 719_468;
first_of_month.checked_add(day.checked_sub(1)?)
}
thread_local! {
static ZONE: std::cell::Cell<Option<chrono_tz::Tz>> =
const { std::cell::Cell::new(None) };
}
pub(crate) fn set_zone(zone: Option<chrono_tz::Tz>) {
ZONE.with(|current| current.set(zone));
}
pub(crate) fn zone_by_name(name: &str) -> Option<chrono_tz::Tz> {
name.parse().ok()
}
#[cfg(test)]
pub(crate) fn with_zone<T>(zone: chrono_tz::Tz, body: impl FnOnce() -> T) -> T {
let previous = ZONE.with(std::cell::Cell::get);
set_zone(Some(zone));
let result = body();
set_zone(previous);
result
}
fn local_to_utc(naive_millis: i64) -> Option<i64> {
match ZONE.with(std::cell::Cell::get) {
Some(zone) => resolve_in(&zone, naive_millis),
None => resolve_in(&Local, naive_millis),
}
}
fn resolve_in<Zone: TimeZone>(zone: &Zone, naive_millis: i64) -> Option<i64> {
let naive = chrono::DateTime::from_timestamp_millis(naive_millis)?.naive_utc();
match zone.from_local_datetime(&naive) {
MappedLocalTime::Single(resolved) => Some(resolved.timestamp_millis()),
MappedLocalTime::Ambiguous(..) | MappedLocalTime::None => {
let day_before = naive.checked_sub_signed(chrono::Duration::days(1))?;
let settled = zone.from_local_datetime(&day_before).earliest()?;
let offset =
settled.naive_local().and_utc().timestamp_millis() - settled.timestamp_millis();
naive_millis.checked_sub(offset)
}
}
}
pub(crate) fn current_year() -> i64 {
i64::from(chrono::Datelike::year(&Local::now()))
}
pub(crate) fn to_iso(timestamp: i64) -> Option<String> {
let moment = chrono::DateTime::from_timestamp_millis(timestamp)?;
Some(if moment.nanosecond() % 1_000_000_000 == 0 {
moment.format("%Y-%m-%dT%H:%M:%SZ").to_string()
} else {
moment.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()
})
}
#[cfg(test)]
mod tests {
use super::*;
fn utc(year: i64, month: i64, day: i64) -> Option<i64> {
let mut components = Components::date(year, month, day);
components.offset_minutes = Some(0);
components.to_timestamp()
}
#[test]
fn the_epoch_is_zero() {
assert_eq!(utc(1970, 1, 1), Some(0));
}
#[test]
fn dates_before_the_epoch_are_negative() {
assert_eq!(utc(1969, 12, 31), Some(-86_400_000));
}
#[test]
fn a_day_past_the_end_of_the_month_rolls_over() {
assert_eq!(utc(2024, 2, 30), utc(2024, 3, 1));
assert_eq!(utc(2023, 2, 29), utc(2023, 3, 1));
}
#[test]
fn a_leap_day_that_exists_does_not_roll_over() {
assert_eq!(utc(2024, 2, 29), Some(1_709_164_800_000));
}
#[test]
fn centuries_divisible_by_four_hundred_are_leap_years() {
assert_eq!(utc(2000, 2, 29), utc(2000, 2, 29));
assert_ne!(utc(1900, 2, 29), utc(1900, 2, 28));
}
#[test]
fn a_month_outside_the_year_carries_into_it() {
assert_eq!(utc(2024, 13, 1), utc(2025, 1, 1));
assert_eq!(utc(2024, 0, 1), utc(2023, 12, 1));
}
#[test]
fn beyond_the_representable_range_there_is_no_instant() {
assert_eq!(utc(300_000, 1, 1), None);
assert_eq!(utc(-300_000, 1, 1), None);
}
#[test]
fn an_instant_renders_as_iso() {
assert_eq!(to_iso(0).as_deref(), Some("1970-01-01T00:00:00Z"));
assert_eq!(
to_iso(1_705_314_645_123).as_deref(),
Some("2024-01-15T10:30:45.123Z")
);
}
}