caldir-core 0.14.0

Core library for caldir calendar storage, sync, and provider protocol
Documentation
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,
    },
}

/// Comparable form of `EventTime`
/// with resolvable zones converted to UTC.
#[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),
            },
        }
    }

    /// Check if this is an all-day date (not a datetime)
    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
        }
    }
}

/// Convert a naive (non-zoned) datetime into a zoned datetime.
/// Picks a deterministic answer when DST makes the wall clock ambiguous
/// (e.g. Stockholm 02:30 on 2026-10-25, which happens twice because clocks roll back)
fn resolve_local<Tz: TimeZone>(naive: NaiveDateTime, tz: &Tz) -> DateTime<Tz> {
    if let Some(dt) = tz.from_local_datetime(&naive).earliest() {
        return dt;
    }
    // DST gap (e.g. Stockholm 02:30 on 2026-03-29): skip forward past the jump.
    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() {
        // 2024-01-01 12:00:00 UTC
        let utc = Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap();

        let event_time = &EventTime::DateTimeUtc(utc);

        // User is in Stockholm:
        let local = event_time.to_local_tz(&chrono_tz::Europe::Stockholm);

        // Local time should be CET (UTC+1) in January = 13:00
        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();

        // Even creator is in NYC:
        let event_time = &EventTime::DateTimeZoned {
            datetime: date_time,
            tzid: "America/New_York".to_string(),
        };

        // User is in Stockholm:
        let local = event_time.to_local_tz(&chrono_tz::Europe::Stockholm);

        // 12:00 in NY (EST, UTC-5)
        // = 18:00 in Stockholm (CET, UTC+1) in January
        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() {
        // In Stockholm, 2024-03-31 02:30 doesn't exist — clocks jumped from
        // 02:00 CET to 03:00 CEST. We should still produce a slug.
        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);

        // Fallback skips forward past the jump, landing at 03:30 CEST.
        assert_eq!(local.format("%Y-%m-%dT%H%M").to_string(), "2024-03-31T0330");
    }

    #[test]
    fn to_local_handles_dst_fall_back_overlap() {
        // In Stockholm, 2024-10-27 02:30 happens twice — clocks rewound from
        // 03:00 CEST back to 02:00 CET. We pick the earliest occurrence.
        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(),
        }));

        // 19:02 at +05:30 = 13:32 UTC
        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() {
        // 12:00 in NYC (EST, UTC-5) in January = 17:00 UTC
        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() {
        // 12:00 in NYC during DST (EDT, UTC-4) in July = 16:00 UTC
        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");
    }
}