Skip to main content

caldir_core/calendar/
event.rs

1mod error;
2
3use crate::{Calendar, Event, EventTime, ParticipationStatus};
4use std::fs::OpenOptions;
5use std::io::{ErrorKind, Write};
6use std::path::{Path, PathBuf};
7
8use chrono::{DateTime, Utc};
9pub use error::CalendarEventError;
10
11#[derive(Debug)]
12pub struct CalendarEvent {
13    event: Event,
14    path: PathBuf,
15}
16
17impl CalendarEvent {
18    pub fn create(calendar: &Calendar, event: Event) -> Result<Self, CalendarEventError> {
19        let base_slug = event.base_slug();
20        let contents = event.to_ics_string();
21
22        let path = write_best_event_file(calendar.path(), &base_slug, None, contents.as_bytes())?;
23        sync_file_mtime(&path, event.last_modified)?;
24
25        Ok(CalendarEvent { event, path })
26    }
27
28    pub fn load(path: impl Into<PathBuf>) -> Result<Self, CalendarEventError> {
29        let path = path.into();
30
31        if !path.is_file() {
32            return Err(CalendarEventError::NotFound(path));
33        }
34
35        let contents = std::fs::read_to_string(&path)
36            .map_err(|err| CalendarEventError::Read(path.clone(), err))?;
37
38        let events = Event::from_ics_str(&contents)
39            .map_err(|err| CalendarEventError::InvalidEvent(path.clone(), err))?;
40
41        let event = match <[Result<Event, _>; 1]>::try_from(events) {
42            Ok([result]) => {
43                result.map_err(|err| CalendarEventError::InvalidEvent(path.clone(), err))?
44            }
45            Err(events) => {
46                return Err(CalendarEventError::ExpectedSingleEvent {
47                    path,
48                    found: events.len(),
49                });
50            }
51        };
52
53        Ok(CalendarEvent { event, path })
54    }
55
56    pub fn update(&mut self, event: Event) -> Result<(), CalendarEventError> {
57        let base_slug = event.base_slug();
58        let contents = event.to_ics_string();
59        let dir = self.path.parent().unwrap_or_else(|| Path::new("."));
60
61        let new_path =
62            write_best_event_file(dir, &base_slug, Some(&self.path), contents.as_bytes())?;
63        sync_file_mtime(&new_path, event.last_modified)?;
64
65        if new_path == self.path {
66            self.event = event;
67            return Ok(());
68        }
69
70        if let Err(err) = std::fs::remove_file(&self.path) {
71            let _ = std::fs::remove_file(&new_path);
72            return Err(err.into());
73        }
74
75        self.event = event;
76        self.path = new_path;
77
78        Ok(())
79    }
80
81    pub fn delete(self) -> Result<(), CalendarEventError> {
82        std::fs::remove_file(&self.path).map_err(|err| CalendarEventError::Delete(self.path, err))
83    }
84
85    pub fn event(&self) -> &Event {
86        &self.event
87    }
88
89    pub fn path(&self) -> &Path {
90        &self.path
91    }
92
93    pub fn filename(&self) -> Option<&str> {
94        self.path.file_name().and_then(|name| name.to_str())
95    }
96
97    // File mtime
98    pub fn modified_at(&self) -> Option<DateTime<Utc>> {
99        std::fs::metadata(self.path())
100            .ok()
101            .and_then(|m| m.modified().ok())
102            .map(DateTime::<Utc>::from)
103    }
104
105    pub fn update_attendee_status(
106        &mut self,
107        email: &str,
108        status: ParticipationStatus,
109    ) -> Result<(), CalendarEventError> {
110        let mut event = self.event.clone();
111
112        event.set_attendee_status(email, status)?;
113
114        event.last_modified = Some(Utc::now());
115
116        // Save file
117        self.update(event)
118    }
119
120    /// Add an EXDATE to a recurring master
121    pub(crate) fn add_exdate(&mut self, exdate: EventTime) -> Result<(), CalendarEventError> {
122        let mut event = self.event.clone();
123
124        let Some(recurrence) = event.recurrence.as_mut() else {
125            return Err(CalendarEventError::NotRecurring(
126                self.event.uid.as_str().to_string(),
127            ));
128        };
129
130        let exdate_utc = exdate.to_utc();
131
132        if recurrence
133            .exdates
134            .iter()
135            .any(|ex| ex.to_utc() == exdate_utc)
136        {
137            return Ok(());
138        }
139
140        recurrence.exdates.push(exdate);
141
142        event.last_modified = Some(Utc::now());
143        event.sequence += 1;
144
145        self.update(event)
146    }
147}
148
149// Pin the file mtime to the event's LAST-MODIFIED so direction detection
150// reflects when the event was changed, not when bytes hit disk. Without this,
151// every pull leaves the file appearing newer than its remote counterpart —
152// any later code change that adds a parsed property would surface as an
153// outgoing push.
154fn sync_file_mtime(
155    path: &Path,
156    last_modified: Option<DateTime<Utc>>,
157) -> Result<(), CalendarEventError> {
158    let Some(ts) = last_modified else {
159        return Ok(());
160    };
161    let ft = filetime::FileTime::from_unix_time(ts.timestamp(), ts.timestamp_subsec_nanos());
162    filetime::set_file_mtime(path, ft)?;
163    Ok(())
164}
165
166fn write_best_event_file(
167    calendar_dir: &Path,
168    base_slug: &str,
169    current_path: Option<&Path>,
170    contents: &[u8],
171) -> Result<PathBuf, CalendarEventError> {
172    let mut suffix = 1;
173
174    loop {
175        let filename = if suffix == 1 {
176            format!("{base_slug}.ics")
177        } else {
178            format!("{base_slug}-{suffix}.ics")
179        };
180        let path = calendar_dir.join(filename);
181
182        if current_path == Some(path.as_path()) {
183            std::fs::write(&path, contents)?;
184            return Ok(path);
185        }
186
187        match OpenOptions::new().write(true).create_new(true).open(&path) {
188            Ok(mut file) => {
189                if let Err(err) = file.write_all(contents) {
190                    let _ = std::fs::remove_file(&path);
191                    return Err(err.into());
192                }
193
194                return Ok(path);
195            }
196            Err(err) if err.kind() == ErrorKind::AlreadyExists => {
197                suffix += 1;
198            }
199            Err(err) => return Err(err.into()),
200        }
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use crate::Attendee;
208    use crate::event::EventError;
209    use crate::test_utils::test_calendar;
210    use crate::test_utils::test_calendar_event;
211    use crate::test_utils::test_event;
212    use chrono::TimeZone;
213    use std::fs;
214
215    fn cal_event_with_attendees(attendees: Vec<Attendee>) -> (tempfile::TempDir, CalendarEvent) {
216        let (tmp, calendar) = test_calendar();
217        let mut event = test_event();
218        event.attendees = attendees;
219        let cal_event = CalendarEvent::create(&calendar, event).unwrap();
220        (tmp, cal_event)
221    }
222
223    #[test]
224    fn create_saves_event_to_file() {
225        let (_tmp, calendar) = test_calendar();
226        let cal_event = CalendarEvent::create(&calendar, test_event()).unwrap();
227
228        assert!(cal_event.path().is_file());
229        assert_eq!(
230            cal_event.filename(),
231            Some("2026-01-01T1200__test-event.ics")
232        );
233    }
234
235    #[test]
236    fn create_generates_unique_filenames_within_calendar() {
237        let (_tmp, calendar) = test_calendar();
238
239        let cal_event_1 = CalendarEvent::create(&calendar, test_event()).unwrap();
240
241        assert_eq!(
242            cal_event_1.filename(),
243            Some("2026-01-01T1200__test-event.ics")
244        );
245
246        let cal_event_2 = CalendarEvent::create(&calendar, test_event()).unwrap();
247
248        assert_eq!(
249            cal_event_2.filename(),
250            Some("2026-01-01T1200__test-event-2.ics")
251        );
252
253        let cal_event_3 = CalendarEvent::create(&calendar, test_event()).unwrap();
254
255        assert_eq!(
256            cal_event_3.filename(),
257            Some("2026-01-01T1200__test-event-3.ics")
258        );
259    }
260
261    #[test]
262    fn create_keeps_base_filenames_in_different_calendars() {
263        let (_tmp, calendar_1) = test_calendar();
264        let cal_event_1 = CalendarEvent::create(&calendar_1, test_event()).unwrap();
265
266        let (_tmp, calendar_2) = test_calendar();
267        let cal_event_2 = CalendarEvent::create(&calendar_2, test_event()).unwrap();
268
269        assert_eq!(
270            cal_event_1.filename().unwrap(),
271            cal_event_2.filename().unwrap()
272        );
273    }
274
275    #[test]
276    fn load_errors_on_missing_file() {
277        let tmp = tempfile::TempDir::new().unwrap();
278        let path = tmp.path().join("missing.ics");
279
280        let err = CalendarEvent::load(path).unwrap_err();
281
282        assert!(matches!(err, CalendarEventError::NotFound(p) if p.ends_with("missing.ics")));
283    }
284
285    #[test]
286    fn load_errors_on_invalid_ics() {
287        let tmp = tempfile::TempDir::new().unwrap();
288        let path = tmp.path().join("test.ics");
289        fs::write(&path, "BEGIN:VCALENDAR").unwrap(); // Missing END
290
291        let err = CalendarEvent::load(path).unwrap_err();
292
293        assert!(matches!(err, CalendarEventError::InvalidEvent(p, _) if p.ends_with("test.ics")));
294    }
295
296    #[test]
297    fn load_parses_valid_ics() {
298        let ics = "BEGIN:VCALENDAR\nVERSION:2.0\nBEGIN:VEVENT\nUID:test-uid@caldir\nDTSTART:20240101T120000Z\nSUMMARY:Test Event\nEND:VEVENT\nEND:VCALENDAR";
299        let tmp = tempfile::TempDir::new().unwrap();
300        let path = tmp.path().join("test.ics");
301        fs::write(&path, ics).unwrap();
302
303        assert!(CalendarEvent::load(path).is_ok());
304    }
305
306    #[test]
307    fn update_renames_file_when_summary_changes() {
308        let (_tmp, mut cal_event) = test_calendar_event();
309
310        assert_eq!(
311            cal_event.filename(),
312            Some("2026-01-01T1200__test-event.ics")
313        );
314
315        let mut event = cal_event.event().clone();
316        event.summary = Some("Planning Session".to_string());
317        cal_event.update(event).unwrap();
318
319        assert_eq!(
320            cal_event.filename(),
321            Some("2026-01-01T1200__planning-session.ics")
322        );
323    }
324
325    #[test]
326    fn update_keeps_filename_when_other_properties_change() {
327        let (_tmp, mut cal_event) = test_calendar_event();
328
329        assert_eq!(
330            cal_event.filename(),
331            Some("2026-01-01T1200__test-event.ics")
332        );
333
334        let mut event = cal_event.event().clone();
335        event.location = Some("Conference Room".to_string());
336        cal_event.update(event).unwrap();
337
338        assert_eq!(
339            cal_event.filename(),
340            Some("2026-01-01T1200__test-event.ics")
341        );
342
343        let contents = fs::read_to_string(cal_event.path()).unwrap();
344        assert!(contents.contains("LOCATION:Conference Room"));
345    }
346
347    #[test]
348    fn create_pins_file_mtime_to_event_last_modified() {
349        let (_tmp, calendar) = test_calendar();
350        let mut event = test_event();
351        let when = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
352        event.last_modified = Some(when);
353
354        let cal_event = CalendarEvent::create(&calendar, event).unwrap();
355
356        assert_eq!(cal_event.modified_at(), Some(when));
357    }
358
359    #[test]
360    fn create_leaves_file_mtime_alone_when_last_modified_is_none() {
361        // Fresh local events have no remote LAST-MODIFIED yet — fall back to
362        // the OS's wall-clock write time so the file's mtime still tracks
363        // local edits.
364        let (_tmp, calendar) = test_calendar();
365        let event = test_event();
366        assert!(event.last_modified.is_none());
367
368        let before = Utc::now();
369        let cal_event = CalendarEvent::create(&calendar, event).unwrap();
370        let after = Utc::now();
371
372        let mtime = cal_event.modified_at().expect("file should have mtime");
373        assert!(mtime >= before - chrono::Duration::seconds(1));
374        assert!(mtime <= after + chrono::Duration::seconds(1));
375    }
376
377    #[test]
378    fn update_pins_file_mtime_to_new_last_modified() {
379        let (_tmp, mut cal_event) = test_calendar_event();
380        let mut event = cal_event.event().clone();
381        let when = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
382        event.last_modified = Some(when);
383
384        cal_event.update(event).unwrap();
385
386        assert_eq!(cal_event.modified_at(), Some(when));
387    }
388
389    #[test]
390    fn update_attendee_status_sets_partstat_and_persists() {
391        let (_tmp, mut cal_event) =
392            cal_event_with_attendees(vec![Attendee::new("bob@example.com")]);
393
394        cal_event
395            .update_attendee_status("bob@example.com", ParticipationStatus::Accepted)
396            .unwrap();
397
398        assert_eq!(
399            cal_event.event().attendees[0].status,
400            Some(ParticipationStatus::Accepted)
401        );
402
403        let contents = fs::read_to_string(cal_event.path()).unwrap();
404        assert!(contents.contains("PARTSTAT=ACCEPTED"));
405    }
406
407    #[test]
408    fn update_attendee_status_preserves_sequence_and_sets_last_modified() {
409        let (_tmp, mut cal_event) =
410            cal_event_with_attendees(vec![Attendee::new("bob@example.com")]);
411        let sequence_before = cal_event.event().sequence;
412
413        let before = Utc::now();
414        cal_event
415            .update_attendee_status("bob@example.com", ParticipationStatus::Declined)
416            .unwrap();
417        let after = Utc::now();
418
419        assert_eq!(cal_event.event().sequence, sequence_before);
420        let last_modified = cal_event
421            .event()
422            .last_modified
423            .expect("last_modified should be set");
424        assert!(last_modified >= before);
425        assert!(last_modified <= after);
426    }
427
428    #[test]
429    fn update_attendee_status_matches_email_case_insensitively() {
430        let (_tmp, mut cal_event) =
431            cal_event_with_attendees(vec![Attendee::new("Bob@Example.com")]);
432
433        cal_event
434            .update_attendee_status("BOB@example.com", ParticipationStatus::Tentative)
435            .unwrap();
436
437        assert_eq!(
438            cal_event.event().attendees[0].status,
439            Some(ParticipationStatus::Tentative)
440        );
441    }
442
443    #[test]
444    fn update_attendee_status_errors_on_unknown_email() {
445        let (_tmp, mut cal_event) =
446            cal_event_with_attendees(vec![Attendee::new("bob@example.com")]);
447
448        let err = cal_event
449            .update_attendee_status("carol@example.com", ParticipationStatus::Accepted)
450            .unwrap_err();
451
452        assert!(matches!(
453            err,
454            CalendarEventError::Event(EventError::AttendeeNotFound { email })
455                if email == "carol@example.com"
456        ));
457    }
458
459    #[test]
460    fn update_updates_filename_to_base_when_base_is_available() {
461        let (_tmp, calendar) = test_calendar();
462
463        let cal_event_1 = CalendarEvent::create(&calendar, test_event()).unwrap();
464        let mut cal_event_2 = CalendarEvent::create(&calendar, test_event()).unwrap();
465
466        assert_eq!(
467            cal_event_2.filename(),
468            Some("2026-01-01T1200__test-event-2.ics")
469        );
470
471        // Delete original event that had "test-event" slug
472        cal_event_1
473            .delete()
474            .expect("Failed to delete calendar event");
475
476        // "test-event" slug is now available for cal_event_2 to use:
477        cal_event_2.update(cal_event_2.event().clone()).unwrap();
478
479        assert_eq!(
480            cal_event_2.filename(),
481            Some("2026-01-01T1200__test-event.ics")
482        );
483    }
484
485    fn recurring_cal_event() -> (tempfile::TempDir, CalendarEvent) {
486        let (tmp, calendar) = test_calendar();
487        let mut event = test_event();
488        event.recurrence = Some(crate::Recurrence::new("FREQ=DAILY"));
489        let cal_event = CalendarEvent::create(&calendar, event).unwrap();
490        (tmp, cal_event)
491    }
492
493    #[test]
494    fn add_exdate_records_exclusion_and_bumps_sequence() {
495        let (_tmp, mut cal_event) = recurring_cal_event();
496        let exdate = EventTime::DateTimeUtc(Utc.with_ymd_and_hms(2026, 1, 3, 12, 0, 0).unwrap());
497
498        cal_event.add_exdate(exdate.clone()).unwrap();
499
500        assert_eq!(
501            cal_event.event().recurrence.as_ref().unwrap().exdates,
502            vec![exdate]
503        );
504        assert_eq!(cal_event.event().sequence, 1);
505    }
506
507    #[test]
508    fn add_exdate_is_idempotent() {
509        let (_tmp, mut cal_event) = recurring_cal_event();
510        let exdate = EventTime::DateTimeUtc(Utc.with_ymd_and_hms(2026, 1, 3, 12, 0, 0).unwrap());
511
512        cal_event.add_exdate(exdate.clone()).unwrap();
513        cal_event.add_exdate(exdate.clone()).unwrap();
514
515        // No duplicate EXDATE, and the second call didn't bump SEQUENCE again.
516        assert_eq!(
517            cal_event.event().recurrence.as_ref().unwrap().exdates,
518            vec![exdate]
519        );
520        assert_eq!(cal_event.event().sequence, 1);
521    }
522
523    #[test]
524    fn add_exdate_errors_when_event_is_not_recurring() {
525        let (_tmp, mut cal_event) = test_calendar_event();
526        assert!(cal_event.event().recurrence.is_none());
527
528        let err = cal_event
529            .add_exdate(EventTime::DateTimeUtc(
530                Utc.with_ymd_and_hms(2026, 1, 3, 12, 0, 0).unwrap(),
531            ))
532            .unwrap_err();
533
534        assert!(matches!(err, CalendarEventError::NotRecurring(_)));
535    }
536}