Skip to main content

caldir_core/event/
to_icalendar.rs

1use crate::event::{Availability, Event, Status};
2use icalendar::{Component, EventLike};
3
4impl From<&Event> for icalendar::Event {
5    fn from(value: &Event) -> Self {
6        let mut event = icalendar::Event::new();
7        event.starts(icalendar::DatePerhapsTime::from(&value.start));
8        event.uid(value.uid.as_str());
9
10        if let Some(end) = &value.end {
11            event.ends(icalendar::DatePerhapsTime::from(end));
12        }
13
14        // Omit STATUS / TRANSP when they hold their RFC 5545 defaults so files
15        // round-trip cleanly (a parsed-then-written event yields the same bytes
16        // it came from, even if the original had no STATUS line). CLASS is
17        // handled separately — it's optional, not defaulted.
18        if value.status != Status::default() {
19            event.append_property(icalendar::Property::new(
20                "STATUS",
21                value.status.as_ics_str(),
22            ));
23        }
24
25        if value.availability != Availability::default() {
26            event.append_property(icalendar::Property::new(
27                "TRANSP",
28                value.availability.as_ics_str(),
29            ));
30        }
31
32        // Write CLASS whenever set (incl. PUBLIC); omit only when unspecified.
33        if let Some(visibility) = value.visibility {
34            event.append_property(icalendar::Property::new("CLASS", visibility.as_ics_str()));
35        }
36
37        if let Some(recurrence) = &value.recurrence {
38            recurrence.apply_to(&mut event);
39        }
40
41        if let Some(recurrence_id) = &value.recurrence_id {
42            event.recurrence_id(icalendar::DatePerhapsTime::from(
43                recurrence_id.as_event_time(),
44            ));
45        }
46
47        if let Some(summary) = &value.summary {
48            event.summary(summary);
49        }
50
51        if let Some(description) = &value.description {
52            event.description(description);
53        }
54
55        if let Some(location) = &value.location {
56            event.location(location);
57        }
58
59        if let Some(last_modified) = value.last_modified {
60            event.last_modified(last_modified);
61        }
62
63        // Omit SEQUENCE when it holds its RFC 5545 default (0) for the same
64        // round-trip-cleanliness reason as STATUS / TRANSP.
65        if value.sequence != 0 {
66            event.append_property(icalendar::Property::new(
67                "SEQUENCE",
68                value.sequence.to_string(),
69            ));
70        }
71
72        if let Some(organizer) = &value.organizer {
73            event.append_property(icalendar::Property::from(organizer));
74        }
75
76        for attendee in &value.attendees {
77            event.append_multi_property(icalendar::Property::from(attendee));
78        }
79
80        // Reminders are emitted directly into the ICS string by
81        // `Event::to_ics_string`, not via `event.alarm(...)`, so we sidestep
82        // icalendar's auto-UID injection on VALARM sub-components. See
83        // `Reminder::ics_block`.
84
85        if let Some(url) = &value.url {
86            event.append_property(icalendar::Property::new("URL", url));
87        }
88
89        for attachment in &value.attachments {
90            event.append_multi_property(icalendar::Property::from(attachment));
91        }
92
93        for x in &value.x_properties {
94            event.append_property(icalendar::Property::from(x));
95        }
96
97        event.done()
98    }
99}
100
101impl From<Event> for icalendar::Event {
102    fn from(value: Event) -> Self {
103        icalendar::Event::from(&value)
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use crate::EventTime;
111    use crate::event::{EventUid, RecurrenceId, Visibility};
112    use crate::test_utils::test_event;
113
114    #[test]
115    fn converts_summary() {
116        let mut event = test_event();
117        event.summary = Some("Hello world".to_string());
118
119        let ical_event: icalendar::Event = event.into();
120
121        assert_eq!(ical_event.get_summary(), Some("Hello world"));
122    }
123
124    #[test]
125    fn converts_location() {
126        let mut event = test_event();
127        event.location = Some("New York".to_string());
128
129        let ical_event: icalendar::Event = event.into();
130
131        assert_eq!(ical_event.get_location(), Some("New York"));
132    }
133
134    #[test]
135    fn converts_uid() {
136        let mut event = test_event();
137        event.uid = EventUid::new("abc123@hooli.com".to_string());
138
139        let ical_event: icalendar::Event = event.into();
140
141        assert_eq!(ical_event.get_uid(), Some("abc123@hooli.com"));
142    }
143
144    #[test]
145    fn converts_description() {
146        let mut event = test_event();
147        event.description = Some("Multi-line\nnotes".to_string());
148
149        let ical_event: icalendar::Event = event.into();
150
151        assert_eq!(ical_event.get_description(), Some("Multi-line\nnotes"));
152    }
153
154    #[test]
155    fn converts_start() {
156        let mut event = test_event();
157        event.start = EventTime::Date(chrono::NaiveDate::from_ymd_opt(2026, 10, 10).unwrap());
158
159        let ical_event: icalendar::Event = event.into();
160
161        assert_eq!(
162            ical_event.get_start(),
163            Some(icalendar::DatePerhapsTime::Date(
164                chrono::NaiveDate::from_ymd_opt(2026, 10, 10).unwrap()
165            ))
166        );
167    }
168
169    #[test]
170    fn converts_end() {
171        let mut event = test_event();
172        event.end = Some(EventTime::Date(
173            chrono::NaiveDate::from_ymd_opt(2026, 10, 11).unwrap(),
174        ));
175
176        let ical_event: icalendar::Event = event.into();
177
178        assert_eq!(
179            ical_event.get_end(),
180            Some(icalendar::DatePerhapsTime::Date(
181                chrono::NaiveDate::from_ymd_opt(2026, 10, 11).unwrap()
182            ))
183        );
184    }
185
186    #[test]
187    fn omits_end_when_none() {
188        let mut event = test_event();
189        event.end = None;
190
191        let ical_event: icalendar::Event = event.into();
192
193        assert_eq!(ical_event.get_end(), None);
194    }
195
196    #[test]
197    fn converts_status() {
198        let mut event = test_event();
199        event.status = Status::Cancelled;
200
201        let ical_event: icalendar::Event = event.into();
202
203        assert_eq!(ical_event.property_value("STATUS"), Some("CANCELLED"));
204    }
205
206    #[test]
207    fn omits_status_when_default() {
208        let mut event = test_event();
209        event.status = Status::Confirmed;
210
211        let ical_event: icalendar::Event = event.into();
212
213        assert_eq!(ical_event.property_value("STATUS"), None);
214    }
215
216    #[test]
217    fn converts_availability() {
218        let mut event = test_event();
219        event.availability = Availability::Free;
220
221        let ical_event: icalendar::Event = event.into();
222
223        assert_eq!(ical_event.property_value("TRANSP"), Some("TRANSPARENT"));
224    }
225
226    #[test]
227    fn omits_availability_when_default() {
228        let mut event = test_event();
229        event.availability = Availability::Busy;
230
231        let ical_event: icalendar::Event = event.into();
232
233        assert_eq!(ical_event.property_value("TRANSP"), None);
234    }
235
236    #[test]
237    fn converts_visibility() {
238        let mut event = test_event();
239        event.visibility = Some(Visibility::Private);
240
241        let ical_event: icalendar::Event = event.into();
242
243        assert_eq!(ical_event.property_value("CLASS"), Some("PRIVATE"));
244    }
245
246    #[test]
247    fn omits_visibility_when_unspecified() {
248        let mut event = test_event();
249        event.visibility = None;
250
251        let ical_event: icalendar::Event = event.into();
252
253        assert_eq!(ical_event.property_value("CLASS"), None);
254    }
255
256    #[test]
257    fn writes_explicit_public_visibility() {
258        // An explicit Some(Public) is distinct from unspecified and must be
259        // written, so the "default vs public" distinction round-trips.
260        let mut event = test_event();
261        event.visibility = Some(Visibility::Public);
262
263        let ical_event: icalendar::Event = event.into();
264
265        assert_eq!(ical_event.property_value("CLASS"), Some("PUBLIC"));
266    }
267
268    #[test]
269    fn converts_recurrence() {
270        let mut event = test_event();
271        event.recurrence = Some(crate::event::Recurrence::new("FREQ=WEEKLY;BYDAY=MO"));
272
273        let ical_event: icalendar::Event = event.into();
274
275        assert_eq!(
276            ical_event.property_value("RRULE"),
277            Some("FREQ=WEEKLY;BYDAY=MO")
278        );
279    }
280
281    #[test]
282    fn omits_recurrence_when_none() {
283        let mut event = test_event();
284        event.recurrence = None;
285
286        let ical_event: icalendar::Event = event.into();
287
288        assert_eq!(ical_event.property_value("RRULE"), None);
289    }
290
291    #[test]
292    fn converts_recurrence_id() {
293        let mut event = test_event();
294        event.recurrence_id = Some(RecurrenceId::from_event_time(EventTime::Date(
295            chrono::NaiveDate::from_ymd_opt(2026, 5, 15).unwrap(),
296        )));
297
298        let ical_event: icalendar::Event = event.into();
299
300        assert_eq!(
301            ical_event.get_recurrence_id(),
302            Some(icalendar::DatePerhapsTime::Date(
303                chrono::NaiveDate::from_ymd_opt(2026, 5, 15).unwrap()
304            ))
305        );
306    }
307
308    #[test]
309    fn omits_recurrence_id_when_none() {
310        let mut event = test_event();
311        event.recurrence_id = None;
312
313        let ical_event: icalendar::Event = event.into();
314
315        assert_eq!(ical_event.get_recurrence_id(), None);
316    }
317
318    #[test]
319    fn converts_last_modified() {
320        let last_modified = chrono::NaiveDate::from_ymd_opt(2026, 5, 2)
321            .unwrap()
322            .and_hms_opt(17, 39, 14)
323            .unwrap()
324            .and_utc();
325
326        let mut event = test_event();
327        event.last_modified = Some(last_modified);
328
329        let ical_event: icalendar::Event = event.into();
330
331        assert_eq!(
332            ical_event.property_value("LAST-MODIFIED"),
333            Some("20260502T173914Z")
334        );
335    }
336
337    #[test]
338    fn omits_last_modified_when_none() {
339        let mut event = test_event();
340        event.last_modified = None;
341
342        let ical_event: icalendar::Event = event.into();
343
344        assert_eq!(ical_event.property_value("LAST-MODIFIED"), None);
345    }
346
347    #[test]
348    fn converts_sequence() {
349        let mut event = test_event();
350        event.sequence = 3;
351
352        let ical_event: icalendar::Event = event.into();
353
354        assert_eq!(ical_event.property_value("SEQUENCE"), Some("3"));
355    }
356
357    #[test]
358    fn converts_negative_sequence() {
359        let mut event = test_event();
360        event.sequence = -1;
361
362        let ical_event: icalendar::Event = event.into();
363
364        assert_eq!(ical_event.property_value("SEQUENCE"), Some("-1"));
365    }
366
367    #[test]
368    fn omits_sequence_when_default() {
369        let mut event = test_event();
370        event.sequence = 0;
371
372        let ical_event: icalendar::Event = event.into();
373
374        assert_eq!(ical_event.property_value("SEQUENCE"), None);
375    }
376
377    #[test]
378    fn converts_organizer() {
379        let mut event = test_event();
380        event.organizer = Some(crate::event::Organizer::new("alice@example.com"));
381
382        let ical_event: icalendar::Event = event.into();
383
384        assert_eq!(
385            ical_event.property_value("ORGANIZER"),
386            Some("mailto:alice@example.com")
387        );
388    }
389
390    #[test]
391    fn omits_organizer_when_none() {
392        let mut event = test_event();
393        event.organizer = None;
394
395        let ical_event: icalendar::Event = event.into();
396
397        assert!(ical_event.properties().get("ORGANIZER").is_none());
398    }
399
400    #[test]
401    fn converts_attendees() {
402        let mut event = test_event();
403        event.attendees = vec![
404            crate::event::Attendee::new("bob@example.com"),
405            crate::event::Attendee::new("carol@example.com"),
406        ];
407
408        let ical_event: icalendar::Event = event.into();
409
410        let attendees = ical_event
411            .multi_properties()
412            .get("ATTENDEE")
413            .expect("ATTENDEE multi-property should be present");
414        assert_eq!(
415            attendees.iter().map(|p| p.value()).collect::<Vec<_>>(),
416            vec!["mailto:bob@example.com", "mailto:carol@example.com"]
417        );
418    }
419
420    #[test]
421    fn omits_attendees_when_empty() {
422        let mut event = test_event();
423        event.attendees = vec![];
424
425        let ical_event: icalendar::Event = event.into();
426
427        assert!(ical_event.multi_properties().get("ATTENDEE").is_none());
428    }
429
430    // Reminders are plumbed via `Event::to_ics_string` rather than
431    // `From<&Event> for icalendar::Event`, so the wire-through test for them
432    // lives in `event.rs` instead.
433
434    #[test]
435    fn converts_url() {
436        let mut event = test_event();
437        event.url = Some("https://meet.example.com/abc-defg-hij".to_string());
438
439        let ical_event: icalendar::Event = event.into();
440
441        assert_eq!(
442            ical_event.property_value("URL"),
443            Some("https://meet.example.com/abc-defg-hij")
444        );
445    }
446
447    #[test]
448    fn omits_url_when_none() {
449        let mut event = test_event();
450        event.url = None;
451
452        let ical_event: icalendar::Event = event.into();
453
454        assert_eq!(ical_event.property_value("URL"), None);
455    }
456
457    #[test]
458    fn converts_attachments() {
459        let mut event = test_event();
460        event.attachments = vec![
461            crate::event::Attachment::new("https://drive.google.com/file/d/abc"),
462            crate::event::Attachment::new("https://example.com/report.doc"),
463        ];
464
465        let ical_event: icalendar::Event = event.into();
466
467        let attachments = ical_event
468            .multi_properties()
469            .get("ATTACH")
470            .expect("ATTACH multi-property should be present");
471        assert_eq!(
472            attachments.iter().map(|p| p.value()).collect::<Vec<_>>(),
473            vec![
474                "https://drive.google.com/file/d/abc",
475                "https://example.com/report.doc"
476            ]
477        );
478    }
479
480    #[test]
481    fn omits_attachments_when_empty() {
482        let mut event = test_event();
483        event.attachments = vec![];
484
485        let ical_event: icalendar::Event = event.into();
486
487        assert!(ical_event.multi_properties().get("ATTACH").is_none());
488    }
489
490    #[test]
491    fn converts_x_properties() {
492        let mut event = test_event();
493        event.x_properties = vec![crate::event::XProperty::new(
494            "X-HOOLI-EVENT-ID",
495            "abc123@hooli.com",
496        )];
497
498        let ical_event: icalendar::Event = event.into();
499
500        assert_eq!(
501            ical_event.property_value("X-HOOLI-EVENT-ID"),
502            Some("abc123@hooli.com")
503        );
504    }
505
506    #[test]
507    fn omits_x_properties_when_empty() {
508        let mut event = test_event();
509        event.x_properties = vec![];
510
511        let ical_event: icalendar::Event = event.into();
512
513        assert!(ical_event.properties().keys().all(|k| !k.starts_with("X-")));
514    }
515}