Skip to main content

caldir_core/event/instance_id/
recurrence_id.rs

1use super::super::time::NormalizedEventTime;
2use crate::EventTime;
3use chrono::{NaiveDate, NaiveDateTime};
4use std::fmt;
5use std::hash::{Hash, Hasher};
6use std::str::FromStr;
7
8// The instance identifier in a recurring event
9#[derive(Debug, Clone, Eq)]
10pub struct RecurrenceId(EventTime);
11
12impl RecurrenceId {
13    pub fn as_event_time(&self) -> &EventTime {
14        &self.0
15    }
16
17    pub fn from_event_time(event_time: EventTime) -> Self {
18        RecurrenceId(event_time)
19    }
20
21    fn normalized(&self) -> NormalizedEventTime {
22        self.0.normalized()
23    }
24}
25
26// Instances that fall on the same start time are treated as same,
27// even if their raw data has different time zones or formats:
28impl PartialEq for RecurrenceId {
29    fn eq(&self, other: &Self) -> bool {
30        self.normalized() == other.normalized()
31    }
32}
33
34impl Hash for RecurrenceId {
35    fn hash<H: Hasher>(&self, state: &mut H) {
36        self.normalized().hash(state);
37    }
38}
39
40// RFC 5545 RECURRENCE-ID value:
41//   YYYYMMDD                       — all-day
42//   YYYYMMDDTHHMMSSZ               — UTC
43//   YYYYMMDDTHHMMSS                — floating
44//   TZID={tzid}:YYYYMMDDTHHMMSS    — zoned
45const TZID_PREFIX: &str = "TZID=";
46
47impl fmt::Display for RecurrenceId {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        match &self.0 {
50            EventTime::Date(date) => write!(f, "{}", date.format("%Y%m%d")),
51            EventTime::DateTimeUtc(datetime) => write!(f, "{}", datetime.format("%Y%m%dT%H%M%SZ")),
52            EventTime::DateTimeFloating(datetime) => {
53                write!(f, "{}", datetime.format("%Y%m%dT%H%M%S"))
54            }
55            EventTime::DateTimeZoned { datetime, tzid } => {
56                write!(
57                    f,
58                    "{TZID_PREFIX}{tzid}:{}",
59                    datetime.format("%Y%m%dT%H%M%S")
60                )
61            }
62        }
63    }
64}
65
66impl FromStr for RecurrenceId {
67    type Err = ();
68
69    fn from_str(s: &str) -> Result<Self, Self::Err> {
70        parse_event_time(s).map(RecurrenceId).ok_or(())
71    }
72}
73
74fn parse_event_time(s: &str) -> Option<EventTime> {
75    if let Some(rest) = s.strip_prefix(TZID_PREFIX) {
76        let (tzid, dt_str) = rest.split_once(':')?;
77        let datetime = NaiveDateTime::parse_from_str(dt_str, "%Y%m%dT%H%M%S").ok()?;
78
79        return Some(EventTime::DateTimeZoned {
80            datetime,
81            tzid: tzid.to_string(),
82        });
83    }
84
85    if s.ends_with('Z') {
86        let datetime = NaiveDateTime::parse_from_str(s, "%Y%m%dT%H%M%SZ").ok()?;
87        return Some(EventTime::DateTimeUtc(datetime.and_utc()));
88    }
89
90    if !s.contains('T') {
91        let date = NaiveDate::parse_from_str(s, "%Y%m%d").ok()?;
92        return Some(EventTime::Date(date));
93    }
94
95    let datetime = NaiveDateTime::parse_from_str(s, "%Y%m%dT%H%M%S").ok()?;
96    Some(EventTime::DateTimeFloating(datetime))
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102    use chrono::NaiveDate;
103
104    fn zoned(tzid: &str, hour: u32) -> RecurrenceId {
105        // 2026-07-14
106        RecurrenceId::from_event_time(EventTime::DateTimeZoned {
107            datetime: NaiveDate::from_ymd_opt(2026, 7, 14)
108                .unwrap()
109                .and_hms_opt(hour, 0, 0)
110                .unwrap(),
111            tzid: tzid.to_string(),
112        })
113    }
114
115    #[test]
116    fn same_instant_in_different_zones_is_equal() {
117        assert_eq!(zoned("Europe/Stockholm", 19), zoned("Europe/London", 18));
118    }
119
120    #[test]
121    fn round_trips_every_rfc5545_form() {
122        for (rid, expected) in [
123            (
124                RecurrenceId::from_event_time(EventTime::Date(
125                    NaiveDate::from_ymd_opt(2026, 1, 9).unwrap(),
126                )),
127                "20260109",
128            ),
129            (
130                RecurrenceId::from_event_time(EventTime::DateTimeUtc(
131                    NaiveDate::from_ymd_opt(2026, 1, 1)
132                        .unwrap()
133                        .and_hms_opt(17, 0, 0)
134                        .unwrap()
135                        .and_utc(),
136                )),
137                "20260101T170000Z",
138            ),
139            (
140                RecurrenceId::from_event_time(EventTime::DateTimeFloating(
141                    NaiveDate::from_ymd_opt(2026, 1, 9)
142                        .unwrap()
143                        .and_hms_opt(10, 0, 0)
144                        .unwrap(),
145                )),
146                "20260109T100000",
147            ),
148            (
149                zoned("Europe/Stockholm", 10),
150                "TZID=Europe/Stockholm:20260714T100000",
151            ),
152        ] {
153            assert_eq!(rid.to_string(), expected);
154            let parsed: RecurrenceId = expected.parse().unwrap();
155            assert_eq!(parsed.as_event_time(), rid.as_event_time());
156        }
157    }
158
159    #[test]
160    fn rejects_non_rfc5545_values() {
161        for value in [
162            "",
163            "not-a-date",
164            "2026-01-09",
165            "TZID=Europe/Stockholm",
166            "20260109T10",
167        ] {
168            assert!(value.parse::<RecurrenceId>().is_err(), "accepted {value}");
169        }
170    }
171}