use chrono::{DateTime, Datelike, NaiveDate, NaiveDateTime, TimeZone, Timelike};
use crate::error::{Result, VolasError};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Tz {
#[default]
Naive,
Utc,
Offset(i32),
Named(chrono_tz::Tz),
}
impl Tz {
pub fn parse(s: &str) -> Result<Tz> {
let s = s.trim();
if s.is_empty() {
return Err(VolasError::Value(
"empty timezone — pass an IANA name, an offset like \"+08:00\", or \"UTC\"".into(),
));
}
if s.eq_ignore_ascii_case("utc") || s == "Z" {
return Ok(Tz::Utc);
}
if let Some(off) = parse_offset_seconds(s) {
return Ok(if off == 0 { Tz::Utc } else { Tz::Offset(off) });
}
s.parse::<chrono_tz::Tz>()
.map(Tz::Named)
.map_err(|_| VolasError::Value(format!("unknown timezone {s:?}")))
}
pub fn fixed_offset_secs(&self) -> Option<i32> {
match self {
Tz::Naive | Tz::Utc => Some(0),
Tz::Offset(s) => Some(*s),
Tz::Named(_) => None,
}
}
pub fn civil_parts(&self, ns: i64) -> (i64, i64, i64, i64, i64, i64) {
match self {
Tz::Naive | Tz::Utc | Tz::Offset(_) => {
let off = self.fixed_offset_secs().unwrap_or(0) as i64;
let local = ns + off * 1_000_000_000;
let secs = local.div_euclid(1_000_000_000);
let nsub = local.rem_euclid(1_000_000_000) as u32;
parts(
DateTime::from_timestamp(secs, nsub)
.unwrap_or_default()
.naive_utc(),
)
}
Tz::Named(tz) => {
let secs = ns.div_euclid(1_000_000_000);
let nsub = ns.rem_euclid(1_000_000_000) as u32;
let dt = DateTime::from_timestamp(secs, nsub)
.unwrap_or_default()
.with_timezone(tz);
(
dt.year() as i64,
dt.month() as i64,
dt.day() as i64,
dt.hour() as i64,
dt.minute() as i64,
dt.second() as i64,
)
}
}
}
pub fn wall_to_utc_ns(&self, y: i32, mo: u32, d: u32, h: u32, mi: u32, s: u32) -> Option<i64> {
let naive = NaiveDate::from_ymd_opt(y, mo, d)?.and_hms_opt(h, mi, s)?;
match self {
Tz::Naive | Tz::Utc | Tz::Offset(_) => {
let off = self.fixed_offset_secs().unwrap_or(0) as i64;
naive
.and_utc()
.timestamp_nanos_opt()
.map(|ns| ns - off * 1_000_000_000)
}
Tz::Named(tz) => match tz.from_local_datetime(&naive) {
chrono::LocalResult::Single(dt) => dt.timestamp_nanos_opt(),
chrono::LocalResult::Ambiguous(_, _) => None,
chrono::LocalResult::None => None,
},
}
}
pub fn wall_to_utc_ns_earliest(
&self,
y: i32,
mo: u32,
d: u32,
h: u32,
mi: u32,
s: u32,
) -> Option<i64> {
match self {
Tz::Naive | Tz::Utc | Tz::Offset(_) => self.wall_to_utc_ns(y, mo, d, h, mi, s),
Tz::Named(tz) => {
let mut naive = NaiveDate::from_ymd_opt(y, mo, d)?.and_hms_opt(h, mi, s)?;
for _ in 0..16 {
match tz.from_local_datetime(&naive) {
chrono::LocalResult::Single(dt) => return dt.timestamp_nanos_opt(),
chrono::LocalResult::Ambiguous(a, _) => return a.timestamp_nanos_opt(),
chrono::LocalResult::None => naive += chrono::Duration::minutes(15),
}
}
None }
}
}
pub fn name(&self) -> String {
match self {
Tz::Naive => "naive".to_string(),
Tz::Utc => "UTC".to_string(),
Tz::Offset(s) => format_offset(*s),
Tz::Named(tz) => tz.name().to_string(),
}
}
pub fn is_utc(&self) -> bool {
matches!(self, Tz::Naive | Tz::Utc)
}
pub fn is_aware(&self) -> bool {
!matches!(self, Tz::Naive)
}
pub fn offset_secs_at(&self, ns: i64) -> i32 {
match self {
Tz::Naive | Tz::Utc => 0,
Tz::Offset(s) => *s,
Tz::Named(tz) => {
use chrono::Offset;
let secs = ns.div_euclid(1_000_000_000);
let nsub = ns.rem_euclid(1_000_000_000) as u32;
let utc = DateTime::from_timestamp(secs, nsub).unwrap_or_default();
tz.offset_from_utc_datetime(&utc.naive_utc()).fix().local_minus_utc()
}
}
}
pub fn dst_secs_at(&self, ns: i64) -> i32 {
match self {
Tz::Named(_) => {
let (y, ..) = self.civil_parts(ns);
let jan = self
.wall_to_utc_ns(y as i32, 1, 15, 12, 0, 0)
.map(|jns| self.offset_secs_at(jns))
.unwrap_or(0);
self.offset_secs_at(ns) - jan.min(self.offset_secs_at(ns))
}
_ => 0,
}
}
}
fn parts(dt: NaiveDateTime) -> (i64, i64, i64, i64, i64, i64) {
(
dt.year() as i64,
dt.month() as i64,
dt.day() as i64,
dt.hour() as i64,
dt.minute() as i64,
dt.second() as i64,
)
}
fn parse_offset_seconds(s: &str) -> Option<i32> {
let sign = match s.as_bytes().first()? {
b'+' => 1,
b'-' => -1,
_ => return None,
};
let rest = &s[1..];
let (hh, mm) = if let Some((h, m)) = rest.split_once(':') {
(h.parse::<i32>().ok()?, m.parse::<i32>().ok()?)
} else if rest.len() <= 2 {
(rest.parse::<i32>().ok()?, 0)
} else if rest.len() == 4 {
(
rest[..2].parse::<i32>().ok()?,
rest[2..].parse::<i32>().ok()?,
)
} else {
return None;
};
if hh > 23 || mm > 59 {
return None;
}
Some(sign * (hh * 3600 + mm * 60))
}
fn format_offset(secs: i32) -> String {
let sign = if secs < 0 { '-' } else { '+' };
let a = secs.abs();
format!("{}{:02}:{:02}", sign, a / 3600, (a % 3600) / 60)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_forms() {
assert!(Tz::parse("").is_err()); assert_eq!(Tz::parse("UTC").unwrap(), Tz::Utc);
assert_eq!(Tz::parse("+00:00").unwrap(), Tz::Utc);
assert_eq!(Tz::parse("+08:00").unwrap(), Tz::Offset(28800));
assert_eq!(Tz::parse("+0800").unwrap(), Tz::Offset(28800));
assert_eq!(Tz::parse("+8").unwrap(), Tz::Offset(28800));
assert_eq!(Tz::parse("-05:00").unwrap(), Tz::Offset(-18000));
assert!(matches!(
Tz::parse("America/New_York").unwrap(),
Tz::Named(_)
));
assert!(Tz::parse("Not/AZone").is_err());
}
#[test]
fn offset_round_trip() {
let tz = Tz::parse("+08:00").unwrap();
let utc_ns = tz.wall_to_utc_ns(2020, 1, 1, 8, 0, 0).unwrap();
assert_eq!(crate::datetime::format_ns(utc_ns), "2020-01-01 00:00:00");
assert_eq!(tz.civil_parts(utc_ns), (2020, 1, 1, 8, 0, 0));
assert_eq!(tz.name(), "+08:00");
}
#[test]
fn named_zone_dst() {
let ny = Tz::parse("America/New_York").unwrap();
let w = ny.wall_to_utc_ns(2021, 1, 1, 12, 0, 0).unwrap();
assert_eq!(crate::datetime::format_ns(w), "2021-01-01 17:00:00");
let s = ny.wall_to_utc_ns(2021, 7, 1, 12, 0, 0).unwrap();
assert_eq!(crate::datetime::format_ns(s), "2021-07-01 16:00:00");
assert_eq!(ny.civil_parts(w), (2021, 1, 1, 12, 0, 0));
assert_eq!(ny.civil_parts(s), (2021, 7, 1, 12, 0, 0));
}
#[test]
fn offset_and_dst_at_instant() {
let fixed = Tz::parse("+08:00").unwrap();
assert_eq!(fixed.offset_secs_at(0), 28800);
assert_eq!(fixed.dst_secs_at(0), 0);
assert_eq!(Tz::Utc.offset_secs_at(0), 0);
let ny = Tz::parse("America/New_York").unwrap();
let summer = ny.wall_to_utc_ns(2021, 7, 1, 12, 0, 0).unwrap();
assert_eq!(ny.offset_secs_at(summer), -4 * 3600); assert_eq!(ny.dst_secs_at(summer), 3600); }
#[test]
fn tz_edge_branches() {
let ny = Tz::parse("America/New_York").unwrap();
assert_eq!(ny.fixed_offset_secs(), None); assert_eq!(Tz::Utc.name(), "UTC");
assert!(ny.wall_to_utc_ns(2020, 11, 1, 1, 30, 0).is_none());
assert!(ny.wall_to_utc_ns(2020, 3, 8, 2, 30, 0).is_none());
assert!(!Tz::Naive.is_aware());
assert!(Tz::Utc.is_aware());
assert_eq!(Tz::Naive.name(), "naive");
assert!(Tz::parse("+123").is_err());
assert!(Tz::parse("+25:00").is_err());
}
}