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
@@ -0,0 +1,13 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Runway//Runway Test Fixture//EN
BEGIN:VEVENT
UID:allday-exdate@fixtures.runway.invalid
DTSTAMP:20260101T000000Z
DTSTART;VALUE=DATE:20260105
DTEND;VALUE=DATE:20260106
SUMMARY:Bin day
RRULE:FREQ=WEEKLY;BYDAY=MO
EXDATE;VALUE=DATE:20260223,20260302
END:VEVENT
END:VCALENDAR
@@ -0,0 +1,12 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Runway//Runway Test Fixture//EN
BEGIN:VEVENT
UID:dst-spring-forward@fixtures.runway.invalid
DTSTAMP:20260301T090000Z
DTSTART;TZID=America/Denver:20260302T090000
DTEND;TZID=America/Denver:20260302T093000
SUMMARY:Weekly across spring forward
RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=4
END:VEVENT
END:VCALENDAR
@@ -0,0 +1,11 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Runway//Runway Test Fixture//EN
BEGIN:VEVENT
UID:duration-not-dtend@fixtures.runway.invalid
DTSTAMP:20260114T090000Z
DTSTART;TZID=America/Denver:20260114T090000
DURATION:PT1H30M
SUMMARY:Ninety minutes expressed as a duration
END:VEVENT
END:VCALENDAR
@@ -0,0 +1,11 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Runway//Runway Test Fixture//EN
BEGIN:VEVENT
UID:floating@fixtures.runway.invalid
DTSTAMP:20260114T090000Z
DTSTART:20260114T090000
DTEND:20260114T100000
SUMMARY:Nine in the morning\, wherever you are
END:VEVENT
END:VCALENDAR
@@ -0,0 +1,12 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Runway//Runway Test Fixture//EN
BEGIN:VEVENT
UID:leap-day@fixtures.runway.invalid
DTSTAMP:20240229T000000Z
DTSTART;VALUE=DATE:20240229
DTEND;VALUE=DATE:20240301
SUMMARY:Leap day
RRULE:FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=29
END:VEVENT
END:VCALENDAR
@@ -0,0 +1,14 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Runway//Runway Test Fixture//EN
BEGIN:VEVENT
UID:rdate-exdate@fixtures.runway.invalid
DTSTAMP:20260105T090000Z
DTSTART;TZID=America/Denver:20260105T090000
DTEND;TZID=America/Denver:20260105T093000
SUMMARY:Weekly with an extra date and two skipped
RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=8
RDATE;TZID=America/Denver:20260110T090000
EXDATE;TZID=America/Denver:20260119T090000,20260202T090000
END:VEVENT
END:VCALENDAR
@@ -0,0 +1,22 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Runway//Runway Test Fixture//EN
BEGIN:VEVENT
UID:series-override@fixtures.runway.invalid
DTSTAMP:20260105T090000Z
DTSTART;TZID=America/Denver:20260105T090000
DTEND;TZID=America/Denver:20260105T100000
SUMMARY:Standup
RRULE:FREQ=WEEKLY;BYDAY=MO
SEQUENCE:0
END:VEVENT
BEGIN:VEVENT
UID:series-override@fixtures.runway.invalid
RECURRENCE-ID;TZID=America/Denver:20260119T090000
DTSTAMP:20260112T142200Z
DTSTART;TZID=America/Denver:20260119T140000
DTEND;TZID=America/Denver:20260119T150000
SUMMARY:Standup (moved to the afternoon)
SEQUENCE:1
END:VEVENT
END:VCALENDAR
@@ -0,0 +1,16 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Runway//Runway Test Fixture//EN
BEGIN:VEVENT
UID:escapes@fixtures.runway.invalid
DTSTAMP:20260114T090000Z
DTSTART;TZID=Europe/Zurich:20260114T090000
DTEND;TZID=Europe/Zurich:20260114T100000
SUMMARY:Lezione d'italiano — caffè\, cioccolato e “virgolette”
DESCRIPTION:Line one\nLine two\; with a semicolon\nA backslash: \\\nA comm
a\, and a colon: here
LOCATION;LANGUAGE=it-CH:Zürich\, Hauptbahnhof
ORGANIZER;CN="Rossi, Giulia (IT: Milano)":mailto:giulia.rossi@example.org
CATEGORIES:Lezioni,Personale
END:VEVENT
END:VCALENDAR