Skip to main content

caldir_core/
event.rs

1mod attachment;
2mod attendee;
3mod availability;
4mod error;
5mod from_icalendar;
6mod instance_id;
7mod occurrences;
8mod organizer;
9mod recurrence;
10mod reminder;
11mod slugify;
12mod status;
13mod time;
14mod to_icalendar;
15pub mod tz_normalize;
16mod visibility;
17mod vtimezone;
18mod x_property;
19
20pub use attachment::Attachment;
21pub use attendee::{Attendee, ParticipationStatus};
22pub use availability::Availability;
23use chrono::{DateTime, Utc};
24pub use error::EventError;
25pub use instance_id::{EventInstanceId, EventUid, RecurrenceId};
26pub use occurrences::expand_in_range;
27pub use organizer::Organizer;
28pub use recurrence::Recurrence;
29pub use reminder::Reminder;
30pub use status::Status;
31pub use time::EventTime;
32pub use visibility::Visibility;
33pub use x_property::XProperty;
34
35const ICS_PRODID: &str = "CALDIR";
36const ICS_VERSION: &str = "2.0";
37const ICS_UID_DOMAIN: &str = "caldir";
38
39#[derive(Debug, Clone, Eq, educe::Educe)]
40#[educe(PartialEq)]
41pub struct Event {
42    pub uid: EventUid,
43    pub summary: Option<String>,
44    pub description: Option<String>,
45    pub location: Option<String>,
46    pub start: EventTime,
47    pub end: Option<EventTime>,
48    pub status: Status,
49    pub availability: Availability,
50    pub visibility: Option<Visibility>,
51    pub recurrence: Option<Recurrence>,
52    pub recurrence_id: Option<RecurrenceId>,
53    pub organizer: Option<Organizer>,
54    pub attendees: Vec<Attendee>,
55    pub reminders: Vec<Reminder>,
56    pub url: Option<String>,
57
58    #[educe(PartialEq(method(attachments_eq)))]
59    pub attachments: Vec<Attachment>,
60
61    #[educe(PartialEq(method(x_properties_eq)))]
62    pub x_properties: Vec<XProperty>,
63
64    #[educe(PartialEq(ignore))]
65    pub last_modified: Option<DateTime<Utc>>,
66
67    #[educe(PartialEq(ignore))]
68    pub sequence: i32,
69}
70
71impl Event {
72    pub fn new(summary: impl Into<String>, start: EventTime) -> Self {
73        Event {
74            uid: new_uid(),
75            summary: Some(summary.into()),
76            description: None,
77            location: None,
78            start,
79            end: None,
80            status: Status::default(),
81            availability: Availability::default(),
82            visibility: None,
83            recurrence: None,
84            recurrence_id: None,
85            last_modified: None,
86            sequence: 0,
87            organizer: None,
88            attendees: Vec::new(),
89            reminders: Vec::new(),
90            url: None,
91            attachments: Vec::new(),
92            x_properties: Vec::new(),
93        }
94    }
95
96    /// Parse ICS document to list of events
97    pub fn from_ics_str(contents: &str) -> Result<Vec<Result<Self, EventError>>, EventError> {
98        let icalendar: icalendar::Calendar = contents
99            .parse()
100            .map_err(|err| EventError::InvalidIcs(contents.to_string(), err))?;
101
102        Ok(icalendar.events().map(Event::try_from).collect())
103    }
104
105    pub(crate) fn load_single(path: &std::path::Path) -> Result<Self, EventError> {
106        let contents =
107            std::fs::read_to_string(path).map_err(|err| EventError::Io(path.to_path_buf(), err))?;
108
109        Self::from_single_ics_str(&contents)
110    }
111
112    pub(crate) fn from_single_ics_str(contents: &str) -> Result<Self, EventError> {
113        let events = Self::from_ics_str(contents)?;
114
115        match <[Result<Self, EventError>; 1]>::try_from(events) {
116            Ok([result]) => result,
117            Err(events) => Err(EventError::UnexpectedEventCount {
118                expected: 1,
119                found: events.len(),
120            }),
121        }
122    }
123
124    pub fn to_ics_string(&self) -> String {
125        let ical_event: icalendar::Event = self.into();
126
127        let ics = icalendar::Calendar::empty()
128            .append_property(icalendar::Property::new("VERSION", ICS_VERSION))
129            .append_property(icalendar::Property::new("PRODID", ICS_PRODID))
130            .push(ical_event)
131            .done()
132            .to_string();
133
134        let ics = self.splice_valarms_into_vevent(ics);
135        self.splice_vtimezones(ics)
136    }
137
138    pub fn occurs_in_range(&self, from: DateTime<Utc>, to: DateTime<Utc>) -> bool {
139        let event_start = self.start.to_utc();
140        let event_end = self.end.as_ref().unwrap_or(&self.start).to_utc();
141
142        // Check if event overlaps with the range [from, to]
143        event_start < to && event_end > from
144    }
145
146    /// Like occurs_in_range(), but expands the RRULE for recurring events
147    /// so a master with `UNTIL` in the past correctly reports no overlap.
148    pub fn has_occurrence_in_range(&self, from: DateTime<Utc>, to: DateTime<Utc>) -> bool {
149        if self.recurrence.is_some() {
150            !occurrences::expand_master(self, from, to, &std::collections::HashMap::new())
151                .is_empty()
152        } else {
153            self.occurs_in_range(from, to)
154        }
155    }
156
157    /// True if email is an attendee but NOT the organizer
158    pub fn is_invite_for(&self, email: &str) -> bool {
159        let is_attendee = self.find_attendee(email).is_some();
160
161        let is_organizer = self
162            .organizer
163            .as_ref()
164            .is_some_and(|o| o.email.eq_ignore_ascii_case(email));
165
166        is_attendee && !is_organizer
167    }
168
169    /// Get the user's participation status for this event
170    pub fn attendee_status(&self, email: &str) -> Option<ParticipationStatus> {
171        self.find_attendee(email)?.status
172    }
173
174    /// Find the attendee matching the given email (case-insensitive)
175    pub fn find_attendee(&self, email: &str) -> Option<&Attendee> {
176        self.attendees
177            .iter()
178            .find(|a| a.email.eq_ignore_ascii_case(email))
179    }
180
181    pub fn set_attendee_status(
182        &mut self,
183        email: &str,
184        status: ParticipationStatus,
185    ) -> Result<(), EventError> {
186        let maybe_attendee = self
187            .attendees
188            .iter_mut()
189            .find(|a| a.email.eq_ignore_ascii_case(email));
190
191        match maybe_attendee {
192            Some(attendee) => {
193                attendee.status = Some(status);
194                Ok(())
195            }
196            None => Err(EventError::AttendeeNotFound {
197                email: email.to_string(),
198            }),
199        }
200    }
201
202    pub fn is_pending_invite_for(&self, email: &str) -> bool {
203        self.is_invite_for(email)
204            && self.attendee_status(email) == Some(ParticipationStatus::NeedsAction)
205    }
206
207    /// Return a clone of this event with a fresh UID.
208    pub fn with_new_uid(&self) -> Self {
209        Event {
210            uid: new_uid(),
211            ..self.clone()
212        }
213    }
214
215    /// Find the first x-property value matching the given name.
216    pub fn x_property(&self, name: &str) -> Option<&str> {
217        self.x_properties
218            .iter()
219            .find(|x| x.name == name)
220            .map(|x| x.value.as_str())
221    }
222
223    #[cfg(test)]
224    pub(crate) fn add_x_property(
225        mut self,
226        name: impl Into<String>,
227        value: impl Into<String>,
228    ) -> Self {
229        self.x_properties
230            .push(XProperty::new(name.into(), value.into()));
231
232        self
233    }
234
235    /// Parse an ICS document expected to contain exactly one valid event.
236    /// Panics on any deviation — for use in tests with known-good fixtures.
237    #[cfg(test)]
238    pub(crate) fn parse_single_ics(contents: &str) -> Event {
239        Self::from_single_ics_str(contents).expect("expected exactly one valid event in ICS")
240    }
241
242    // icalendar library adds UID to every VALARM
243    // we don't want that, so we construct them with `Reminder::ics_block` instead:
244    fn splice_valarms_into_vevent(&self, ics: String) -> String {
245        if self.reminders.is_empty() {
246            return ics;
247        }
248
249        let valarms: String = self.reminders.iter().map(Reminder::ics_block).collect();
250
251        ics.replacen("END:VEVENT\r\n", &format!("{valarms}END:VEVENT\r\n"), 1)
252    }
253
254    fn splice_vtimezones(&self, ics: String) -> String {
255        let vtimezones = vtimezone::ics_blocks(self);
256        if vtimezones.is_empty() {
257            return ics;
258        }
259
260        ics.replacen(
261            "BEGIN:VEVENT\r\n",
262            &format!("{vtimezones}BEGIN:VEVENT\r\n"),
263            1,
264        )
265    }
266
267    pub fn event_instance_id(&self) -> EventInstanceId {
268        EventInstanceId::new(self.uid.clone(), self.recurrence_id.clone())
269    }
270
271    /// Make sure local copy doesn't strip provider-managed metadata from remote
272    pub fn with_x_properties_merged_from(mut self, base: &Event) -> Self {
273        for local in &mut self.x_properties {
274            if let Some(base_prop) = base.x_properties.iter().find(|p| p.name == local.name) {
275                for (k, v) in &base_prop.params {
276                    if !local.params.iter().any(|(lk, _)| lk == k) {
277                        local.params.push((k.clone(), v.clone()));
278                    }
279                }
280            }
281        }
282        for base_prop in &base.x_properties {
283            if !self.x_properties.iter().any(|p| p.name == base_prop.name) {
284                self.x_properties.push(base_prop.clone());
285            }
286        }
287        self
288    }
289}
290
291fn new_uid() -> EventUid {
292    let uid = format!("{}@{}", uuid::Uuid::new_v4(), ICS_UID_DOMAIN);
293    EventUid::new(uid)
294}
295
296// Order-independent compare: BTreeMap-parsed ICS files give alphabetical
297// order, but providers (e.g. Google) build x_properties in insertion order.
298fn x_properties_eq(a: &[XProperty], b: &[XProperty]) -> bool {
299    a.len() == b.len() && a.iter().all(|x| b.contains(x))
300}
301
302// The URI is the attachment's identity. Compare on it alone (order-independent)
303// so adding/removing/changing an attachment registers as a content change,
304// while volatile params (SIZE, MANAGED-ID) drifting doesn't cause sync churn.
305fn attachments_eq(a: &[Attachment], b: &[Attachment]) -> bool {
306    a.len() == b.len() && a.iter().all(|x| b.iter().any(|y| y.uri == x.uri))
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312    use chrono::TimeZone;
313    use pretty_assertions::assert_eq;
314
315    #[test]
316    fn new_generates_uid_with_caldir_domain() {
317        let event = Event::new(
318            "Test",
319            time::EventTime::Date(chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()),
320        );
321
322        assert!(event.uid.as_str().ends_with("@caldir"));
323        let prefix = event.uid.as_str().trim_end_matches("@caldir");
324        assert!(uuid::Uuid::parse_str(prefix).is_ok());
325    }
326
327    #[test]
328    fn new_generates_unique_uids() {
329        let start = time::EventTime::Date(chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap());
330        let a = Event::new("Test", start.clone());
331        let b = Event::new("Test", start);
332
333        assert_ne!(a.uid, b.uid);
334    }
335
336    #[test]
337    fn rejects_invalid_ics() {
338        // Missing "END:VCALENDAR"
339        let ics = r"BEGIN:VCALENDAR
340VERSION:2.0
341BEGIN:VEVENT
342UID:test-uid@caldir
343DTSTART:20240101T120000Z
344SUMMARY:Test Event
345END:VEVENT"
346            .replace('\n', "\r\n");
347
348        let result = Event::from_ics_str(&ics);
349        assert!(matches!(result, Err(EventError::InvalidIcs(_, _))));
350    }
351
352    #[test]
353    fn rejects_event_without_start() {
354        let result = Event::try_from(&icalendar::Event::new().done());
355
356        assert!(matches!(result, Err(EventError::MissingStart)));
357    }
358
359    #[test]
360    fn normalizes_windows_tzid_to_iana_on_parse_and_write() {
361        let ics = r"BEGIN:VCALENDAR
362VERSION:2.0
363BEGIN:VEVENT
364UID:test-uid@caldir
365DTSTART;TZID=Pacific Standard Time:20240101T120000
366SUMMARY:Test
367END:VEVENT
368END:VCALENDAR"
369            .replace('\n', "\r\n");
370
371        let event = Event::parse_single_ics(&ics);
372
373        assert_eq!(
374            event.start,
375            EventTime::DateTimeZoned {
376                datetime: chrono::NaiveDate::from_ymd_opt(2024, 1, 1)
377                    .unwrap()
378                    .and_hms_opt(12, 0, 0)
379                    .unwrap(),
380                tzid: "America/Los_Angeles".to_string(),
381            }
382        );
383        assert!(
384            event
385                .to_ics_string()
386                .contains("DTSTART;TZID=America/Los_Angeles:20240101T120000")
387        );
388    }
389
390    #[test]
391    fn normalizes_offset_tzid_preserving_instant_and_round_trip() {
392        let ics = r"BEGIN:VCALENDAR
393VERSION:2.0
394BEGIN:VEVENT
395UID:test-uid@caldir
396DTSTART;TZID=GMT+0100:20260724T190200
397SUMMARY:Trip to Sheffield
398END:VEVENT
399END:VCALENDAR"
400            .replace('\n', "\r\n");
401
402        let event = Event::parse_single_ics(&ics);
403
404        assert_eq!(
405            event.start,
406            EventTime::DateTimeZoned {
407                datetime: chrono::NaiveDate::from_ymd_opt(2026, 7, 24)
408                    .unwrap()
409                    .and_hms_opt(19, 2, 0)
410                    .unwrap(),
411                tzid: "Etc/GMT-1".to_string(),
412            }
413        );
414        assert_eq!(
415            event.start.to_utc(),
416            DateTime::parse_from_rfc3339("2026-07-24T18:02:00Z")
417                .unwrap()
418                .with_timezone(&Utc)
419        );
420
421        let serialized = event.to_ics_string();
422        assert!(serialized.contains("DTSTART;TZID=Etc/GMT-1:20260724T190200"));
423        assert_eq!(Event::parse_single_ics(&serialized), event);
424    }
425
426    #[test]
427    fn to_ics_string_sets_calendar_headers() {
428        let event = Event::new(
429            "Test",
430            EventTime::Date(chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()),
431        );
432
433        let ics = event.to_ics_string();
434
435        assert!(ics.contains("VERSION:2.0"));
436        assert!(ics.contains("PRODID:CALDIR"));
437    }
438
439    #[test]
440    fn to_ics_string_updates_dtstamp() {
441        let original_ics = r"BEGIN:VCALENDAR
442VERSION:2.0
443BEGIN:VEVENT
444UID:test@caldir
445DTSTAMP:20200101T000000Z
446DTSTART:20260101
447SUMMARY:Test
448END:VEVENT
449END:VCALENDAR
450"
451        .replace('\n', "\r\n");
452
453        let event = Event::parse_single_ics(&original_ics);
454        let serialized = event.to_ics_string();
455
456        assert_ne!(dtstamp_line(&original_ics), dtstamp_line(&serialized));
457    }
458
459    #[test]
460    fn round_trips_advanced_event_without_data_loss() {
461        let original_ics = r"BEGIN:VCALENDAR
462VERSION:2.0
463PRODID:CALDIR
464BEGIN:VTIMEZONE
465TZID:Europe/Oslo
466BEGIN:DAYLIGHT
467DTSTART:20250330T020000
468TZOFFSETFROM:+0100
469TZOFFSETTO:+0200
470RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
471TZNAME:CEST
472END:DAYLIGHT
473BEGIN:STANDARD
474DTSTART:20251026T030000
475TZOFFSETFROM:+0200
476TZOFFSETTO:+0100
477RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
478TZNAME:CET
479END:STANDARD
480END:VTIMEZONE
481BEGIN:VEVENT
482CLASS:CONFIDENTIAL
483DTSTAMP:20260502T173914Z
484DESCRIPTION:https://docs.example.com/document/d/abc123def456ghijklmnopqrstu
485 v/edit?usp=sharing\n
486DTEND;TZID=Europe/Oslo:20260515T164500
487DTSTART;TZID=Europe/Oslo:20260515T160000
488LAST-MODIFIED:20260502T173914Z
489LOCATION:Conference Room A
490ORGANIZER:mailto:alice@example.com
491RECURRENCE-ID;TZID=Europe/Oslo:20260515T160000
492RRULE:FREQ=WEEKLY;BYDAY=FR
493SEQUENCE:1
494SUMMARY:Friday retro
495TRANSP:TRANSPARENT
496UID:event-uid-123@example.com
497URL:https://meet.example.com/abc-defg-hij
498X-HOOLI-CONFERENCE:https://meet.example.com/abc-defg-hij
499X-HOOLI-EVENT-ID:event-uid-123_20260515T140000Z
500ATTENDEE;PARTSTAT=ACCEPTED:mailto:bob@example.com
501ATTENDEE;PARTSTAT=DECLINED:mailto:alice@example.com
502ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:carol@example.com
503EXDATE;TZID=Europe/Oslo:20260522T160000
504EXDATE;TZID=Europe/Oslo:20260529T160000
505BEGIN:VALARM
506ACTION:DISPLAY
507DESCRIPTION:Reminder
508TRIGGER;RELATED=START:-PT1H
509END:VALARM
510BEGIN:VALARM
511ACTION:DISPLAY
512DESCRIPTION:Reminder
513TRIGGER;RELATED=START:-PT30M
514END:VALARM
515END:VEVENT
516END:VCALENDAR
517"
518        .replace('\n', "\r\n");
519
520        let event = Event::parse_single_ics(&original_ics);
521        let serialized_ics = event.to_ics_string();
522
523        assert_eq!(strip_dtstamp(&original_ics), strip_dtstamp(&serialized_ics));
524    }
525
526    #[test]
527    fn occurs_in_range_returns_true_for_event_inside_range() {
528        let mut event = Event::new(
529            "Test",
530            EventTime::DateTimeUtc(Utc.with_ymd_and_hms(2026, 5, 15, 12, 0, 0).unwrap()),
531        );
532        event.end = Some(EventTime::DateTimeUtc(
533            Utc.with_ymd_and_hms(2026, 5, 15, 13, 0, 0).unwrap(),
534        ));
535
536        let from = Utc.with_ymd_and_hms(2026, 5, 15, 0, 0, 0).unwrap();
537        let to = Utc.with_ymd_and_hms(2026, 5, 16, 0, 0, 0).unwrap();
538
539        assert!(event.occurs_in_range(from, to));
540    }
541
542    #[test]
543    fn occurs_in_range_returns_false_for_event_before_range() {
544        let mut event = Event::new(
545            "Test",
546            EventTime::DateTimeUtc(Utc.with_ymd_and_hms(2026, 5, 14, 12, 0, 0).unwrap()),
547        );
548        event.end = Some(EventTime::DateTimeUtc(
549            Utc.with_ymd_and_hms(2026, 5, 14, 13, 0, 0).unwrap(),
550        ));
551
552        let from = Utc.with_ymd_and_hms(2026, 5, 15, 0, 0, 0).unwrap();
553        let to = Utc.with_ymd_and_hms(2026, 5, 16, 0, 0, 0).unwrap();
554
555        assert!(!event.occurs_in_range(from, to));
556    }
557
558    #[test]
559    fn occurs_in_range_returns_false_for_event_after_range() {
560        let mut event = Event::new(
561            "Test",
562            EventTime::DateTimeUtc(Utc.with_ymd_and_hms(2026, 5, 17, 12, 0, 0).unwrap()),
563        );
564        event.end = Some(EventTime::DateTimeUtc(
565            Utc.with_ymd_and_hms(2026, 5, 17, 13, 0, 0).unwrap(),
566        ));
567
568        let from = Utc.with_ymd_and_hms(2026, 5, 15, 0, 0, 0).unwrap();
569        let to = Utc.with_ymd_and_hms(2026, 5, 16, 0, 0, 0).unwrap();
570
571        assert!(!event.occurs_in_range(from, to));
572    }
573
574    #[test]
575    fn occurs_in_range_returns_true_for_event_overlapping_range_start() {
576        let mut event = Event::new(
577            "Test",
578            EventTime::DateTimeUtc(Utc.with_ymd_and_hms(2026, 5, 14, 23, 0, 0).unwrap()),
579        );
580        event.end = Some(EventTime::DateTimeUtc(
581            Utc.with_ymd_and_hms(2026, 5, 15, 1, 0, 0).unwrap(),
582        ));
583
584        let from = Utc.with_ymd_and_hms(2026, 5, 15, 0, 0, 0).unwrap();
585        let to = Utc.with_ymd_and_hms(2026, 5, 16, 0, 0, 0).unwrap();
586
587        assert!(event.occurs_in_range(from, to));
588    }
589
590    #[test]
591    fn occurs_in_range_returns_true_for_event_overlapping_range_end() {
592        let mut event = Event::new(
593            "Test",
594            EventTime::DateTimeUtc(Utc.with_ymd_and_hms(2026, 5, 15, 23, 0, 0).unwrap()),
595        );
596        event.end = Some(EventTime::DateTimeUtc(
597            Utc.with_ymd_and_hms(2026, 5, 16, 1, 0, 0).unwrap(),
598        ));
599
600        let from = Utc.with_ymd_and_hms(2026, 5, 15, 0, 0, 0).unwrap();
601        let to = Utc.with_ymd_and_hms(2026, 5, 16, 0, 0, 0).unwrap();
602
603        assert!(event.occurs_in_range(from, to));
604    }
605
606    #[test]
607    fn occurs_in_range_uses_start_as_end_when_end_missing() {
608        let event = Event::new(
609            "Test",
610            EventTime::DateTimeUtc(Utc.with_ymd_and_hms(2026, 5, 15, 12, 0, 0).unwrap()),
611        );
612
613        let from = Utc.with_ymd_and_hms(2026, 5, 15, 0, 0, 0).unwrap();
614        let to = Utc.with_ymd_and_hms(2026, 5, 16, 0, 0, 0).unwrap();
615
616        assert!(event.occurs_in_range(from, to));
617    }
618
619    #[test]
620    fn occurs_in_range_excludes_event_ending_exactly_at_range_start() {
621        let mut event = Event::new(
622            "Test",
623            EventTime::DateTimeUtc(Utc.with_ymd_and_hms(2026, 5, 14, 23, 0, 0).unwrap()),
624        );
625        event.end = Some(EventTime::DateTimeUtc(
626            Utc.with_ymd_and_hms(2026, 5, 15, 0, 0, 0).unwrap(),
627        ));
628
629        let from = Utc.with_ymd_and_hms(2026, 5, 15, 0, 0, 0).unwrap();
630        let to = Utc.with_ymd_and_hms(2026, 5, 16, 0, 0, 0).unwrap();
631
632        assert!(!event.occurs_in_range(from, to));
633    }
634
635    #[test]
636    fn occurs_in_range_excludes_event_starting_exactly_at_range_end() {
637        let mut event = Event::new(
638            "Test",
639            EventTime::DateTimeUtc(Utc.with_ymd_and_hms(2026, 5, 16, 0, 0, 0).unwrap()),
640        );
641        event.end = Some(EventTime::DateTimeUtc(
642            Utc.with_ymd_and_hms(2026, 5, 16, 1, 0, 0).unwrap(),
643        ));
644
645        let from = Utc.with_ymd_and_hms(2026, 5, 15, 0, 0, 0).unwrap();
646        let to = Utc.with_ymd_and_hms(2026, 5, 16, 0, 0, 0).unwrap();
647
648        assert!(!event.occurs_in_range(from, to));
649    }
650
651    #[test]
652    fn is_invite_for_returns_true_when_attendee_and_not_organizer() {
653        let mut event = Event::new(
654            "Test",
655            EventTime::Date(chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()),
656        );
657        event.organizer = Some(Organizer::new("alice@example.com"));
658        event.attendees = vec![Attendee::new("bob@example.com")];
659
660        assert!(event.is_invite_for("bob@example.com"));
661    }
662
663    #[test]
664    fn is_invite_for_returns_false_when_email_is_organizer() {
665        let mut event = Event::new(
666            "Test",
667            EventTime::Date(chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()),
668        );
669        event.organizer = Some(Organizer::new("alice@example.com"));
670        event.attendees = vec![Attendee::new("alice@example.com")];
671
672        assert!(!event.is_invite_for("alice@example.com"));
673    }
674
675    #[test]
676    fn is_invite_for_returns_false_when_email_is_not_an_attendee() {
677        let mut event = Event::new(
678            "Test",
679            EventTime::Date(chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()),
680        );
681        event.organizer = Some(Organizer::new("alice@example.com"));
682        event.attendees = vec![Attendee::new("bob@example.com")];
683
684        assert!(!event.is_invite_for("carol@example.com"));
685    }
686
687    #[test]
688    fn is_invite_for_returns_false_when_event_has_no_attendees() {
689        let event = Event::new(
690            "Test",
691            EventTime::Date(chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()),
692        );
693
694        assert!(!event.is_invite_for("bob@example.com"));
695    }
696
697    #[test]
698    fn is_invite_for_returns_true_when_organizer_is_missing() {
699        let mut event = Event::new(
700            "Test",
701            EventTime::Date(chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()),
702        );
703        event.attendees = vec![Attendee::new("bob@example.com")];
704
705        assert!(event.is_invite_for("bob@example.com"));
706    }
707
708    #[test]
709    fn is_invite_for_matches_email_case_insensitively() {
710        let mut event = Event::new(
711            "Test",
712            EventTime::Date(chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()),
713        );
714        event.organizer = Some(Organizer::new("alice@example.com"));
715        event.attendees = vec![Attendee::new("Bob@Example.com")];
716
717        assert!(event.is_invite_for("bob@example.com"));
718        assert!(event.is_invite_for("BOB@EXAMPLE.COM"));
719    }
720
721    #[test]
722    fn is_invite_for_matches_organizer_email_case_insensitively() {
723        let mut event = Event::new(
724            "Test",
725            EventTime::Date(chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()),
726        );
727        event.organizer = Some(Organizer::new("Alice@Example.com"));
728        event.attendees = vec![Attendee::new("alice@example.com")];
729
730        assert!(!event.is_invite_for("ALICE@example.com"));
731    }
732
733    #[test]
734    fn attendee_status_returns_status_for_matching_attendee() {
735        let mut event = Event::new(
736            "Test",
737            EventTime::Date(chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()),
738        );
739        event.attendees = vec![Attendee {
740            email: "bob@example.com".to_string(),
741            name: None,
742            status: Some(ParticipationStatus::Accepted),
743        }];
744
745        assert_eq!(
746            event.attendee_status("bob@example.com"),
747            Some(ParticipationStatus::Accepted)
748        );
749    }
750
751    #[test]
752    fn attendee_status_returns_none_when_email_is_not_an_attendee() {
753        let mut event = Event::new(
754            "Test",
755            EventTime::Date(chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()),
756        );
757        event.attendees = vec![Attendee {
758            email: "bob@example.com".to_string(),
759            name: None,
760            status: Some(ParticipationStatus::Accepted),
761        }];
762
763        assert_eq!(event.attendee_status("carol@example.com"), None);
764    }
765
766    #[test]
767    fn attendee_status_returns_none_when_attendee_has_no_status() {
768        let mut event = Event::new(
769            "Test",
770            EventTime::Date(chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()),
771        );
772        event.attendees = vec![Attendee::new("bob@example.com")];
773
774        assert_eq!(event.attendee_status("bob@example.com"), None);
775    }
776
777    #[test]
778    fn set_attendee_status_updates_matching_attendee() {
779        let mut event = Event::new(
780            "Test",
781            EventTime::Date(chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()),
782        );
783        event.attendees = vec![Attendee::new("bob@example.com")];
784
785        let result = event.set_attendee_status("BOB@example.com", ParticipationStatus::Accepted);
786
787        assert!(result.is_ok());
788        assert_eq!(
789            event.attendee_status("bob@example.com"),
790            Some(ParticipationStatus::Accepted)
791        );
792    }
793
794    #[test]
795    fn set_attendee_status_errors_when_email_is_not_an_attendee() {
796        let mut event = Event::new(
797            "Test",
798            EventTime::Date(chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()),
799        );
800        event.attendees = vec![Attendee::new("bob@example.com")];
801
802        let result = event.set_attendee_status("carol@example.com", ParticipationStatus::Accepted);
803
804        assert!(matches!(
805            result,
806            Err(EventError::AttendeeNotFound { email }) if email == "carol@example.com"
807        ));
808    }
809
810    #[test]
811    fn with_new_uid_changes_the_uid() {
812        let mut event = Event::new(
813            "Test",
814            EventTime::Date(chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()),
815        );
816        event.uid = EventUid::new("original@caldir");
817
818        let cloned = event.with_new_uid();
819
820        assert_ne!(cloned.uid, event.uid);
821    }
822
823    #[test]
824    fn with_new_uid_preserves_other_fields() {
825        let mut event = Event::new(
826            "Annual review",
827            EventTime::Date(chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()),
828        );
829        event.description = Some("notes".to_string());
830        event.location = Some("HQ".to_string());
831
832        let cloned = event.with_new_uid();
833
834        assert_eq!(cloned.summary, event.summary);
835        assert_eq!(cloned.description, event.description);
836        assert_eq!(cloned.location, event.location);
837        assert_eq!(cloned.start, event.start);
838    }
839
840    #[test]
841    fn is_pending_invite_for_true_when_attendee_needs_action() {
842        let mut event = Event::new(
843            "Test",
844            EventTime::Date(chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()),
845        );
846        event.organizer = Some(Organizer::new("alice@example.com"));
847        event.attendees = vec![Attendee {
848            email: "bob@example.com".to_string(),
849            name: None,
850            status: Some(ParticipationStatus::NeedsAction),
851        }];
852
853        assert!(event.is_pending_invite_for("bob@example.com"));
854    }
855
856    #[test]
857    fn is_pending_invite_for_false_when_already_accepted() {
858        let mut event = Event::new(
859            "Test",
860            EventTime::Date(chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()),
861        );
862        event.attendees = vec![Attendee {
863            email: "bob@example.com".to_string(),
864            name: None,
865            status: Some(ParticipationStatus::Accepted),
866        }];
867
868        assert!(!event.is_pending_invite_for("bob@example.com"));
869    }
870
871    #[test]
872    fn is_pending_invite_for_false_when_email_is_organizer() {
873        let mut event = Event::new(
874            "Test",
875            EventTime::Date(chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()),
876        );
877        event.organizer = Some(Organizer::new("alice@example.com"));
878        event.attendees = vec![Attendee {
879            email: "alice@example.com".to_string(),
880            name: None,
881            status: Some(ParticipationStatus::NeedsAction),
882        }];
883
884        assert!(!event.is_pending_invite_for("alice@example.com"));
885    }
886
887    #[test]
888    fn attendee_status_matches_email_case_insensitively() {
889        let mut event = Event::new(
890            "Test",
891            EventTime::Date(chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()),
892        );
893        event.attendees = vec![Attendee {
894            email: "Bob@Example.com".to_string(),
895            name: None,
896            status: Some(ParticipationStatus::Tentative),
897        }];
898
899        assert_eq!(
900            event.attendee_status("bob@example.com"),
901            Some(ParticipationStatus::Tentative)
902        );
903    }
904
905    #[test]
906    fn with_x_properties_merged_from_preserves_base_only_params() {
907        // Example of local file written by old parser that stripped X-APPLE-STRUCTURED-LOCATION params.
908        // Make sure we don't strip them from the remote
909        let mut local = Event::new(
910            "Flight",
911            EventTime::Date(chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()),
912        );
913        local.x_properties = vec![XProperty::new(
914            "X-APPLE-STRUCTURED-LOCATION",
915            "geo:51.47,-0.45",
916        )];
917
918        let mut remote = local.clone();
919        remote.x_properties = vec![XProperty {
920            name: "X-APPLE-STRUCTURED-LOCATION".to_string(),
921            value: "geo:51.47,-0.45".to_string(),
922            params: vec![
923                ("VALUE".to_string(), "URI".to_string()),
924                ("X-TITLE".to_string(), "London Heathrow".to_string()),
925            ],
926        }];
927
928        let merged = local.with_x_properties_merged_from(&remote);
929
930        assert_eq!(merged.x_properties.len(), 1);
931        assert_eq!(
932            merged.x_properties[0].params,
933            vec![
934                ("VALUE".to_string(), "URI".to_string()),
935                ("X-TITLE".to_string(), "London Heathrow".to_string()),
936            ]
937        );
938    }
939
940    #[test]
941    fn with_x_properties_merged_from_local_params_win_shared_keys() {
942        let mut local = Event::new(
943            "Test",
944            EventTime::Date(chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()),
945        );
946        local.x_properties = vec![XProperty {
947            name: "X-APPLE-STRUCTURED-LOCATION".to_string(),
948            value: "geo:0,0".to_string(),
949            params: vec![("X-TITLE".to_string(), "New title".to_string())],
950        }];
951
952        let mut remote = local.clone();
953        remote.x_properties = vec![XProperty {
954            name: "X-APPLE-STRUCTURED-LOCATION".to_string(),
955            value: "geo:0,0".to_string(),
956            params: vec![("X-TITLE".to_string(), "Old title".to_string())],
957        }];
958
959        let merged = local.with_x_properties_merged_from(&remote);
960
961        assert_eq!(
962            merged.x_properties[0].params,
963            vec![("X-TITLE".to_string(), "New title".to_string())]
964        );
965    }
966
967    #[test]
968    fn with_x_properties_merged_from_carries_through_base_only_properties() {
969        // If local is missing the whole property, preserve it from base.
970        let mut local = Event::new(
971            "Test",
972            EventTime::Date(chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()),
973        );
974        local.x_properties = vec![];
975
976        let mut remote = local.clone();
977        remote.x_properties = vec![XProperty::new("X-GOOGLE-EVENT-ID", "abc123")];
978
979        let merged = local.with_x_properties_merged_from(&remote);
980
981        assert_eq!(merged.x_properties.len(), 1);
982        assert_eq!(merged.x_properties[0].name, "X-GOOGLE-EVENT-ID");
983        assert_eq!(merged.x_properties[0].value, "abc123");
984    }
985
986    #[test]
987    fn from_ics_str_returns_empty_for_calendar_without_events() {
988        let ics = r"BEGIN:VCALENDAR
989VERSION:2.0
990END:VCALENDAR
991"
992        .replace('\n', "\r\n");
993        let events = Event::from_ics_str(&ics).unwrap();
994        assert!(events.is_empty());
995    }
996
997    #[test]
998    fn from_ics_str_parses_single_event() {
999        let ics = r"BEGIN:VCALENDAR
1000VERSION:2.0
1001BEGIN:VEVENT
1002UID:only@caldir
1003DTSTART:20260301T100000Z
1004SUMMARY:Only event
1005END:VEVENT
1006END:VCALENDAR
1007"
1008        .replace('\n', "\r\n");
1009
1010        let events = Event::from_ics_str(&ics).unwrap();
1011
1012        assert_eq!(events.len(), 1);
1013        let event = events.into_iter().next().unwrap().unwrap();
1014        assert_eq!(event.uid.as_str(), "only@caldir");
1015        assert_eq!(event.summary.as_deref(), Some("Only event"));
1016    }
1017
1018    #[test]
1019    fn from_ics_str_returns_events_in_document_order() {
1020        let ics = r"BEGIN:VCALENDAR
1021VERSION:2.0
1022BEGIN:VEVENT
1023UID:first@caldir
1024DTSTART:20260301T100000Z
1025SUMMARY:First
1026END:VEVENT
1027BEGIN:VEVENT
1028UID:second@caldir
1029DTSTART:20260302T100000Z
1030SUMMARY:Second
1031END:VEVENT
1032BEGIN:VEVENT
1033UID:third@caldir
1034DTSTART:20260303T100000Z
1035SUMMARY:Third
1036END:VEVENT
1037END:VCALENDAR
1038"
1039        .replace('\n', "\r\n");
1040
1041        let events: Vec<Event> = Event::from_ics_str(&ics)
1042            .unwrap()
1043            .into_iter()
1044            .map(Result::unwrap)
1045            .collect();
1046
1047        assert_eq!(events.len(), 3);
1048        assert_eq!(events[0].uid.as_str(), "first@caldir");
1049        assert_eq!(events[1].uid.as_str(), "second@caldir");
1050        assert_eq!(events[2].uid.as_str(), "third@caldir");
1051    }
1052
1053    #[test]
1054    fn from_ics_str_handles_vtimezone_and_tzid_events() {
1055        let ics = r"BEGIN:VCALENDAR
1056VERSION:2.0
1057BEGIN:VTIMEZONE
1058TZID:Europe/Stockholm
1059BEGIN:STANDARD
1060DTSTART:19701025T030000
1061TZOFFSETFROM:+0200
1062TZOFFSETTO:+0100
1063RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
1064TZNAME:CET
1065END:STANDARD
1066BEGIN:DAYLIGHT
1067DTSTART:19700329T020000
1068TZOFFSETFROM:+0100
1069TZOFFSETTO:+0200
1070RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
1071TZNAME:CEST
1072END:DAYLIGHT
1073END:VTIMEZONE
1074BEGIN:VEVENT
1075UID:zoned@caldir
1076DTSTART;TZID=Europe/Stockholm:20260615T100000
1077SUMMARY:Zoned event
1078END:VEVENT
1079END:VCALENDAR
1080"
1081        .replace('\n', "\r\n");
1082
1083        let event = Event::parse_single_ics(&ics);
1084
1085        assert!(matches!(
1086            event.start,
1087            EventTime::DateTimeZoned { ref tzid, .. } if tzid == "Europe/Stockholm"
1088        ));
1089    }
1090
1091    #[test]
1092    fn from_ics_str_rejects_malformed_top_level() {
1093        // Missing END:VCALENDAR
1094        let ics = r"BEGIN:VCALENDAR
1095VERSION:2.0
1096BEGIN:VEVENT
1097UID:x@caldir
1098DTSTART:20260301T100000Z
1099END:VEVENT
1100"
1101        .replace('\n', "\r\n");
1102
1103        let result = Event::from_ics_str(&ics);
1104
1105        assert!(matches!(result, Err(EventError::InvalidIcs(_, _))));
1106    }
1107
1108    #[test]
1109    fn from_ics_str_surfaces_per_event_parse_errors() {
1110        // Second VEVENT is missing UID — surfaces as an inner Err, while the
1111        // first event still parses. Callers (e.g. webcal) decide whether to
1112        // skip or fail.
1113        let ics = r"BEGIN:VCALENDAR
1114VERSION:2.0
1115BEGIN:VEVENT
1116UID:good@caldir
1117DTSTART:20260301T100000Z
1118SUMMARY:Good
1119END:VEVENT
1120BEGIN:VEVENT
1121DTSTART:20260302T100000Z
1122SUMMARY:Missing UID
1123END:VEVENT
1124END:VCALENDAR
1125"
1126        .replace('\n', "\r\n");
1127
1128        let events = Event::from_ics_str(&ics).unwrap();
1129
1130        assert_eq!(events.len(), 2);
1131        assert_eq!(events[0].as_ref().unwrap().uid.as_str(), "good@caldir");
1132        assert!(matches!(events[1], Err(EventError::MissingUid)));
1133    }
1134
1135    fn dtstamp_line(ics: &str) -> &str {
1136        ics.lines()
1137            .find(|line| line.starts_with("DTSTAMP:"))
1138            .expect("DTSTAMP line should be present")
1139    }
1140
1141    // DTSTAMP updates every time ICS is written
1142    // so we need to ignore it when comparing the original and serialized ICS.
1143    fn strip_dtstamp(ics: &str) -> String {
1144        ics.lines()
1145            .filter(|line| !line.starts_with("DTSTAMP:"))
1146            .collect::<Vec<_>>()
1147            .join("\r\n")
1148            + "\r\n"
1149    }
1150}