use super::tz_normalize::{self, Tzid};
use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, TimeZone, Utc};
use icalendar::{CalendarDateTime, DatePerhapsTime};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum EventTime {
Date(NaiveDate),
DateTimeUtc(DateTime<Utc>),
DateTimeFloating(NaiveDateTime),
DateTimeZoned {
datetime: NaiveDateTime,
tzid: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) enum NormalizedEventTime {
Date(NaiveDate),
Instant(DateTime<Utc>),
Floating(NaiveDateTime),
}
impl EventTime {
pub fn to_local_tz<Tz: TimeZone>(&self, tz: &Tz) -> DateTime<Tz> {
match self {
EventTime::Date(date) => resolve_local(
date.and_hms_opt(0, 0, 0)
.expect("midnight should be a valid NaiveDateTime"),
tz,
),
EventTime::DateTimeFloating(datetime) => resolve_local(*datetime, tz),
EventTime::DateTimeUtc(datetime) => datetime.with_timezone(tz),
EventTime::DateTimeZoned { datetime, tzid } => match parse_tzid(tzid) {
Some(event_tz) => resolve_local(*datetime, &event_tz).with_timezone(tz),
None => resolve_local(*datetime, tz),
},
}
}
pub fn to_utc(&self) -> DateTime<Utc> {
match self {
EventTime::Date(date) => date
.and_hms_opt(0, 0, 0)
.expect("midnight should be a valid NaiveDateTime")
.and_local_timezone(chrono::Local)
.unwrap()
.with_timezone(&Utc),
EventTime::DateTimeFloating(datetime) => datetime
.and_local_timezone(chrono::Local)
.unwrap()
.with_timezone(&Utc),
EventTime::DateTimeUtc(datetime) => *datetime,
EventTime::DateTimeZoned { datetime, tzid } => match parse_tzid(tzid) {
Some(event_tz) => datetime
.and_local_timezone(event_tz)
.unwrap()
.with_timezone(&Utc),
None => datetime
.and_local_timezone(chrono::Local)
.unwrap()
.with_timezone(&Utc),
},
}
}
pub fn is_date(&self) -> bool {
matches!(self, EventTime::Date(_))
}
pub(crate) fn normalized(&self) -> NormalizedEventTime {
match self {
EventTime::Date(date) => NormalizedEventTime::Date(*date),
EventTime::DateTimeUtc(datetime) => NormalizedEventTime::Instant(*datetime),
EventTime::DateTimeFloating(datetime) => NormalizedEventTime::Floating(*datetime),
EventTime::DateTimeZoned { datetime, tzid } => match parse_tzid(tzid) {
Some(tz) => {
NormalizedEventTime::Instant(resolve_local(*datetime, &tz).with_timezone(&Utc))
}
None => NormalizedEventTime::Floating(*datetime),
},
}
}
}
fn parse_tzid(tzid: &str) -> Option<chrono_tz::Tz> {
match tzid.parse() {
Ok(tz) => Some(tz),
Err(_) => {
tz_normalize::warn_unknown(tzid);
None
}
}
}
fn resolve_local<Tz: TimeZone>(naive: NaiveDateTime, tz: &Tz) -> DateTime<Tz> {
if let Some(dt) = tz.from_local_datetime(&naive).earliest() {
return dt;
}
let bumped = naive + chrono::Duration::hours(1);
tz.from_local_datetime(&bumped)
.earliest()
.unwrap_or_else(|| tz.from_utc_datetime(&naive))
}
impl From<&EventTime> for DatePerhapsTime {
fn from(value: &EventTime) -> Self {
match value {
EventTime::Date(date) => (*date).into(),
EventTime::DateTimeUtc(datetime) => (*datetime).into(),
EventTime::DateTimeFloating(datetime) => (*datetime).into(),
EventTime::DateTimeZoned { datetime, tzid } => CalendarDateTime::WithTimezone {
date_time: *datetime,
tzid: tzid.to_string(),
}
.into(),
}
}
}
impl From<EventTime> for DatePerhapsTime {
fn from(value: EventTime) -> Self {
DatePerhapsTime::from(&value)
}
}
impl From<DatePerhapsTime> for EventTime {
fn from(value: DatePerhapsTime) -> Self {
match value {
DatePerhapsTime::Date(date) => EventTime::Date(date),
DatePerhapsTime::DateTime(CalendarDateTime::Floating(datetime)) => {
EventTime::DateTimeFloating(datetime)
}
DatePerhapsTime::DateTime(CalendarDateTime::Utc(datetime)) => {
EventTime::DateTimeUtc(datetime)
}
DatePerhapsTime::DateTime(CalendarDateTime::WithTimezone { date_time, tzid }) => {
match tz_normalize::classify(&tzid) {
Tzid::Iana(tzid) => EventTime::DateTimeZoned {
datetime: date_time,
tzid,
},
Tzid::FixedOffset(seconds) => {
let offset =
FixedOffset::east_opt(seconds).expect("validated fixed offset");
let datetime = offset
.from_local_datetime(&date_time)
.single()
.expect("fixed offsets have no ambiguous local times")
.with_timezone(&Utc);
EventTime::DateTimeUtc(datetime)
}
Tzid::Unknown => {
tz_normalize::warn_unknown(&tzid);
EventTime::DateTimeZoned {
datetime: date_time,
tzid,
}
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn to_local_converts_utc_event_to_target_timezone() {
let utc = Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap();
let event_time = &EventTime::DateTimeUtc(utc);
let local = event_time.to_local_tz(&chrono_tz::Europe::Stockholm);
assert_eq!(local.format("%Y-%m-%dT%H%M").to_string(), "2024-01-01T1300");
}
#[test]
fn to_local_converts_zoned_event_to_target_timezone() {
let date_time = NaiveDate::from_ymd_opt(2024, 1, 1)
.unwrap()
.and_hms_opt(12, 0, 0)
.unwrap();
let event_time = &EventTime::DateTimeZoned {
datetime: date_time,
tzid: "America/New_York".to_string(),
};
let local = event_time.to_local_tz(&chrono_tz::Europe::Stockholm);
assert_eq!(local.format("%Y-%m-%dT%H%M").to_string(), "2024-01-01T1800");
}
#[test]
fn to_local_treats_unknown_zoned_event_as_floating() {
let date_time = NaiveDate::from_ymd_opt(2024, 1, 1)
.unwrap()
.and_hms_opt(12, 0, 0)
.unwrap();
let event_time = EventTime::DateTimeZoned {
datetime: date_time,
tzid: "Bogus/Zone".to_string(),
};
let local = event_time.to_local_tz(&chrono_tz::Europe::Stockholm);
assert_eq!(local.format("%Y-%m-%dT%H%M").to_string(), "2024-01-01T1200");
}
#[test]
fn unknown_zoned_event_matches_floating_semantics() {
let datetime = NaiveDate::from_ymd_opt(2024, 1, 1)
.unwrap()
.and_hms_opt(12, 0, 0)
.unwrap();
let zoned = EventTime::DateTimeZoned {
datetime,
tzid: "Bogus/Zone".to_string(),
};
let floating = EventTime::DateTimeFloating(datetime);
assert_eq!(
zoned.to_local_tz(&chrono_tz::Europe::Stockholm),
floating.to_local_tz(&chrono_tz::Europe::Stockholm)
);
assert_eq!(zoned.to_utc(), floating.to_utc());
assert_eq!(zoned.normalized(), NormalizedEventTime::Floating(datetime));
}
#[test]
fn to_local_handles_dst_spring_forward_gap() {
let naive = NaiveDate::from_ymd_opt(2024, 3, 31)
.unwrap()
.and_hms_opt(2, 30, 0)
.unwrap();
let event_time = EventTime::DateTimeFloating(naive);
let local = event_time.to_local_tz(&chrono_tz::Europe::Stockholm);
assert_eq!(local.format("%Y-%m-%dT%H%M").to_string(), "2024-03-31T0330");
}
#[test]
fn to_local_handles_dst_fall_back_overlap() {
let naive = NaiveDate::from_ymd_opt(2024, 10, 27)
.unwrap()
.and_hms_opt(2, 30, 0)
.unwrap();
let event_time = EventTime::DateTimeFloating(naive);
let local = event_time.to_local_tz(&chrono_tz::Europe::Stockholm);
assert_eq!(local.format("%Y-%m-%dT%H%M").to_string(), "2024-10-27T0230");
}
#[test]
fn fractional_offset_tzid_parses_to_utc() {
let datetime = NaiveDate::from_ymd_opt(2026, 7, 24)
.unwrap()
.and_hms_opt(19, 2, 0)
.unwrap();
let parsed = EventTime::from(DatePerhapsTime::DateTime(CalendarDateTime::WithTimezone {
date_time: datetime,
tzid: "GMT+0530".to_string(),
}));
assert_eq!(
parsed,
EventTime::DateTimeUtc(Utc.with_ymd_and_hms(2026, 7, 24, 13, 32, 0).unwrap())
);
}
#[test]
fn windows_display_names_preserve_dst_rules() {
for (tzid, date, expected_tzid, expected_utc) in [
(
"(UTC+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",
(2026, 1, 15),
"Europe/Berlin",
(2026, 1, 15, 14),
),
(
"(UTC+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",
(2026, 7, 15),
"Europe/Berlin",
(2026, 7, 15, 13),
),
(
"(UTC-05:00) Eastern Time (US & Canada)",
(2026, 1, 15),
"America/New_York",
(2026, 1, 15, 20),
),
(
"(UTC-05:00) Eastern Time (US & Canada)",
(2026, 7, 15),
"America/New_York",
(2026, 7, 15, 19),
),
] {
let datetime = NaiveDate::from_ymd_opt(date.0, date.1, date.2)
.unwrap()
.and_hms_opt(15, 0, 0)
.unwrap();
let parsed =
EventTime::from(DatePerhapsTime::DateTime(CalendarDateTime::WithTimezone {
date_time: datetime,
tzid: tzid.to_string(),
}));
assert!(
matches!(&parsed, EventTime::DateTimeZoned { tzid, .. } if tzid == expected_tzid),
"{tzid} on {datetime} normalized incorrectly"
);
assert_eq!(
parsed.to_utc(),
Utc.with_ymd_and_hms(
expected_utc.0,
expected_utc.1,
expected_utc.2,
expected_utc.3,
0,
0,
)
.unwrap(),
"{tzid} on {datetime} resolved incorrectly"
);
}
}
#[test]
fn to_utc_passes_through_utc_event() {
let utc = Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap();
let event_time = EventTime::DateTimeUtc(utc);
assert_eq!(event_time.to_utc(), utc);
}
#[test]
fn to_utc_converts_zoned_event_using_tzid() {
let datetime = NaiveDate::from_ymd_opt(2024, 1, 1)
.unwrap()
.and_hms_opt(12, 0, 0)
.unwrap();
let event_time = EventTime::DateTimeZoned {
datetime,
tzid: "America/New_York".to_string(),
};
let utc = event_time.to_utc();
assert_eq!(utc.format("%Y-%m-%dT%H%M").to_string(), "2024-01-01T1700");
}
#[test]
fn to_utc_respects_dst_for_zoned_event() {
let datetime = NaiveDate::from_ymd_opt(2024, 7, 1)
.unwrap()
.and_hms_opt(12, 0, 0)
.unwrap();
let event_time = EventTime::DateTimeZoned {
datetime,
tzid: "America/New_York".to_string(),
};
let utc = event_time.to_utc();
assert_eq!(utc.format("%Y-%m-%dT%H%M").to_string(), "2024-07-01T1600");
}
}