1mod config;
2mod error;
3mod event;
4mod state;
5
6use crate::event::{EventInstanceId, EventTime, EventUid, Recurrence, expand_in_range};
7use crate::utils::slugify;
8use crate::{Event, RemoteConfig};
9use std::path::{Path, PathBuf};
10
11use chrono::{DateTime, Utc};
12pub use config::{CalendarConfig, CalendarConfigError};
13pub use error::CalendarError;
14pub use event::CalendarEvent;
15pub use event::CalendarEventError;
16pub(crate) use state::SyncBases;
17pub use state::{CalendarState, CalendarStateError};
18
19const DOTDIR_NAME: &str = ".caldir";
20
21const CONFIG_FILE_NAME: &str = "config.toml";
23
24const STATE_DIR_NAME: &str = "state";
26
27fn calendar_dotdir(calendar_path: &Path) -> PathBuf {
28 calendar_path.join(DOTDIR_NAME)
29}
30
31fn calendar_config_path(calendar_path: &Path) -> PathBuf {
32 calendar_dotdir(calendar_path).join(CONFIG_FILE_NAME)
33}
34
35fn calendar_state_dir(calendar_path: &Path) -> PathBuf {
36 calendar_dotdir(calendar_path).join(STATE_DIR_NAME)
37}
38
39#[derive(Debug)]
40pub struct Calendar {
41 path: PathBuf,
42 config: Option<CalendarConfig>,
43 state: CalendarState,
44}
45
46impl Calendar {
47 pub fn create(path: &Path, config: Option<CalendarConfig>) -> Result<Self, CalendarError> {
49 if path.exists() {
51 return Err(CalendarError::AlreadyExists(path.to_path_buf()));
52 }
53
54 std::fs::create_dir_all(calendar_dotdir(path))?;
56
57 if let Some(ref config) = config {
59 let config_path = calendar_config_path(path);
60 CalendarConfig::write(config, &config_path)?;
61 }
62
63 Ok(Self {
64 path: path.to_path_buf(),
65 config,
66 state: CalendarState::new(),
67 })
68 }
69
70 pub fn load(path: &Path) -> Result<Self, CalendarError> {
72 if !path.is_dir() {
73 return Err(CalendarError::NotFound(path.to_path_buf()));
74 }
75
76 let config_path = calendar_config_path(path);
77 let config = CalendarConfig::load_optional(&config_path)?;
78
79 let state_dir = calendar_state_dir(path);
80 let state = CalendarState::load(&state_dir)?;
81
82 Ok(Self {
83 path: path.to_path_buf(),
84 config,
85 state,
86 })
87 }
88
89 pub fn path(&self) -> &Path {
90 &self.path
91 }
92
93 pub fn config_path(&self) -> PathBuf {
94 calendar_config_path(self.path())
95 }
96
97 pub fn state(&self) -> &CalendarState {
98 &self.state
99 }
100
101 pub fn slug(&self) -> Option<&str> {
102 self.path().file_name().and_then(|s| s.to_str())
103 }
104
105 pub fn events(&self) -> Result<Vec<CalendarEvent>, CalendarError> {
107 let mut events: Vec<CalendarEvent> = Vec::new();
108
109 for entry in std::fs::read_dir(self.path())? {
110 let entry = entry?;
111 let path = entry.path();
112
113 if entry.file_type()?.is_file() && path.extension().is_some_and(|ext| ext == "ics") {
114 events.push(CalendarEvent::load(path)?);
115 }
116 }
117
118 Ok(events)
119 }
120
121 pub fn event(&self, event_slug: &str) -> Result<CalendarEvent, CalendarError> {
123 let event_path = self.path().join(format!("{}.ics", event_slug));
124 let calendar_event = CalendarEvent::load(event_path)?;
125 Ok(calendar_event)
126 }
127
128 pub fn event_by_instance_id(
129 &self,
130 id: &EventInstanceId,
131 ) -> Result<Option<CalendarEvent>, CalendarError> {
132 let found = self
133 .events()?
134 .into_iter()
135 .find(|ce| ce.event().event_instance_id() == *id);
136
137 Ok(found)
138 }
139
140 pub fn master_event_for(&self, uid: &str) -> Result<Option<Event>, CalendarError> {
144 let master = self
145 .events()?
146 .into_iter()
147 .find(|ce| ce.event().uid.as_str() == uid && ce.event().recurrence.is_some())
148 .map(|ce| ce.event().clone());
149 Ok(master)
150 }
151
152 pub fn is_read_only(&self) -> bool {
153 self.config
154 .as_ref()
155 .and_then(|c| c.read_only())
156 .unwrap_or(false)
157 }
158
159 pub fn name(&self) -> Option<&str> {
160 self.config.as_ref().and_then(|c| c.name())
161 }
162
163 pub fn color(&self) -> Option<&str> {
164 self.config.as_ref().and_then(|c| c.color())
165 }
166
167 pub fn read_only_setting(&self) -> Option<bool> {
168 self.config.as_ref().and_then(|c| c.read_only())
169 }
170
171 pub fn split_recurring_series_at(
183 &self,
184 master_uid: &EventUid,
185 split_start: EventTime,
186 split_end: EventTime,
187 new_recurrence: Option<Recurrence>,
188 ) -> Result<Event, CalendarError> {
189 let mut all_events = self.events()?;
190
191 let master_idx = all_events
192 .iter()
193 .position(|ce| ce.event().uid == *master_uid && ce.event().recurrence.is_some())
194 .ok_or_else(|| CalendarError::MasterNotFound(master_uid.as_str().to_string()))?;
195
196 let mut master_ce = all_events.swap_remove(master_idx);
197 let master_event = master_ce.event().clone();
198 let master_recurrence = master_event
199 .recurrence
200 .as_ref()
201 .ok_or_else(|| CalendarError::NotRecurring(master_uid.as_str().to_string()))?;
202
203 let truncated_recurrence =
204 master_recurrence.truncate_before(&master_event.start, &split_start);
205
206 let truncated_master = Event {
207 recurrence: Some(truncated_recurrence),
208 last_modified: Some(Utc::now()),
209 sequence: master_event.sequence + 1,
210 ..master_event.clone()
211 };
212 master_ce.update(truncated_master)?;
213
214 let new_master = Event {
215 start: split_start.clone(),
216 end: Some(split_end),
217 recurrence: new_recurrence,
218 recurrence_id: None,
219 last_modified: Some(Utc::now()),
220 sequence: 0,
221 ..master_event.with_new_uid()
222 };
223 let new_master_ce = self.create_event(new_master)?;
224 let new_master_event = new_master_ce.event().clone();
225
226 let split_start_utc = split_start.to_utc();
227 for ce in all_events.into_iter() {
228 if ce.event().uid != *master_uid {
229 continue;
230 }
231 let Some(rid) = ce.event().recurrence_id.as_ref() else {
232 continue;
233 };
234 if rid.as_event_time().to_utc() >= split_start_utc {
235 ce.delete()?;
236 }
237 }
238
239 Ok(new_master_event)
240 }
241
242 pub fn delete_recurring_instance(&self, id: &EventInstanceId) -> Result<(), CalendarError> {
245 let uid = id.uid();
246
247 let Some(recurrence_id) = id.recurrence_id() else {
248 return Err(CalendarError::NotRecurring(uid.as_str().to_string()));
249 };
250
251 let mut master = None;
252 let mut override_event = None;
253
254 for ce in self.events()? {
256 if ce.event().uid != *uid {
257 continue;
258 }
259
260 let is_override_for_occurrence = ce.event().event_instance_id() == *id;
261
262 if ce.event().recurrence.is_some() {
263 master = Some(ce);
264 } else if is_override_for_occurrence {
265 override_event = Some(ce);
266 }
267 }
268
269 if master.is_none() && override_event.is_none() {
270 return Err(CalendarError::MasterNotFound(uid.as_str().to_string()));
271 }
272
273 if let Some(ce) = override_event {
275 ce.delete()?;
276 }
277
278 if let Some(mut ce) = master {
280 ce.add_exdate(recurrence_id.as_event_time().clone())?;
281 }
282
283 Ok(())
284 }
285
286 pub fn update_recurring_instance(
288 &self,
289 id: &EventInstanceId,
290 apply: impl FnOnce(&mut Event),
291 ) -> Result<CalendarEvent, CalendarError> {
292 let Some(recurrence_id) = id.recurrence_id() else {
293 return Err(CalendarError::NotRecurring(id.uid().as_str().to_string()));
294 };
295
296 let existing = self.event_by_instance_id(id)?;
297
298 match existing {
299 Some(mut ce) => {
301 let mut event = ce.event().clone();
302 apply(&mut event);
303
304 event.last_modified = Some(Utc::now());
305 event.sequence += 1;
306 ce.update(event)?;
307
308 Ok(ce)
309 }
310 None => {
312 let master = self
313 .master_event_for(id.uid().as_str())?
314 .ok_or_else(|| CalendarError::MasterNotFound(id.uid().as_str().to_string()))?;
315
316 let mut event = master.occurrence_at(recurrence_id.as_event_time().clone());
317
318 event.sequence = 0;
319 event.last_modified = Some(Utc::now());
320 apply(&mut event);
321
322 self.create_event(event)
323 }
324 }
325 }
326
327 pub fn expanded_events_in_range(
329 &self,
330 from: DateTime<Utc>,
331 to: DateTime<Utc>,
332 ) -> Result<Vec<Event>, CalendarError> {
333 let events = self.events()?.into_iter().map(|ce| ce.event().clone());
334 Ok(expand_in_range(events, from, to))
335 }
336
337 pub fn create_event(&self, event: Event) -> Result<CalendarEvent, CalendarError> {
339 let calendar_event = CalendarEvent::create(self, event)?;
340 Ok(calendar_event)
341 }
342
343 pub fn delete_event(&self, event_slug: &str) -> Result<(), CalendarError> {
345 let event = self.event(event_slug)?;
346 event.delete()?;
347 Ok(())
348 }
349
350 pub fn remote_config(&self) -> Option<&RemoteConfig> {
351 self.config.as_ref().and_then(|c| c.remote_config())
352 }
353
354 pub fn has_remote(&self) -> bool {
355 self.remote_config().is_some()
356 }
357
358 pub fn config(&self) -> Option<&CalendarConfig> {
359 self.config.as_ref()
360 }
361
362 pub fn remote_email(&self) -> Option<&str> {
363 self.remote_config()
364 .and_then(|remote_config| remote_config.account_email())
365 }
366
367 pub(crate) fn record_sync_bases(
368 &mut self,
369 events: impl IntoIterator<Item = Event>,
370 ) -> Result<(), CalendarError> {
371 self.state
372 .record_sync_bases(events, &calendar_state_dir(&self.path))?;
373 Ok(())
374 }
375
376 pub fn base_slug_for(name: Option<&str>) -> String {
377 name.map(slugify).unwrap_or_else(|| "calendar".to_string())
378 }
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384 use crate::test_utils::{
385 test_caldir, test_calendar, test_calendar_config, test_calendar_path, test_event,
386 };
387
388 #[test]
389 fn create_creates_caldir_subdirectory() {
390 let (_, path) = test_calendar_path();
391
392 let calendar = Calendar::create(&path, None).unwrap();
393
394 assert!(calendar.path().join(".caldir").is_dir());
395 }
396
397 #[test]
398 fn create_without_config_does_not_create_config_file() {
399 let (_, path) = test_calendar_path();
400
401 let calendar = Calendar::create(&path, None).unwrap();
402
403 assert!(!calendar.config_path().is_file());
404 }
405
406 #[test]
407 fn create_with_config_writes_config_file() {
408 let (_, path) = test_calendar_path();
409 let config = test_calendar_config();
410
411 let calendar = Calendar::create(&path, Some(config.clone())).unwrap();
412
413 let expected_config_path = &path.join(".caldir").join("config.toml");
414
415 assert!(calendar.config_path().is_file());
417 assert_eq!(&calendar.config_path(), expected_config_path);
418
419 let loaded_config = CalendarConfig::load(&calendar.config_path()).unwrap();
421 assert_eq!(loaded_config, config);
422 }
423
424 #[test]
425 fn load_returns_existing_calendar() {
426 let (_, path) = test_calendar_path();
427 let result = Calendar::create(&path, None);
428
429 assert!(result.is_ok());
430 assert_eq!(result.unwrap().path(), path);
431 }
432
433 #[test]
434 fn load_errors_when_directory_missing() {
435 let (tmp, _caldir) = test_caldir();
436
437 let result = Calendar::load(&tmp.path().join("missing"));
438
439 assert!(matches!(result, Err(CalendarError::NotFound(_))));
440 }
441
442 #[test]
443 fn load_errors_when_not_directory() {
444 let (tmp, _caldir) = test_caldir();
445
446 let file_path = tmp.path().join("not_a_directory");
447 std::fs::write(&file_path, "I am a file, not a directory").unwrap();
448
449 let result = Calendar::load(&file_path);
450
451 assert!(matches!(result, Err(CalendarError::NotFound(p)) if p == file_path));
452 }
453
454 #[test]
455 fn events_only_returns_events_from_current_calendar() {
456 let (_tmp, caldir) = test_caldir();
457
458 let work = caldir.create_calendar("work", None).unwrap();
460 let personal = caldir.create_calendar("personal", None).unwrap();
461
462 work.create_event(test_event()).unwrap();
463 work.create_event(test_event()).unwrap();
464 personal.create_event(test_event()).unwrap();
465
466 assert_eq!(work.events().unwrap().len(), 2);
467 assert_eq!(personal.events().unwrap().len(), 1);
468 }
469
470 #[test]
471 fn events_ignores_non_ics_files() {
472 let (_tmp, calendar) = test_calendar();
473
474 calendar.create_event(test_event()).unwrap();
475
476 std::fs::write(calendar.path().join("color"), "#ff0000").unwrap();
478 std::fs::write(calendar.path().join("displayname"), "Work").unwrap();
479 std::fs::write(calendar.path().join("README.md"), "notes").unwrap();
480
481 let events = calendar.events().unwrap();
482
483 assert_eq!(events.len(), 1);
484 }
485
486 #[test]
487 fn event_returns_event_by_slug() {
488 let (_tmp, calendar) = test_calendar();
489 let created = calendar.create_event(test_event()).unwrap();
490
491 let found = calendar.event("2026-01-01T1200__test-event").unwrap();
492
493 assert_eq!(found.path(), created.path());
494 }
495
496 #[test]
497 fn event_errors_when_file_missing() {
498 let (_tmp, calendar) = test_calendar();
499
500 let result = calendar.event("does-not-exist");
501
502 assert!(matches!(
503 result,
504 Err(CalendarError::Event(CalendarEventError::NotFound(_)))
505 ));
506 }
507
508 #[test]
509 fn delete_event_removes_file() {
510 let (_tmp, calendar) = test_calendar();
511 let cal_event = calendar.create_event(test_event()).unwrap();
512 let path = cal_event.path().to_path_buf();
513 assert!(path.is_file());
514
515 calendar
516 .delete_event("2026-01-01T1200__test-event")
517 .unwrap();
518
519 assert!(!path.exists());
520 }
521
522 use crate::event::RecurrenceId;
523 use chrono::TimeZone;
524
525 fn make_master(uid: &str, start: DateTime<Utc>, rrule: &str) -> Event {
526 let mut event = Event::new("Daily standup", EventTime::DateTimeUtc(start));
527 event.uid = crate::event::EventUid::new(uid);
528 event.description = Some("Notes".to_string());
529 event.location = Some("Office".to_string());
530 event.end = Some(EventTime::DateTimeUtc(start + chrono::Duration::hours(1)));
531 event.recurrence = Some(Recurrence::new(rrule));
532 event
533 }
534
535 fn make_override(uid: &str, instance: DateTime<Utc>, summary: &str) -> Event {
536 let mut event = Event::new(summary, EventTime::DateTimeUtc(instance));
537 event.uid = crate::event::EventUid::new(uid);
538 event.recurrence_id = Some(RecurrenceId::from_event_time(EventTime::DateTimeUtc(
539 instance,
540 )));
541 event
542 }
543
544 fn loaded_master(cal: &Calendar, uid: &str) -> Event {
545 cal.master_event_for(uid)
546 .unwrap()
547 .unwrap_or_else(|| panic!("master {uid} should exist"))
548 }
549
550 fn loaded_overrides(cal: &Calendar, uid: &str) -> Vec<Event> {
551 cal.events()
552 .unwrap()
553 .into_iter()
554 .map(|ce| ce.event().clone())
555 .filter(|e| e.uid.as_str() == uid && e.recurrence_id.is_some())
556 .collect()
557 }
558
559 fn t(year: i32, month: u32, day: u32, hour: u32, min: u32) -> DateTime<Utc> {
560 Utc.with_ymd_and_hms(year, month, day, hour, min, 0)
561 .unwrap()
562 }
563
564 fn instance_id(uid: &str, occurrence: EventTime) -> EventInstanceId {
565 EventInstanceId::new(
566 EventUid::new(uid),
567 Some(RecurrenceId::from_event_time(occurrence)),
568 )
569 }
570
571 #[test]
572 fn master_event_for_returns_master_when_present() {
573 let (_tmp, calendar) = test_calendar();
574 calendar
575 .create_event(make_master(
576 "series@caldir",
577 t(2026, 4, 1, 10, 0),
578 "FREQ=DAILY",
579 ))
580 .unwrap();
581
582 let master = calendar.master_event_for("series@caldir").unwrap();
583
584 assert!(master.is_some());
585 assert_eq!(master.unwrap().uid.as_str(), "series@caldir");
586 }
587
588 #[test]
589 fn master_event_for_returns_none_when_missing() {
590 let (_tmp, calendar) = test_calendar();
591
592 let master = calendar.master_event_for("nonexistent").unwrap();
593
594 assert!(master.is_none());
595 }
596
597 #[test]
598 fn master_event_for_ignores_non_recurring_events_with_same_uid() {
599 let (_tmp, calendar) = test_calendar();
600
601 let mut event = test_event();
602 event.uid = crate::event::EventUid::new("series@caldir");
603 calendar.create_event(event).unwrap();
604
605 let master = calendar.master_event_for("series@caldir").unwrap();
607
608 assert!(master.is_none());
609 }
610
611 #[test]
612 fn event_by_instance_id_finds_non_recurring_event() {
613 let (_tmp, calendar) = test_calendar();
614 let cal_event = calendar.create_event(test_event()).unwrap();
615 let id = cal_event.event().event_instance_id();
616
617 let found = calendar.event_by_instance_id(&id).unwrap();
618
619 assert!(found.is_some());
620 assert_eq!(found.unwrap().path(), cal_event.path());
621 }
622
623 #[test]
624 fn event_by_instance_id_finds_recurring_instance_override() {
625 let (_tmp, calendar) = test_calendar();
626 calendar
627 .create_event(make_master(
628 "series@caldir",
629 t(2026, 4, 1, 10, 0),
630 "FREQ=DAILY",
631 ))
632 .unwrap();
633 let override_event =
634 make_override("series@caldir", t(2026, 4, 3, 10, 0), "Special standup");
635 let override_id = override_event.event_instance_id();
636 calendar.create_event(override_event).unwrap();
637
638 let found = calendar.event_by_instance_id(&override_id).unwrap();
639
640 assert!(found.is_some());
641 assert_eq!(
642 found.unwrap().event().summary.as_deref(),
643 Some("Special standup")
644 );
645 }
646
647 #[test]
648 fn event_by_instance_id_returns_none_when_missing() {
649 let (_tmp, calendar) = test_calendar();
650 let id = test_event().event_instance_id();
651
652 let found = calendar.event_by_instance_id(&id).unwrap();
653
654 assert!(found.is_none());
655 }
656
657 #[test]
658 fn is_read_only_false_when_no_config() {
659 let (_tmp, calendar) = test_calendar();
660
661 assert!(!calendar.is_read_only());
662 }
663
664 #[test]
665 fn is_read_only_false_when_config_has_no_read_only_field() {
666 let (_tmp, caldir) = test_caldir();
667 let config = CalendarConfig::new(Some("Test".to_string()), None, None, None);
668 let calendar = caldir.create_calendar("test", Some(config)).unwrap();
669
670 assert!(!calendar.is_read_only());
671 }
672
673 #[test]
674 fn is_read_only_true_when_config_says_true() {
675 let (_tmp, caldir) = test_caldir();
676 let config = CalendarConfig::new(Some("Test".to_string()), None, Some(true), None);
677 let calendar = caldir.create_calendar("test", Some(config)).unwrap();
678
679 assert!(calendar.is_read_only());
680 }
681
682 #[test]
683 fn is_read_only_false_when_config_says_false() {
684 let (_tmp, caldir) = test_caldir();
685 let config = CalendarConfig::new(Some("Test".to_string()), None, Some(false), None);
686 let calendar = caldir.create_calendar("test", Some(config)).unwrap();
687
688 assert!(!calendar.is_read_only());
689 }
690
691 #[test]
692 fn split_truncates_master_rrule_before_split() {
693 let (_tmp, cal) = test_calendar();
694 let uid = "master@test";
695 cal.create_event(make_master(uid, t(2026, 4, 1, 10, 0), "FREQ=DAILY"))
696 .unwrap();
697
698 let split_start = EventTime::DateTimeUtc(t(2026, 4, 5, 10, 0));
699 let split_end = EventTime::DateTimeUtc(t(2026, 4, 5, 11, 0));
700 cal.split_recurring_series_at(&EventUid::new(uid), split_start, split_end, None)
701 .unwrap();
702
703 let master = loaded_master(&cal, uid);
704 let rrule = &master.recurrence.as_ref().unwrap().rrule;
705 assert_eq!(rrule, "FREQ=DAILY;UNTIL=20260405T095959Z");
707 }
708
709 #[test]
710 fn split_creates_new_master_with_fresh_uid_and_inherited_metadata() {
711 let (_tmp, cal) = test_calendar();
712 let uid = "master@test";
713 cal.create_event(make_master(uid, t(2026, 4, 1, 10, 0), "FREQ=DAILY"))
714 .unwrap();
715
716 let new_recurrence = Some(Recurrence::new("FREQ=WEEKLY"));
717 let new_master = cal
718 .split_recurring_series_at(
719 &EventUid::new(uid),
720 EventTime::DateTimeUtc(t(2026, 4, 5, 10, 0)),
721 EventTime::DateTimeUtc(t(2026, 4, 5, 11, 30)),
722 new_recurrence,
723 )
724 .unwrap();
725
726 assert_ne!(new_master.uid.as_str(), uid);
728 assert_eq!(new_master.summary.as_deref(), Some("Daily standup"));
730 assert_eq!(new_master.description.as_deref(), Some("Notes"));
731 assert_eq!(new_master.location.as_deref(), Some("Office"));
732 assert_eq!(
734 new_master.start,
735 EventTime::DateTimeUtc(t(2026, 4, 5, 10, 0))
736 );
737 assert_eq!(
738 new_master.end,
739 Some(EventTime::DateTimeUtc(t(2026, 4, 5, 11, 30)))
740 );
741 assert_eq!(new_master.recurrence.as_ref().unwrap().rrule, "FREQ=WEEKLY");
742 assert!(new_master.recurrence_id.is_none());
743 }
744
745 #[test]
746 fn split_bumps_master_sequence() {
747 let (_tmp, cal) = test_calendar();
748 let uid = "master@test";
749 let mut master = make_master(uid, t(2026, 4, 1, 10, 0), "FREQ=DAILY");
750 master.sequence = 3;
751 cal.create_event(master).unwrap();
752
753 cal.split_recurring_series_at(
754 &EventUid::new(uid),
755 EventTime::DateTimeUtc(t(2026, 4, 5, 10, 0)),
756 EventTime::DateTimeUtc(t(2026, 4, 5, 11, 0)),
757 None,
758 )
759 .unwrap();
760
761 assert_eq!(loaded_master(&cal, uid).sequence, 4);
762 }
763
764 #[test]
765 fn split_drops_overrides_at_or_after_split_keeps_earlier_ones() {
766 let (_tmp, cal) = test_calendar();
767 let uid = "master@test";
768 cal.create_event(make_master(uid, t(2026, 4, 1, 10, 0), "FREQ=DAILY"))
769 .unwrap();
770
771 cal.create_event(make_override(uid, t(2026, 4, 3, 10, 0), "before"))
772 .unwrap();
773 cal.create_event(make_override(uid, t(2026, 4, 5, 10, 0), "at-split"))
774 .unwrap();
775 cal.create_event(make_override(uid, t(2026, 4, 7, 10, 0), "after"))
776 .unwrap();
777
778 cal.split_recurring_series_at(
779 &EventUid::new(uid),
780 EventTime::DateTimeUtc(t(2026, 4, 5, 10, 0)),
781 EventTime::DateTimeUtc(t(2026, 4, 5, 11, 0)),
782 None,
783 )
784 .unwrap();
785
786 let overrides = loaded_overrides(&cal, uid);
787 assert_eq!(
788 overrides.len(),
789 1,
790 "only the pre-split override should remain"
791 );
792 assert_eq!(overrides[0].summary.as_deref(), Some("before"));
793 }
794
795 #[test]
796 fn split_drops_exdates_at_or_after_split() {
797 let (_tmp, cal) = test_calendar();
798 let uid = "master@test";
799 let mut master = make_master(uid, t(2026, 4, 1, 10, 0), "FREQ=DAILY");
800 let kept = EventTime::DateTimeUtc(t(2026, 4, 3, 10, 0));
801 let dropped_at = EventTime::DateTimeUtc(t(2026, 4, 5, 10, 0));
802 let dropped_after = EventTime::DateTimeUtc(t(2026, 4, 6, 10, 0));
803 master.recurrence = Some(Recurrence {
804 rrule: "FREQ=DAILY".into(),
805 exdates: vec![kept.clone(), dropped_at, dropped_after],
806 rdates: vec![],
807 });
808 cal.create_event(master).unwrap();
809
810 cal.split_recurring_series_at(
811 &EventUid::new(uid),
812 EventTime::DateTimeUtc(t(2026, 4, 5, 10, 0)),
813 EventTime::DateTimeUtc(t(2026, 4, 5, 11, 0)),
814 None,
815 )
816 .unwrap();
817
818 assert_eq!(
819 loaded_master(&cal, uid).recurrence.unwrap().exdates,
820 vec![kept]
821 );
822 }
823
824 #[test]
825 fn split_with_no_new_recurrence_creates_single_event() {
826 let (_tmp, cal) = test_calendar();
827 let uid = "master@test";
828 cal.create_event(make_master(uid, t(2026, 4, 1, 10, 0), "FREQ=DAILY"))
829 .unwrap();
830
831 let new_master = cal
832 .split_recurring_series_at(
833 &EventUid::new(uid),
834 EventTime::DateTimeUtc(t(2026, 4, 5, 10, 0)),
835 EventTime::DateTimeUtc(t(2026, 4, 5, 11, 0)),
836 None,
837 )
838 .unwrap();
839
840 assert!(new_master.recurrence.is_none());
841 }
842
843 #[test]
844 fn split_errors_when_master_not_found() {
845 let (_tmp, cal) = test_calendar();
846
847 let err = cal
848 .split_recurring_series_at(
849 &EventUid::new("nonexistent"),
850 EventTime::DateTimeUtc(t(2026, 4, 5, 10, 0)),
851 EventTime::DateTimeUtc(t(2026, 4, 5, 11, 0)),
852 None,
853 )
854 .unwrap_err();
855
856 assert!(matches!(err, CalendarError::MasterNotFound(_)));
857 }
858
859 #[test]
860 fn split_errors_when_event_is_not_recurring() {
861 let (_tmp, cal) = test_calendar();
862 let mut single = test_event();
866 single.uid = crate::event::EventUid::new("solo@test");
867 cal.create_event(single).unwrap();
868
869 let err = cal
870 .split_recurring_series_at(
871 &EventUid::new("solo@test"),
872 EventTime::DateTimeUtc(t(2026, 4, 5, 10, 0)),
873 EventTime::DateTimeUtc(t(2026, 4, 5, 11, 0)),
874 None,
875 )
876 .unwrap_err();
877
878 assert!(matches!(err, CalendarError::MasterNotFound(_)));
879 }
880
881 #[test]
882 fn delete_recurring_instance_adds_exdate_to_master() {
883 let (_tmp, cal) = test_calendar();
884 let uid = "series@caldir";
885 cal.create_event(make_master(uid, t(2026, 4, 1, 10, 0), "FREQ=DAILY"))
886 .unwrap();
887
888 let occurrence = EventTime::DateTimeUtc(t(2026, 4, 3, 10, 0));
889 cal.delete_recurring_instance(&instance_id(uid, occurrence.clone()))
890 .unwrap();
891
892 assert_eq!(
893 loaded_master(&cal, uid).recurrence.unwrap().exdates,
894 vec![occurrence]
895 );
896 }
897
898 #[test]
899 fn delete_recurring_instance_removes_it_from_expansion() {
900 let (_tmp, cal) = test_calendar();
901 let uid = "series@caldir";
902 cal.create_event(make_master(uid, t(2026, 4, 1, 10, 0), "FREQ=DAILY"))
903 .unwrap();
904
905 let occurrence = EventTime::DateTimeUtc(t(2026, 4, 3, 10, 0));
906 cal.delete_recurring_instance(&instance_id(uid, occurrence.clone()))
907 .unwrap();
908
909 let starts: Vec<_> = cal
910 .expanded_events_in_range(t(2026, 4, 1, 0, 0), t(2026, 4, 5, 0, 0))
911 .unwrap()
912 .into_iter()
913 .map(|e| e.start.to_utc())
914 .collect();
915 assert_eq!(
916 starts,
917 vec![
918 t(2026, 4, 1, 10, 0),
919 t(2026, 4, 2, 10, 0),
920 t(2026, 4, 4, 10, 0)
921 ]
922 );
923 }
924
925 #[test]
926 fn delete_recurring_instance_deletes_materialized_override() {
927 let (_tmp, cal) = test_calendar();
928 let uid = "series@caldir";
929 cal.create_event(make_master(uid, t(2026, 4, 1, 10, 0), "FREQ=DAILY"))
930 .unwrap();
931 cal.create_event(make_override(uid, t(2026, 4, 3, 10, 0), "moved standup"))
933 .unwrap();
934
935 cal.delete_recurring_instance(&instance_id(
936 uid,
937 EventTime::DateTimeUtc(t(2026, 4, 3, 10, 0)),
938 ))
939 .unwrap();
940
941 assert!(loaded_overrides(&cal, uid).is_empty());
943 assert_eq!(
944 loaded_master(&cal, uid).recurrence.unwrap().exdates,
945 vec![EventTime::DateTimeUtc(t(2026, 4, 3, 10, 0))]
946 );
947 }
948
949 #[test]
950 fn delete_recurring_instance_bumps_sequence() {
951 let (_tmp, cal) = test_calendar();
952 let uid = "series@caldir";
953 let mut master = make_master(uid, t(2026, 4, 1, 10, 0), "FREQ=DAILY");
954 master.sequence = 3;
955 cal.create_event(master).unwrap();
956
957 cal.delete_recurring_instance(&instance_id(
958 uid,
959 EventTime::DateTimeUtc(t(2026, 4, 3, 10, 0)),
960 ))
961 .unwrap();
962
963 assert_eq!(loaded_master(&cal, uid).sequence, 4);
964 }
965
966 #[test]
967 fn delete_recurring_instance_is_idempotent() {
968 let (_tmp, cal) = test_calendar();
969 let uid = "series@caldir";
970 cal.create_event(make_master(uid, t(2026, 4, 1, 10, 0), "FREQ=DAILY"))
971 .unwrap();
972
973 let occurrence = EventTime::DateTimeUtc(t(2026, 4, 3, 10, 0));
974 cal.delete_recurring_instance(&instance_id(uid, occurrence.clone()))
975 .unwrap();
976 cal.delete_recurring_instance(&instance_id(uid, occurrence.clone()))
977 .unwrap();
978
979 let master = loaded_master(&cal, uid);
980 assert_eq!(master.recurrence.unwrap().exdates, vec![occurrence]);
982 assert_eq!(master.sequence, 1);
983 }
984
985 #[test]
986 fn delete_recurring_instance_succeeds_for_orphan_override_without_master() {
987 let (_tmp, cal) = test_calendar();
988 let uid = "series@caldir";
989 cal.create_event(make_override(uid, t(2026, 4, 3, 10, 0), "orphan"))
991 .unwrap();
992
993 cal.delete_recurring_instance(&instance_id(
994 uid,
995 EventTime::DateTimeUtc(t(2026, 4, 3, 10, 0)),
996 ))
997 .unwrap();
998
999 assert!(loaded_overrides(&cal, uid).is_empty());
1000 }
1001
1002 #[test]
1003 fn delete_recurring_instance_errors_when_uid_missing() {
1004 let (_tmp, cal) = test_calendar();
1005
1006 let err = cal
1007 .delete_recurring_instance(&instance_id(
1008 "nonexistent",
1009 EventTime::DateTimeUtc(t(2026, 4, 3, 10, 0)),
1010 ))
1011 .unwrap_err();
1012
1013 assert!(matches!(err, CalendarError::MasterNotFound(_)));
1014 }
1015
1016 #[test]
1017 fn update_recurring_instance_creates_override_from_master() {
1018 let (_tmp, cal) = test_calendar();
1019 let uid = "series@caldir";
1020 cal.create_event(make_master(uid, t(2026, 4, 1, 10, 0), "FREQ=DAILY"))
1021 .unwrap();
1022
1023 let occurrence = EventTime::DateTimeUtc(t(2026, 4, 3, 10, 0));
1024 cal.update_recurring_instance(&instance_id(uid, occurrence.clone()), |event| {
1025 event.summary = Some("Moved standup".to_string());
1026 event.start = EventTime::DateTimeUtc(t(2026, 4, 3, 14, 0));
1027 event.end = Some(EventTime::DateTimeUtc(t(2026, 4, 3, 15, 0)));
1028 })
1029 .unwrap();
1030
1031 let overrides = loaded_overrides(&cal, uid);
1032 assert_eq!(overrides.len(), 1);
1033 let override_event = &overrides[0];
1034 assert!(override_event.recurrence.is_none());
1036 assert_eq!(
1037 override_event
1038 .recurrence_id
1039 .as_ref()
1040 .unwrap()
1041 .as_event_time(),
1042 &occurrence
1043 );
1044 assert_eq!(override_event.summary.as_deref(), Some("Moved standup"));
1046 assert_eq!(
1047 override_event.start,
1048 EventTime::DateTimeUtc(t(2026, 4, 3, 14, 0))
1049 );
1050 assert_eq!(override_event.location.as_deref(), Some("Office"));
1052 assert!(
1054 loaded_master(&cal, uid)
1055 .recurrence
1056 .unwrap()
1057 .exdates
1058 .is_empty()
1059 );
1060 }
1061
1062 #[test]
1063 fn update_recurring_instance_overrides_only_that_occurrence_in_expansion() {
1064 let (_tmp, cal) = test_calendar();
1065 let uid = "series@caldir";
1066 cal.create_event(make_master(uid, t(2026, 4, 1, 10, 0), "FREQ=DAILY"))
1067 .unwrap();
1068
1069 cal.update_recurring_instance(
1070 &instance_id(uid, EventTime::DateTimeUtc(t(2026, 4, 3, 10, 0))),
1071 |event| event.summary = Some("Special".to_string()),
1072 )
1073 .unwrap();
1074
1075 let summaries: Vec<_> = cal
1076 .expanded_events_in_range(t(2026, 4, 1, 0, 0), t(2026, 4, 5, 0, 0))
1077 .unwrap()
1078 .into_iter()
1079 .map(|e| e.summary.unwrap_or_default())
1080 .collect();
1081 assert_eq!(
1082 summaries,
1083 vec!["Daily standup", "Daily standup", "Special", "Daily standup"]
1084 );
1085 }
1086
1087 #[test]
1088 fn update_recurring_instance_edits_existing_override_in_place() {
1089 let (_tmp, cal) = test_calendar();
1090 let uid = "series@caldir";
1091 cal.create_event(make_master(uid, t(2026, 4, 1, 10, 0), "FREQ=DAILY"))
1092 .unwrap();
1093 cal.create_event(make_override(uid, t(2026, 4, 3, 10, 0), "first edit"))
1094 .unwrap();
1095
1096 cal.update_recurring_instance(
1097 &instance_id(uid, EventTime::DateTimeUtc(t(2026, 4, 3, 10, 0))),
1098 |event| event.summary = Some("second edit".to_string()),
1099 )
1100 .unwrap();
1101
1102 let overrides = loaded_overrides(&cal, uid);
1104 assert_eq!(overrides.len(), 1);
1105 assert_eq!(overrides[0].summary.as_deref(), Some("second edit"));
1106 }
1107
1108 #[test]
1109 fn update_recurring_instance_bumps_existing_override_sequence() {
1110 let (_tmp, cal) = test_calendar();
1111 let uid = "series@caldir";
1112 cal.create_event(make_master(uid, t(2026, 4, 1, 10, 0), "FREQ=DAILY"))
1113 .unwrap();
1114 let mut override_event = make_override(uid, t(2026, 4, 3, 10, 0), "edit");
1115 override_event.sequence = 2;
1116 cal.create_event(override_event).unwrap();
1117
1118 cal.update_recurring_instance(
1119 &instance_id(uid, EventTime::DateTimeUtc(t(2026, 4, 3, 10, 0))),
1120 |event| event.summary = Some("re-edit".to_string()),
1121 )
1122 .unwrap();
1123
1124 assert_eq!(loaded_overrides(&cal, uid)[0].sequence, 3);
1125 }
1126
1127 #[test]
1128 fn update_recurring_instance_errors_when_not_recurring() {
1129 let (_tmp, cal) = test_calendar();
1130
1131 let err = cal
1132 .update_recurring_instance(
1133 &EventInstanceId::new(EventUid::new("solo@test"), None),
1134 |_| {},
1135 )
1136 .unwrap_err();
1137
1138 assert!(matches!(err, CalendarError::NotRecurring(_)));
1139 }
1140
1141 #[test]
1142 fn update_recurring_instance_errors_when_master_missing() {
1143 let (_tmp, cal) = test_calendar();
1144
1145 let err = cal
1146 .update_recurring_instance(
1147 &instance_id("nonexistent", EventTime::DateTimeUtc(t(2026, 4, 3, 10, 0))),
1148 |_| {},
1149 )
1150 .unwrap_err();
1151
1152 assert!(matches!(err, CalendarError::MasterNotFound(_)));
1153 }
1154}