caldir_core/event/
instance_id.rs1mod event_uid;
2mod recurrence_id;
3
4pub use event_uid::EventUid;
5pub use recurrence_id::RecurrenceId;
6
7use std::fmt;
8
9#[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
27const 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 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 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}