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 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 event_start < to && event_end > from
144 }
145
146 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 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 pub fn attendee_status(&self, email: &str) -> Option<ParticipationStatus> {
171 self.find_attendee(email)?.status
172 }
173
174 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 pub fn with_new_uid(&self) -> Self {
209 Event {
210 uid: new_uid(),
211 ..self.clone()
212 }
213 }
214
215 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 #[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 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 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
296fn x_properties_eq(a: &[XProperty], b: &[XProperty]) -> bool {
299 a.len() == b.len() && a.iter().all(|x| b.contains(x))
300}
301
302fn 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 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 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 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 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 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 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}