feat(calendar): recurrence expansion parser and conformance fixtures #51
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/issue-13-recurrence"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Motivation
Recurrence is where a calendar service most easily lies to its user. Radicale answers a bounded
C:expandREPORT, 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 anEXDATEd or cancelled instance must not consume capacity. TodayGET /v1/agendamaps 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
REPORToutput 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) — normalizesREPORToutput into the occurrence set(RRULE ∪ RDATE) ∖ EXDATEinside the half-open query window:(collection_id, uid, original RECURRENCE-ID). Every expanded occurrence carries a non-nullrecurrence_id;nullstays reserved for an unexpanded master. Moving an occurrence changes itsDTSTART, never its identity.RECURRENCE-ID, and one moved out is dropped without duplicating the master instance.STATUS:CANCELLEDoccurrences andEXDATEd instances leave both display and capacity; a cancelled series master cancels the series.RANGE=THISANDFUTURE/THISANDPRIOR, an unresolvableTZID, anRRULEoutside the expanded subset (BYSETPOS, ordinalBYDAY,HOURLYand below), a span over 93 days or an expansion over 10,000 instances are refused explicitly (calendar_unsupported/limit_exceededwith a narrow-the-range instruction). Nothing is silently truncated, and an unsupported window is never declared free.COUNTis measured fromDTSTART, 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'sRANGE, and theDTSTART/DUETZID) onExpandedComponent, and merges repeatedRDATE/EXDATElines. No second parser is forked;parse_components/parse_report_multistatusremain the only entry point for calendar-data.src/api/agenda.rs—GET /v1/agendamaps expanded occurrences, so agenda items and capacity follow the same occurrence set. A collection whose recurrence cannot be expanded degrades tounsupportedcoverage with anincomplete_calendarwarning and is never reported as an outage or as free time; a bound the query itself exceeded islimit_exceeded(HTTP 422) rather than a truncated page.src/domain/recurrence_create.rs(new) andsrc/api/creation.rs—POST /v1/tasksandPOST /v1/eventsaccept structuredrrule/rdates/exdatesalongside 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 areinvalid_input(HTTP 422, exit 2); unknown members are still rejected bydeny_unknown_fields.src/calendar/ical.rs— the new-resource builder writes the recurrence of a new series:RRULEverbatim, and oneRDATE/EXDATEproperty per value soVALUE=DATEstays accurate for date-only series. Existing resources are still never round-tripped through it.Cargo.tomlis untouched.Verification
tests/recurrence_tests.rs(new, 23 tests) drives the normative fixture fromdocs/technical/verification.mdverbatim (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 byEXDATE; 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_exceededfor a 94-day span and forCOUNT=20000,RANGE=THISANDFUTURE, an unresolvable customVTIMEZONE, and unsupportedRRULEparts.GET /v1/agendaand assert the returned items,recurrence_id,consumes_capacityandcompleteflags; a single unsupported collection returns200withstatus: partial,coverage.state: unsupportedand anincomplete_calendarwarning, and both limit cases return422 limit_exceeded.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 isidempotency_conflict.nix develop --command cargo test: 18 suites green (217 tests), includingagenda_tasks,caldav_report,creation,updateandcrash_recoveryunchanged.cargo clippy --all-targets -- -D warningsandcargo fmt --checkare 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.
Coordinator review — APPROVED
Verified
4ce566emyself (read the diff and the tests, ran the suite).The load-bearing criterion is satisfied literally:
c05_c06_normative_fixture_expands_verbatimdrives the CRLF fixture fromdocs/technical/verification.mdoverexpand_calendar_datain[2030-04-08T00:00:00Z,2030-04-13T00:00:00Z)and asserts exactly April 8 09:00, April 11 11:00 with identity20300410T090000Z, and April 12 09:00 — with April 9 and the original April 10 09:00 block absent, and the moved occurrence keeping itsSUMMARY/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;EXDATEandSTATUS:CANCELLEDoccurrences disappear from both agenda items and capacity; the master is never reported as an occurrence;>93days and>10,000instances are refused with a named bound;RANGE=THISANDFUTUREand an unresolvableVTIMEZONEare refused rather than approximated; CRLF round-trips; and structuredrrule/rdates/exdatescreation writes a conforming master (including keeping theDATEvalue 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) andLimitExceededare no longer counted as collection outages, so an unsupported collection degrades to an explicit partial agenda withcoverage.state=unsupportedwhile a genuine outage still yieldsdependency_unavailable— C07 and C01 both hold, andtest_c01_all_collections_down_is_dependency_unavailable_never_emptystill passes. I also confirmed neitherreport.rsnorrecurrence.rscan emitClarificationRequired, so that variant cannot be misclassified as an outage.Independent verification on
4ce566e:cargo test240 passed / 0 failed across 18 suites includingrecurrence_tests(23);cargo clippy --all-targets -- -D warnings0 errors;cargo fmt --checkclean; the pre-existingagenda_tasks,caldav_report,creation,updateandcrash_recoverysuites unchanged.Nit, no change required:
c10_an_unsupported_rule_part_is_refused_not_droppedis named after C10 (the ETag-conflict scenario) but exercises the unsupported-rule criterion. Worth renaming the next time that file is touched.Cargo.tomlis untouched and no dependency was added, so the deferred authors chore correctly stays pending.Merging.
feat(calendar): recurrence expansion parser and conformance fixtures (#13)to feat(calendar): recurrence expansion parser and conformance fixtures