Skip to main content

caldir_core/event/
from_icalendar.rs

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        // STATUS and TRANSP default to CONFIRMED / OPAQUE per RFC 5545, so a
33        // missing line is treated as that default rather than an "unset" state.
34        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        // Optional: absent (or unrecognized) CLASS stays None, distinct from
45        // an explicit CLASS:PUBLIC.
46        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        // ATTACH is a multi-property (RFC 5545); URI links are kept, inline
53        // binary blobs are dropped — see `Attachment::from_property`.
54        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 defaults to 0 per RFC 5545; treat missing or
81            // unparseable values the same as an explicit 0.
82            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
104// A bare `SUMMARY:` (or DESCRIPTION/LOCATION) line carries no information
105// `None` doesn't. Normalizing here keeps cross-provider comparison stable —
106// Google omits the property when empty, iCloud sometimes emits it bare.
107fn 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        // Bare `SUMMARY:` lines (and friends) should round-trip equal to a
162        // missing property — keeps `Some("") != None` from showing as a diff
163        // when one side has the bare line and the other has nothing.
164        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        // Exercises the real string-parse path (not the builder): confirms a
539        // URI ATTACH is routed into multi_properties and captured, with params.
540        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        // Binary blobs are filtered out at parse — caldir links, never embeds.
556        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}