use std::fmt;
use std::str::FromStr;
use anyhow::{Context, bail};
use chrono::{FixedOffset, LocalResult, NaiveDateTime, Offset, TimeZone};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Zone(Kind);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Kind {
Fixed(i32),
Named(chrono_tz::Tz),
}
impl Default for Zone {
fn default() -> Self {
Zone::UTC
}
}
impl Zone {
pub const UTC: Zone = Zone(Kind::Fixed(0));
pub const fn fixed(seconds: i32) -> Zone {
Zone(Kind::Fixed(seconds))
}
pub fn offset_at(&self, unix: i64) -> i64 {
match &self.0 {
Kind::Fixed(offset) => i64::from(*offset),
Kind::Named(tz) => {
let utc = chrono::DateTime::from_timestamp(unix, 0).unwrap_or_default();
i64::from(
tz.offset_from_utc_datetime(&utc.naive_utc())
.fix()
.local_minus_utc(),
)
}
}
}
pub fn local(&self, unix: i64) -> NaiveDateTime {
let utc = chrono::DateTime::from_timestamp(unix, 0).unwrap_or_default();
(utc + chrono::TimeDelta::seconds(self.offset_at(unix))).naive_utc()
}
pub fn resolve(&self, local: NaiveDateTime) -> Option<i64> {
let result = match &self.0 {
Kind::Fixed(offset) => FixedOffset::east_opt(*offset)?
.from_local_datetime(&local)
.map(|t| t.timestamp()),
Kind::Named(tz) => tz.from_local_datetime(&local).map(|t| t.timestamp()),
};
match result {
LocalResult::Single(at) => Some(at),
LocalResult::Ambiguous(first, second) => Some(first.min(second)),
LocalResult::None => None,
}
}
pub fn fixed_at(&self, unix: i64) -> FixedOffset {
FixedOffset::east_opt(self.offset_at(unix) as i32)
.unwrap_or_else(|| FixedOffset::east_opt(0).expect("zero offset"))
}
}
impl FromStr for Zone {
type Err = anyhow::Error;
fn from_str(value: &str) -> anyhow::Result<Self> {
let value = value.trim();
if value.is_empty() || value.eq_ignore_ascii_case("utc") || value == "Z" {
return Ok(Zone::UTC);
}
if let Some(sign) = value
.strip_prefix('+')
.map(|rest| (1, rest))
.or_else(|| value.strip_prefix('-').map(|rest| (-1, rest)))
{
let (sign, rest) = sign;
let (hour, minute) = rest
.split_once(':')
.context("expected an offset like +07:00")?;
let (hour, minute): (i32, i32) = (hour.parse()?, minute.parse()?);
if !(0..=14).contains(&hour) || !(0..60).contains(&minute) {
bail!("`{value}` is not a UTC offset");
}
return Ok(Zone::fixed(sign * (hour * 3600 + minute * 60)));
}
match value.parse::<chrono_tz::Tz>() {
Ok(tz) => Ok(Zone(Kind::Named(tz))),
Err(_) => bail!(
"unknown time zone `{value}`: use UTC, an offset like +07:00 or an IANA name \
like Asia/Jakarta"
),
}
}
}
impl fmt::Display for Zone {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.0 {
Kind::Fixed(0) => f.write_str("UTC"),
Kind::Fixed(offset) => {
let sign = if *offset < 0 { '-' } else { '+' };
let offset = offset.abs();
write!(f, "{sign}{:02}:{:02}", offset / 3600, offset % 3600 / 60)
}
Kind::Named(tz) => f.write_str(tz.name()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn at(text: &str) -> NaiveDateTime {
text.parse().unwrap()
}
#[test]
fn parses_every_form() {
assert_eq!("UTC".parse::<Zone>().unwrap(), Zone::UTC);
assert_eq!("".parse::<Zone>().unwrap(), Zone::UTC);
assert_eq!(
"-03:30".parse::<Zone>().unwrap(),
Zone::fixed(-(3 * 3600 + 1800))
);
assert_eq!(
"Asia/Jakarta".parse::<Zone>().unwrap().to_string(),
"Asia/Jakarta"
);
assert_eq!("+07:00".parse::<Zone>().unwrap().to_string(), "+07:00");
assert!("Mars/Olympus".parse::<Zone>().is_err());
assert!("+25:00".parse::<Zone>().is_err());
assert!("+7".parse::<Zone>().is_err());
}
#[test]
fn daylight_saving_gaps_and_repeats() {
let ams: Zone = "Europe/Amsterdam".parse().unwrap();
assert_eq!(ams.resolve(at("2026-03-29T02:30:00")), None);
let three = ams.resolve(at("2026-03-29T03:00:00")).unwrap();
assert_eq!(ams.offset_at(three), 7200);
let twice = ams.resolve(at("2026-10-25T02:30:00")).unwrap();
assert_eq!(ams.offset_at(twice), 7200);
assert_eq!(ams.local(twice), at("2026-10-25T02:30:00"));
assert_eq!(
ams.local(twice + 3600),
at("2026-10-25T02:30:00"),
"the repeat"
);
}
}