feat(calendar): recurrence expansion parser and conformance fixtures #51

Merged
yuris merged 1 commit from feat/issue-13-recurrence into main 2026-09-22 22:20:34 +00:00
Owner

Motivation

Recurrence is where a calendar service most easily lies to its user. Radicale answers a bounded C:expand REPORT, but the response still has to be validated: a series master alone is not an occurrence set, an override that moved an occurrence is not a second occurrence, and an EXDATEd or cancelled instance must not consume capacity. Today GET /v1/agenda maps whatever components came back straight into items, so a recurring series shows its master instead of its instances and an unsupported expansion is indistinguishable from an empty calendar.

This change normalizes REPORT output into deterministic occurrences with a stable identity, and makes every case the adapter cannot reproduce losslessly explicit instead of plausible.

Changes

  • src/calendar/recurrence.rs (new) — normalizes REPORT output into the occurrence set (RRULE ∪ RDATE) ∖ EXDATE inside the half-open query window:
    • canonical identity is (collection_id, uid, original RECURRENCE-ID). Every expanded occurrence carries a non-null recurrence_id; null stays reserved for an unexpanded master. Moving an occurrence changes its DTSTART, never its identity.
    • detached overrides apply by identity, so an occurrence moved into the window from outside is kept with its original, out-of-window RECURRENCE-ID, and one moved out is dropped without duplicating the master instance.
    • STATUS:CANCELLED occurrences and EXDATEd instances leave both display and capacity; a cancelled series master cancels the series.
    • fail-safe over plausible: RANGE=THISANDFUTURE/THISANDPRIOR, an unresolvable TZID, an RRULE outside the expanded subset (BYSETPOS, ordinal BYDAY, HOURLY and below), a span over 93 days or an expansion over 10,000 instances are refused explicitly (calendar_unsupported / limit_exceeded with a narrow-the-range instruction). Nothing is silently truncated, and an unsupported window is never declared free.
    • expansion is bounded by the query window and the instance ceiling: COUNT is measured from DTSTART, an unbounded rule stops at the window edge, and a rule that needs more than the step budget to reach the window is refused rather than approximated. DST gaps skip the instance with a disclosed warning instead of shifting it onto a neighbouring time.
  • src/calendar/report.rs — surfaces the recurrence properties the existing parser already reads (RRULE, RDATE, EXDATE, RECURRENCE-ID's RANGE, and the DTSTART/DUE TZID) on ExpandedComponent, and merges repeated RDATE/EXDATE lines. No second parser is forked; parse_components/parse_report_multistatus remain the only entry point for calendar-data.
  • src/api/agenda.rs — GET /v1/agenda maps expanded occurrences, so agenda items and capacity follow the same occurrence set. A collection whose recurrence cannot be expanded degrades to unsupported coverage with an incomplete_calendar warning and is never reported as an outage or as free time; a bound the query itself exceeded is limit_exceeded (HTTP 422) rather than a truncated page.
  • src/domain/recurrence_create.rs (new) and src/api/creation.rs — POST /v1/tasks and POST /v1/events accept structured rrule/rdates/exdates alongside the existing fields. The rule is canonicalized (FREQ=DAILY;COUNT=3) and bound into the idempotency fingerprint, so the same series written two ways never splits into two operations. Malformed rules, unsupported frequencies and value-type mismatches are invalid_input (HTTP 422, exit 2); unknown members are still rejected by deny_unknown_fields.
  • src/calendar/ical.rs — the new-resource builder writes the recurrence of a new series: RRULE verbatim, and one RDATE/EXDATE property per value so VALUE=DATE stays accurate for date-only series. Existing resources are still never round-tripped through it.
  • No new dependency; Cargo.toml is untouched.

Verification

  • tests/recurrence_tests.rs (new, 23 tests) drives the normative fixture from docs/technical/verification.md verbatim (CRLF, per RFC 5545 §3.1) and asserts the exact expansion in [2030-04-08T00:00:00Z, 2030-04-13T00:00:00Z): April 8 09:00, April 11 11:00 with identity April 10 09:00, April 12 09:00; April 9 excluded by EXDATE; no original April 10 block. It also covers CRLF/LF equivalence, a moved-out override (absent from both windows, present once at its target), a moved-in override carrying its original identity, a cancelled occurrence, identity stability across two different moves of the same occurrence, limit_exceeded for a 94-day span and for COUNT=20000, RANGE=THISANDFUTURE, an unresolvable custom VTIMEZONE, and unsupported RRULE parts.
  • HTTP-level tests drive the same fixture over a mock CalDAV server through GET /v1/agenda and assert the returned items, recurrence_id, consumes_capacity and complete flags; a single unsupported collection returns 200 with status: partial, coverage.state: unsupported and an incomplete_calendar warning, and both limit cases return 422 limit_exceeded.
  • Recurring creation is verified end to end: the master the daemon PUTs is read back from the mock backend, asserted to carry the canonical RRULE/RDATE/EXDATE, expanded to the sequence the caller described, and then observed in the live agenda. Replay of the same series under the same operation key returns the recorded outcome without a second write; a different series under that key is idempotency_conflict.
  • nix develop --command cargo test: 18 suites green (217 tests), including agenda_tasks, caldav_report, creation, update and crash_recovery unchanged. cargo clippy --all-targets -- -D warnings and cargo fmt --check are clean.

Closes #13

Refs #8 (bounded REPORT expansion), #10 (conditional creation), #11 (conditional updates). Occurrence-scoped mutation (series vs occurrence editing) stays in #15; preservation-safe patching stays in #14.

## Motivation Recurrence is where a calendar service most easily lies to its user. Radicale answers a bounded `C:expand` `REPORT`, but the response still has to be validated: a series master alone is not an occurrence set, an override that moved an occurrence is not a second occurrence, and an `EXDATE`d or cancelled instance must not consume capacity. Today `GET /v1/agenda` maps whatever components came back straight into items, so a recurring series shows its master instead of its instances and an unsupported expansion is indistinguishable from an empty calendar. This change normalizes `REPORT` output into deterministic occurrences with a stable identity, and makes every case the adapter cannot reproduce losslessly explicit instead of plausible. ## Changes - `src/calendar/recurrence.rs` (new) — normalizes `REPORT` output into the occurrence set `(RRULE ∪ RDATE) ∖ EXDATE` inside the half-open query window: - canonical identity is `(collection_id, uid, original RECURRENCE-ID)`. Every expanded occurrence carries a non-null `recurrence_id`; `null` stays reserved for an unexpanded master. Moving an occurrence changes its `DTSTART`, never its identity. - detached overrides apply by identity, so an occurrence moved into the window from outside is kept with its original, out-of-window `RECURRENCE-ID`, and one moved out is dropped without duplicating the master instance. - `STATUS:CANCELLED` occurrences and `EXDATE`d instances leave both display and capacity; a cancelled series master cancels the series. - fail-safe over plausible: `RANGE=THISANDFUTURE`/`THISANDPRIOR`, an unresolvable `TZID`, an `RRULE` outside the expanded subset (`BYSETPOS`, ordinal `BYDAY`, `HOURLY` and below), a span over 93 days or an expansion over 10,000 instances are refused explicitly (`calendar_unsupported` / `limit_exceeded` with a narrow-the-range instruction). Nothing is silently truncated, and an unsupported window is never declared free. - expansion is bounded by the query window and the instance ceiling: `COUNT` is measured from `DTSTART`, an unbounded rule stops at the window edge, and a rule that needs more than the step budget to reach the window is refused rather than approximated. DST gaps skip the instance with a disclosed warning instead of shifting it onto a neighbouring time. - `src/calendar/report.rs` — surfaces the recurrence properties the existing parser already reads (`RRULE`, `RDATE`, `EXDATE`, `RECURRENCE-ID`'s `RANGE`, and the `DTSTART`/`DUE` `TZID`) on `ExpandedComponent`, and merges repeated `RDATE`/`EXDATE` lines. No second parser is forked; `parse_components`/`parse_report_multistatus` remain the only entry point for calendar-data. - `src/api/agenda.rs` — `GET /v1/agenda` maps expanded occurrences, so agenda items and capacity follow the same occurrence set. A collection whose recurrence cannot be expanded degrades to `unsupported` coverage with an `incomplete_calendar` warning and is never reported as an outage or as free time; a bound the query itself exceeded is `limit_exceeded` (HTTP 422) rather than a truncated page. - `src/domain/recurrence_create.rs` (new) and `src/api/creation.rs` — `POST /v1/tasks` and `POST /v1/events` accept structured `rrule`/`rdates`/`exdates` alongside the existing fields. The rule is canonicalized (`FREQ=DAILY;COUNT=3`) and bound into the idempotency fingerprint, so the same series written two ways never splits into two operations. Malformed rules, unsupported frequencies and value-type mismatches are `invalid_input` (HTTP 422, exit 2); unknown members are still rejected by `deny_unknown_fields`. - `src/calendar/ical.rs` — the new-resource builder writes the recurrence of a new series: `RRULE` verbatim, and one `RDATE`/`EXDATE` property per value so `VALUE=DATE` stays accurate for date-only series. Existing resources are still never round-tripped through it. - No new dependency; `Cargo.toml` is untouched. ## Verification - `tests/recurrence_tests.rs` (new, 23 tests) drives the normative fixture from `docs/technical/verification.md` verbatim (CRLF, per RFC 5545 §3.1) and asserts the exact expansion in `[2030-04-08T00:00:00Z, 2030-04-13T00:00:00Z)`: April 8 09:00, April 11 11:00 with identity April 10 09:00, April 12 09:00; April 9 excluded by `EXDATE`; no original April 10 block. It also covers CRLF/LF equivalence, a moved-out override (absent from both windows, present once at its target), a moved-in override carrying its original identity, a cancelled occurrence, identity stability across two different moves of the same occurrence, `limit_exceeded` for a 94-day span and for `COUNT=20000`, `RANGE=THISANDFUTURE`, an unresolvable custom `VTIMEZONE`, and unsupported `RRULE` parts. - HTTP-level tests drive the same fixture over a mock CalDAV server through `GET /v1/agenda` and assert the returned items, `recurrence_id`, `consumes_capacity` and `complete` flags; a single unsupported collection returns `200` with `status: partial`, `coverage.state: unsupported` and an `incomplete_calendar` warning, and both limit cases return `422 limit_exceeded`. - Recurring creation is verified end to end: the master the daemon PUTs is read back from the mock backend, asserted to carry the canonical `RRULE`/`RDATE`/`EXDATE`, expanded to the sequence the caller described, and then observed in the live agenda. Replay of the same series under the same operation key returns the recorded outcome without a second write; a different series under that key is `idempotency_conflict`. - `nix develop --command cargo test`: 18 suites green (217 tests), including `agenda_tasks`, `caldav_report`, `creation`, `update` and `crash_recovery` unchanged. `cargo clippy --all-targets -- -D warnings` and `cargo fmt --check` are clean. ## Related Issues Closes #13 Refs #8 (bounded REPORT expansion), #10 (conditional creation), #11 (conditional updates). Occurrence-scoped mutation (series vs occurrence editing) stays in #15; preservation-safe patching stays in #14.
Normalize bounded CalDAV REPORT output into deterministic occurrences:

* `src/calendar/recurrence.rs` materializes `(RRULE ∪ RDATE) ∖ EXDATE` inside
  the half-open query window, applies detached overrides by identity (keeping
  moved-in occurrences, dropping moved-out ones without duplicating the
  master), drops `STATUS:CANCELLED` occurrences, and refuses rather than
  guesses: `RANGE=THISANDFUTURE`/`THISANDPRIOR`, an unresolvable `TZID`, an
  `RRULE` outside the expanded subset, a >93-day span or a >10,000-instance
  expansion.
* `src/calendar/report.rs` surfaces the recurrence properties the parser
  already reads (`RRULE`, `RDATE`, `EXDATE`, `RECURRENCE-ID`'s `RANGE`, and the
  `DTSTART`/`DUE` `TZID`) instead of forking a second parser.
* `GET /v1/agenda` reports expanded occurrences, so agenda items and capacity
  follow the same occurrence set; an unsupported collection degrades to
  `unsupported` coverage with an `incomplete_calendar` warning and is never
  declared free, while a bound the query itself exceeded is `limit_exceeded`.
* `POST /v1/tasks` and `POST /v1/events` accept structured `rrule`/`rdates`/
  `exdates` (`src/domain/recurrence_create.rs`) and write a valid RFC 5545
  master through the new-resource builder, with the canonical rule bound into
  the idempotency fingerprint.

`tests/recurrence_tests.rs` covers the verbatim fixture, moved-in/moved-out
overrides, identity stability under a move, CRLF normalization, both limits,
`RANGE=THISANDFUTURE` and custom `VTIMEZONE` fail-safes, and recurring
creation, at HTTP level where the behaviour is HTTP level (C05-C08).
Author
Owner

Coordinator review — APPROVED

Verified 4ce566e myself (read the diff and the tests, ran the suite).

The load-bearing criterion is satisfied literally: c05_c06_normative_fixture_expands_verbatim drives the CRLF fixture from docs/technical/verification.md over expand_calendar_data in [2030-04-08T00:00:00Z,2030-04-13T00:00:00Z) and asserts exactly April 8 09:00, April 11 11:00 with identity 20300410T090000Z, and April 12 09:00 — with April 9 and the original April 10 09:00 block absent, and the moved occurrence keeping its SUMMARY/DTEND. That is the normative expectation, not an approximation of it.

Also verified: identity is (collection, uid, original RECURRENCE-ID) and stable across two different moves; moved-out overrides leave nothing behind and moved-in overrides keep their out-of-window identity; EXDATE and STATUS:CANCELLED occurrences disappear from both agenda items and capacity; the master is never reported as an occurrence; >93 days and >10,000 instances are refused with a named bound; RANGE=THISANDFUTURE and an unresolvable VTIMEZONE are refused rather than approximated; CRLF round-trips; and structured rrule/rdates/exdates creation writes a conforming master (including keeping the DATE value type for a dated series) with the canonical rule bound into the idempotency fingerprint.

The coverage refinement is right and I checked its edges: capability failures (CalendarUnsupported/Preservation) and LimitExceeded are no longer counted as collection outages, so an unsupported collection degrades to an explicit partial agenda with coverage.state=unsupported while a genuine outage still yields dependency_unavailable — C07 and C01 both hold, and test_c01_all_collections_down_is_dependency_unavailable_never_empty still passes. I also confirmed neither report.rs nor recurrence.rs can emit ClarificationRequired, so that variant cannot be misclassified as an outage.

Independent verification on 4ce566e: cargo test 240 passed / 0 failed across 18 suites including recurrence_tests (23); cargo clippy --all-targets -- -D warnings 0 errors; cargo fmt --check clean; the pre-existing agenda_tasks, caldav_report, creation, update and crash_recovery suites unchanged.

Nit, no change required: c10_an_unsupported_rule_part_is_refused_not_dropped is named after C10 (the ETag-conflict scenario) but exercises the unsupported-rule criterion. Worth renaming the next time that file is touched.

Cargo.toml is untouched and no dependency was added, so the deferred authors chore correctly stays pending.

Merging.

## Coordinator review — APPROVED Verified `4ce566e` myself (read the diff and the tests, ran the suite). The load-bearing criterion is satisfied literally: `c05_c06_normative_fixture_expands_verbatim` drives the CRLF fixture from `docs/technical/verification.md` over `expand_calendar_data` in `[2030-04-08T00:00:00Z,2030-04-13T00:00:00Z)` and asserts exactly April 8 09:00, April 11 11:00 with identity `20300410T090000Z`, and April 12 09:00 — with April 9 and the original April 10 09:00 block absent, and the moved occurrence keeping its `SUMMARY`/`DTEND`. That is the normative expectation, not an approximation of it. Also verified: identity is `(collection, uid, original RECURRENCE-ID)` and stable across two different moves; moved-out overrides leave nothing behind and moved-in overrides keep their out-of-window identity; `EXDATE` and `STATUS:CANCELLED` occurrences disappear from both agenda items and capacity; the master is never reported as an occurrence; `>93` days and `>10,000` instances are refused with a named bound; `RANGE=THISANDFUTURE` and an unresolvable `VTIMEZONE` are refused rather than approximated; CRLF round-trips; and structured `rrule`/`rdates`/`exdates` creation writes a conforming master (including keeping the `DATE` value type for a dated series) with the canonical rule bound into the idempotency fingerprint. The coverage refinement is right and I checked its edges: capability failures (`CalendarUnsupported`/`Preservation`) and `LimitExceeded` are no longer counted as collection outages, so an unsupported collection degrades to an explicit partial agenda with `coverage.state=unsupported` while a genuine outage still yields `dependency_unavailable` — C07 and C01 both hold, and `test_c01_all_collections_down_is_dependency_unavailable_never_empty` still passes. I also confirmed neither `report.rs` nor `recurrence.rs` can emit `ClarificationRequired`, so that variant cannot be misclassified as an outage. Independent verification on `4ce566e`: `cargo test` **240 passed / 0 failed** across 18 suites including `recurrence_tests` (23); `cargo clippy --all-targets -- -D warnings` 0 errors; `cargo fmt --check` clean; the pre-existing `agenda_tasks`, `caldav_report`, `creation`, `update` and `crash_recovery` suites unchanged. Nit, no change required: `c10_an_unsupported_rule_part_is_refused_not_dropped` is named after C10 (the ETag-conflict scenario) but exercises the unsupported-rule criterion. Worth renaming the next time that file is touched. `Cargo.toml` is untouched and no dependency was added, so the deferred authors chore correctly stays pending. Merging.
yuris merged commit 9befe29f6f into main 2026-09-22 22:20:34 +00:00
yuris changed title from feat(calendar): recurrence expansion parser and conformance fixtures (#13) to feat(calendar): recurrence expansion parser and conformance fixtures 2026-09-23 05:12:06 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
yuris/life-service!51
No description provided.