Add the RFC 5545 domain model

VEvent and friends, transcribed from the previous calendar-models crate
and tightened so that the states which caused its timezone and recurrence
bugs cannot be represented.

The substantive changes from v1:

CalendarDateTime is an enum over the four forms RFC 5545 actually admits
(date, floating, UTC, zoned) instead of a NaiveDateTime plus a loose
Option<String> zone plus an all_day flag that could all disagree. A zoned
value carries an IANA identifier, never a UTC offset -- an offset cannot
tell standard time from daylight time, which is why recurring events
drifted an hour across DST.

EventEnd is an enum, because DTEND and DURATION are mutually exclusive.
Priority validates 0-9 on construction and on deserialisation. EditScope
replaces dispatch on strings like "this_and_future", which appeared 53
times and turned typos into silent fallthrough.

CalendarObject models a CalDAV resource as it really is: one UID, one
master, N RECURRENCE-ID overrides. v1 flattened this to a bare event list,
which made overrides look like duplicates and motivated ~500 lines of
title-matching heuristics that silently discarded events.

Dropped VJournal, VFreeBusy, VTimeZone, TodoStatus, FreeBusyType and
Period: defined but never used.

28 tests cover serde round-trips, the exact JSON shape (the model is the
wire format, so changing it should be deliberate), duration fallbacks,
validation boundaries and master/override separation.

uuid needs an explicit entropy source on wasm; without it the frontend
cannot compile the shared model. Verified that the default feature set
pulls in neither icalendar, rrule, chrono-tz, quick-xml nor reqwest.
This commit is contained in:
2026-08-26 12:29:59 -04:00
parent bf63024711
commit 52cd5a961d
10 changed files with 1344 additions and 4 deletions
+82
View File
@@ -0,0 +1,82 @@
//! A CalDAV calendar object resource — one `.ics` file on the server.
//!
//! This is the level at which CalDAV actually operates, and modelling it
//! explicitly is what makes correct recurrence handling possible.
//!
//! A single resource holds a `VCALENDAR` containing one or more `VEVENT`s that
//! all share a `UID`: the series master, plus one override per modified
//! occurrence, each distinguished by its `RECURRENCE-ID`. The previous
//! iteration flattened that structure into a bare list of events, which made
//! masters and overrides indistinguishable — so a correctly-formed feed looked
//! like it contained duplicates, and ~500 lines of title-matching heuristics
//! were added to "clean up" the result by silently discarding events. Keeping
//! the resource intact removes the need for any of that.
use super::event::VEvent;
use serde::{Deserialize, Serialize};
/// One addressable resource on a CalDAV server, with its concurrency metadata.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CalendarObject {
/// Path of this resource on the server, relative to its host.
pub href: String,
/// Server-assigned `ETag`. Absent for an object not yet written.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub etag: Option<String>,
/// Path of the collection holding this resource.
pub calendar_path: String,
/// The `VEVENT`s inside, sharing one `UID`: at most one master plus any
/// number of overrides.
pub events: Vec<VEvent>,
}
impl CalendarObject {
/// The series master — the event without a `RECURRENCE-ID`.
///
/// Absent when a resource contains only overrides, which is unusual but
/// legal and which servers do emit.
pub fn master(&self) -> Option<&VEvent> {
self.events.iter().find(|e| !e.is_override())
}
/// The overrides, each replacing one occurrence of the master.
pub fn overrides(&self) -> impl Iterator<Item = &VEvent> {
self.events.iter().filter(|e| e.is_override())
}
/// The `UID` shared by every event in this resource.
pub fn uid(&self) -> Option<&str> {
self.events.first().map(|e| e.uid.as_str())
}
/// Whether every contained event agrees on the `UID`, as RFC 4791 requires.
///
/// Worth asserting in tests against real servers rather than assuming.
pub fn has_consistent_uid(&self) -> bool {
let mut uids = self.events.iter().map(|e| e.uid.as_str());
match uids.next() {
None => true,
Some(first) => uids.all(|u| u == first),
}
}
}
/// Which occurrences of a series an edit or deletion applies to.
///
/// Replaces the previous iteration's stringly-typed dispatch on values like
/// `"this_only"`, `"this_and_future"` and `"delete_series"`, which appeared 53
/// times across the codebase and turned a typo into a silent runtime
/// fallthrough rather than a compile error.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EditScope {
/// Just the occurrence named by the request. Realised as an `EXDATE` on the
/// master plus a new override carrying a `RECURRENCE-ID`.
#[default]
ThisOnly,
/// This occurrence and everything after it. Realised by truncating the
/// master with `UNTIL` and creating a fresh series.
ThisAndFuture,
/// Every occurrence. Realised by editing the master in place.
EntireSeries,
}