Skip to main content

caldir_core/
calendar.rs

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
21// ~/caldir/my_calendar/.caldir/config.toml
22const CONFIG_FILE_NAME: &str = "config.toml";
23
24// ~/caldir/my_calendar/.caldir/state/known_event_ids
25const 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    /// Create new calendar
48    pub fn create(path: &Path, config: Option<CalendarConfig>) -> Result<Self, CalendarError> {
49        // Error if path already exists:
50        if path.exists() {
51            return Err(CalendarError::AlreadyExists(path.to_path_buf()));
52        }
53
54        // create calendar directory and its .caldir/ subdirectory:
55        std::fs::create_dir_all(calendar_dotdir(path))?;
56
57        // Create calendar config file (if config is provided):
58        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    /// Load existing calendar
71    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    /// Load all events in calendar
106    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    /// Load specific event in calendar
122    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    /// Find the master event of a recurring series given its uid.
141    /// i.e. a master event is one whose `recurrence` field is set.
142    /// Instance overrides (with `recurrence_id` set) are not considered masters.
143    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    /// Split a recurring series at `split_start`:
172    ///
173    /// Original master:
174    /// 1. RRULE is truncated to end strictly before `split_start`
175    /// 2. Any EXDATEs at or after `split_start` are dropped
176    /// 3. Any override files at or after `split_start` are deleted
177    ///
178    /// New master:
179    /// 1. Starts at `split_start`
180    /// 2. Inherit all other metadata (summary, description, location etc) from original
181    /// 3. Gets a fresh UID and a reset SEQUENCE
182    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    /// Exclude a single occurrence from a recurring series
243    /// i.e. "delete this instance only"
244    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        // Find master event (+ potential override event)
255        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 override exists, delete it:
274        if let Some(ce) = override_event {
275            ce.delete()?;
276        }
277
278        // If a master exists, record the exclusion as an EXDATE.
279        if let Some(mut ce) = master {
280            ce.add_exdate(recurrence_id.as_event_time().clone())?;
281        }
282
283        Ok(())
284    }
285
286    /// Override a single occurrence of a recurring series
287    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            // An override file already exists -> edit it in place
300            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            // No override exists yet -> synthesize one from the master
311            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    /// List all events occurring within time range
328    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    /// Create new event in calendar
338    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    /// Delete event from calendar
344    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        // Config file is located in the right place:
416        assert!(calendar.config_path().is_file());
417        assert_eq!(&calendar.config_path(), expected_config_path);
418
419        // with the right content:
420        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        // 2 calendars in the same caldir data directory:
459        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        // Drop in stray files that other tools (e.g. vdirsyncer) might leave around.
477        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        // Same uid, but no recurrence → not a master.
606        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        // UNTIL is one second before split_start, in UTC form.
706        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        // Fresh UID, not the master's.
727        assert_ne!(new_master.uid.as_str(), uid);
728        // Inherits metadata.
729        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        // Uses the new start/end and recurrence.
733        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        // Non-recurring event with the target uid. Since master_event_for filters
863        // by recurrence.is_some(), this looks like the master "isn't there" —
864        // surface as MasterNotFound, which is the right user-facing message.
865        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        // A previously moved/edited instance for 04-03 sits on disk as an override.
932        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        // Override file is gone, and the base occurrence is excluded from the master.
942        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        // No duplicate EXDATE, and the second call didn't bump SEQUENCE again.
981        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        // Override with no master in the calendar.
990        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        // Carries the occurrence's recurrence id, not its own RRULE.
1035        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        // Edited fields applied.
1045        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        // Metadata inherited from the master.
1051        assert_eq!(override_event.location.as_deref(), Some("Office"));
1052        // Master is untouched.
1053        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        // Still a single override (updated, not duplicated).
1103        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}