1use crate::event::{
2 Attachment, Attendee, Availability, Event, EventError, EventTime, EventUid, Organizer,
3 Recurrence, RecurrenceId, Reminder, Status, Visibility, XProperty,
4};
5use icalendar::{Component, EventLike};
6
7impl TryFrom<&icalendar::Event> for Event {
8 type Error = EventError;
9
10 fn try_from(value: &icalendar::Event) -> Result<Self, Self::Error> {
11 let start: EventTime = value.get_start().ok_or(EventError::MissingStart)?.into();
12
13 let end = value.get_end().map(EventTime::from);
14
15 let recurrence = Recurrence::from_ical_event(value);
16
17 let recurrence_id = value
18 .get_recurrence_id()
19 .map(EventTime::from)
20 .map(RecurrenceId::from_event_time);
21
22 let uid = value.get_uid().ok_or(EventError::MissingUid)?.to_string();
23
24 let organizer = value.properties().get("ORGANIZER").map(Organizer::from);
25
26 let attendees = value
27 .multi_properties()
28 .get("ATTENDEE")
29 .map(|props| props.iter().map(Attendee::from).collect())
30 .unwrap_or_default();
31
32 let status = value
35 .property_value("STATUS")
36 .and_then(Status::from_ics_str)
37 .unwrap_or_default();
38
39 let availability = value
40 .property_value("TRANSP")
41 .and_then(Availability::from_ics_str)
42 .unwrap_or_default();
43
44 let visibility = value
47 .property_value("CLASS")
48 .and_then(Visibility::from_ics_str);
49
50 let reminders = Reminder::from_ical_event(value);
51
52 let attachments = value
55 .multi_properties()
56 .get("ATTACH")
57 .map(|props| props.iter().filter_map(Attachment::from_property).collect())
58 .unwrap_or_default();
59
60 let x_properties = value
61 .properties()
62 .iter()
63 .filter(|(name, _)| name.starts_with("X-"))
64 .map(|(_, prop)| XProperty::from(prop))
65 .collect();
66
67 Ok(Event {
68 uid: EventUid::new(uid),
69 summary: non_empty(value.get_summary()),
70 description: non_empty(value.get_description()),
71 location: non_empty(value.get_location()),
72 start,
73 end,
74 status,
75 availability,
76 visibility,
77 recurrence,
78 recurrence_id,
79 last_modified: value.get_last_modified(),
80 sequence: value
83 .property_value("SEQUENCE")
84 .and_then(|s| s.parse().ok())
85 .unwrap_or(0),
86 organizer,
87 attendees,
88 reminders,
89 url: value.property_value("URL").map(ToString::to_string),
90 attachments,
91 x_properties,
92 })
93 }
94}
95
96impl TryFrom<icalendar::Event> for Event {
97 type Error = EventError;
98
99 fn try_from(value: icalendar::Event) -> Result<Self, Self::Error> {
100 Event::try_from(&value)
101 }
102}
103
104fn non_empty(s: Option<&str>) -> Option<String> {
108 s.filter(|v| !v.is_empty()).map(ToString::to_string)
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114 use crate::test_utils::test_icalendar_event;
115
116 #[test]
117 fn succeeds_when_ical_event_has_start_and_uid() {
118 let ical_event = test_icalendar_event().done();
119
120 assert!(Event::try_from(ical_event).is_ok());
121 }
122
123 #[test]
124 fn errors_when_ical_event_missing_uid() {
125 let ical_event = icalendar::Event::new()
126 .starts(icalendar::DatePerhapsTime::Date(
127 chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
128 ))
129 .summary("Test Event")
130 .done();
131
132 let result = Event::try_from(ical_event);
133
134 assert!(matches!(result, Err(EventError::MissingUid)));
135 }
136
137 #[test]
138 fn errors_when_ical_event_missing_start() {
139 let ical_event = icalendar::Event::new()
140 .uid("test-uid@caldir")
141 .summary("Test Event")
142 .location("Test Location")
143 .done();
144
145 let result = Event::try_from(ical_event);
146
147 assert!(matches!(result, Err(EventError::MissingStart)));
148 }
149
150 #[test]
151 fn converts_summary() {
152 let ical_event = test_icalendar_event().summary("Hello world").done();
153
154 let event = Event::try_from(ical_event).unwrap();
155
156 assert_eq!(event.summary.as_deref(), Some("Hello world"));
157 }
158
159 #[test]
160 fn empty_summary_description_and_location_become_none() {
161 let ical_event = test_icalendar_event()
165 .summary("")
166 .description("")
167 .location("")
168 .done();
169
170 let event = Event::try_from(ical_event).unwrap();
171
172 assert_eq!(event.summary, None);
173 assert_eq!(event.description, None);
174 assert_eq!(event.location, None);
175 }
176
177 #[test]
178 fn converts_location() {
179 let ical_event = test_icalendar_event().location("London").done();
180
181 let event = Event::try_from(ical_event).unwrap();
182
183 assert_eq!(event.location.as_deref(), Some("London"));
184 }
185
186 #[test]
187 fn converts_uid() {
188 let ical_event = test_icalendar_event().uid("abc123@hooli.com").done();
189
190 let event = Event::try_from(ical_event).unwrap();
191
192 assert_eq!(event.uid.as_str(), "abc123@hooli.com");
193 }
194
195 #[test]
196 fn converts_description() {
197 let ical_event = test_icalendar_event()
198 .description("Multi-line\nnotes")
199 .done();
200
201 let event = Event::try_from(ical_event).unwrap();
202
203 assert_eq!(event.description.as_deref(), Some("Multi-line\nnotes"));
204 }
205
206 #[test]
207 fn converts_start_date() {
208 let ical_event = test_icalendar_event()
209 .starts(icalendar::DatePerhapsTime::Date(
210 chrono::NaiveDate::from_ymd_opt(2026, 2, 10).unwrap(),
211 ))
212 .done();
213
214 let event = Event::try_from(ical_event).unwrap();
215
216 assert_eq!(
217 event.start,
218 EventTime::Date(chrono::NaiveDate::from_ymd_opt(2026, 2, 10).unwrap())
219 );
220 }
221
222 #[test]
223 fn converts_end() {
224 let ical_event = test_icalendar_event()
225 .ends(icalendar::DatePerhapsTime::Date(
226 chrono::NaiveDate::from_ymd_opt(2026, 2, 11).unwrap(),
227 ))
228 .done();
229
230 let event = Event::try_from(ical_event).unwrap();
231
232 assert_eq!(
233 event.end,
234 Some(EventTime::Date(
235 chrono::NaiveDate::from_ymd_opt(2026, 2, 11).unwrap()
236 ))
237 );
238 }
239
240 #[test]
241 fn end_is_none_when_missing() {
242 let ical_event = test_icalendar_event().done();
243
244 let event = Event::try_from(ical_event).unwrap();
245
246 assert_eq!(event.end, None);
247 }
248
249 #[test]
250 fn converts_status() {
251 let ical_event = test_icalendar_event()
252 .append_property(icalendar::Property::new("STATUS", "TENTATIVE"))
253 .done();
254
255 let event = Event::try_from(ical_event).unwrap();
256
257 assert_eq!(event.status, Status::Tentative);
258 }
259
260 #[test]
261 fn status_defaults_to_confirmed_when_missing() {
262 let ical_event = test_icalendar_event().done();
263
264 let event = Event::try_from(ical_event).unwrap();
265
266 assert_eq!(event.status, Status::Confirmed);
267 }
268
269 #[test]
270 fn converts_availability() {
271 let ical_event = test_icalendar_event()
272 .append_property(icalendar::Property::new("TRANSP", "TRANSPARENT"))
273 .done();
274
275 let event = Event::try_from(ical_event).unwrap();
276
277 assert_eq!(event.availability, Availability::Free);
278 }
279
280 #[test]
281 fn availability_defaults_to_busy_when_missing() {
282 let ical_event = test_icalendar_event().done();
283
284 let event = Event::try_from(ical_event).unwrap();
285
286 assert_eq!(event.availability, Availability::Busy);
287 }
288
289 #[test]
290 fn converts_visibility() {
291 let ical_event = test_icalendar_event()
292 .append_property(icalendar::Property::new("CLASS", "PRIVATE"))
293 .done();
294
295 let event = Event::try_from(ical_event).unwrap();
296
297 assert_eq!(event.visibility, Some(Visibility::Private));
298 }
299
300 #[test]
301 fn visibility_is_none_when_missing() {
302 let ical_event = test_icalendar_event().done();
303
304 let event = Event::try_from(ical_event).unwrap();
305
306 assert_eq!(event.visibility, None);
307 }
308
309 #[test]
310 fn converts_recurrence() {
311 let ical_event = test_icalendar_event()
312 .append_property(icalendar::Property::new("RRULE", "FREQ=WEEKLY;BYDAY=MO"))
313 .done();
314
315 let event = Event::try_from(ical_event).unwrap();
316
317 assert_eq!(
318 event.recurrence,
319 Some(crate::event::Recurrence::new("FREQ=WEEKLY;BYDAY=MO"))
320 );
321 }
322
323 #[test]
324 fn recurrence_is_none_when_missing() {
325 let ical_event = test_icalendar_event().done();
326
327 let event = Event::try_from(ical_event).unwrap();
328
329 assert_eq!(event.recurrence, None);
330 }
331
332 #[test]
333 fn converts_recurrence_id() {
334 let ical_event = test_icalendar_event()
335 .recurrence_id(icalendar::DatePerhapsTime::Date(
336 chrono::NaiveDate::from_ymd_opt(2026, 5, 15).unwrap(),
337 ))
338 .done();
339
340 let event = Event::try_from(ical_event).unwrap();
341
342 assert_eq!(
343 event.recurrence_id.unwrap().as_event_time(),
344 &EventTime::Date(chrono::NaiveDate::from_ymd_opt(2026, 5, 15).unwrap())
345 );
346 }
347
348 #[test]
349 fn recurrence_id_is_none_when_missing() {
350 let ical_event = test_icalendar_event().done();
351
352 let event = Event::try_from(ical_event).unwrap();
353
354 assert_eq!(event.recurrence_id, None);
355 }
356
357 #[test]
358 fn converts_last_modified() {
359 let last_modified = chrono::NaiveDate::from_ymd_opt(2026, 5, 2)
360 .unwrap()
361 .and_hms_opt(17, 39, 14)
362 .unwrap()
363 .and_utc();
364
365 let ical_event = test_icalendar_event().last_modified(last_modified).done();
366
367 let event = Event::try_from(ical_event).unwrap();
368
369 assert_eq!(event.last_modified, Some(last_modified));
370 }
371
372 #[test]
373 fn last_modified_is_none_when_missing() {
374 let ical_event = test_icalendar_event().done();
375
376 let event = Event::try_from(ical_event).unwrap();
377
378 assert_eq!(event.last_modified, None);
379 }
380
381 #[test]
382 fn converts_sequence() {
383 let ical_event = test_icalendar_event().sequence(3).done();
384
385 let event = Event::try_from(ical_event).unwrap();
386
387 assert_eq!(event.sequence, 3);
388 }
389
390 #[test]
391 fn converts_negative_sequence() {
392 let ical_event = test_icalendar_event()
393 .append_property(icalendar::Property::new("SEQUENCE", "-1"))
394 .done();
395
396 let event = Event::try_from(ical_event).unwrap();
397
398 assert_eq!(event.sequence, -1);
399 }
400
401 #[test]
402 fn sequence_defaults_to_zero_when_missing() {
403 let ical_event = test_icalendar_event().done();
404
405 let event = Event::try_from(ical_event).unwrap();
406
407 assert_eq!(event.sequence, 0);
408 }
409
410 #[test]
411 fn converts_organizer() {
412 let ical_event = test_icalendar_event()
413 .append_property(icalendar::Property::new(
414 "ORGANIZER",
415 "mailto:alice@example.com",
416 ))
417 .done();
418
419 let event = Event::try_from(ical_event).unwrap();
420
421 assert_eq!(event.organizer, Some(Organizer::new("alice@example.com")));
422 }
423
424 #[test]
425 fn organizer_is_none_when_missing() {
426 let ical_event = test_icalendar_event().done();
427
428 let event = Event::try_from(ical_event).unwrap();
429
430 assert_eq!(event.organizer, None);
431 }
432
433 #[test]
434 fn converts_attendees() {
435 let ical_event = test_icalendar_event()
436 .append_multi_property(icalendar::Property::new(
437 "ATTENDEE",
438 "mailto:bob@example.com",
439 ))
440 .append_multi_property(icalendar::Property::new(
441 "ATTENDEE",
442 "mailto:carol@example.com",
443 ))
444 .done();
445
446 let event = Event::try_from(ical_event).unwrap();
447
448 assert_eq!(
449 event.attendees,
450 vec![
451 crate::event::Attendee::new("bob@example.com"),
452 crate::event::Attendee::new("carol@example.com"),
453 ]
454 );
455 }
456
457 #[test]
458 fn attendees_is_empty_when_missing() {
459 let ical_event = test_icalendar_event().done();
460
461 let event = Event::try_from(ical_event).unwrap();
462
463 assert!(event.attendees.is_empty());
464 }
465
466 #[test]
467 fn converts_reminders() {
468 let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:test@caldir\r\nDTSTART:20260101T120000Z\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nTRIGGER:-PT10M\r\nEND:VALARM\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
469 let event = crate::event::Event::parse_single_ics(ics);
470
471 assert_eq!(event.reminders.len(), 1);
472 assert_eq!(event.reminders[0], crate::event::Reminder::from_minutes(10));
473 }
474
475 #[test]
476 fn reminders_is_empty_when_missing() {
477 let ical_event = test_icalendar_event().done();
478
479 let event = Event::try_from(ical_event).unwrap();
480
481 assert!(event.reminders.is_empty());
482 }
483
484 #[test]
485 fn converts_url() {
486 let ical_event = test_icalendar_event()
487 .append_property(icalendar::Property::new(
488 "URL",
489 "https://meet.example.com/abc-defg-hij",
490 ))
491 .done();
492
493 let event = Event::try_from(ical_event).unwrap();
494
495 assert_eq!(
496 event.url.as_deref(),
497 Some("https://meet.example.com/abc-defg-hij")
498 );
499 }
500
501 #[test]
502 fn url_is_none_when_missing() {
503 let ical_event = test_icalendar_event().done();
504
505 let event = Event::try_from(ical_event).unwrap();
506
507 assert_eq!(event.url, None);
508 }
509
510 #[test]
511 fn converts_attachments() {
512 let ical_event = test_icalendar_event()
513 .append_multi_property(icalendar::Property::new(
514 "ATTACH",
515 "https://drive.google.com/file/d/abc",
516 ))
517 .done();
518
519 let event = Event::try_from(ical_event).unwrap();
520
521 assert_eq!(
522 event.attachments,
523 vec![Attachment::new("https://drive.google.com/file/d/abc")]
524 );
525 }
526
527 #[test]
528 fn attachments_is_empty_when_missing() {
529 let ical_event = test_icalendar_event().done();
530
531 let event = Event::try_from(ical_event).unwrap();
532
533 assert!(event.attachments.is_empty());
534 }
535
536 #[test]
537 fn parses_uri_attachment_from_ics_string() {
538 let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:test@caldir\r\nDTSTART:20260101T120000Z\r\nATTACH;FMTTYPE=application/pdf;FILENAME=agenda.pdf:https://example.com/agenda.pdf\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
541
542 let event = crate::event::Event::parse_single_ics(ics);
543
544 assert_eq!(event.attachments.len(), 1);
545 assert_eq!(event.attachments[0].uri, "https://example.com/agenda.pdf");
546 assert!(
547 event.attachments[0]
548 .params
549 .contains(&("FILENAME".to_string(), "agenda.pdf".to_string()))
550 );
551 }
552
553 #[test]
554 fn drops_inline_binary_attachments() {
555 let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:test@caldir\r\nDTSTART:20260101T120000Z\r\nATTACH;ENCODING=BASE64;VALUE=BINARY:VGhlIHF1aWNr\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
557
558 let event = crate::event::Event::parse_single_ics(ics);
559
560 assert!(event.attachments.is_empty());
561 }
562
563 #[test]
564 fn converts_x_properties() {
565 let ical_event = test_icalendar_event()
566 .append_property(icalendar::Property::new(
567 "X-HOOLI-EVENT-ID",
568 "abc123@hooli.com",
569 ))
570 .done();
571
572 let event = Event::try_from(ical_event).unwrap();
573
574 assert_eq!(
575 event.x_properties,
576 vec![XProperty::new("X-HOOLI-EVENT-ID", "abc123@hooli.com")]
577 );
578 }
579
580 #[test]
581 fn ignores_non_x_properties() {
582 let ical_event = test_icalendar_event().summary("Hello").done();
583
584 let event = Event::try_from(ical_event).unwrap();
585
586 assert!(event.x_properties.is_empty());
587 }
588
589 #[test]
590 fn normalizes_windows_dtstart_tzid_to_iana() {
591 let mut dtstart = icalendar::Property::new("DTSTART", "20260601T090000");
592 dtstart.add_parameter("TZID", "E. South America Standard Time");
593 let ical_event = icalendar::Event::new()
594 .uid("outlook-feed-event@caldir")
595 .append_property(dtstart.done())
596 .summary("Daily standup")
597 .done();
598
599 let event = Event::try_from(ical_event).unwrap();
600
601 match event.start {
602 EventTime::DateTimeZoned { tzid, .. } => {
603 assert_eq!(tzid, "America/Sao_Paulo");
604 }
605 other => panic!("expected DateTimeZoned, got {other:?}"),
606 }
607 }
608
609 #[test]
610 fn normalizes_windows_recurrence_id_tzid_to_iana() {
611 let ical_event = test_icalendar_event()
612 .recurrence_id(icalendar::DatePerhapsTime::DateTime(
613 icalendar::CalendarDateTime::WithTimezone {
614 date_time: chrono::NaiveDate::from_ymd_opt(2026, 6, 5)
615 .unwrap()
616 .and_hms_opt(9, 0, 0)
617 .unwrap(),
618 tzid: "W. Europe Standard Time".to_string(),
619 },
620 ))
621 .done();
622
623 let event = Event::try_from(ical_event).unwrap();
624 let rid_time = event.recurrence_id.unwrap().as_event_time().clone();
625 match rid_time {
626 EventTime::DateTimeZoned { tzid, .. } => assert_eq!(tzid, "Europe/Berlin"),
627 other => panic!("expected DateTimeZoned, got {other:?}"),
628 }
629 }
630
631 #[test]
632 fn passes_iana_tzid_through_unchanged() {
633 let mut dtstart = icalendar::Property::new("DTSTART", "20260101T120000");
634 dtstart.add_parameter("TZID", "America/New_York");
635 let ical_event = icalendar::Event::new()
636 .uid("iana-event@caldir")
637 .append_property(dtstart.done())
638 .done();
639
640 let event = Event::try_from(ical_event).unwrap();
641 match event.start {
642 EventTime::DateTimeZoned { tzid, .. } => assert_eq!(tzid, "America/New_York"),
643 other => panic!("expected DateTimeZoned, got {other:?}"),
644 }
645 }
646}