use std::{fmt, str::FromStr};
use chrono::{DateTime, FixedOffset, LocalResult, NaiveDateTime, SecondsFormat, TimeZone, Utc};
use chrono_tz::Tz;
use crate::{value::parse_civil_timestamp, Error, Result};
const EXPLICIT_TIMESTAMP_FORMATS: &[&str] = &[
"%Y-%m-%dT%H:%M:%S%.f%:z",
"%Y-%m-%dT%H:%M:%S%:z",
"%Y-%m-%dT%H:%M:%S%.fZ",
"%Y-%m-%dT%H:%M:%SZ",
"%Y-%m-%d %H:%M:%S%.f%:z",
"%Y-%m-%d %H:%M:%S%:z",
"%Y-%m-%d %H:%M:%S%.fZ",
"%Y-%m-%d %H:%M:%SZ",
];
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum SessionTimeZone {
#[default]
Utc,
Fixed(FixedOffset),
Named(Tz),
}
impl SessionTimeZone {
pub fn parse(value: &str) -> Result<Self> {
let value = value.trim();
if value.eq_ignore_ascii_case("UTC") || value.eq_ignore_ascii_case("Z") {
return Ok(Self::Utc);
}
if value.starts_with('+') || value.starts_with('-') {
let offset = parse_fixed_offset(value)?;
return Ok(if offset.local_minus_utc() == 0 {
Self::Utc
} else {
Self::Fixed(offset)
});
}
Tz::from_str(value).map(Self::Named).map_err(|_| {
Error::invalid_argument(format!(
"invalid time zone '{value}'; expected UTC, a fixed offset such as +07:00, or an IANA name such as Asia/Barnaul"
))
})
}
pub fn civil_to_instant(self, civil: NaiveDateTime) -> Result<DateTime<Utc>> {
let local = match self {
Self::Utc => Utc
.from_local_datetime(&civil)
.map(|value| value.with_timezone(&Utc)),
Self::Fixed(offset) => offset
.from_local_datetime(&civil)
.map(|value| value.with_timezone(&Utc)),
Self::Named(zone) => zone
.from_local_datetime(&civil)
.map(|value| value.with_timezone(&Utc)),
};
match local {
LocalResult::Single(value) => Ok(value),
LocalResult::None => Err(Error::invalid_argument(format!(
"civil timestamp {civil} does not exist in time zone {self}"
))),
LocalResult::Ambiguous(_, _) => Err(Error::invalid_argument(format!(
"civil timestamp {civil} is ambiguous in time zone {self}; use an explicit offset"
))),
}
}
pub fn instant_to_civil(self, instant: DateTime<Utc>) -> NaiveDateTime {
match self {
Self::Utc => instant.naive_utc(),
Self::Fixed(offset) => instant.with_timezone(&offset).naive_local(),
Self::Named(zone) => instant.with_timezone(&zone).naive_local(),
}
}
pub fn parse_instant(self, value: &str) -> Result<DateTime<Utc>> {
if let Ok(instant) = parse_timestamp_with_explicit_offset(value) {
return Ok(instant);
}
self.civil_to_instant(parse_civil_timestamp(value)?)
}
pub fn format_instant(self, instant: DateTime<Utc>) -> String {
match self {
Self::Utc => instant.to_rfc3339_opts(SecondsFormat::AutoSi, false),
Self::Fixed(offset) => instant
.with_timezone(&offset)
.to_rfc3339_opts(SecondsFormat::AutoSi, false),
Self::Named(zone) => instant
.with_timezone(&zone)
.to_rfc3339_opts(SecondsFormat::AutoSi, false),
}
}
}
impl fmt::Display for SessionTimeZone {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Utc => formatter.write_str("UTC"),
Self::Fixed(offset) => {
let seconds = offset.local_minus_utc();
let sign = if seconds < 0 { '-' } else { '+' };
let absolute = seconds.unsigned_abs();
write!(
formatter,
"{sign}{:02}:{:02}",
absolute / 3_600,
(absolute % 3_600) / 60
)
}
Self::Named(zone) => zone.fmt(formatter),
}
}
}
fn parse_fixed_offset(value: &str) -> Result<FixedOffset> {
let bytes = value.as_bytes();
if bytes.len() != 6 || bytes[3] != b':' {
return Err(Error::invalid_argument(format!(
"invalid fixed time-zone offset '{value}'; expected +HH:MM or -HH:MM"
)));
}
let hours = parse_two_digits(&bytes[1..3], value)?;
let minutes = parse_two_digits(&bytes[4..6], value)?;
if hours > 23 || minutes > 59 {
return Err(Error::invalid_argument(format!(
"invalid fixed time-zone offset '{value}'"
)));
}
let magnitude = hours * 3_600 + minutes * 60;
let seconds = if bytes[0] == b'-' {
-magnitude
} else if bytes[0] == b'+' {
magnitude
} else {
return Err(Error::invalid_argument(format!(
"invalid fixed time-zone offset '{value}'"
)));
};
if seconds == 0 {
return Ok(FixedOffset::east_opt(0).expect("zero offset is valid"));
}
FixedOffset::east_opt(seconds)
.ok_or_else(|| Error::invalid_argument(format!("invalid fixed time-zone offset '{value}'")))
}
pub fn parse_timestamp_with_explicit_offset(value: &str) -> Result<DateTime<Utc>> {
let value = value.trim();
if let Ok(timestamp) = DateTime::parse_from_rfc3339(value) {
return Ok(timestamp.with_timezone(&Utc));
}
for format in EXPLICIT_TIMESTAMP_FORMATS {
if let Ok(timestamp) = DateTime::parse_from_str(value, format) {
return Ok(timestamp.with_timezone(&Utc));
}
}
Err(Error::parse(format!(
"timestamp has no valid explicit time-zone offset: {value}"
)))
}
fn parse_two_digits(bytes: &[u8], original: &str) -> Result<i32> {
if bytes.len() != 2 || !bytes.iter().all(u8::is_ascii_digit) {
return Err(Error::invalid_argument(format!(
"invalid fixed time-zone offset '{original}'"
)));
}
Ok(i32::from(bytes[0] - b'0') * 10 + i32::from(bytes[1] - b'0'))
}
#[cfg(test)]
mod tests {
use chrono::NaiveDate;
use super::*;
fn civil(year: i32, month: u32, day: u32, hour: u32, minute: u32) -> NaiveDateTime {
NaiveDate::from_ymd_opt(year, month, day)
.unwrap()
.and_hms_opt(hour, minute, 0)
.unwrap()
}
#[test]
fn parses_and_canonicalizes_supported_zone_forms() {
assert_eq!(SessionTimeZone::parse("UTC").unwrap().to_string(), "UTC");
assert_eq!(SessionTimeZone::parse("z").unwrap().to_string(), "UTC");
assert_eq!(SessionTimeZone::parse("+00:00").unwrap().to_string(), "UTC");
assert_eq!(
SessionTimeZone::parse("+07:00").unwrap().to_string(),
"+07:00"
);
assert_eq!(
SessionTimeZone::parse("Asia/Barnaul").unwrap().to_string(),
"Asia/Barnaul"
);
}
#[test]
fn rejects_invalid_fixed_offsets() {
for value in ["+7:00", "+24:00", "+02:60", "+01", "+aa:00"] {
assert!(SessionTimeZone::parse(value).is_err(), "{value}");
}
}
#[test]
fn fixed_offset_round_trip_is_exact() {
let zone = SessionTimeZone::parse("+07:00").unwrap();
let civil = civil(2026, 9, 11, 12, 30);
let instant = zone.civil_to_instant(civil).unwrap();
assert_eq!(instant.to_rfc3339(), "2026-09-11T05:30:00+00:00");
assert_eq!(zone.instant_to_civil(instant), civil);
assert_eq!(zone.format_instant(instant), "2026-09-11T12:30:00+07:00");
}
#[test]
fn explicit_input_offset_wins_over_session_zone() {
let zone = SessionTimeZone::parse("America/New_York").unwrap();
let explicit = zone.parse_instant("2026-09-11T12:30:00+07:00").unwrap();
assert_eq!(explicit.to_rfc3339(), "2026-09-11T05:30:00+00:00");
let contextual = zone.parse_instant("2026-09-11 12:30:00").unwrap();
assert_eq!(contextual.to_rfc3339(), "2026-09-11T16:30:00+00:00");
}
#[test]
fn named_zone_applies_historical_rules() {
let zone = SessionTimeZone::parse("America/New_York").unwrap();
let winter = zone.civil_to_instant(civil(2026, 1, 15, 12, 0)).unwrap();
let summer = zone.civil_to_instant(civil(2026, 7, 15, 12, 0)).unwrap();
assert_eq!(winter.to_rfc3339(), "2026-01-15T17:00:00+00:00");
assert_eq!(summer.to_rfc3339(), "2026-07-15T16:00:00+00:00");
}
#[test]
fn named_zone_rejects_dst_gap_and_overlap() {
let zone = SessionTimeZone::parse("America/New_York").unwrap();
assert!(zone.civil_to_instant(civil(2026, 3, 8, 2, 30)).is_err());
assert!(zone.civil_to_instant(civil(2026, 11, 1, 1, 30)).is_err());
}
}