mod utc;
mod offset;
mod file;
mod local;
pub use utc::TzUtc;
pub use offset::TzOffset;
pub use file::{TzFile, TzFileData};
pub use local::TzLocal;
use std::sync::{LazyLock, RwLock};
use rustc_hash::FxHashMap;
use chrono::{NaiveDateTime, TimeDelta};
use crate::error::TzError;
pub trait TzOps {
fn utcoffset(&self, dt: NaiveDateTime, fold: bool) -> i32;
fn dst(&self, dt: NaiveDateTime, fold: bool) -> i32;
fn tzname(&self, dt: NaiveDateTime, fold: bool) -> &str;
fn is_ambiguous(&self, dt: NaiveDateTime) -> bool;
fn fromutc(&self, dt: NaiveDateTime) -> NaiveDateTime;
}
impl TzOps for TzUtc {
#[inline] fn utcoffset(&self, dt: NaiveDateTime, fold: bool) -> i32 { self.utcoffset(dt, fold) }
#[inline] fn dst(&self, dt: NaiveDateTime, fold: bool) -> i32 { self.dst(dt, fold) }
#[inline] fn tzname(&self, dt: NaiveDateTime, fold: bool) -> &str { self.tzname(dt, fold) }
#[inline] fn is_ambiguous(&self, dt: NaiveDateTime) -> bool { self.is_ambiguous(dt) }
#[inline] fn fromutc(&self, dt: NaiveDateTime) -> NaiveDateTime { self.fromutc(dt) }
}
impl TzOps for TzOffset {
#[inline] fn utcoffset(&self, dt: NaiveDateTime, fold: bool) -> i32 { self.utcoffset(dt, fold) }
#[inline] fn dst(&self, dt: NaiveDateTime, fold: bool) -> i32 { self.dst(dt, fold) }
#[inline] fn tzname(&self, dt: NaiveDateTime, fold: bool) -> &str { self.tzname(dt, fold) }
#[inline] fn is_ambiguous(&self, dt: NaiveDateTime) -> bool { self.is_ambiguous(dt) }
#[inline] fn fromutc(&self, dt: NaiveDateTime) -> NaiveDateTime { self.fromutc(dt) }
}
impl TzOps for TzFile {
#[inline] fn utcoffset(&self, dt: NaiveDateTime, fold: bool) -> i32 { self.utcoffset(dt, fold) }
#[inline] fn dst(&self, dt: NaiveDateTime, fold: bool) -> i32 { self.dst(dt, fold) }
#[inline] fn tzname(&self, dt: NaiveDateTime, fold: bool) -> &str { self.tzname(dt, fold) }
#[inline] fn is_ambiguous(&self, dt: NaiveDateTime) -> bool { self.is_ambiguous(dt) }
#[inline] fn fromutc(&self, dt: NaiveDateTime) -> NaiveDateTime { self.fromutc(dt) }
}
impl TzOps for TzLocal {
#[inline] fn utcoffset(&self, dt: NaiveDateTime, fold: bool) -> i32 { self.utcoffset(dt, fold) }
#[inline] fn dst(&self, dt: NaiveDateTime, fold: bool) -> i32 { self.dst(dt, fold) }
#[inline] fn tzname(&self, dt: NaiveDateTime, fold: bool) -> &str { self.tzname(dt, fold) }
#[inline] fn is_ambiguous(&self, dt: NaiveDateTime) -> bool { self.is_ambiguous(dt) }
#[inline] fn fromutc(&self, dt: NaiveDateTime) -> NaiveDateTime { self.fromutc(dt) }
}
#[derive(Debug, Clone)]
pub enum TimeZone {
Utc(TzUtc),
Offset(TzOffset),
File(TzFile),
Local(TzLocal),
}
impl TzOps for TimeZone {
#[inline]
fn utcoffset(&self, dt: NaiveDateTime, fold: bool) -> i32 {
match self {
TimeZone::Utc(tz) => tz.utcoffset(dt, fold),
TimeZone::Offset(tz) => tz.utcoffset(dt, fold),
TimeZone::File(tz) => tz.utcoffset(dt, fold),
TimeZone::Local(tz) => tz.utcoffset(dt, fold),
}
}
#[inline]
fn dst(&self, dt: NaiveDateTime, fold: bool) -> i32 {
match self {
TimeZone::Utc(tz) => tz.dst(dt, fold),
TimeZone::Offset(tz) => tz.dst(dt, fold),
TimeZone::File(tz) => tz.dst(dt, fold),
TimeZone::Local(tz) => tz.dst(dt, fold),
}
}
#[inline]
fn tzname(&self, dt: NaiveDateTime, fold: bool) -> &str {
match self {
TimeZone::Utc(tz) => tz.tzname(dt, fold),
TimeZone::Offset(tz) => tz.tzname(dt, fold),
TimeZone::File(tz) => tz.tzname(dt, fold),
TimeZone::Local(tz) => tz.tzname(dt, fold),
}
}
#[inline]
fn is_ambiguous(&self, dt: NaiveDateTime) -> bool {
match self {
TimeZone::Utc(tz) => tz.is_ambiguous(dt),
TimeZone::Offset(tz) => tz.is_ambiguous(dt),
TimeZone::File(tz) => tz.is_ambiguous(dt),
TimeZone::Local(tz) => tz.is_ambiguous(dt),
}
}
#[inline]
fn fromutc(&self, dt: NaiveDateTime) -> NaiveDateTime {
match self {
TimeZone::Utc(tz) => tz.fromutc(dt),
TimeZone::Offset(tz) => tz.fromutc(dt),
TimeZone::File(tz) => tz.fromutc(dt),
TimeZone::Local(tz) => tz.fromutc(dt),
}
}
}
impl TimeZone {
#[inline]
pub fn utcoffset_delta(&self, dt: NaiveDateTime, fold: bool) -> TimeDelta {
TimeDelta::seconds(self.utcoffset(dt, fold) as i64)
}
#[inline]
pub fn dst_delta(&self, dt: NaiveDateTime, fold: bool) -> TimeDelta {
TimeDelta::seconds(self.dst(dt, fold) as i64)
}
}
impl TimeZone {
#[inline]
pub fn utc() -> Self {
TimeZone::Utc(TzUtc)
}
#[inline]
pub fn offset(name: Option<&str>, offset_secs: i32) -> Self {
TimeZone::Offset(TzOffset::new(name, offset_secs))
}
#[inline]
pub fn local() -> Self {
TimeZone::Local(TzLocal::new())
}
}
pub(super) const TZPATHS: &[&str] = &[
"/usr/share/zoneinfo",
"/usr/lib/zoneinfo",
"/usr/share/lib/zoneinfo",
"/etc/zoneinfo",
];
const UTC_NAMES: &[&str] = &["UTC", "utc", "GMT", "gmt", "Z", "z"];
static TZ_CACHE: LazyLock<RwLock<FxHashMap<Box<str>, TimeZone>>> =
LazyLock::new(|| RwLock::new(FxHashMap::default()));
pub fn gettz(name: Option<&str>) -> Result<TimeZone, TzError> {
let key = name.unwrap_or("");
if let Ok(cache) = TZ_CACHE.read() {
if let Some(tz) = cache.get(key) {
return Ok(tz.clone());
}
}
let tz = resolve_tz(key)?;
if !key.is_empty() {
if let Ok(mut cache) = TZ_CACHE.write() {
cache.entry(key.into()).or_insert_with(|| tz.clone());
}
}
Ok(tz)
}
pub fn cache_clear() {
if let Ok(mut cache) = TZ_CACHE.write() {
cache.clear();
}
}
fn resolve_tz(name: &str) -> Result<TimeZone, TzError> {
if name.is_empty() {
return Ok(TimeZone::local());
}
let name = name.strip_prefix(':').unwrap_or(name);
if UTC_NAMES.contains(&name) {
return Ok(TimeZone::utc());
}
if name.starts_with('/') {
let tz = TzFile::from_path(name)?;
return Ok(TimeZone::File(tz));
}
let normalized = name.replace(' ', "_");
for base in TZPATHS {
let path = format!("{}/{}", base, normalized);
if let Ok(tz) = TzFile::from_path(&path) {
return Ok(TimeZone::File(tz));
}
}
Err(TzError::NotFound(name.into()))
}
pub fn datetime_exists(dt: NaiveDateTime, tz: &impl TzOps) -> bool {
let offset_secs = tz.utcoffset(dt, false) as i64;
let utc = dt - TimeDelta::seconds(offset_secs);
let wall = tz.fromutc(utc);
wall == dt
}
pub fn datetime_ambiguous(dt: NaiveDateTime, tz: &impl TzOps) -> bool {
tz.is_ambiguous(dt)
}
pub fn resolve_imaginary(dt: NaiveDateTime, tz: &impl TzOps) -> NaiveDateTime {
if datetime_exists(dt, tz) {
return dt;
}
let day = TimeDelta::hours(24);
let off_before = tz.utcoffset(dt - day, false) as i64;
let off_after = tz.utcoffset(dt + day, false) as i64;
dt + TimeDelta::seconds(off_after - off_before)
}
#[cfg(test)]
mod tests {
use chrono::NaiveDate;
use super::*;
fn dt(y: i32, m: u32, d: u32, h: u32, mi: u32, s: u32) -> NaiveDateTime {
NaiveDate::from_ymd_opt(y, m, d).unwrap().and_hms_opt(h, mi, s).unwrap()
}
#[test]
fn test_timezone_utc_dispatch() {
let tz = TimeZone::utc();
let d = dt(2024, 6, 15, 12, 0, 0);
assert_eq!(tz.utcoffset(d, false), 0);
assert_eq!(tz.dst(d, false), 0);
assert_eq!(tz.tzname(d, false), "UTC");
assert!(!tz.is_ambiguous(d));
assert_eq!(tz.fromutc(d), d);
}
#[test]
fn test_timezone_offset_dispatch() {
let tz = TimeZone::offset(Some("JST"), 9 * 3600);
let d = dt(2024, 6, 15, 0, 0, 0);
assert_eq!(tz.utcoffset(d, false), 32400);
assert_eq!(tz.dst(d, false), 0);
assert_eq!(tz.tzname(d, false), "JST");
assert!(!tz.is_ambiguous(d));
assert_eq!(tz.fromutc(d), dt(2024, 6, 15, 9, 0, 0));
}
#[test]
fn test_timezone_local_dispatch() {
let tz = TimeZone::local();
let d = dt(2024, 6, 15, 12, 0, 0);
let off = tz.utcoffset(d, false);
assert!(off.abs() <= 14 * 3600);
}
#[test]
fn test_utcoffset_delta() {
let tz = TimeZone::offset(Some("EST"), -5 * 3600);
let d = dt(2024, 1, 1, 0, 0, 0);
assert_eq!(tz.utcoffset_delta(d, false), TimeDelta::hours(-5));
}
#[test]
fn test_gettz_iana_name() {
let tz = gettz(Some("America/New_York")).unwrap();
assert_eq!(tz.utcoffset(dt(2024, 1, 15, 12, 0, 0), false), -5 * 3600);
assert_eq!(tz.utcoffset(dt(2024, 6, 15, 12, 0, 0), false), -4 * 3600);
}
#[test]
fn test_gettz_tokyo() {
let tz = gettz(Some("Asia/Tokyo")).unwrap();
assert_eq!(tz.utcoffset(dt(2024, 6, 15, 12, 0, 0), false), 9 * 3600);
assert_eq!(tz.tzname(dt(2024, 6, 15, 12, 0, 0), false), "JST");
}
#[test]
fn test_gettz_empty_is_local() {
let tz = gettz(None).unwrap();
let d = dt(2024, 6, 15, 12, 0, 0);
let off = tz.utcoffset(d, false);
assert!(off.abs() <= 14 * 3600);
}
#[test]
fn test_gettz_not_found() {
let err = gettz(Some("NonExistent/Timezone")).unwrap_err();
assert!(matches!(err, TzError::NotFound(_)));
}
#[test]
fn test_gettz_caching() {
let tz1 = gettz(Some("Asia/Tokyo")).unwrap();
let tz2 = gettz(Some("Asia/Tokyo")).unwrap();
let d = dt(2024, 1, 1, 0, 0, 0);
assert_eq!(tz1.utcoffset(d, false), tz2.utcoffset(d, false));
}
#[test]
fn test_gettz_absolute_path() {
let tz = gettz(Some("/usr/share/zoneinfo/UTC")).unwrap();
assert_eq!(tz.utcoffset(dt(2024, 1, 1, 0, 0, 0), false), 0);
}
#[test]
fn test_datetime_exists_gap() {
let tz = gettz(Some("America/New_York")).unwrap();
assert!(!datetime_exists(dt(2024, 3, 10, 2, 30, 0), &tz));
assert!(datetime_exists(dt(2024, 3, 10, 3, 30, 0), &tz));
assert!(datetime_exists(dt(2024, 3, 10, 1, 30, 0), &tz));
}
#[test]
fn test_datetime_ambiguous_overlap() {
let tz = gettz(Some("America/New_York")).unwrap();
assert!(datetime_ambiguous(dt(2024, 11, 3, 1, 30, 0), &tz));
assert!(!datetime_ambiguous(dt(2024, 11, 3, 0, 30, 0), &tz));
assert!(!datetime_ambiguous(dt(2024, 11, 3, 2, 30, 0), &tz));
}
#[test]
fn test_resolve_imaginary() {
let tz = gettz(Some("America/New_York")).unwrap();
let resolved = resolve_imaginary(dt(2024, 3, 10, 2, 30, 0), &tz);
assert_eq!(resolved, dt(2024, 3, 10, 3, 30, 0));
}
#[test]
fn test_resolve_imaginary_existing() {
let tz = gettz(Some("America/New_York")).unwrap();
let d = dt(2024, 6, 15, 12, 0, 0);
assert_eq!(resolve_imaginary(d, &tz), d); }
#[test]
fn test_datetime_exists_utc() {
let tz = TimeZone::utc();
assert!(datetime_exists(dt(2024, 3, 10, 2, 30, 0), &tz));
}
#[test]
fn test_datetime_ambiguous_utc() {
let tz = TimeZone::utc();
assert!(!datetime_ambiguous(dt(2024, 11, 3, 1, 30, 0), &tz));
}
#[test]
fn test_resolve_imaginary_utc() {
let tz = TimeZone::utc();
let d = dt(2024, 3, 10, 2, 30, 0);
assert_eq!(resolve_imaginary(d, &tz), d);
}
#[test]
fn test_datetime_exists_fixed_offset() {
let tz = TimeZone::offset(Some("EST"), -5 * 3600);
assert!(datetime_exists(dt(2024, 3, 10, 2, 30, 0), &tz));
}
#[test]
fn test_dst_delta() {
let tz = TimeZone::offset(Some("EST"), -5 * 3600);
let d = dt(2024, 1, 1, 0, 0, 0);
assert_eq!(tz.dst_delta(d, false), TimeDelta::zero());
}
#[test]
fn test_dst_delta_with_dst_timezone() {
let tz = gettz(Some("America/New_York")).unwrap();
let d_summer = dt(2024, 7, 15, 12, 0, 0);
assert_eq!(tz.dst_delta(d_summer, false), TimeDelta::hours(1));
let d_winter = dt(2024, 1, 15, 12, 0, 0);
assert_eq!(tz.dst_delta(d_winter, false), TimeDelta::zero());
}
#[test]
fn test_cache_clear() {
let _ = gettz(Some("UTC")).unwrap();
cache_clear();
let tz = gettz(Some("UTC")).unwrap();
assert_eq!(tz.utcoffset(dt(2024, 1, 1, 0, 0, 0), false), 0);
}
#[test]
fn test_gettz_all_utc_aliases() {
for name in &["UTC", "utc", "GMT", "gmt", "Z", "z"] {
let tz = gettz(Some(name)).unwrap();
assert_eq!(tz.utcoffset(dt(2024, 1, 1, 0, 0, 0), false), 0,
"failed for alias: {name}");
}
}
#[test]
fn test_gettz_colon_prefix() {
let tz = gettz(Some(":America/New_York")).unwrap();
assert_eq!(tz.utcoffset(dt(2024, 1, 15, 12, 0, 0), false), -5 * 3600);
}
#[test]
fn test_gettz_empty_string_is_local() {
let tz = gettz(Some("")).unwrap();
let d = dt(2024, 6, 15, 12, 0, 0);
let off = tz.utcoffset(d, false);
assert!(off.abs() <= 14 * 3600);
}
#[test]
fn test_timezone_file_dispatch() {
let tz = gettz(Some("Asia/Tokyo")).unwrap();
let d = dt(2024, 6, 15, 12, 0, 0);
assert_eq!(tz.utcoffset(d, false), 9 * 3600);
assert_eq!(tz.dst(d, false), 0);
assert_eq!(tz.tzname(d, false), "JST");
assert!(!tz.is_ambiguous(d));
let wall = tz.fromutc(dt(2024, 6, 15, 0, 0, 0));
assert_eq!(wall, dt(2024, 6, 15, 9, 0, 0));
}
#[test]
fn test_datetime_exists_gap_boundaries() {
let tz = gettz(Some("America/New_York")).unwrap();
assert!(datetime_exists(dt(2024, 3, 10, 1, 59, 59), &tz));
assert!(datetime_exists(dt(2024, 3, 10, 3, 0, 0), &tz));
assert!(!datetime_exists(dt(2024, 3, 10, 2, 0, 0), &tz));
}
#[test]
fn test_resolve_imaginary_gap_start() {
let tz = gettz(Some("America/New_York")).unwrap();
let resolved = resolve_imaginary(dt(2024, 3, 10, 2, 0, 0), &tz);
assert_eq!(resolved, dt(2024, 3, 10, 3, 0, 0));
}
#[test]
fn test_resolve_imaginary_gap_end() {
let tz = gettz(Some("America/New_York")).unwrap();
let resolved = resolve_imaginary(dt(2024, 3, 10, 2, 59, 0), &tz);
assert_eq!(resolved, dt(2024, 3, 10, 3, 59, 0));
}
#[test]
fn test_datetime_ambiguous_fixed_offset() {
let tz = TimeZone::offset(Some("EST"), -5 * 3600);
assert!(!datetime_ambiguous(dt(2024, 11, 3, 1, 30, 0), &tz));
}
#[test]
fn test_resolve_imaginary_fixed_offset() {
let tz = TimeZone::offset(Some("EST"), -5 * 3600);
let d = dt(2024, 3, 10, 2, 30, 0);
assert_eq!(resolve_imaginary(d, &tz), d);
}
#[test]
fn test_gettz_europe() {
let tz = gettz(Some("Europe/London")).unwrap();
assert_eq!(tz.utcoffset(dt(2024, 1, 15, 12, 0, 0), false), 0);
assert_eq!(tz.utcoffset(dt(2024, 7, 15, 12, 0, 0), false), 3600);
}
#[test]
fn test_gettz_southern_hemisphere() {
let tz = gettz(Some("Australia/Sydney")).unwrap();
assert_eq!(tz.utcoffset(dt(2024, 1, 15, 12, 0, 0), false), 11 * 3600);
assert_eq!(tz.utcoffset(dt(2024, 7, 15, 12, 0, 0), false), 10 * 3600);
}
#[test]
fn test_timezone_local_dst() {
let tz = gettz(None).unwrap(); let d = dt(2024, 7, 15, 12, 0, 0);
let _ = tz.dst(d, false);
}
#[test]
fn test_timezone_local_tzname() {
let tz = gettz(None).unwrap();
let d = dt(2024, 1, 15, 12, 0, 0);
let name = tz.tzname(d, false);
assert!(!name.is_empty());
}
#[test]
fn test_timezone_local_is_ambiguous() {
let tz = gettz(None).unwrap();
let d = dt(2024, 6, 15, 12, 0, 0);
let _ = tz.is_ambiguous(d);
}
#[test]
fn test_timezone_local_fromutc() {
let tz = gettz(None).unwrap();
let d = dt(2024, 1, 15, 12, 0, 0);
let wall = tz.fromutc(d);
let offset = tz.utcoffset(wall, false);
let diff = (wall - d).num_seconds() as i32;
assert_eq!(diff, offset);
}
}