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 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 self.update(event)
118 }
119
120 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
149fn 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(); 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 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 cal_event_1
473 .delete()
474 .expect("Failed to delete calendar event");
475
476 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 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}