Skip to main content

caldir_core/event/
instance_id.rs

1mod event_uid;
2mod recurrence_id;
3
4pub use event_uid::EventUid;
5pub use recurrence_id::RecurrenceId;
6
7use std::fmt;
8
9// UID + RecurrenceId = the actual unique ID per event
10#[derive(Debug, Clone, PartialEq, Eq, Hash)]
11pub struct EventInstanceId((EventUid, Option<RecurrenceId>));
12
13impl EventInstanceId {
14    pub fn new(uid: EventUid, recurrence_id: Option<RecurrenceId>) -> Self {
15        EventInstanceId((uid, recurrence_id))
16    }
17
18    pub fn uid(&self) -> &EventUid {
19        &self.0.0
20    }
21
22    pub fn recurrence_id(&self) -> Option<&RecurrenceId> {
23        self.0.1.as_ref()
24    }
25}
26
27// Stable, round-trippable string form. Format:
28//   non-recurring:        {uid}
29//   recurring instance:   {uid}__{recurrence_id}
30// The recurrence id is the RFC 5545 RECURRENCE-ID value (see `RecurrenceId`).
31//
32// Conversion from `&str` is infallible: UIDs may legitimately contain `__`
33// (RFC 5545 allows any text), so when the suffix after the last `__` isn't a
34// parseable recurrence id, the whole string is treated as the UID.
35const RID_SEPARATOR: &str = "__";
36
37impl fmt::Display for EventInstanceId {
38    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39        let uid_str = self.uid().as_str();
40        match self.recurrence_id() {
41            Some(rid) => write!(f, "{uid_str}{RID_SEPARATOR}{rid}"),
42            None => write!(f, "{uid_str}"),
43        }
44    }
45}
46
47impl From<&str> for EventInstanceId {
48    fn from(s: &str) -> Self {
49        if let Some((uid_str, rid_str)) = s.rsplit_once(RID_SEPARATOR)
50            && let Ok(rid) = rid_str.parse::<RecurrenceId>()
51        {
52            return EventInstanceId::new(EventUid::new(uid_str), Some(rid));
53        }
54
55        EventInstanceId::new(EventUid::new(s), None)
56    }
57}
58
59impl From<String> for EventInstanceId {
60    fn from(s: String) -> Self {
61        EventInstanceId::from(s.as_str())
62    }
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68    use crate::EventTime;
69    use chrono::{NaiveDate, TimeZone};
70    use pretty_assertions::assert_eq;
71
72    fn uid() -> EventUid {
73        EventUid::new("abc123@hooli.com".to_string())
74    }
75
76    fn assert_round_trip(id: &EventInstanceId, expected_str: &str) {
77        let s = id.to_string();
78        assert_eq!(s, expected_str);
79        let parsed = EventInstanceId::from(s);
80        assert_eq!(&parsed, id);
81    }
82
83    #[test]
84    fn round_trips_non_recurring_event() {
85        let id = EventInstanceId::new(uid(), None);
86        assert_round_trip(&id, "abc123@hooli.com");
87    }
88
89    #[test]
90    fn round_trips_date_recurrence_id() {
91        let id = EventInstanceId::new(
92            uid(),
93            Some(RecurrenceId::from_event_time(EventTime::Date(
94                NaiveDate::from_ymd_opt(2026, 1, 9).unwrap(),
95            ))),
96        );
97        assert_round_trip(&id, "abc123@hooli.com__20260109");
98    }
99
100    #[test]
101    fn round_trips_utc_recurrence_id() {
102        let id = EventInstanceId::new(
103            uid(),
104            Some(RecurrenceId::from_event_time(EventTime::DateTimeUtc(
105                chrono::Utc.with_ymd_and_hms(2026, 1, 1, 17, 0, 0).unwrap(),
106            ))),
107        );
108        assert_round_trip(&id, "abc123@hooli.com__20260101T170000Z");
109    }
110
111    #[test]
112    fn round_trips_floating_recurrence_id() {
113        let datetime = NaiveDate::from_ymd_opt(2026, 1, 9)
114            .unwrap()
115            .and_hms_opt(10, 0, 0)
116            .unwrap();
117        let id = EventInstanceId::new(
118            uid(),
119            Some(RecurrenceId::from_event_time(EventTime::DateTimeFloating(
120                datetime,
121            ))),
122        );
123        assert_round_trip(&id, "abc123@hooli.com__20260109T100000");
124    }
125
126    #[test]
127    fn round_trips_zoned_recurrence_id() {
128        let datetime = NaiveDate::from_ymd_opt(2026, 1, 9)
129            .unwrap()
130            .and_hms_opt(10, 0, 0)
131            .unwrap();
132        let id = EventInstanceId::new(
133            uid(),
134            Some(RecurrenceId::from_event_time(EventTime::DateTimeZoned {
135                datetime,
136                tzid: "Europe/Stockholm".to_string(),
137            })),
138        );
139        assert_round_trip(
140            &id,
141            "abc123@hooli.com__TZID=Europe/Stockholm:20260109T100000",
142        );
143    }
144
145    #[test]
146    fn round_trips_uid_containing_double_underscore_with_recurrence_id() {
147        // Some providers emit UIDs with embedded "__" — rsplit_once on the
148        // last occurrence keeps the rid intact.
149        let weird_uid = EventUid::new("event__weird@example.com".to_string());
150        let id = EventInstanceId::new(
151            weird_uid,
152            Some(RecurrenceId::from_event_time(EventTime::Date(
153                NaiveDate::from_ymd_opt(2026, 1, 9).unwrap(),
154            ))),
155        );
156        assert_round_trip(&id, "event__weird@example.com__20260109");
157    }
158
159    #[test]
160    fn round_trips_uid_containing_double_underscore_without_recurrence_id() {
161        // Without a trailing rid, the suffix after the last "__" isn't a
162        // valid recurrence id, so the whole string is the UID.
163        let id = EventInstanceId::new(EventUid::new("event__weird@example.com".to_string()), None);
164        assert_round_trip(&id, "event__weird@example.com");
165    }
166
167    #[test]
168    fn treats_malformed_recurrence_suffix_as_part_of_uid() {
169        let id = EventInstanceId::from("abc__not-a-date");
170        assert_eq!(id.uid().as_str(), "abc__not-a-date");
171        assert!(id.recurrence_id().is_none());
172    }
173
174    #[test]
175    fn treats_zoned_suffix_without_datetime_as_part_of_uid() {
176        let id = EventInstanceId::from("abc__TZID=Europe/Stockholm");
177        assert_eq!(id.uid().as_str(), "abc__TZID=Europe/Stockholm");
178        assert!(id.recurrence_id().is_none());
179    }
180}