feat(cal): add CalendarEvent::to_ical serializer #345

Merged
KofTwentyTwo merged 1 commits from story/ws-e2-to-ical into dev 2026-07-31 20:52:07 +00:00
KofTwentyTwo commented 2026-07-31 20:50:45 +00:00 (Migrated from github.com)

Summary

  • Adds ToIcal::to_ical (extension trait on nuncio_core::model::CalendarEvent, implemented in crates/nuncio-cal/src/ical.rs), the missing serializer needed for CalDAV write-back.
  • Serializes a single VEVENT (UID, DTSTAMP, DTSTART, DTEND, SUMMARY, LOCATION, RRULE) wrapped in a VCALENDAR shell (VERSION:2.0, PRODID), matching the shape IcalParserAdapter::parse_ical consumes.
  • DTSTART/DTEND are always emitted as UTC Z-suffixed DATE-TIME values: CalendarEvent only stores a resolved UTC unix timestamp (the parser already collapses TZID/floating/Z forms down to one UTC instant), so re-parsing the emitted UTC form always reproduces the same instant regardless of the original wire form.
  • TEXT values (SUMMARY, LOCATION) are escaped per RFC 5545 3.3.11: backslash, semicolon, comma, and embedded newlines (\n); RRULE is left unescaped since it is a structured value, not TEXT.
  • An extension trait (rather than an inherent impl) is used because CalendarEvent is owned by nuncio-core, and Rust forbids inherent impls on foreign types.

Test plan

  • to_ical_roundtrips_through_parse_ical_for_all_event_shapes — round-trips parse_ical(to_ical(event)) back to an equal event for: a plain timed event, an all-day (whole-UTC-day) event, an event whose UTC instant originated from a TZID source, and a recurring event carrying an RRULE.
  • to_ical_escapes_rfc5545_special_characters_in_text_fields — asserts the literal escaped SUMMARY/LOCATION output and that the escaped payload still round-trips.
  • to_ical_wraps_a_single_vevent_in_a_vcalendar_shell — asserts the VCALENDAR/VERSION:2.0/single-VEVENT shape.
  • cargo fmt --all -- --check — clean
  • cargo check-all (clippy --all-targets --workspace -D warnings) — 0 warnings
  • cargo test-all — 100% pass, 0 ignored (26 tests in nuncio-cal, full workspace suite green; pre-commit hook ran and passed all three gates)

Refs #310

## Summary - Adds `ToIcal::to_ical` (extension trait on `nuncio_core::model::CalendarEvent`, implemented in `crates/nuncio-cal/src/ical.rs`), the missing serializer needed for CalDAV write-back. - Serializes a single `VEVENT` (`UID`, `DTSTAMP`, `DTSTART`, `DTEND`, `SUMMARY`, `LOCATION`, `RRULE`) wrapped in a `VCALENDAR` shell (`VERSION:2.0`, `PRODID`), matching the shape `IcalParserAdapter::parse_ical` consumes. - `DTSTART`/`DTEND` are always emitted as UTC `Z`-suffixed `DATE-TIME` values: `CalendarEvent` only stores a resolved UTC unix timestamp (the parser already collapses `TZID`/floating/`Z` forms down to one UTC instant), so re-parsing the emitted UTC form always reproduces the same instant regardless of the original wire form. - `TEXT` values (`SUMMARY`, `LOCATION`) are escaped per RFC 5545 3.3.11: backslash, semicolon, comma, and embedded newlines (`\n`); `RRULE` is left unescaped since it is a structured value, not `TEXT`. - An extension trait (rather than an inherent impl) is used because `CalendarEvent` is owned by `nuncio-core`, and Rust forbids inherent impls on foreign types. ## Test plan - [x] `to_ical_roundtrips_through_parse_ical_for_all_event_shapes` — round-trips `parse_ical(to_ical(event))` back to an equal event for: a plain timed event, an all-day (whole-UTC-day) event, an event whose UTC instant originated from a `TZID` source, and a recurring event carrying an `RRULE`. - [x] `to_ical_escapes_rfc5545_special_characters_in_text_fields` — asserts the literal escaped `SUMMARY`/`LOCATION` output and that the escaped payload still round-trips. - [x] `to_ical_wraps_a_single_vevent_in_a_vcalendar_shell` — asserts the `VCALENDAR`/`VERSION:2.0`/single-`VEVENT` shape. - [x] `cargo fmt --all -- --check` — clean - [x] `cargo check-all` (clippy `--all-targets --workspace -D warnings`) — 0 warnings - [x] `cargo test-all` — 100% pass, 0 ignored (26 tests in `nuncio-cal`, full workspace suite green; pre-commit hook ran and passed all three gates) Refs #310
Sign in to join this conversation.