Rules come from the rrule crate. What is ours is the layer above it:
reconciling a series master with the RECURRENCE-ID overrides that replace
individual occurrences, which is where the subtlety actually lives. An
override suppresses the occurrence it names, and an override whose master
falls outside the window is still emitted -- a published feed truncates
series at its edge, and those are real events.
Occurrences know their own RECURRENCE-ID. v1 encoded instance identity as
a "{uid}-{timestamp}" string and split it apart again in the view layer.
TZID resolution is a ladder: IANA, then Windows zone names through a CLDR
table generated from windowsZones.xml, then an assumption that is reported
rather than hidden. Exchange writes "Pacific Standard Time" and every zone
in the real feed maps. Deriving a VTIMEZONE's own offsets is not built --
nothing in the corpus needs it, and the definitions are already carried
should that change.
Daylight saving is settled explicitly rather than by unwrap. An ambiguous
time takes the earlier reading; a time inside a spring-forward gap slides
past it by the gap's own length, so 02:15 and 02:45 stay distinct and stay
in order instead of both snapping to 03:00.
Tested against known-good outputs: DST both directions, leap day, nth
weekday, BYMONTHDAY on short months, COUNT against UNTIL, EXDATE against
an override, and the real Outlook feed -- where the assertion is that no
series ever yields two occurrences at one instant, which is what the old
importer's title-matching heuristics were standing in for.
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.
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.
Five crates: runway-core (pure domain), runway-caldav (protocol),
runway-server (axum), runway-web (leptos), runway-cli (smoke tool).
runway-core is feature-gated into model/ical/recurrence so the frontend
can depend on the shared types without pulling icalendar and rrule into
the WASM bundle. The previous iteration shipped reqwest, ical and regex
to the browser for a dead module; the feature split makes that mistake
structurally hard to repeat.
Guardrails are compiler- and CI-enforced rather than aspirational:
workspace lints deny unwrap_used/expect_used, dead_code and unsafe_code,
clippy.toml caps function length and arity, deny.toml pins licences.
Toolchain is pinned per-project so the machine-wide default is untouched.
Cargo.lock is committed this time.
docs/legacy-audit.md carries the marked-up feature decisions and is the
spec for the rewrite.