1use super::tz_normalize::{self, Tzid};
2use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, TimeZone, Utc};
3use icalendar::{CalendarDateTime, DatePerhapsTime};
4
5#[derive(Debug, Clone, PartialEq, Eq, Hash)]
6pub enum EventTime {
7 Date(NaiveDate),
8 DateTimeUtc(DateTime<Utc>),
9 DateTimeFloating(NaiveDateTime),
10 DateTimeZoned {
11 datetime: NaiveDateTime,
12 tzid: String,
13 },
14}
15
16#[derive(Debug, Clone, PartialEq, Eq, Hash)]
19pub(crate) enum NormalizedEventTime {
20 Date(NaiveDate),
21 Instant(DateTime<Utc>),
22 Floating(NaiveDateTime),
23}
24
25impl EventTime {
26 pub fn to_local_tz<Tz: TimeZone>(&self, tz: &Tz) -> DateTime<Tz> {
27 match self {
28 EventTime::Date(date) => resolve_local(
29 date.and_hms_opt(0, 0, 0)
30 .expect("midnight should be a valid NaiveDateTime"),
31 tz,
32 ),
33 EventTime::DateTimeFloating(datetime) => resolve_local(*datetime, tz),
34 EventTime::DateTimeUtc(datetime) => datetime.with_timezone(tz),
35 EventTime::DateTimeZoned { datetime, tzid } => match parse_tzid(tzid) {
36 Some(event_tz) => resolve_local(*datetime, &event_tz).with_timezone(tz),
37 None => resolve_local(*datetime, tz),
38 },
39 }
40 }
41
42 pub fn to_utc(&self) -> DateTime<Utc> {
43 self.to_local_tz(&chrono::Local).with_timezone(&Utc)
44 }
45
46 pub fn is_date(&self) -> bool {
48 matches!(self, EventTime::Date(_))
49 }
50
51 pub(crate) fn normalized(&self) -> NormalizedEventTime {
52 match self {
53 EventTime::Date(date) => NormalizedEventTime::Date(*date),
54 EventTime::DateTimeUtc(datetime) => NormalizedEventTime::Instant(*datetime),
55 EventTime::DateTimeFloating(datetime) => NormalizedEventTime::Floating(*datetime),
56 EventTime::DateTimeZoned { datetime, tzid } => match parse_tzid(tzid) {
57 Some(tz) => {
58 NormalizedEventTime::Instant(resolve_local(*datetime, &tz).with_timezone(&Utc))
59 }
60 None => NormalizedEventTime::Floating(*datetime),
61 },
62 }
63 }
64}
65
66fn parse_tzid(tzid: &str) -> Option<chrono_tz::Tz> {
67 match tzid.parse() {
68 Ok(tz) => Some(tz),
69 Err(_) => {
70 tz_normalize::warn_unknown(tzid);
71 None
72 }
73 }
74}
75
76fn resolve_local<Tz: TimeZone>(naive: NaiveDateTime, tz: &Tz) -> DateTime<Tz> {
80 if let Some(dt) = tz.from_local_datetime(&naive).earliest() {
81 return dt;
82 }
83 let bumped = naive + chrono::Duration::hours(1);
85 tz.from_local_datetime(&bumped)
86 .earliest()
87 .unwrap_or_else(|| tz.from_utc_datetime(&naive))
88}
89
90impl From<&EventTime> for DatePerhapsTime {
91 fn from(value: &EventTime) -> Self {
92 match value {
93 EventTime::Date(date) => (*date).into(),
94 EventTime::DateTimeUtc(datetime) => (*datetime).into(),
95 EventTime::DateTimeFloating(datetime) => (*datetime).into(),
96 EventTime::DateTimeZoned { datetime, tzid } => CalendarDateTime::WithTimezone {
97 date_time: *datetime,
98 tzid: tzid.to_string(),
99 }
100 .into(),
101 }
102 }
103}
104
105impl From<EventTime> for DatePerhapsTime {
106 fn from(value: EventTime) -> Self {
107 DatePerhapsTime::from(&value)
108 }
109}
110
111impl From<DatePerhapsTime> for EventTime {
112 fn from(value: DatePerhapsTime) -> Self {
113 match value {
114 DatePerhapsTime::Date(date) => EventTime::Date(date),
115 DatePerhapsTime::DateTime(CalendarDateTime::Floating(datetime)) => {
116 EventTime::DateTimeFloating(datetime)
117 }
118 DatePerhapsTime::DateTime(CalendarDateTime::Utc(datetime)) => {
119 EventTime::DateTimeUtc(datetime)
120 }
121 DatePerhapsTime::DateTime(CalendarDateTime::WithTimezone { date_time, tzid }) => {
122 match tz_normalize::classify(&tzid) {
123 Tzid::Iana(tzid) => EventTime::DateTimeZoned {
124 datetime: date_time,
125 tzid,
126 },
127 Tzid::FixedOffset(seconds) => {
128 let offset =
129 FixedOffset::east_opt(seconds).expect("validated fixed offset");
130 let datetime = offset
131 .from_local_datetime(&date_time)
132 .single()
133 .expect("fixed offsets have no ambiguous local times")
134 .with_timezone(&Utc);
135 EventTime::DateTimeUtc(datetime)
136 }
137 Tzid::Unknown => {
138 tz_normalize::warn_unknown(&tzid);
139 EventTime::DateTimeZoned {
140 datetime: date_time,
141 tzid,
142 }
143 }
144 }
145 }
146 }
147 }
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 #[test]
155 fn to_local_converts_utc_event_to_target_timezone() {
156 let utc = Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap();
158
159 let event_time = &EventTime::DateTimeUtc(utc);
160
161 let local = event_time.to_local_tz(&chrono_tz::Europe::Stockholm);
163
164 assert_eq!(local.format("%Y-%m-%dT%H%M").to_string(), "2024-01-01T1300");
166 }
167
168 #[test]
169 fn to_local_converts_zoned_event_to_target_timezone() {
170 let date_time = NaiveDate::from_ymd_opt(2024, 1, 1)
171 .unwrap()
172 .and_hms_opt(12, 0, 0)
173 .unwrap();
174
175 let event_time = &EventTime::DateTimeZoned {
177 datetime: date_time,
178 tzid: "America/New_York".to_string(),
179 };
180
181 let local = event_time.to_local_tz(&chrono_tz::Europe::Stockholm);
183
184 assert_eq!(local.format("%Y-%m-%dT%H%M").to_string(), "2024-01-01T1800");
187 }
188
189 #[test]
190 fn to_local_treats_unknown_zoned_event_as_floating() {
191 let date_time = NaiveDate::from_ymd_opt(2024, 1, 1)
192 .unwrap()
193 .and_hms_opt(12, 0, 0)
194 .unwrap();
195 let event_time = EventTime::DateTimeZoned {
196 datetime: date_time,
197 tzid: "Bogus/Zone".to_string(),
198 };
199
200 let local = event_time.to_local_tz(&chrono_tz::Europe::Stockholm);
201
202 assert_eq!(local.format("%Y-%m-%dT%H%M").to_string(), "2024-01-01T1200");
203 }
204
205 #[test]
206 fn unknown_zoned_event_matches_floating_semantics() {
207 let datetime = NaiveDate::from_ymd_opt(2024, 1, 1)
208 .unwrap()
209 .and_hms_opt(12, 0, 0)
210 .unwrap();
211 let zoned = EventTime::DateTimeZoned {
212 datetime,
213 tzid: "Bogus/Zone".to_string(),
214 };
215 let floating = EventTime::DateTimeFloating(datetime);
216
217 assert_eq!(
218 zoned.to_local_tz(&chrono_tz::Europe::Stockholm),
219 floating.to_local_tz(&chrono_tz::Europe::Stockholm)
220 );
221 assert_eq!(zoned.to_utc(), floating.to_utc());
222 assert_eq!(zoned.normalized(), NormalizedEventTime::Floating(datetime));
223 }
224
225 #[test]
226 fn to_local_handles_dst_spring_forward_gap() {
227 let naive = NaiveDate::from_ymd_opt(2024, 3, 31)
230 .unwrap()
231 .and_hms_opt(2, 30, 0)
232 .unwrap();
233
234 let event_time = EventTime::DateTimeFloating(naive);
235
236 let local = event_time.to_local_tz(&chrono_tz::Europe::Stockholm);
237
238 assert_eq!(local.format("%Y-%m-%dT%H%M").to_string(), "2024-03-31T0330");
240 }
241
242 #[test]
243 fn to_local_handles_dst_fall_back_overlap() {
244 let naive = NaiveDate::from_ymd_opt(2024, 10, 27)
247 .unwrap()
248 .and_hms_opt(2, 30, 0)
249 .unwrap();
250
251 let event_time = EventTime::DateTimeFloating(naive);
252
253 let local = event_time.to_local_tz(&chrono_tz::Europe::Stockholm);
254
255 assert_eq!(local.format("%Y-%m-%dT%H%M").to_string(), "2024-10-27T0230");
256 }
257
258 #[test]
259 fn fractional_offset_tzid_parses_to_utc() {
260 let datetime = NaiveDate::from_ymd_opt(2026, 7, 24)
261 .unwrap()
262 .and_hms_opt(19, 2, 0)
263 .unwrap();
264 let parsed = EventTime::from(DatePerhapsTime::DateTime(CalendarDateTime::WithTimezone {
265 date_time: datetime,
266 tzid: "GMT+0530".to_string(),
267 }));
268
269 assert_eq!(
271 parsed,
272 EventTime::DateTimeUtc(Utc.with_ymd_and_hms(2026, 7, 24, 13, 32, 0).unwrap())
273 );
274 }
275
276 #[test]
277 fn windows_display_names_preserve_dst_rules() {
278 for (tzid, date, expected_tzid, expected_utc) in [
279 (
280 "(UTC+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",
281 (2026, 1, 15),
282 "Europe/Berlin",
283 (2026, 1, 15, 14),
284 ),
285 (
286 "(UTC+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna",
287 (2026, 7, 15),
288 "Europe/Berlin",
289 (2026, 7, 15, 13),
290 ),
291 (
292 "(UTC-05:00) Eastern Time (US & Canada)",
293 (2026, 1, 15),
294 "America/New_York",
295 (2026, 1, 15, 20),
296 ),
297 (
298 "(UTC-05:00) Eastern Time (US & Canada)",
299 (2026, 7, 15),
300 "America/New_York",
301 (2026, 7, 15, 19),
302 ),
303 ] {
304 let datetime = NaiveDate::from_ymd_opt(date.0, date.1, date.2)
305 .unwrap()
306 .and_hms_opt(15, 0, 0)
307 .unwrap();
308 let parsed =
309 EventTime::from(DatePerhapsTime::DateTime(CalendarDateTime::WithTimezone {
310 date_time: datetime,
311 tzid: tzid.to_string(),
312 }));
313
314 assert!(
315 matches!(&parsed, EventTime::DateTimeZoned { tzid, .. } if tzid == expected_tzid),
316 "{tzid} on {datetime} normalized incorrectly"
317 );
318 assert_eq!(
319 parsed.to_utc(),
320 Utc.with_ymd_and_hms(
321 expected_utc.0,
322 expected_utc.1,
323 expected_utc.2,
324 expected_utc.3,
325 0,
326 0,
327 )
328 .unwrap(),
329 "{tzid} on {datetime} resolved incorrectly"
330 );
331 }
332 }
333
334 #[test]
335 fn to_utc_passes_through_utc_event() {
336 let utc = Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap();
337
338 let event_time = EventTime::DateTimeUtc(utc);
339
340 assert_eq!(event_time.to_utc(), utc);
341 }
342
343 #[test]
344 fn to_utc_converts_zoned_event_using_tzid() {
345 let datetime = NaiveDate::from_ymd_opt(2024, 1, 1)
347 .unwrap()
348 .and_hms_opt(12, 0, 0)
349 .unwrap();
350 let event_time = EventTime::DateTimeZoned {
351 datetime,
352 tzid: "America/New_York".to_string(),
353 };
354
355 let utc = event_time.to_utc();
356
357 assert_eq!(utc.format("%Y-%m-%dT%H%M").to_string(), "2024-01-01T1700");
358 }
359
360 #[test]
361 fn to_utc_respects_dst_for_zoned_event() {
362 let datetime = NaiveDate::from_ymd_opt(2024, 7, 1)
364 .unwrap()
365 .and_hms_opt(12, 0, 0)
366 .unwrap();
367 let event_time = EventTime::DateTimeZoned {
368 datetime,
369 tzid: "America/New_York".to_string(),
370 };
371
372 let utc = event_time.to_utc();
373
374 assert_eq!(utc.format("%Y-%m-%dT%H%M").to_string(), "2024-07-01T1600");
375 }
376
377 #[test]
378 fn to_utc_handles_dst_gap_and_overlap() {
379 for (datetime, expected) in [
380 ((2026, 3, 29, 1, 30), (2026, 3, 29, 1, 30)),
381 ((2026, 10, 25, 1, 30), (2026, 10, 25, 0, 30)),
382 ] {
383 let event_time = EventTime::DateTimeZoned {
384 datetime: NaiveDate::from_ymd_opt(datetime.0, datetime.1, datetime.2)
385 .unwrap()
386 .and_hms_opt(datetime.3, datetime.4, 0)
387 .unwrap(),
388 tzid: "Europe/London".to_string(),
389 };
390
391 assert_eq!(
392 event_time.to_utc(),
393 Utc.with_ymd_and_hms(
394 expected.0, expected.1, expected.2, expected.3, expected.4, 0,
395 )
396 .unwrap()
397 );
398 }
399 }
400}