One path with an EditScope on the write verbs. v1 had a second parallel tree at /api/calendar/events/series/* -- 1,165 lines mostly duplicating the non-series handlers, dispatching on string literals in 53 places where a typo was a runtime fallthrough. What a scoped edit means to the stored .ics lives in runway-core::series, pure and tested without a server, because that is the subtle part and v1 shipped it with no coverage at all. Editing one occurrence writes an override and no EXDATE: an EXDATE says the occurrence does not happen, an override says it happens differently, and writing both is contradictory. Deleting one writes the EXDATE and removes any override that named it. Splitting a series divides its bound rather than dropping it. Six weekly occurrences split at the third become two plus four, not two plus forever -- the count is what the person asked for and it should survive being cut. Overrides after the split move to the new series; moving a whole series shifts its overrides' RECURRENCE-IDs by the same amount instead of leaving them pointing at occurrences that no longer exist. Every write states a precondition. There is no unconditional path: an update without an ETag is refused, and a stale one is a conflict rather than a silent overwrite. UIDs are minted server-side, because a client-supplied one could collide with and replace an unrelated event. Reads use time-range and fan out across calendars concurrently. Zones that cannot be resolved are reported in the response instead of being rendered as though they were fine. Two tests found real bugs: sub-second timestamps cannot survive iCalendar's one-second resolution, and splitting at the first occurrence was dropping the recurrence rule and quietly turning a series into a single event.
Runway
Passive infrastructure for life's coordination.
A CalDAV web client in Rust — Leptos/WASM frontend, Axum backend, speaking to any RFC-compliant CalDAV server (developed against Baikal).
This is a ground-up rewrite. Its predecessor lives in ../calendar; the reasons it was replaced,
and the feature-by-feature decisions that shaped this one, are recorded in
docs/legacy-audit.md — that document is the spec.
Layout
| Crate | Role |
|---|---|
runway-core |
RFC 5545 model, iCalendar round-trip, recurrence expansion. Pure, no I/O. |
runway-caldav |
CalDAV client: discovery, time-ranged queries, ETag-aware CRUD. |
runway-server |
Axum backend: CalDAV proxy, sessions, preferences, ICS feeds. |
runway-web |
Leptos CSR frontend. |
runway-cli |
Smoke tool for exercising a real CalDAV server from the terminal. |
runway-core is feature-gated (model / ical / recurrence) so the frontend gets the shared
types without pulling the parser and RRULE engine into the WASM bundle.
Development
The toolchain is pinned in rust-toolchain.toml and installs automatically on first use.
cargo check --workspace
cargo test --workspace
cargo clippy --workspace -- -D warnings
The CalDAV integration tests need a server and are skipped without one. This starts a throwaway Baikal in a container, installs it, runs them against it, and tears it down:
crates/runway-caldav/tests/baikal/run.sh
Running the backend needs an encryption key for stored CalDAV credentials. It must stay the same across restarts — a key invented at startup would silently make every saved credential unreadable — so the server refuses to start without one rather than generating a throwaway:
export RUNWAY_SECRET_KEY=$(cargo run -q -p runway-server -- genkey)
export RUNWAY_DATABASE_URL=sqlite:runway.db # default
export RUNWAY_BIND=0.0.0.0:3000 # default
export RUNWAY_INSECURE_COOKIES=1 # local HTTP only
cargo run -p runway-server
The CLI drives the same stack the app does, which makes it the quickest way to tell a display bug from a data one:
export RUNWAY_CALDAV_URL=https://example.com/dav.php/
export RUNWAY_CALDAV_USER=you
export RUNWAY_CALDAV_PASSWORD=... # from the environment, not a flag
cargo run -p runway-cli -- calendars
cargo run -p runway-cli -- list-events --from 2026-08-24 --to 2026-08-31
Principles
Carried over from the audit, and enforced by lints and CI rather than by good intentions:
- One representation of an event.
VEventis the model and the wire format. No parallel DTOs. - Use the library. Recurrence is
rrule, iCalendar isicalendar, XML isquick-xml. - Fix the parse, not the output. Never post-process data to paper over a bad parse.
- State lives where it's owned. No localStorage-as-global, no prop-drilling megacomponents.
- Test thoroughly. Real servers over mocks; the router under test is the router that ships.