mod config;
mod error;
mod event;
mod state;
use crate::event::{EventInstanceId, EventTime, EventUid, Recurrence, expand_in_range};
use crate::utils::slugify;
use crate::{Event, RemoteConfig};
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
pub use config::CalendarConfig;
pub use error::CalendarError;
pub use event::CalendarEvent;
pub(crate) use event::CalendarEventError;
pub use state::CalendarState;
pub(crate) use state::SyncedEventIds;
const DOTDIR_NAME: &str = ".caldir";
const CONFIG_FILE_NAME: &str = "config.toml";
const STATE_DIR_NAME: &str = "state";
fn calendar_dotdir(calendar_path: &Path) -> PathBuf {
calendar_path.join(DOTDIR_NAME)
}
fn calendar_config_path(calendar_path: &Path) -> PathBuf {
calendar_dotdir(calendar_path).join(CONFIG_FILE_NAME)
}
fn calendar_state_dir(calendar_path: &Path) -> PathBuf {
calendar_dotdir(calendar_path).join(STATE_DIR_NAME)
}
#[derive(Debug)]
pub struct Calendar {
path: PathBuf,
config: Option<CalendarConfig>,
state: CalendarState,
}
impl Calendar {
pub fn create(path: &Path, config: Option<CalendarConfig>) -> Result<Self, CalendarError> {
if path.exists() {
return Err(CalendarError::AlreadyExists(path.to_path_buf()));
}
std::fs::create_dir_all(calendar_dotdir(path))?;
if let Some(ref config) = config {
let config_path = calendar_config_path(path);
CalendarConfig::write(config, &config_path)?;
}
let state_dir = calendar_state_dir(path);
let state = CalendarState::new();
state.write(&state_dir)?;
Ok(Self {
path: path.to_path_buf(),
config,
state,
})
}
pub fn load(path: &Path) -> Result<Self, CalendarError> {
if !path.is_dir() {
return Err(CalendarError::NotFound(path.to_path_buf()));
}
let config_path = calendar_config_path(path);
let config = CalendarConfig::load_optional(&config_path)?;
let state_dir = calendar_state_dir(path);
let state = CalendarState::load(&state_dir)?;
Ok(Self {
path: path.to_path_buf(),
config,
state,
})
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn config_path(&self) -> PathBuf {
calendar_config_path(self.path())
}
pub fn state(&self) -> &CalendarState {
&self.state
}
pub fn slug(&self) -> Option<&str> {
self.path().file_name().and_then(|s| s.to_str())
}
pub fn events(&self) -> Result<Vec<CalendarEvent>, CalendarError> {
let mut events: Vec<CalendarEvent> = Vec::new();
for entry in std::fs::read_dir(self.path())? {
let entry = entry?;
let path = entry.path();
if entry.file_type()?.is_file() && path.extension().is_some_and(|ext| ext == "ics") {
events.push(CalendarEvent::load(path)?);
}
}
Ok(events)
}
pub fn event(&self, event_slug: &str) -> Result<CalendarEvent, CalendarError> {
let event_path = self.path().join(format!("{}.ics", event_slug));
let calendar_event = CalendarEvent::load(event_path)?;
Ok(calendar_event)
}
pub fn event_by_instance_id(
&self,
id: &EventInstanceId,
) -> Result<Option<CalendarEvent>, CalendarError> {
let found = self
.events()?
.into_iter()
.find(|ce| ce.event().event_instance_id() == *id);
Ok(found)
}
pub fn master_event_for(&self, uid: &str) -> Result<Option<Event>, CalendarError> {
let master = self
.events()?
.into_iter()
.find(|ce| ce.event().uid.as_str() == uid && ce.event().recurrence.is_some())
.map(|ce| ce.event().clone());
Ok(master)
}
pub fn is_read_only(&self) -> bool {
self.config
.as_ref()
.and_then(|c| c.read_only())
.unwrap_or(false)
}
pub fn name(&self) -> Option<&str> {
self.config.as_ref().and_then(|c| c.name())
}
pub fn color(&self) -> Option<&str> {
self.config.as_ref().and_then(|c| c.color())
}
pub fn read_only_setting(&self) -> Option<bool> {
self.config.as_ref().and_then(|c| c.read_only())
}
pub fn split_recurring_series_at(
&self,
master_uid: &EventUid,
split_start: EventTime,
split_end: EventTime,
new_recurrence: Option<Recurrence>,
) -> Result<Event, CalendarError> {
let mut all_events = self.events()?;
let master_idx = all_events
.iter()
.position(|ce| ce.event().uid == *master_uid && ce.event().recurrence.is_some())
.ok_or_else(|| CalendarError::MasterNotFound(master_uid.as_str().to_string()))?;
let mut master_ce = all_events.swap_remove(master_idx);
let master_event = master_ce.event().clone();
let master_recurrence = master_event
.recurrence
.as_ref()
.ok_or_else(|| CalendarError::NotRecurring(master_uid.as_str().to_string()))?;
let truncated_recurrence =
master_recurrence.truncate_before(&master_event.start, &split_start);
let truncated_master = Event {
recurrence: Some(truncated_recurrence),
last_modified: Some(Utc::now()),
sequence: master_event.sequence + 1,
..master_event.clone()
};
master_ce.update(truncated_master)?;
let new_master = Event {
start: split_start.clone(),
end: Some(split_end),
recurrence: new_recurrence,
recurrence_id: None,
last_modified: Some(Utc::now()),
sequence: 0,
..master_event.with_new_uid()
};
let new_master_ce = self.create_event(new_master)?;
let new_master_event = new_master_ce.event().clone();
let split_start_utc = split_start.to_utc();
for ce in all_events.into_iter() {
if ce.event().uid != *master_uid {
continue;
}
let Some(rid) = ce.event().recurrence_id.as_ref() else {
continue;
};
if rid.as_event_time().to_utc() >= split_start_utc {
ce.delete()?;
}
}
Ok(new_master_event)
}
pub fn delete_recurring_instance(&self, id: &EventInstanceId) -> Result<(), CalendarError> {
let uid = id.uid();
let Some(recurrence_id) = id.recurrence_id() else {
return Err(CalendarError::NotRecurring(uid.as_str().to_string()));
};
let mut master = None;
let mut override_event = None;
for ce in self.events()? {
if ce.event().uid != *uid {
continue;
}
let is_override_for_occurrence = ce.event().event_instance_id() == *id;
if ce.event().recurrence.is_some() {
master = Some(ce);
} else if is_override_for_occurrence {
override_event = Some(ce);
}
}
if master.is_none() && override_event.is_none() {
return Err(CalendarError::MasterNotFound(uid.as_str().to_string()));
}
if let Some(ce) = override_event {
ce.delete()?;
}
if let Some(mut ce) = master {
ce.add_exdate(recurrence_id.as_event_time().clone())?;
}
Ok(())
}
pub fn update_recurring_instance(
&self,
id: &EventInstanceId,
apply: impl FnOnce(&mut Event),
) -> Result<CalendarEvent, CalendarError> {
let Some(recurrence_id) = id.recurrence_id() else {
return Err(CalendarError::NotRecurring(id.uid().as_str().to_string()));
};
let existing = self.event_by_instance_id(id)?;
match existing {
Some(mut ce) => {
let mut event = ce.event().clone();
apply(&mut event);
event.last_modified = Some(Utc::now());
event.sequence += 1;
ce.update(event)?;
Ok(ce)
}
None => {
let master = self
.master_event_for(id.uid().as_str())?
.ok_or_else(|| CalendarError::MasterNotFound(id.uid().as_str().to_string()))?;
let mut event = master.occurrence_at(recurrence_id.as_event_time().clone());
event.sequence = 0;
event.last_modified = Some(Utc::now());
apply(&mut event);
self.create_event(event)
}
}
}
pub fn expanded_events_in_range(
&self,
from: DateTime<Utc>,
to: DateTime<Utc>,
) -> Result<Vec<Event>, CalendarError> {
let events = self.events()?.into_iter().map(|ce| ce.event().clone());
Ok(expand_in_range(events, from, to))
}
pub fn create_event(&self, event: Event) -> Result<CalendarEvent, CalendarError> {
let calendar_event = CalendarEvent::create(self, event)?;
Ok(calendar_event)
}
pub fn delete_event(&self, event_slug: &str) -> Result<(), CalendarError> {
let event = self.event(event_slug)?;
event.delete()?;
Ok(())
}
pub fn remote_config(&self) -> Option<&RemoteConfig> {
self.config.as_ref().and_then(|c| c.remote_config())
}
pub fn has_remote(&self) -> bool {
self.remote_config().is_some()
}
pub(crate) fn config(&self) -> Option<&CalendarConfig> {
self.config.as_ref()
}
pub fn remote_email(&self) -> Option<&str> {
self.remote_config()
.and_then(|remote_config| remote_config.account_identifier())
.filter(|id| id.contains('@'))
}
pub(crate) fn record_synced_ids(
&mut self,
ids: impl IntoIterator<Item = EventInstanceId>,
) -> Result<(), CalendarError> {
self.state
.add_new_synced_ids(ids)
.write(&calendar_state_dir(&self.path))?;
Ok(())
}
pub fn base_slug_for(name: Option<&str>) -> String {
name.map(slugify).unwrap_or_else(|| "calendar".to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::{
test_caldir, test_calendar, test_calendar_config, test_calendar_path, test_event,
};
#[test]
fn create_creates_caldir_subdirectory() {
let (_, path) = test_calendar_path();
let calendar = Calendar::create(&path, None).unwrap();
assert!(calendar.path().join(".caldir").is_dir());
}
#[test]
fn create_without_config_does_not_create_config_file() {
let (_, path) = test_calendar_path();
let calendar = Calendar::create(&path, None).unwrap();
assert!(!calendar.config_path().is_file());
}
#[test]
fn create_with_config_writes_config_file() {
let (_, path) = test_calendar_path();
let config = test_calendar_config();
let calendar = Calendar::create(&path, Some(config.clone())).unwrap();
let expected_config_path = &path.join(".caldir").join("config.toml");
assert!(calendar.config_path().is_file());
assert_eq!(&calendar.config_path(), expected_config_path);
let loaded_config = CalendarConfig::load(&calendar.config_path()).unwrap();
assert_eq!(loaded_config, config);
}
#[test]
fn load_returns_existing_calendar() {
let (_, path) = test_calendar_path();
let result = Calendar::create(&path, None);
assert!(result.is_ok());
assert_eq!(result.unwrap().path(), path);
}
#[test]
fn load_errors_when_directory_missing() {
let (tmp, _caldir) = test_caldir();
let result = Calendar::load(&tmp.path().join("missing"));
assert!(matches!(result, Err(CalendarError::NotFound(_))));
}
#[test]
fn load_errors_when_not_directory() {
let (tmp, _caldir) = test_caldir();
let file_path = tmp.path().join("not_a_directory");
std::fs::write(&file_path, "I am a file, not a directory").unwrap();
let result = Calendar::load(&file_path);
assert!(matches!(result, Err(CalendarError::NotFound(p)) if p == file_path));
}
#[test]
fn events_only_returns_events_from_current_calendar() {
let (_tmp, caldir) = test_caldir();
let work = caldir.create_calendar("work", None).unwrap();
let personal = caldir.create_calendar("personal", None).unwrap();
work.create_event(test_event()).unwrap();
work.create_event(test_event()).unwrap();
personal.create_event(test_event()).unwrap();
assert_eq!(work.events().unwrap().len(), 2);
assert_eq!(personal.events().unwrap().len(), 1);
}
#[test]
fn events_ignores_non_ics_files() {
let (_tmp, calendar) = test_calendar();
calendar.create_event(test_event()).unwrap();
std::fs::write(calendar.path().join("color"), "#ff0000").unwrap();
std::fs::write(calendar.path().join("displayname"), "Work").unwrap();
std::fs::write(calendar.path().join("README.md"), "notes").unwrap();
let events = calendar.events().unwrap();
assert_eq!(events.len(), 1);
}
#[test]
fn event_returns_event_by_slug() {
let (_tmp, calendar) = test_calendar();
let created = calendar.create_event(test_event()).unwrap();
let found = calendar.event("2026-01-01T1200__test-event").unwrap();
assert_eq!(found.path(), created.path());
}
#[test]
fn event_errors_when_file_missing() {
let (_tmp, calendar) = test_calendar();
let result = calendar.event("does-not-exist");
assert!(matches!(
result,
Err(CalendarError::Event(CalendarEventError::NotFound(_)))
));
}
#[test]
fn delete_event_removes_file() {
let (_tmp, calendar) = test_calendar();
let cal_event = calendar.create_event(test_event()).unwrap();
let path = cal_event.path().to_path_buf();
assert!(path.is_file());
calendar
.delete_event("2026-01-01T1200__test-event")
.unwrap();
assert!(!path.exists());
}
use crate::event::RecurrenceId;
use chrono::TimeZone;
fn make_master(uid: &str, start: DateTime<Utc>, rrule: &str) -> Event {
let mut event = Event::new("Daily standup", EventTime::DateTimeUtc(start));
event.uid = crate::event::EventUid::new(uid);
event.description = Some("Notes".to_string());
event.location = Some("Office".to_string());
event.end = Some(EventTime::DateTimeUtc(start + chrono::Duration::hours(1)));
event.recurrence = Some(Recurrence::new(rrule));
event
}
fn make_override(uid: &str, instance: DateTime<Utc>, summary: &str) -> Event {
let mut event = Event::new(summary, EventTime::DateTimeUtc(instance));
event.uid = crate::event::EventUid::new(uid);
event.recurrence_id = Some(RecurrenceId::from_event_time(EventTime::DateTimeUtc(
instance,
)));
event
}
fn loaded_master(cal: &Calendar, uid: &str) -> Event {
cal.master_event_for(uid)
.unwrap()
.unwrap_or_else(|| panic!("master {uid} should exist"))
}
fn loaded_overrides(cal: &Calendar, uid: &str) -> Vec<Event> {
cal.events()
.unwrap()
.into_iter()
.map(|ce| ce.event().clone())
.filter(|e| e.uid.as_str() == uid && e.recurrence_id.is_some())
.collect()
}
fn t(year: i32, month: u32, day: u32, hour: u32, min: u32) -> DateTime<Utc> {
Utc.with_ymd_and_hms(year, month, day, hour, min, 0)
.unwrap()
}
fn instance_id(uid: &str, occurrence: EventTime) -> EventInstanceId {
EventInstanceId::new(
EventUid::new(uid),
Some(RecurrenceId::from_event_time(occurrence)),
)
}
#[test]
fn master_event_for_returns_master_when_present() {
let (_tmp, calendar) = test_calendar();
calendar
.create_event(make_master(
"series@caldir",
t(2026, 4, 1, 10, 0),
"FREQ=DAILY",
))
.unwrap();
let master = calendar.master_event_for("series@caldir").unwrap();
assert!(master.is_some());
assert_eq!(master.unwrap().uid.as_str(), "series@caldir");
}
#[test]
fn master_event_for_returns_none_when_missing() {
let (_tmp, calendar) = test_calendar();
let master = calendar.master_event_for("nonexistent").unwrap();
assert!(master.is_none());
}
#[test]
fn master_event_for_ignores_non_recurring_events_with_same_uid() {
let (_tmp, calendar) = test_calendar();
let mut event = test_event();
event.uid = crate::event::EventUid::new("series@caldir");
calendar.create_event(event).unwrap();
let master = calendar.master_event_for("series@caldir").unwrap();
assert!(master.is_none());
}
#[test]
fn event_by_instance_id_finds_non_recurring_event() {
let (_tmp, calendar) = test_calendar();
let cal_event = calendar.create_event(test_event()).unwrap();
let id = cal_event.event().event_instance_id();
let found = calendar.event_by_instance_id(&id).unwrap();
assert!(found.is_some());
assert_eq!(found.unwrap().path(), cal_event.path());
}
#[test]
fn event_by_instance_id_finds_recurring_instance_override() {
let (_tmp, calendar) = test_calendar();
calendar
.create_event(make_master(
"series@caldir",
t(2026, 4, 1, 10, 0),
"FREQ=DAILY",
))
.unwrap();
let override_event =
make_override("series@caldir", t(2026, 4, 3, 10, 0), "Special standup");
let override_id = override_event.event_instance_id();
calendar.create_event(override_event).unwrap();
let found = calendar.event_by_instance_id(&override_id).unwrap();
assert!(found.is_some());
assert_eq!(
found.unwrap().event().summary.as_deref(),
Some("Special standup")
);
}
#[test]
fn event_by_instance_id_returns_none_when_missing() {
let (_tmp, calendar) = test_calendar();
let id = test_event().event_instance_id();
let found = calendar.event_by_instance_id(&id).unwrap();
assert!(found.is_none());
}
#[test]
fn is_read_only_false_when_no_config() {
let (_tmp, calendar) = test_calendar();
assert!(!calendar.is_read_only());
}
#[test]
fn is_read_only_false_when_config_has_no_read_only_field() {
let (_tmp, caldir) = test_caldir();
let config = CalendarConfig::new(Some("Test".to_string()), None, None, None);
let calendar = caldir.create_calendar("test", Some(config)).unwrap();
assert!(!calendar.is_read_only());
}
#[test]
fn is_read_only_true_when_config_says_true() {
let (_tmp, caldir) = test_caldir();
let config = CalendarConfig::new(Some("Test".to_string()), None, Some(true), None);
let calendar = caldir.create_calendar("test", Some(config)).unwrap();
assert!(calendar.is_read_only());
}
#[test]
fn is_read_only_false_when_config_says_false() {
let (_tmp, caldir) = test_caldir();
let config = CalendarConfig::new(Some("Test".to_string()), None, Some(false), None);
let calendar = caldir.create_calendar("test", Some(config)).unwrap();
assert!(!calendar.is_read_only());
}
#[test]
fn split_truncates_master_rrule_before_split() {
let (_tmp, cal) = test_calendar();
let uid = "master@test";
cal.create_event(make_master(uid, t(2026, 4, 1, 10, 0), "FREQ=DAILY"))
.unwrap();
let split_start = EventTime::DateTimeUtc(t(2026, 4, 5, 10, 0));
let split_end = EventTime::DateTimeUtc(t(2026, 4, 5, 11, 0));
cal.split_recurring_series_at(&EventUid::new(uid), split_start, split_end, None)
.unwrap();
let master = loaded_master(&cal, uid);
let rrule = &master.recurrence.as_ref().unwrap().rrule;
assert_eq!(rrule, "FREQ=DAILY;UNTIL=20260405T095959Z");
}
#[test]
fn split_creates_new_master_with_fresh_uid_and_inherited_metadata() {
let (_tmp, cal) = test_calendar();
let uid = "master@test";
cal.create_event(make_master(uid, t(2026, 4, 1, 10, 0), "FREQ=DAILY"))
.unwrap();
let new_recurrence = Some(Recurrence::new("FREQ=WEEKLY"));
let new_master = cal
.split_recurring_series_at(
&EventUid::new(uid),
EventTime::DateTimeUtc(t(2026, 4, 5, 10, 0)),
EventTime::DateTimeUtc(t(2026, 4, 5, 11, 30)),
new_recurrence,
)
.unwrap();
assert_ne!(new_master.uid.as_str(), uid);
assert_eq!(new_master.summary.as_deref(), Some("Daily standup"));
assert_eq!(new_master.description.as_deref(), Some("Notes"));
assert_eq!(new_master.location.as_deref(), Some("Office"));
assert_eq!(
new_master.start,
EventTime::DateTimeUtc(t(2026, 4, 5, 10, 0))
);
assert_eq!(
new_master.end,
Some(EventTime::DateTimeUtc(t(2026, 4, 5, 11, 30)))
);
assert_eq!(new_master.recurrence.as_ref().unwrap().rrule, "FREQ=WEEKLY");
assert!(new_master.recurrence_id.is_none());
}
#[test]
fn split_bumps_master_sequence() {
let (_tmp, cal) = test_calendar();
let uid = "master@test";
let mut master = make_master(uid, t(2026, 4, 1, 10, 0), "FREQ=DAILY");
master.sequence = 3;
cal.create_event(master).unwrap();
cal.split_recurring_series_at(
&EventUid::new(uid),
EventTime::DateTimeUtc(t(2026, 4, 5, 10, 0)),
EventTime::DateTimeUtc(t(2026, 4, 5, 11, 0)),
None,
)
.unwrap();
assert_eq!(loaded_master(&cal, uid).sequence, 4);
}
#[test]
fn split_drops_overrides_at_or_after_split_keeps_earlier_ones() {
let (_tmp, cal) = test_calendar();
let uid = "master@test";
cal.create_event(make_master(uid, t(2026, 4, 1, 10, 0), "FREQ=DAILY"))
.unwrap();
cal.create_event(make_override(uid, t(2026, 4, 3, 10, 0), "before"))
.unwrap();
cal.create_event(make_override(uid, t(2026, 4, 5, 10, 0), "at-split"))
.unwrap();
cal.create_event(make_override(uid, t(2026, 4, 7, 10, 0), "after"))
.unwrap();
cal.split_recurring_series_at(
&EventUid::new(uid),
EventTime::DateTimeUtc(t(2026, 4, 5, 10, 0)),
EventTime::DateTimeUtc(t(2026, 4, 5, 11, 0)),
None,
)
.unwrap();
let overrides = loaded_overrides(&cal, uid);
assert_eq!(
overrides.len(),
1,
"only the pre-split override should remain"
);
assert_eq!(overrides[0].summary.as_deref(), Some("before"));
}
#[test]
fn split_drops_exdates_at_or_after_split() {
let (_tmp, cal) = test_calendar();
let uid = "master@test";
let mut master = make_master(uid, t(2026, 4, 1, 10, 0), "FREQ=DAILY");
let kept = EventTime::DateTimeUtc(t(2026, 4, 3, 10, 0));
let dropped_at = EventTime::DateTimeUtc(t(2026, 4, 5, 10, 0));
let dropped_after = EventTime::DateTimeUtc(t(2026, 4, 6, 10, 0));
master.recurrence = Some(Recurrence {
rrule: "FREQ=DAILY".into(),
exdates: vec![kept.clone(), dropped_at, dropped_after],
rdates: vec![],
});
cal.create_event(master).unwrap();
cal.split_recurring_series_at(
&EventUid::new(uid),
EventTime::DateTimeUtc(t(2026, 4, 5, 10, 0)),
EventTime::DateTimeUtc(t(2026, 4, 5, 11, 0)),
None,
)
.unwrap();
assert_eq!(
loaded_master(&cal, uid).recurrence.unwrap().exdates,
vec![kept]
);
}
#[test]
fn split_with_no_new_recurrence_creates_single_event() {
let (_tmp, cal) = test_calendar();
let uid = "master@test";
cal.create_event(make_master(uid, t(2026, 4, 1, 10, 0), "FREQ=DAILY"))
.unwrap();
let new_master = cal
.split_recurring_series_at(
&EventUid::new(uid),
EventTime::DateTimeUtc(t(2026, 4, 5, 10, 0)),
EventTime::DateTimeUtc(t(2026, 4, 5, 11, 0)),
None,
)
.unwrap();
assert!(new_master.recurrence.is_none());
}
#[test]
fn split_errors_when_master_not_found() {
let (_tmp, cal) = test_calendar();
let err = cal
.split_recurring_series_at(
&EventUid::new("nonexistent"),
EventTime::DateTimeUtc(t(2026, 4, 5, 10, 0)),
EventTime::DateTimeUtc(t(2026, 4, 5, 11, 0)),
None,
)
.unwrap_err();
assert!(matches!(err, CalendarError::MasterNotFound(_)));
}
#[test]
fn split_errors_when_event_is_not_recurring() {
let (_tmp, cal) = test_calendar();
let mut single = test_event();
single.uid = crate::event::EventUid::new("solo@test");
cal.create_event(single).unwrap();
let err = cal
.split_recurring_series_at(
&EventUid::new("solo@test"),
EventTime::DateTimeUtc(t(2026, 4, 5, 10, 0)),
EventTime::DateTimeUtc(t(2026, 4, 5, 11, 0)),
None,
)
.unwrap_err();
assert!(matches!(err, CalendarError::MasterNotFound(_)));
}
#[test]
fn delete_recurring_instance_adds_exdate_to_master() {
let (_tmp, cal) = test_calendar();
let uid = "series@caldir";
cal.create_event(make_master(uid, t(2026, 4, 1, 10, 0), "FREQ=DAILY"))
.unwrap();
let occurrence = EventTime::DateTimeUtc(t(2026, 4, 3, 10, 0));
cal.delete_recurring_instance(&instance_id(uid, occurrence.clone()))
.unwrap();
assert_eq!(
loaded_master(&cal, uid).recurrence.unwrap().exdates,
vec![occurrence]
);
}
#[test]
fn delete_recurring_instance_removes_it_from_expansion() {
let (_tmp, cal) = test_calendar();
let uid = "series@caldir";
cal.create_event(make_master(uid, t(2026, 4, 1, 10, 0), "FREQ=DAILY"))
.unwrap();
let occurrence = EventTime::DateTimeUtc(t(2026, 4, 3, 10, 0));
cal.delete_recurring_instance(&instance_id(uid, occurrence.clone()))
.unwrap();
let starts: Vec<_> = cal
.expanded_events_in_range(t(2026, 4, 1, 0, 0), t(2026, 4, 5, 0, 0))
.unwrap()
.into_iter()
.map(|e| e.start.to_utc())
.collect();
assert_eq!(
starts,
vec![
t(2026, 4, 1, 10, 0),
t(2026, 4, 2, 10, 0),
t(2026, 4, 4, 10, 0)
]
);
}
#[test]
fn delete_recurring_instance_deletes_materialized_override() {
let (_tmp, cal) = test_calendar();
let uid = "series@caldir";
cal.create_event(make_master(uid, t(2026, 4, 1, 10, 0), "FREQ=DAILY"))
.unwrap();
cal.create_event(make_override(uid, t(2026, 4, 3, 10, 0), "moved standup"))
.unwrap();
cal.delete_recurring_instance(&instance_id(
uid,
EventTime::DateTimeUtc(t(2026, 4, 3, 10, 0)),
))
.unwrap();
assert!(loaded_overrides(&cal, uid).is_empty());
assert_eq!(
loaded_master(&cal, uid).recurrence.unwrap().exdates,
vec![EventTime::DateTimeUtc(t(2026, 4, 3, 10, 0))]
);
}
#[test]
fn delete_recurring_instance_bumps_sequence() {
let (_tmp, cal) = test_calendar();
let uid = "series@caldir";
let mut master = make_master(uid, t(2026, 4, 1, 10, 0), "FREQ=DAILY");
master.sequence = 3;
cal.create_event(master).unwrap();
cal.delete_recurring_instance(&instance_id(
uid,
EventTime::DateTimeUtc(t(2026, 4, 3, 10, 0)),
))
.unwrap();
assert_eq!(loaded_master(&cal, uid).sequence, 4);
}
#[test]
fn delete_recurring_instance_is_idempotent() {
let (_tmp, cal) = test_calendar();
let uid = "series@caldir";
cal.create_event(make_master(uid, t(2026, 4, 1, 10, 0), "FREQ=DAILY"))
.unwrap();
let occurrence = EventTime::DateTimeUtc(t(2026, 4, 3, 10, 0));
cal.delete_recurring_instance(&instance_id(uid, occurrence.clone()))
.unwrap();
cal.delete_recurring_instance(&instance_id(uid, occurrence.clone()))
.unwrap();
let master = loaded_master(&cal, uid);
assert_eq!(master.recurrence.unwrap().exdates, vec![occurrence]);
assert_eq!(master.sequence, 1);
}
#[test]
fn delete_recurring_instance_succeeds_for_orphan_override_without_master() {
let (_tmp, cal) = test_calendar();
let uid = "series@caldir";
cal.create_event(make_override(uid, t(2026, 4, 3, 10, 0), "orphan"))
.unwrap();
cal.delete_recurring_instance(&instance_id(
uid,
EventTime::DateTimeUtc(t(2026, 4, 3, 10, 0)),
))
.unwrap();
assert!(loaded_overrides(&cal, uid).is_empty());
}
#[test]
fn delete_recurring_instance_errors_when_uid_missing() {
let (_tmp, cal) = test_calendar();
let err = cal
.delete_recurring_instance(&instance_id(
"nonexistent",
EventTime::DateTimeUtc(t(2026, 4, 3, 10, 0)),
))
.unwrap_err();
assert!(matches!(err, CalendarError::MasterNotFound(_)));
}
#[test]
fn update_recurring_instance_creates_override_from_master() {
let (_tmp, cal) = test_calendar();
let uid = "series@caldir";
cal.create_event(make_master(uid, t(2026, 4, 1, 10, 0), "FREQ=DAILY"))
.unwrap();
let occurrence = EventTime::DateTimeUtc(t(2026, 4, 3, 10, 0));
cal.update_recurring_instance(&instance_id(uid, occurrence.clone()), |event| {
event.summary = Some("Moved standup".to_string());
event.start = EventTime::DateTimeUtc(t(2026, 4, 3, 14, 0));
event.end = Some(EventTime::DateTimeUtc(t(2026, 4, 3, 15, 0)));
})
.unwrap();
let overrides = loaded_overrides(&cal, uid);
assert_eq!(overrides.len(), 1);
let override_event = &overrides[0];
assert!(override_event.recurrence.is_none());
assert_eq!(
override_event
.recurrence_id
.as_ref()
.unwrap()
.as_event_time(),
&occurrence
);
assert_eq!(override_event.summary.as_deref(), Some("Moved standup"));
assert_eq!(
override_event.start,
EventTime::DateTimeUtc(t(2026, 4, 3, 14, 0))
);
assert_eq!(override_event.location.as_deref(), Some("Office"));
assert!(
loaded_master(&cal, uid)
.recurrence
.unwrap()
.exdates
.is_empty()
);
}
#[test]
fn update_recurring_instance_overrides_only_that_occurrence_in_expansion() {
let (_tmp, cal) = test_calendar();
let uid = "series@caldir";
cal.create_event(make_master(uid, t(2026, 4, 1, 10, 0), "FREQ=DAILY"))
.unwrap();
cal.update_recurring_instance(
&instance_id(uid, EventTime::DateTimeUtc(t(2026, 4, 3, 10, 0))),
|event| event.summary = Some("Special".to_string()),
)
.unwrap();
let summaries: Vec<_> = cal
.expanded_events_in_range(t(2026, 4, 1, 0, 0), t(2026, 4, 5, 0, 0))
.unwrap()
.into_iter()
.map(|e| e.summary.unwrap_or_default())
.collect();
assert_eq!(
summaries,
vec!["Daily standup", "Daily standup", "Special", "Daily standup"]
);
}
#[test]
fn update_recurring_instance_edits_existing_override_in_place() {
let (_tmp, cal) = test_calendar();
let uid = "series@caldir";
cal.create_event(make_master(uid, t(2026, 4, 1, 10, 0), "FREQ=DAILY"))
.unwrap();
cal.create_event(make_override(uid, t(2026, 4, 3, 10, 0), "first edit"))
.unwrap();
cal.update_recurring_instance(
&instance_id(uid, EventTime::DateTimeUtc(t(2026, 4, 3, 10, 0))),
|event| event.summary = Some("second edit".to_string()),
)
.unwrap();
let overrides = loaded_overrides(&cal, uid);
assert_eq!(overrides.len(), 1);
assert_eq!(overrides[0].summary.as_deref(), Some("second edit"));
}
#[test]
fn update_recurring_instance_bumps_existing_override_sequence() {
let (_tmp, cal) = test_calendar();
let uid = "series@caldir";
cal.create_event(make_master(uid, t(2026, 4, 1, 10, 0), "FREQ=DAILY"))
.unwrap();
let mut override_event = make_override(uid, t(2026, 4, 3, 10, 0), "edit");
override_event.sequence = 2;
cal.create_event(override_event).unwrap();
cal.update_recurring_instance(
&instance_id(uid, EventTime::DateTimeUtc(t(2026, 4, 3, 10, 0))),
|event| event.summary = Some("re-edit".to_string()),
)
.unwrap();
assert_eq!(loaded_overrides(&cal, uid)[0].sequence, 3);
}
#[test]
fn update_recurring_instance_errors_when_not_recurring() {
let (_tmp, cal) = test_calendar();
let err = cal
.update_recurring_instance(
&EventInstanceId::new(EventUid::new("solo@test"), None),
|_| {},
)
.unwrap_err();
assert!(matches!(err, CalendarError::NotRecurring(_)));
}
#[test]
fn update_recurring_instance_errors_when_master_missing() {
let (_tmp, cal) = test_calendar();
let err = cal
.update_recurring_instance(
&instance_id("nonexistent", EventTime::DateTimeUtc(t(2026, 4, 3, 10, 0))),
|_| {},
)
.unwrap_err();
assert!(matches!(err, CalendarError::MasterNotFound(_)));
}
}