Add the iCalendar round-trip

Parsing goes through icalendar's low-level parser, which keeps properties
in order and keeps repeated ones. Writing is ours: that crate's writer
escapes a whole property value as text, so CATEGORIES:Work,Personal would
go out as one category named "Work,Personal" to every other client.

Anything the model does not interpret is carried rather than dropped --
X-MOZ-LASTACK, X-EVOLUTION-ALARM-UID, ACKNOWLEDGED, the X-MICROSOFT-CDO
set, unrecognised ATTENDEE parameters, and whole VTODO/VJOURNAL
components. A calendar has several clients writing to it and this one is
not the authority on which properties matter.

VTIMEZONE is modelled properly, and TZID is stored exactly as written:
Exchange names its zones "Pacific Standard Time", which no IANA lookup
resolves, and normalising at parse time would make the document
unrepresentable. Mapping to a real zone belongs at the point of use.

Tested against a golden corpus captured from the live Baikal (seven
producing clients over five years) and a published Outlook feed, scrubbed
of private content with the structure left byte-for-byte. Eight
hand-written fixtures cover what neither server had: DURATION, floating
times, RDATE, DST boundaries, leap day, and the full escape set.

The contract is that parse -> write -> parse is stable, plus a check that
no property name loses occurrences across the trip, since a parser that
dropped ATTENDEE entirely would round-trip perfectly and still be wrong.
This commit is contained in:
2026-08-26 14:32:38 -04:00
parent 52cd5a961d
commit 7043a151f6
35 changed files with 3397 additions and 11 deletions
+16 -7
View File
@@ -12,6 +12,7 @@
//! were added to "clean up" the result by silently discarding events. Keeping
//! the resource intact removes the need for any of that.
use super::calendar::VCalendar;
use super::event::VEvent;
use serde::{Deserialize, Serialize};
@@ -25,35 +26,43 @@ pub struct CalendarObject {
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>,
/// The `VCALENDAR` this resource contains — events, and the zone
/// definitions they reference. Both halves have to travel together: a `PUT`
/// that dropped the `VTIMEZONE` would leave the resource unreadable to
/// clients that cannot resolve the identifier on their own.
pub calendar: VCalendar,
}
impl CalendarObject {
/// The `VEVENT`s inside, sharing one `UID`: at most one master plus any
/// number of overrides.
pub fn events(&self) -> &[VEvent] {
&self.calendar.events
}
/// 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())
self.calendar.master()
}
/// 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())
self.calendar.overrides()
}
/// The `UID` shared by every event in this resource.
pub fn uid(&self) -> Option<&str> {
self.events.first().map(|e| e.uid.as_str())
self.calendar.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());
let mut uids = self.calendar.events.iter().map(|e| e.uid.as_str());
match uids.next() {
None => true,
Some(first) => uids.all(|u| u == first),