caldir_core/event/instance_id/
recurrence_id.rs1use 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#[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
26impl 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
40const 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 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}