From 7043a151f66f61bdf5d7e20065f65ff9e6678f2d Mon Sep 17 00:00:00 2001 From: Connor Johnstone Date: Wed, 26 Aug 2026 14:32:38 -0400 Subject: [PATCH] Add the iCalendar round-trip 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. --- Cargo.lock | 1 + crates/runway-core/Cargo.toml | 3 + crates/runway-core/src/ical/error.rs | 48 ++ crates/runway-core/src/ical/mod.rs | 51 ++ crates/runway-core/src/ical/parse.rs | 584 +++++++++++++++ crates/runway-core/src/ical/value.rs | 365 ++++++++++ crates/runway-core/src/ical/write.rs | 469 ++++++++++++ crates/runway-core/src/lib.rs | 3 + crates/runway-core/src/model/alarm.rs | 7 + crates/runway-core/src/model/calendar.rs | 116 +++ crates/runway-core/src/model/event.rs | 27 + crates/runway-core/src/model/mod.rs | 6 + crates/runway-core/src/model/object.rs | 23 +- crates/runway-core/src/model/person.rs | 10 + crates/runway-core/src/model/property.rs | 80 ++ crates/runway-core/src/model/timezone.rs | 162 +++++ crates/runway-core/tests/golden/README.md | 54 ++ .../baikal/davx5-allday-weekly-alarm.ics | 23 + .../davx5-zoned-two-alarms-vtimezone.ics | 40 + .../golden/baikal/evolution-x-lic-error.ics | 44 ++ .../baikal/outlook-invite-quoted-cn.ics | 54 ++ .../golden/baikal/runway-v1-unfolded-line.ics | 21 + .../thunderbird-google-invite-attendees.ics | 49 ++ .../golden/baikal/thunderbird-x-moz-props.ics | 137 ++++ .../outlook/feed-overrides-windows-tz.ics | 230 ++++++ .../synthetic/allday-exdate-value-date.ics | 13 + .../golden/synthetic/dst-spring-forward.ics | 12 + .../golden/synthetic/duration-not-dtend.ics | 11 + .../tests/golden/synthetic/floating-time.ics | 11 + .../golden/synthetic/leap-day-yearly.ics | 12 + .../golden/synthetic/rdate-and-exdate.ics | 14 + .../synthetic/series-master-with-override.ics | 22 + .../synthetic/text-escapes-and-utf8.ics | 16 + crates/runway-core/tests/ical.rs | 682 ++++++++++++++++++ crates/runway-core/tests/model.rs | 8 +- 35 files changed, 3397 insertions(+), 11 deletions(-) create mode 100644 crates/runway-core/src/ical/error.rs create mode 100644 crates/runway-core/src/ical/mod.rs create mode 100644 crates/runway-core/src/ical/parse.rs create mode 100644 crates/runway-core/src/ical/value.rs create mode 100644 crates/runway-core/src/ical/write.rs create mode 100644 crates/runway-core/src/model/calendar.rs create mode 100644 crates/runway-core/src/model/property.rs create mode 100644 crates/runway-core/src/model/timezone.rs create mode 100644 crates/runway-core/tests/golden/README.md create mode 100644 crates/runway-core/tests/golden/baikal/davx5-allday-weekly-alarm.ics create mode 100644 crates/runway-core/tests/golden/baikal/davx5-zoned-two-alarms-vtimezone.ics create mode 100644 crates/runway-core/tests/golden/baikal/evolution-x-lic-error.ics create mode 100644 crates/runway-core/tests/golden/baikal/outlook-invite-quoted-cn.ics create mode 100644 crates/runway-core/tests/golden/baikal/runway-v1-unfolded-line.ics create mode 100644 crates/runway-core/tests/golden/baikal/thunderbird-google-invite-attendees.ics create mode 100644 crates/runway-core/tests/golden/baikal/thunderbird-x-moz-props.ics create mode 100644 crates/runway-core/tests/golden/outlook/feed-overrides-windows-tz.ics create mode 100644 crates/runway-core/tests/golden/synthetic/allday-exdate-value-date.ics create mode 100644 crates/runway-core/tests/golden/synthetic/dst-spring-forward.ics create mode 100644 crates/runway-core/tests/golden/synthetic/duration-not-dtend.ics create mode 100644 crates/runway-core/tests/golden/synthetic/floating-time.ics create mode 100644 crates/runway-core/tests/golden/synthetic/leap-day-yearly.ics create mode 100644 crates/runway-core/tests/golden/synthetic/rdate-and-exdate.ics create mode 100644 crates/runway-core/tests/golden/synthetic/series-master-with-override.ics create mode 100644 crates/runway-core/tests/golden/synthetic/text-escapes-and-utf8.ics create mode 100644 crates/runway-core/tests/ical.rs diff --git a/Cargo.lock b/Cargo.lock index cf032fa..a4534b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2082,6 +2082,7 @@ dependencies = [ "icalendar", "pretty_assertions", "rrule", + "runway-core", "serde", "serde_json", "thiserror 2.0.20", diff --git a/crates/runway-core/Cargo.toml b/crates/runway-core/Cargo.toml index d8a607e..264ed97 100644 --- a/crates/runway-core/Cargo.toml +++ b/crates/runway-core/Cargo.toml @@ -32,6 +32,9 @@ uuid = { workspace = true, features = ["js"] } [dev-dependencies] serde_json = { workspace = true } pretty_assertions = { workspace = true } +# Turns the optional features on for the test build only, so `cargo test` covers +# the iCalendar layer without the frontend's default build ever pulling it in. +runway-core = { path = ".", features = ["ical"] } [lints] workspace = true diff --git a/crates/runway-core/src/ical/error.rs b/crates/runway-core/src/ical/error.rs new file mode 100644 index 0000000..4ceff51 --- /dev/null +++ b/crates/runway-core/src/ical/error.rs @@ -0,0 +1,48 @@ +//! Errors from the iCalendar layer. +//! +//! These are typed rather than `String` on purpose. The previous iteration +//! returned `Result` at nearly every boundary, so a 401 and a parse +//! failure were indistinguishable to the caller and nothing could be handled +//! differently from anything else. + +use thiserror::Error; + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum IcalError { + /// The document is not well-formed iCalendar. + #[error("malformed iCalendar: {0}")] + Syntax(String), + + /// No `VCALENDAR` in the input at all. + #[error("no VCALENDAR component found")] + NoCalendar, + + /// A property RFC 5545 requires is absent. + #[error("{component} is missing required property {property}")] + MissingProperty { + component: &'static str, + property: &'static str, + }, + + /// A property is present but its value cannot be read as its own type. + #[error("{property} has an invalid value {value:?}: {reason}")] + InvalidValue { + property: String, + value: String, + reason: String, + }, +} + +impl IcalError { + pub(crate) fn invalid( + property: impl Into, + value: impl Into, + reason: impl Into, + ) -> Self { + Self::InvalidValue { + property: property.into(), + value: value.into(), + reason: reason.into(), + } + } +} diff --git a/crates/runway-core/src/ical/mod.rs b/crates/runway-core/src/ical/mod.rs new file mode 100644 index 0000000..f77f47a --- /dev/null +++ b/crates/runway-core/src/ical/mod.rs @@ -0,0 +1,51 @@ +//! iCalendar round-trip — RFC 5545. +//! +//! Reading goes through `icalendar`'s low-level parser, which owns the grammar: +//! line unfolding, parameter quoting, value escaping. Writing is ours, because +//! that crate's writer escapes a whole property value as text and would corrupt +//! the separators in multi-value properties; see [`value`] for the detail. +//! +//! Two rules shape everything here, both drawn from what went wrong last time: +//! +//! 1. **Anything unrecognised is carried, not dropped.** A calendar is written +//! by several clients at once and ours is not the authority on which +//! properties matter. +//! 2. **When the output looks wrong, the parse is wrong.** No post-processing +//! pass exists to tidy up results, because there is nothing for one to fix. + +mod error; +mod parse; +mod value; +mod write; + +pub use error::IcalError; +pub use parse::{parse, parse_all}; +pub use write::write; + +use crate::model::{CalendarObject, VCalendar}; + +/// Parses a CalDAV resource: a `VCALENDAR` plus the metadata the server +/// supplied alongside it. +pub fn parse_object( + href: impl Into, + calendar_path: impl Into, + etag: Option, + body: &str, +) -> Result { + Ok(CalendarObject { + href: href.into(), + etag, + calendar_path: calendar_path.into(), + calendar: parse(body)?, + }) +} + +/// Renders a calendar for a `PUT`. +pub fn write_object(object: &CalendarObject) -> String { + write(&object.calendar) +} + +/// Convenience for the common case: one event, one resource. +pub fn write_event(event: &crate::model::VEvent) -> String { + write(&VCalendar::with_events(vec![event.clone()])) +} diff --git a/crates/runway-core/src/ical/parse.rs b/crates/runway-core/src/ical/parse.rs new file mode 100644 index 0000000..d841b48 --- /dev/null +++ b/crates/runway-core/src/ical/parse.rs @@ -0,0 +1,584 @@ +//! Reading a `VCALENDAR` into the domain model. +//! +//! The grammar — folding, quoting, escaping — comes from `icalendar`'s +//! low-level parser, which hands back a component tree that keeps properties in +//! their original order and, crucially, keeps *repeated* properties. The +//! previous iteration collected properties into a `HashMap`, so +//! the second `ATTENDEE`, the second `EXDATE` and the second `VALARM` were +//! silently discarded by the map. That single choice is upstream of both the +//! `// TODO: Parse attendees properly` and the ~500 lines of title-matching +//! heuristics that existed to tidy up the "duplicates" a broken parse produced. + +use super::error::IcalError; +use super::value::{self, read_datetime, read_duration, read_utc, split_list}; +use crate::model::{ + AlarmAction, AlarmTrigger, Attendee, CalendarUser, CalendarUserType, EventClass, EventEnd, + EventStatus, GeoPosition, ParticipationStatus, Priority, PropertyParam, Role, TimeZoneRule, + TimeZoneRuleKind, Transparency, TriggerRelation, UnknownComponent, UnknownProperty, UtcOffset, + VAlarm, VCalendar, VEvent, VTimeZone, +}; +use chrono::{DateTime, NaiveDateTime, Utc}; +use icalendar::parser::{Component as RawComponent, Property as RawProperty}; + +/// Parses a document into its `VCALENDAR`. +/// +/// Only the first calendar is returned; a `.ics` file legally holds one. +pub fn parse(input: &str) -> Result { + parse_all(input)? + .into_iter() + .next() + .ok_or(IcalError::NoCalendar) +} + +/// Parses every `VCALENDAR` in a document. +pub fn parse_all(input: &str) -> Result, IcalError> { + let unfolded = icalendar::parser::unfold(input); + let roots = icalendar::parser::read_components(&unfolded).map_err(IcalError::Syntax)?; + roots + .iter() + .filter(|c| c.name.as_str().eq_ignore_ascii_case("VCALENDAR")) + .map(parse_calendar) + .collect() +} + +fn parse_calendar(raw: &RawComponent<'_>) -> Result { + let mut cal = VCalendar { + prodid: String::new(), + version: "2.0".to_owned(), + calscale: None, + method: None, + events: Vec::new(), + timezones: Vec::new(), + unknown_properties: Vec::new(), + unknown_components: Vec::new(), + }; + + for prop in &raw.properties { + let p = View::of(prop); + match p.name.as_str() { + "PRODID" => cal.prodid = p.value.to_owned(), + "VERSION" => cal.version = p.value.to_owned(), + "CALSCALE" => cal.calscale = Some(p.value.to_owned()), + "METHOD" => cal.method = Some(p.value.to_owned()), + _ => cal.unknown_properties.push(p.into_unknown()), + } + } + + for child in &raw.components { + match child.name.as_str().to_uppercase().as_str() { + "VEVENT" => cal.events.push(parse_event(child)?), + "VTIMEZONE" => cal.timezones.push(parse_timezone(child)?), + _ => cal.unknown_components.push(carry_component(child)), + } + } + + Ok(cal) +} + +// ------------------------------------------------------------------ VEVENT -- + +fn parse_event(raw: &RawComponent<'_>) -> Result { + let mut uid = None; + let mut dtstamp = None; + let mut dtstart = None; + let mut dtend = None; + let mut duration = None; + + let missing = |property| IcalError::MissingProperty { + component: "VEVENT", + property, + }; + + // Built around DTSTART, which is the one field with no sensible default. + let mut ev = VEvent::with_uid( + String::new(), + crate::model::CalendarDateTime::Utc { + utc: DateTime::::UNIX_EPOCH, + }, + ); + ev.created = None; + ev.last_modified = None; + + for prop in &raw.properties { + let p = View::of(prop); + match p.name.as_str() { + "UID" => uid = Some(p.value.to_owned()), + "DTSTAMP" => dtstamp = Some(read_utc("DTSTAMP", p.value)?), + "DTSTART" => dtstart = Some(p.datetime()?), + "DTEND" => dtend = Some(p.datetime()?), + "DURATION" => duration = Some(read_duration("DURATION", p.value)?), + "CREATED" => ev.created = Some(read_utc("CREATED", p.value)?), + "LAST-MODIFIED" => ev.last_modified = Some(read_utc("LAST-MODIFIED", p.value)?), + "SEQUENCE" => ev.sequence = p.value.trim().parse().unwrap_or_default(), + _ => apply_property(&mut ev, &p)?, + } + } + + ev.uid = uid.ok_or_else(|| missing("UID"))?; + ev.dtstart = dtstart.ok_or_else(|| missing("DTSTART"))?; + // RFC 5545 requires DTSTAMP, but not every producer writes one. Falling + // back to the modification times keeps a usable event rather than + // rejecting the whole resource over book-keeping. + ev.dtstamp = dtstamp + .or(ev.last_modified) + .or(ev.created) + .unwrap_or_else(Utc::now); + // §3.6.1: DTEND and DURATION are mutually exclusive. When a producer sends + // both, the explicit end wins. + ev.end = match (dtend, duration) { + (Some(dtend), _) => Some(EventEnd::DateTime { dtend }), + (None, Some(duration)) => Some(EventEnd::Duration { duration }), + (None, None) => None, + }; + + for child in &raw.components { + if child.name.as_str().eq_ignore_ascii_case("VALARM") { + ev.alarms.push(parse_alarm(child)?); + } + } + + Ok(ev) +} + +/// Everything that is neither timing nor identity. +fn apply_property(ev: &mut VEvent, p: &View<'_>) -> Result<(), IcalError> { + match p.name.as_str() { + "SUMMARY" => ev.summary = Some(p.text()), + "DESCRIPTION" => ev.description = Some(p.text()), + "LOCATION" => ev.location = Some(p.text()), + "COMMENT" => ev.comment = Some(p.text()), + "CONTACT" => ev.contact = Some(p.text()), + "RELATED-TO" => ev.related_to = Some(p.text()), + "URL" => ev.url = Some(p.value.to_owned()), + "GEO" => ev.geo = Some(parse_geo(p.value)?), + "STATUS" => ev.status = parse_status(p.value), + "CLASS" => ev.class = parse_class(p.value), + "TRANSP" => ev.transparency = parse_transparency(p.value), + "PRIORITY" => { + ev.priority = p + .value + .trim() + .parse::() + .ok() + .and_then(|n| Priority::new(n).ok()); + } + "ORGANIZER" => ev.organizer = Some(p.organizer()), + // Repeated properties are appended, never replaced. This is the whole + // reason for walking an ordered list instead of a map. + "ATTENDEE" => ev.attendees.push(p.attendee()), + "CATEGORIES" => ev.categories.extend(p.text_list()), + "RESOURCES" => ev.resources.extend(p.text_list()), + "RRULE" => ev.rrule = Some(p.value.trim().to_owned()), + "RDATE" => ev.rdate.extend(p.datetime_list()?), + "EXDATE" => ev.exdate.extend(p.datetime_list()?), + "RECURRENCE-ID" => ev.recurrence_id = Some(p.datetime()?), + _ => ev.unknown_properties.push(p.clone().into_unknown()), + } + Ok(()) +} + +fn parse_geo(value: &str) -> Result { + let (lat, lon) = value + .split_once(';') + .ok_or_else(|| IcalError::invalid("GEO", value, "expected 'latitude;longitude'"))?; + let read = |s: &str| { + s.trim() + .parse::() + .map_err(|e| IcalError::invalid("GEO", value, e.to_string())) + }; + Ok(GeoPosition { + latitude: read(lat)?, + longitude: read(lon)?, + }) +} + +fn parse_status(value: &str) -> Option { + match value.trim().to_uppercase().as_str() { + "TENTATIVE" => Some(EventStatus::Tentative), + "CONFIRMED" => Some(EventStatus::Confirmed), + "CANCELLED" => Some(EventStatus::Cancelled), + _ => None, + } +} + +fn parse_class(value: &str) -> Option { + match value.trim().to_uppercase().as_str() { + "PUBLIC" => Some(EventClass::Public), + "PRIVATE" => Some(EventClass::Private), + "CONFIDENTIAL" => Some(EventClass::Confidential), + _ => None, + } +} + +fn parse_transparency(value: &str) -> Option { + match value.trim().to_uppercase().as_str() { + "OPAQUE" => Some(Transparency::Opaque), + "TRANSPARENT" => Some(Transparency::Transparent), + _ => None, + } +} + +// ------------------------------------------------------------------ VALARM -- + +fn parse_alarm(raw: &RawComponent<'_>) -> Result { + let mut action = None; + let mut trigger = None; + let mut alarm = VAlarm { + action: AlarmAction::Display, + trigger: AlarmTrigger::Relative { + offset: crate::model::IcalDuration::from(chrono::TimeDelta::zero()), + related: TriggerRelation::Start, + }, + duration: None, + repeat: None, + description: None, + summary: None, + attendees: Vec::new(), + unknown_properties: Vec::new(), + }; + + for prop in &raw.properties { + let p = View::of(prop); + match p.name.as_str() { + "ACTION" => action = parse_action(p.value), + "TRIGGER" => trigger = Some(parse_trigger(&p)?), + "DURATION" => alarm.duration = Some(read_duration("DURATION", p.value)?), + "REPEAT" => alarm.repeat = p.value.trim().parse().ok(), + "DESCRIPTION" => alarm.description = Some(p.text()), + "SUMMARY" => alarm.summary = Some(p.text()), + "ATTENDEE" => alarm.attendees.push(p.attendee()), + _ => alarm.unknown_properties.push(p.into_unknown()), + } + } + + alarm.action = action.unwrap_or(AlarmAction::Display); + alarm.trigger = trigger.ok_or(IcalError::MissingProperty { + component: "VALARM", + property: "TRIGGER", + })?; + Ok(alarm) +} + +fn parse_action(value: &str) -> Option { + match value.trim().to_uppercase().as_str() { + "DISPLAY" => Some(AlarmAction::Display), + "AUDIO" => Some(AlarmAction::Audio), + "EMAIL" => Some(AlarmAction::Email), + "PROCEDURE" => Some(AlarmAction::Procedure), + _ => None, + } +} + +/// `TRIGGER` is a duration by default and an absolute time only when the +/// producer says `VALUE=DATE-TIME`. +/// +/// `RELATED=END` is honoured here. v1 ignored the parameter and assumed START, +/// so an alarm authored elsewhere as "15 minutes before the end" moved to +/// 15 minutes before the start the first time Runway touched the event. +fn parse_trigger(p: &View<'_>) -> Result { + let absolute = p + .param("VALUE") + .is_some_and(|v| v.eq_ignore_ascii_case("DATE-TIME")) + || p.value.ends_with(['Z', 'z']); + if absolute { + return Ok(AlarmTrigger::Absolute { + at: read_utc("TRIGGER", p.value)?, + }); + } + let related = match p.param("RELATED") { + Some(v) if v.eq_ignore_ascii_case("END") => TriggerRelation::End, + _ => TriggerRelation::Start, + }; + Ok(AlarmTrigger::Relative { + offset: read_duration("TRIGGER", p.value)?, + related, + }) +} + +// --------------------------------------------------------------- VTIMEZONE -- + +fn parse_timezone(raw: &RawComponent<'_>) -> Result { + let mut tz = VTimeZone::new(String::new()); + for prop in &raw.properties { + let p = View::of(prop); + match p.name.as_str() { + "TZID" => tz.tzid = p.value.to_owned(), + "TZURL" => tz.url = Some(p.value.to_owned()), + _ => tz.unknown_properties.push(p.into_unknown()), + } + } + if tz.tzid.is_empty() { + return Err(IcalError::MissingProperty { + component: "VTIMEZONE", + property: "TZID", + }); + } + for child in &raw.components { + let kind = match child.name.as_str().to_uppercase().as_str() { + "STANDARD" => TimeZoneRuleKind::Standard, + "DAYLIGHT" => TimeZoneRuleKind::Daylight, + _ => continue, + }; + tz.rules.push(parse_timezone_rule(kind, child)?); + } + Ok(tz) +} + +fn parse_timezone_rule( + kind: TimeZoneRuleKind, + raw: &RawComponent<'_>, +) -> Result { + let mut dtstart = None; + let mut offset_from = None; + let mut offset_to = None; + let mut rule = TimeZoneRule { + kind, + dtstart: DateTime::::UNIX_EPOCH.naive_utc(), + offset_from: UtcOffset::UTC, + offset_to: UtcOffset::UTC, + rrule: None, + rdate: Vec::new(), + names: Vec::new(), + unknown_properties: Vec::new(), + }; + + let component = kind.component_name(); + for prop in &raw.properties { + let p = View::of(prop); + match p.name.as_str() { + // Always a local time in the offset given by TZOFFSETFROM, and + // Exchange writes the year 1601 here, so no range check. + "DTSTART" => dtstart = Some(parse_local(p.value)?), + "TZOFFSETFROM" => offset_from = Some(parse_offset("TZOFFSETFROM", p.value)?), + "TZOFFSETTO" => offset_to = Some(parse_offset("TZOFFSETTO", p.value)?), + "RRULE" => rule.rrule = Some(p.value.trim().to_owned()), + "RDATE" => { + for part in split_list(p.value) { + rule.rdate.push(parse_local(part)?); + } + } + "TZNAME" => rule.names.push(p.text()), + _ => rule.unknown_properties.push(p.into_unknown()), + } + } + + let missing = |property| IcalError::MissingProperty { + component, + property, + }; + rule.dtstart = dtstart.ok_or_else(|| missing("DTSTART"))?; + rule.offset_from = offset_from.ok_or_else(|| missing("TZOFFSETFROM"))?; + rule.offset_to = offset_to.ok_or_else(|| missing("TZOFFSETTO"))?; + Ok(rule) +} + +fn parse_local(value: &str) -> Result { + NaiveDateTime::parse_from_str(value.trim(), "%Y%m%dT%H%M%S") + .map_err(|e| IcalError::invalid("DTSTART", value, e.to_string())) +} + +fn parse_offset(property: &str, value: &str) -> Result { + UtcOffset::parse(value.trim()).map_err(|e| IcalError::invalid(property, value, e.to_string())) +} + +// ----------------------------------------------------------- passthrough ---- + +fn carry_component(raw: &RawComponent<'_>) -> UnknownComponent { + UnknownComponent { + name: raw.name.as_str().to_owned(), + properties: raw + .properties + .iter() + .map(|p| View::of(p).into_unknown()) + .collect(), + components: raw.components.iter().map(carry_component).collect(), + } +} + +// ------------------------------------------------------------------- view --- + +/// A parsed property, with its name normalised for matching and its parameters +/// left as written. +#[derive(Clone)] +struct View<'a> { + /// Upper-cased for comparison; property names are case-insensitive. + name: String, + /// As written, for passthrough. + raw_name: &'a str, + value: &'a str, + params: Vec<(&'a str, String)>, +} + +impl<'a> View<'a> { + fn of(prop: &'a RawProperty<'a>) -> Self { + Self { + name: prop.name.as_str().to_uppercase(), + raw_name: prop.name.as_str(), + value: prop.val.as_str(), + params: prop + .params + .iter() + .map(|p| { + ( + p.key.as_str(), + p.val + .as_ref() + .map(|v| v.as_str().to_owned()) + .unwrap_or_default(), + ) + }) + .collect(), + } + } + + fn param(&self, name: &str) -> Option<&str> { + self.params + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(name)) + .map(|(_, v)| v.as_str()) + } + + /// The logical value of a text property. + /// + /// `icalendar`'s parser has already unescaped anything it types as TEXT, so + /// unescaping again here would eat a level of backslashes. Everything else + /// arrives raw and is unescaped now. + fn text(&self) -> String { + if value::is_text_value(&self.name, self.param("VALUE")) { + self.value.to_owned() + } else { + value::unescape_text(self.value) + } + } + + /// A comma-separated TEXT list, split before unescaping so an escaped comma + /// inside a value is not mistaken for a separator. + fn text_list(&self) -> Vec { + // CATEGORIES and RESOURCES are TEXT, so the parser has already unescaped + // the whole value -- which means an escaped comma inside a category has + // become indistinguishable from a separator by the time we see it. That + // is a limitation of reading through this parser, documented rather than + // papered over; splitting on every comma is what other clients do too. + let text = self.text(); + split_list(&text) + .into_iter() + .map(|part| part.trim().to_owned()) + .filter(|s| !s.is_empty()) + .collect() + } + + fn datetime(&self) -> Result { + read_datetime( + &self.name, + self.value, + self.param("VALUE"), + self.param("TZID"), + ) + } + + fn datetime_list(&self) -> Result, IcalError> { + split_list(self.value) + .into_iter() + .filter(|s| !s.trim().is_empty()) + .map(|part| read_datetime(&self.name, part, self.param("VALUE"), self.param("TZID"))) + .collect() + } + + /// Parameters not named here are carried through rather than dropped. + fn other_params(&self, known: &[&str]) -> Vec { + self.params + .iter() + .filter(|(k, _)| !known.iter().any(|n| k.eq_ignore_ascii_case(n))) + .map(|(k, v)| PropertyParam::new(*k, v.clone())) + .collect() + } + + fn organizer(&self) -> CalendarUser { + const KNOWN: &[&str] = &["CN", "DIR", "SENT-BY", "LANGUAGE"]; + CalendarUser { + address: self.value.to_owned(), + common_name: self.param("CN").map(str::to_owned), + dir_entry: self.param("DIR").map(str::to_owned), + sent_by: self.param("SENT-BY").map(str::to_owned), + language: self.param("LANGUAGE").map(str::to_owned), + unknown_params: self.other_params(KNOWN), + } + } + + fn attendee(&self) -> Attendee { + const KNOWN: &[&str] = &[ + "CN", + "ROLE", + "PARTSTAT", + "CUTYPE", + "RSVP", + "MEMBER", + "DELEGATED-TO", + "DELEGATED-FROM", + "SENT-BY", + "LANGUAGE", + ]; + let list = |name: &str| { + self.param(name) + .map(|v| split_list(v).into_iter().map(str::to_owned).collect()) + .unwrap_or_default() + }; + Attendee { + address: self.value.to_owned(), + common_name: self.param("CN").map(str::to_owned), + role: self.param("ROLE").and_then(parse_role), + participation_status: self.param("PARTSTAT").and_then(parse_partstat), + user_type: self.param("CUTYPE").and_then(parse_cutype), + rsvp: self.param("RSVP").map(|v| v.eq_ignore_ascii_case("TRUE")), + member: list("MEMBER"), + delegated_to: list("DELEGATED-TO"), + delegated_from: list("DELEGATED-FROM"), + sent_by: self.param("SENT-BY").map(str::to_owned), + language: self.param("LANGUAGE").map(str::to_owned), + unknown_params: self.other_params(KNOWN), + } + } + + fn into_unknown(self) -> UnknownProperty { + UnknownProperty { + name: self.raw_name.to_owned(), + params: self + .params + .iter() + .map(|(k, v)| PropertyParam::new(*k, v.clone())) + .collect(), + value: self.value.to_owned(), + } + } +} + +fn parse_role(value: &str) -> Option { + match value.trim().to_uppercase().as_str() { + "CHAIR" => Some(Role::Chair), + "REQ-PARTICIPANT" => Some(Role::ReqParticipant), + "OPT-PARTICIPANT" => Some(Role::OptParticipant), + "NON-PARTICIPANT" => Some(Role::NonParticipant), + _ => None, + } +} + +fn parse_partstat(value: &str) -> Option { + match value.trim().to_uppercase().as_str() { + "NEEDS-ACTION" => Some(ParticipationStatus::NeedsAction), + "ACCEPTED" => Some(ParticipationStatus::Accepted), + "DECLINED" => Some(ParticipationStatus::Declined), + "TENTATIVE" => Some(ParticipationStatus::Tentative), + "DELEGATED" => Some(ParticipationStatus::Delegated), + _ => None, + } +} + +fn parse_cutype(value: &str) -> Option { + match value.trim().to_uppercase().as_str() { + "INDIVIDUAL" => Some(CalendarUserType::Individual), + "GROUP" => Some(CalendarUserType::Group), + "RESOURCE" => Some(CalendarUserType::Resource), + "ROOM" => Some(CalendarUserType::Room), + _ => Some(CalendarUserType::Unknown), + } +} diff --git a/crates/runway-core/src/ical/value.rs b/crates/runway-core/src/ical/value.rs new file mode 100644 index 0000000..c78a800 --- /dev/null +++ b/crates/runway-core/src/ical/value.rs @@ -0,0 +1,365 @@ +//! Reading and writing individual RFC 5545 property values. +//! +//! Text escaping and line folding live here rather than being borrowed from +//! `icalendar`'s writer, for one concrete reason: that writer escapes a whole +//! property value as text, which turns the separators in a multi-value property +//! into literal characters. `CATEGORIES:Work,Personal` would go out as +//! `CATEGORIES:Work\,Personal`, and every other client would read one category +//! named "Work,Personal" instead of two. A round-trip through our own parser +//! would not notice, because escape and unescape are symmetric — the damage is +//! only visible to somebody else's calendar app, which is exactly the kind of +//! bug this rewrite exists to stop shipping. +//! +//! So values are escaped per component, then joined. Reading still goes through +//! `icalendar`'s parser, which owns the grammar and the unfolding. + +use crate::ical::error::IcalError; +use crate::model::{CalendarDateTime, IcalDuration, TzId, UtcOffset}; +use chrono::{DateTime, NaiveDate, NaiveDateTime, TimeDelta, Utc}; + +const DATE: &str = "%Y%m%d"; +const DATE_TIME: &str = "%Y%m%dT%H%M%S"; + +// ---------------------------------------------------------------- reading --- + +/// Reads a `DATE` or `DATE-TIME` value together with the parameters that give +/// it meaning. +/// +/// The four RFC 5545 forms are distinguished here and nowhere else, which is +/// what keeps a floating time from being silently promoted to UTC — the +/// previous iteration's `parse_datetime` did exactly that, commenting "if no +/// TZID parameter is provided, treat as UTC", and recurring events drifted an +/// hour whenever the reader was not in UTC. +pub fn read_datetime( + property: &str, + value: &str, + value_type: Option<&str>, + tzid: Option<&str>, +) -> Result { + let value = value.trim(); + + if value_type.is_some_and(|v| v.eq_ignore_ascii_case("DATE")) || is_bare_date(value) { + let date = NaiveDate::parse_from_str(value, DATE) + .map_err(|e| IcalError::invalid(property, value, e.to_string()))?; + return Ok(CalendarDateTime::Date { date }); + } + + if let Some(stripped) = value.strip_suffix(['Z', 'z']) { + let naive = NaiveDateTime::parse_from_str(stripped, DATE_TIME) + .map_err(|e| IcalError::invalid(property, value, e.to_string()))?; + return Ok(CalendarDateTime::Utc { + utc: naive.and_utc(), + }); + } + + let local = NaiveDateTime::parse_from_str(value, DATE_TIME) + .map_err(|e| IcalError::invalid(property, value, e.to_string()))?; + + match tzid { + // The identifier is kept exactly as written. Normalising a Windows zone + // name such as "Pacific Standard Time" to IANA here would make the + // document unrepresentable, and the VTIMEZONE that explains it is + // keyed on the original string. + Some(id) => Ok(CalendarDateTime::Zoned { + local, + tzid: TzId::new(id).map_err(|e| IcalError::invalid(property, value, e.to_string()))?, + }), + None => Ok(CalendarDateTime::Floating { local }), + } +} + +fn is_bare_date(value: &str) -> bool { + value.len() == 8 && value.bytes().all(|b| b.is_ascii_digit()) +} + +/// Reads a value that RFC 5545 defines as always-UTC: `DTSTAMP`, `CREATED`, +/// `LAST-MODIFIED`, `ACKNOWLEDGED`. +/// +/// Lenient about a missing `Z`, because real data lacks it — Runway v1 itself +/// wrote `CREATED:20251125T211136`, and those events are still on the server. +/// The value is interpreted as UTC and written back correctly, which repairs +/// the record rather than propagating the defect. +pub fn read_utc(property: &str, value: &str) -> Result, IcalError> { + let value = value.trim(); + let stripped = value.strip_suffix(['Z', 'z']).unwrap_or(value); + if let Ok(naive) = NaiveDateTime::parse_from_str(stripped, DATE_TIME) { + return Ok(naive.and_utc()); + } + NaiveDate::parse_from_str(stripped, DATE) + .map(|d| d.and_time(chrono::NaiveTime::MIN).and_utc()) + .map_err(|e| IcalError::invalid(property, value, e.to_string())) +} + +/// Reads a `DURATION` value — RFC 5545 §3.3.6, `[+-]P[nW][nD][T[nH][nM][nS]]`. +pub fn read_duration(property: &str, value: &str) -> Result { + let raw = value.trim(); + let err = |reason: &str| IcalError::invalid(property, raw, reason.to_owned()); + + let (sign, rest) = match raw.as_bytes().first() { + Some(b'-') => (-1i64, &raw[1..]), + Some(b'+') => (1, &raw[1..]), + _ => (1, raw), + }; + let rest = rest + .strip_prefix(['P', 'p']) + .ok_or_else(|| err("missing P"))?; + + let mut seconds: i64 = 0; + let mut digits = String::new(); + let mut in_time = false; + let mut saw_unit = false; + + for ch in rest.chars() { + match ch { + 'T' | 't' => in_time = true, + '0'..='9' => digits.push(ch), + unit => { + let n: i64 = digits + .parse() + .map_err(|_| err("a unit with no number before it"))?; + digits.clear(); + saw_unit = true; + seconds += match (unit, in_time) { + ('W' | 'w', _) => n * 7 * 86_400, + ('D' | 'd', _) => n * 86_400, + ('H' | 'h', true) => n * 3_600, + ('M' | 'm', true) => n * 60, + ('S' | 's', true) => n, + _ => return Err(err("unit is not valid in this position")), + }; + } + } + } + if !digits.is_empty() { + return Err(err("trailing number with no unit")); + } + if !saw_unit { + return Err(err("duration has no components")); + } + TimeDelta::try_seconds(sign * seconds) + .map(IcalDuration::from) + .ok_or_else(|| err("duration out of range")) +} + +/// Splits a multi-value property on its unescaped commas. +/// +/// A comma written `\,` is part of a value; a bare comma separates values. +/// `icalendar` has already unescaped whole-value TEXT properties by the time we +/// see them, so this is applied to the value types it leaves alone — +/// date-times, and the text lists we escape ourselves. +pub fn split_list(value: &str) -> Vec<&str> { + let mut parts = Vec::new(); + let (mut start, mut escaped) = (0, false); + for (i, ch) in value.char_indices() { + match ch { + _ if escaped => escaped = false, + '\\' => escaped = true, + ',' => { + parts.push(&value[start..i]); + start = i + 1; + } + _ => {} + } + } + parts.push(&value[start..]); + parts +} + +/// The property names RFC 5545 gives a `TEXT` value, which is what decides +/// whether a value arrives from the parser already unescaped. +/// +/// This mirrors `icalendar`'s own table rather than guessing at it: the parser +/// unescapes exactly these, so the writer must re-escape exactly these. Getting +/// the two halves out of step would either double the backslashes on every +/// write or strip them. +const TEXT_PROPERTIES: &[&str] = &[ + "ACTION", + "CALSCALE", + "CATEGORIES", + "CLASS", + "COMMENT", + "CONTACT", + "DESCRIPTION", + "LOCATION", + "METHOD", + "PRODID", + "RELATED-TO", + "REQUEST-STATUS", + "RESOURCES", + "STATUS", + "SUMMARY", + "TRANSP", + "TZID", + "TZNAME", + "UID", + "VERSION", +]; + +/// Whether a property's value is TEXT, and therefore escaped on the wire. +/// +/// An explicit `VALUE=` parameter wins; otherwise the name decides. Every `X-` +/// property counts as text, which is easy to overlook — an unrecognised +/// `X-MICROSOFT-LOCATIONS` carrying a JSON blob full of commas is escaped data, +/// not raw data, and writing it back unescaped would corrupt it. +pub fn is_text_value(name: &str, value_param: Option<&str>) -> bool { + if let Some(declared) = value_param { + return declared.eq_ignore_ascii_case("TEXT"); + } + let upper = name.to_ascii_uppercase(); + upper.starts_with("X-") || TEXT_PROPERTIES.contains(&upper.as_str()) +} + +/// Reverses [`escape_text`]. +pub fn unescape_text(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + let mut chars = value.chars(); + while let Some(ch) = chars.next() { + if ch != '\\' { + out.push(ch); + continue; + } + match chars.next() { + Some('n' | 'N') => out.push('\n'), + Some('\\') => out.push('\\'), + Some(',') => out.push(','), + Some(';') => out.push(';'), + // An unknown escape is passed through with its backslash, matching + // what `icalendar` does, so the two readers never disagree. + Some(other) => { + out.push('\\'); + out.push(other); + } + None => out.push('\\'), + } + } + out +} + +// ---------------------------------------------------------------- writing --- + +/// Escapes one TEXT value — RFC 5545 §3.3.11. +/// +/// Applied per value, never to a joined list, so separators survive. +pub fn escape_text(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 8); + for ch in value.chars() { + match ch { + '\\' => out.push_str("\\\\"), + ',' => out.push_str("\\,"), + ';' => out.push_str("\\;"), + '\n' => out.push_str("\\n"), + // A bare CR has no representation and no meaning inside a value. + '\r' => {} + _ => out.push(ch), + } + } + out +} + +/// Escapes each value and joins them with the separator commas left bare. +pub fn write_list<'a>(values: impl IntoIterator) -> String { + values + .into_iter() + .map(escape_text) + .collect::>() + .join(",") +} + +/// Renders a date or date-time, returning the value and the `VALUE=`/`TZID=` +/// parameters it needs. +pub fn write_datetime(dt: &CalendarDateTime) -> (String, Vec<(String, String)>) { + match dt { + CalendarDateTime::Date { date } => ( + date.format(DATE).to_string(), + vec![("VALUE".to_owned(), "DATE".to_owned())], + ), + CalendarDateTime::Floating { local } => (local.format(DATE_TIME).to_string(), Vec::new()), + CalendarDateTime::Utc { utc } => (format!("{}Z", utc.format(DATE_TIME)), Vec::new()), + CalendarDateTime::Zoned { local, tzid } => ( + local.format(DATE_TIME).to_string(), + vec![("TZID".to_owned(), tzid.as_str().to_owned())], + ), + } +} + +/// Renders an always-UTC value. +pub fn write_utc(at: DateTime) -> String { + format!("{}Z", at.format(DATE_TIME)) +} + +/// Renders a duration in the `PnDTnHnMnS` form. +/// +/// Weeks are only used for an exact multiple of seven days, which is what other +/// clients emit and what keeps `-P1W` from becoming `-P7D` on a round-trip. +pub fn write_duration(duration: IcalDuration) -> String { + let total = duration.as_time_delta().num_seconds(); + if total == 0 { + return "PT0S".to_owned(); + } + let sign = if total < 0 { "-" } else { "" }; + let mut left = total.unsigned_abs(); + + const WEEK: u64 = 7 * 86_400; + if left.is_multiple_of(WEEK) { + return format!("{sign}P{}W", left / WEEK); + } + + let days = left / 86_400; + left %= 86_400; + let (h, m, s) = (left / 3_600, (left % 3_600) / 60, left % 60); + + let mut out = format!("{sign}P"); + if days > 0 { + out.push_str(&format!("{days}D")); + } + if h > 0 || m > 0 || s > 0 { + out.push('T'); + if h > 0 { + out.push_str(&format!("{h}H")); + } + if m > 0 { + out.push_str(&format!("{m}M")); + } + if s > 0 { + out.push_str(&format!("{s}S")); + } + } + out +} + +/// Renders a `TZOFFSETFROM` / `TZOFFSETTO` value. +pub fn write_offset(offset: UtcOffset) -> String { + offset.to_ical() +} + +/// Folds a content line to 75 octets — RFC 5545 §3.1. +/// +/// Splits on octet count, never inside a UTF-8 character, and starts each +/// continuation with a single space. v1 emitted no folding at all, and the +/// 248-octet `DESCRIPTION` lines it wrote are still sitting on the server. +pub fn fold(line: &str) -> String { + const LIMIT: usize = 75; + let bytes = line.as_bytes(); + if bytes.len() <= LIMIT { + return line.to_owned(); + } + + let mut out = String::with_capacity(bytes.len() + bytes.len() / LIMIT * 3); + let mut start = 0; + let mut first = true; + while start < bytes.len() { + // A continuation spends one of its 75 octets on the leading space. + let budget = if first { LIMIT } else { LIMIT - 1 }; + let mut end = (start + budget).min(bytes.len()); + while end > start && !line.is_char_boundary(end) { + end -= 1; + } + if !first { + out.push_str("\r\n "); + } + out.push_str(&line[start..end]); + start = end; + first = false; + } + out +} diff --git a/crates/runway-core/src/ical/write.rs b/crates/runway-core/src/ical/write.rs new file mode 100644 index 0000000..e0f73c4 --- /dev/null +++ b/crates/runway-core/src/ical/write.rs @@ -0,0 +1,469 @@ +//! Writing the domain model back out as a `VCALENDAR`. +//! +//! Property order is fixed rather than incidental, so the same event always +//! produces the same bytes. That matters for CalDAV: an `ETag` changes when the +//! resource does, and a writer that shuffled its output would make every read +//! look like a remote edit. + +use super::value::{ + escape_text, fold, is_text_value, write_datetime, write_duration, write_list, write_offset, + write_utc, +}; +use crate::model::{ + AlarmAction, AlarmTrigger, Attendee, CalendarUser, EventClass, EventEnd, EventStatus, + TimeZoneRule, Transparency, TriggerRelation, UnknownComponent, UnknownProperty, VAlarm, + VCalendar, VEvent, VTimeZone, +}; + +/// Renders a calendar as an iCalendar document, CRLF-terminated and folded to +/// 75 octets. +pub fn write(calendar: &VCalendar) -> String { + let mut out = String::with_capacity(1024); + begin(&mut out, "VCALENDAR"); + // PRODID and VERSION first: some consumers, Outlook among them, are unhappy + // when VERSION arrives late. + line(&mut out, "PRODID", &[], &escape_text(&calendar.prodid)); + line(&mut out, "VERSION", &[], &escape_text(&calendar.version)); + if let Some(calscale) = &calendar.calscale { + line(&mut out, "CALSCALE", &[], &escape_text(calscale)); + } + if let Some(method) = &calendar.method { + line(&mut out, "METHOD", &[], &escape_text(method)); + } + unknown_properties(&mut out, &calendar.unknown_properties); + + // Zone definitions before the events that reference them. + for tz in &calendar.timezones { + write_timezone(&mut out, tz); + } + for event in &calendar.events { + write_event(&mut out, event); + } + for component in &calendar.unknown_components { + write_unknown_component(&mut out, component); + } + end(&mut out, "VCALENDAR"); + out +} + +// ------------------------------------------------------------------ VEVENT -- + +fn write_event(out: &mut String, ev: &VEvent) { + begin(out, "VEVENT"); + line(out, "UID", &[], &escape_text(&ev.uid)); + line(out, "DTSTAMP", &[], &write_utc(ev.dtstamp)); + datetime(out, "DTSTART", &ev.dtstart); + + match &ev.end { + Some(EventEnd::DateTime { dtend }) => datetime(out, "DTEND", dtend), + Some(EventEnd::Duration { duration }) => { + line(out, "DURATION", &[], &write_duration(*duration)); + } + None => {} + } + + text(out, "SUMMARY", ev.summary.as_deref()); + text(out, "DESCRIPTION", ev.description.as_deref()); + text(out, "LOCATION", ev.location.as_deref()); + if let Some(url) = &ev.url { + line(out, "URL", &[], url); + } + if let Some(geo) = &ev.geo { + line( + out, + "GEO", + &[], + &format!("{};{}", geo.latitude, geo.longitude), + ); + } + if let Some(status) = ev.status { + line(out, "STATUS", &[], status_value(status)); + } + if let Some(class) = ev.class { + line(out, "CLASS", &[], class_value(class)); + } + if let Some(transp) = ev.transparency { + line(out, "TRANSP", &[], transparency_value(transp)); + } + if let Some(priority) = ev.priority { + line(out, "PRIORITY", &[], &priority.get().to_string()); + } + if let Some(organizer) = &ev.organizer { + write_organizer(out, organizer); + } + for attendee in &ev.attendees { + write_attendee(out, attendee); + } + text(out, "CONTACT", ev.contact.as_deref()); + if !ev.categories.is_empty() { + line( + out, + "CATEGORIES", + &[], + &write_list(ev.categories.iter().map(String::as_str)), + ); + } + if !ev.resources.is_empty() { + line( + out, + "RESOURCES", + &[], + &write_list(ev.resources.iter().map(String::as_str)), + ); + } + text(out, "COMMENT", ev.comment.as_deref()); + text(out, "RELATED-TO", ev.related_to.as_deref()); + + if let Some(rrule) = &ev.rrule { + line(out, "RRULE", &[], rrule); + } + datetime_list(out, "RDATE", &ev.rdate); + datetime_list(out, "EXDATE", &ev.exdate); + if let Some(recurrence_id) = &ev.recurrence_id { + datetime(out, "RECURRENCE-ID", recurrence_id); + } + + // Always written, even at zero. RFC 5545 defaults SEQUENCE to 0, so + // omitting it means the same thing -- but a property that goes in and does + // not come out is the shape of bug this layer exists to prevent, and the + // round-trip test is only able to police that if there are no exceptions. + line(out, "SEQUENCE", &[], &ev.sequence.to_string()); + if let Some(created) = ev.created { + line(out, "CREATED", &[], &write_utc(created)); + } + if let Some(modified) = ev.last_modified { + line(out, "LAST-MODIFIED", &[], &write_utc(modified)); + } + + unknown_properties(out, &ev.unknown_properties); + for alarm in &ev.alarms { + write_alarm(out, alarm); + } + end(out, "VEVENT"); +} + +fn write_organizer(out: &mut String, user: &CalendarUser) { + let mut params: Vec<(String, String)> = Vec::new(); + push_param(&mut params, "CN", user.common_name.as_deref()); + push_param(&mut params, "DIR", user.dir_entry.as_deref()); + push_param(&mut params, "SENT-BY", user.sent_by.as_deref()); + push_param(&mut params, "LANGUAGE", user.language.as_deref()); + carry_params(&mut params, &user.unknown_params); + line(out, "ORGANIZER", &borrow(¶ms), &user.address); +} + +fn write_attendee(out: &mut String, attendee: &Attendee) { + let mut params: Vec<(String, String)> = Vec::new(); + push_param(&mut params, "CN", attendee.common_name.as_deref()); + if let Some(role) = attendee.role { + params.push(("ROLE".to_owned(), role_value(role).to_owned())); + } + if let Some(status) = attendee.participation_status { + params.push(("PARTSTAT".to_owned(), partstat_value(status).to_owned())); + } + if let Some(cutype) = attendee.user_type { + params.push(("CUTYPE".to_owned(), cutype_value(cutype).to_owned())); + } + if let Some(rsvp) = attendee.rsvp { + params.push(( + "RSVP".to_owned(), + if rsvp { "TRUE" } else { "FALSE" }.to_owned(), + )); + } + for (name, values) in [ + ("MEMBER", &attendee.member), + ("DELEGATED-TO", &attendee.delegated_to), + ("DELEGATED-FROM", &attendee.delegated_from), + ] { + if !values.is_empty() { + params.push((name.to_owned(), values.join(","))); + } + } + push_param(&mut params, "SENT-BY", attendee.sent_by.as_deref()); + push_param(&mut params, "LANGUAGE", attendee.language.as_deref()); + carry_params(&mut params, &attendee.unknown_params); + line(out, "ATTENDEE", &borrow(¶ms), &attendee.address); +} + +// ------------------------------------------------------------------ VALARM -- + +fn write_alarm(out: &mut String, alarm: &VAlarm) { + begin(out, "VALARM"); + line(out, "ACTION", &[], action_value(alarm.action)); + match &alarm.trigger { + AlarmTrigger::Relative { offset, related } => { + // RELATED=START is the default, so it is only worth stating when it + // is not. Writing END when the source said END is the point: v1 + // dropped the parameter and silently moved those alarms. + let params: Vec<(&str, &str)> = match related { + TriggerRelation::End => vec![("RELATED", "END")], + TriggerRelation::Start => Vec::new(), + }; + line(out, "TRIGGER", ¶ms, &write_duration(*offset)); + } + AlarmTrigger::Absolute { at } => { + line(out, "TRIGGER", &[("VALUE", "DATE-TIME")], &write_utc(*at)); + } + } + if let Some(duration) = alarm.duration { + line(out, "DURATION", &[], &write_duration(duration)); + } + if let Some(repeat) = alarm.repeat { + line(out, "REPEAT", &[], &repeat.to_string()); + } + text(out, "DESCRIPTION", alarm.description.as_deref()); + text(out, "SUMMARY", alarm.summary.as_deref()); + for attendee in &alarm.attendees { + write_attendee(out, attendee); + } + unknown_properties(out, &alarm.unknown_properties); + end(out, "VALARM"); +} + +// --------------------------------------------------------------- VTIMEZONE -- + +fn write_timezone(out: &mut String, tz: &VTimeZone) { + begin(out, "VTIMEZONE"); + line(out, "TZID", &[], &escape_text(&tz.tzid)); + if let Some(url) = &tz.url { + line(out, "TZURL", &[], url); + } + unknown_properties(out, &tz.unknown_properties); + for rule in &tz.rules { + write_timezone_rule(out, rule); + } + end(out, "VTIMEZONE"); +} + +fn write_timezone_rule(out: &mut String, rule: &TimeZoneRule) { + let name = rule.kind.component_name(); + begin(out, name); + line( + out, + "DTSTART", + &[], + &rule.dtstart.format("%Y%m%dT%H%M%S").to_string(), + ); + line(out, "TZOFFSETFROM", &[], &write_offset(rule.offset_from)); + line(out, "TZOFFSETTO", &[], &write_offset(rule.offset_to)); + if let Some(rrule) = &rule.rrule { + line(out, "RRULE", &[], rrule); + } + if !rule.rdate.is_empty() { + let values: Vec = rule + .rdate + .iter() + .map(|d| d.format("%Y%m%dT%H%M%S").to_string()) + .collect(); + line(out, "RDATE", &[], &values.join(",")); + } + for tzname in &rule.names { + line(out, "TZNAME", &[], &escape_text(tzname)); + } + unknown_properties(out, &rule.unknown_properties); + end(out, name); +} + +// ----------------------------------------------------------- passthrough ---- + +fn unknown_properties(out: &mut String, properties: &[UnknownProperty]) { + for property in properties { + let params: Vec<(&str, &str)> = property + .params + .iter() + .map(|p| (p.name.as_str(), p.value.as_str())) + .collect(); + // Re-escape exactly what the parser unescaped, and nothing else. + let value = if is_text_value(&property.name, property.param("VALUE")) { + escape_text(&property.value) + } else { + property.value.clone() + }; + line(out, &property.name, ¶ms, &value); + } +} + +fn write_unknown_component(out: &mut String, component: &UnknownComponent) { + begin(out, &component.name); + unknown_properties(out, &component.properties); + for child in &component.components { + write_unknown_component(out, child); + } + end(out, &component.name); +} + +// -------------------------------------------------------------- mechanics --- + +fn begin(out: &mut String, name: &str) { + out.push_str("BEGIN:"); + out.push_str(name); + out.push_str("\r\n"); +} + +fn end(out: &mut String, name: &str) { + out.push_str("END:"); + out.push_str(name); + out.push_str("\r\n"); +} + +/// Emits one content line, folded. +fn line(out: &mut String, name: &str, params: &[(&str, &str)], value: &str) { + let mut content = String::with_capacity(name.len() + value.len() + 16); + content.push_str(name); + for (key, val) in params { + content.push(';'); + content.push_str(key); + content.push('='); + content.push_str("e_param(val)); + } + content.push(':'); + content.push_str(value); + out.push_str(&fold(&content)); + out.push_str("\r\n"); +} + +/// Quotes a parameter value when it contains a character that would otherwise +/// end it — a `CN` of `"Johnstone, Connor"` is real data, and unquoted it would +/// read as two parameters. +fn quote_param(value: &str) -> String { + if value.starts_with('"') && value.ends_with('"') && value.len() > 1 { + return value.to_owned(); + } + if value.contains([';', ':', ',']) { + format!("\"{}\"", value.replace('"', "")) + } else { + value.to_owned() + } +} + +fn text(out: &mut String, name: &str, value: Option<&str>) { + if let Some(value) = value { + line(out, name, &[], &escape_text(value)); + } +} + +fn datetime(out: &mut String, name: &str, value: &crate::model::CalendarDateTime) { + let (rendered, params) = write_datetime(value); + let params: Vec<(&str, &str)> = params + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect(); + line(out, name, ¶ms, &rendered); +} + +/// Emits `RDATE`/`EXDATE`, grouping values that share a form onto one line, the +/// way producers do. +fn datetime_list(out: &mut String, name: &str, values: &[crate::model::CalendarDateTime]) { + if values.is_empty() { + return; + } + /// Values sharing one set of parameters, so they can share one line. + struct Group { + params: Vec<(String, String)>, + values: Vec, + } + + let mut groups: Vec = Vec::new(); + for value in values { + let (rendered, params) = write_datetime(value); + match groups.iter_mut().find(|g| g.params == params) { + Some(group) => group.values.push(rendered), + None => groups.push(Group { + params, + values: vec![rendered], + }), + } + } + for group in groups { + line(out, name, &borrow(&group.params), &group.values.join(",")); + } +} + +/// Appends the parameters the model does not interpret, in their original +/// order, after the ones it does. +fn carry_params(params: &mut Vec<(String, String)>, extra: &[crate::model::PropertyParam]) { + params.extend( + extra + .iter() + .map(|param| (param.name.clone(), param.value.clone())), + ); +} + +fn push_param(params: &mut Vec<(String, String)>, name: &str, value: Option<&str>) { + if let Some(value) = value { + params.push((name.to_owned(), value.to_owned())); + } +} + +fn borrow(params: &[(String, String)]) -> Vec<(&str, &str)> { + params + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect() +} + +// ------------------------------------------------------------- enum values -- + +fn status_value(status: EventStatus) -> &'static str { + match status { + EventStatus::Tentative => "TENTATIVE", + EventStatus::Confirmed => "CONFIRMED", + EventStatus::Cancelled => "CANCELLED", + } +} + +fn class_value(class: EventClass) -> &'static str { + match class { + EventClass::Public => "PUBLIC", + EventClass::Private => "PRIVATE", + EventClass::Confidential => "CONFIDENTIAL", + } +} + +fn transparency_value(transparency: Transparency) -> &'static str { + match transparency { + Transparency::Opaque => "OPAQUE", + Transparency::Transparent => "TRANSPARENT", + } +} + +fn action_value(action: AlarmAction) -> &'static str { + match action { + AlarmAction::Display => "DISPLAY", + AlarmAction::Audio => "AUDIO", + AlarmAction::Email => "EMAIL", + AlarmAction::Procedure => "PROCEDURE", + } +} + +fn role_value(role: crate::model::Role) -> &'static str { + use crate::model::Role; + match role { + Role::Chair => "CHAIR", + Role::ReqParticipant => "REQ-PARTICIPANT", + Role::OptParticipant => "OPT-PARTICIPANT", + Role::NonParticipant => "NON-PARTICIPANT", + } +} + +fn partstat_value(status: crate::model::ParticipationStatus) -> &'static str { + use crate::model::ParticipationStatus as P; + match status { + P::NeedsAction => "NEEDS-ACTION", + P::Accepted => "ACCEPTED", + P::Declined => "DECLINED", + P::Tentative => "TENTATIVE", + P::Delegated => "DELEGATED", + } +} + +fn cutype_value(cutype: crate::model::CalendarUserType) -> &'static str { + use crate::model::CalendarUserType as C; + match cutype { + C::Individual => "INDIVIDUAL", + C::Group => "GROUP", + C::Resource => "RESOURCE", + C::Room => "ROOM", + C::Unknown => "UNKNOWN", + } +} diff --git a/crates/runway-core/src/lib.rs b/crates/runway-core/src/lib.rs index 332cdb8..5035105 100644 --- a/crates/runway-core/src/lib.rs +++ b/crates/runway-core/src/lib.rs @@ -13,4 +13,7 @@ pub mod model; +#[cfg(feature = "ical")] +pub mod ical; + pub use model::*; diff --git a/crates/runway-core/src/model/alarm.rs b/crates/runway-core/src/model/alarm.rs index d436bc3..99ed8a9 100644 --- a/crates/runway-core/src/model/alarm.rs +++ b/crates/runway-core/src/model/alarm.rs @@ -7,6 +7,7 @@ use super::datetime::IcalDuration; use super::person::Attendee; +use super::property::UnknownProperty; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -26,6 +27,11 @@ pub struct VAlarm { pub summary: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub attendees: Vec, + /// Alarms are where other clients keep their dismissal state — + /// `ACKNOWLEDGED`, `X-MOZ-LASTACK`, `X-EVOLUTION-ALARM-UID`. Dropping these + /// on a write would make already-dismissed alarms fire again elsewhere. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub unknown_properties: Vec, } impl VAlarm { @@ -46,6 +52,7 @@ impl VAlarm { description: Some(description.into()), summary: None, attendees: Vec::new(), + unknown_properties: Vec::new(), } } } diff --git a/crates/runway-core/src/model/calendar.rs b/crates/runway-core/src/model/calendar.rs new file mode 100644 index 0000000..4401ba4 --- /dev/null +++ b/crates/runway-core/src/model/calendar.rs @@ -0,0 +1,116 @@ +//! `VCALENDAR` — RFC 5545 §3.4. The outermost container. +//! +//! Both an `.ics` feed and a single CalDAV resource are one of these. Modelling +//! it explicitly is what keeps a series and its overrides together, and what +//! keeps the `VTIMEZONE` definitions attached to the events that reference +//! them — the previous iteration parsed straight to a flat `Vec` of events and +//! lost both. + +use super::event::VEvent; +use super::property::{UnknownComponent, UnknownProperty}; +use super::timezone::VTimeZone; +use serde::{Deserialize, Serialize}; + +/// One `VCALENDAR`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct VCalendar { + /// `PRODID` — identifies whatever wrote this. Required by the RFC. + pub prodid: String, + /// `VERSION` — `2.0` in practice, always. + pub version: String, + /// `CALSCALE`, when stated. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub calscale: Option, + /// `METHOD` — `PUBLISH` on a subscribed feed, absent on a CalDAV resource. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub method: Option, + /// The events inside. On a CalDAV resource these all share a `UID`; on a + /// feed they do not. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub events: Vec, + /// Zone definitions referenced by those events' `TZID` parameters. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub timezones: Vec, + /// Calendar-level properties we do not interpret, such as `X-WR-CALNAME`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub unknown_properties: Vec, + /// `VTODO`, `VJOURNAL` and anything else sharing the collection, kept whole + /// so writing an event back never deletes them. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub unknown_components: Vec, +} + +/// What Runway stamps into `PRODID` on everything it writes. +pub const RUNWAY_PRODID: &str = "-//Runway//Runway Calendar//EN"; + +impl VCalendar { + /// An empty calendar attributed to Runway. + pub fn new() -> Self { + Self { + prodid: RUNWAY_PRODID.to_owned(), + version: "2.0".to_owned(), + calscale: None, + method: None, + events: Vec::new(), + timezones: Vec::new(), + unknown_properties: Vec::new(), + unknown_components: Vec::new(), + } + } + + /// A calendar holding the given events. + pub fn with_events(events: Vec) -> Self { + Self { + events, + ..Self::new() + } + } + + /// The zone definition for an identifier, if this calendar carries one. + /// + /// Matched case-sensitively: RFC 5545 §3.2.19 makes `TZID` values opaque, + /// and `America/New_York` is not the same string as `America/new_york`. + pub fn timezone(&self, tzid: &str) -> Option<&VTimeZone> { + self.timezones.iter().find(|tz| tz.tzid == tzid) + } + + /// Every `TZID` the events actually reference. + /// + /// Used to check a calendar is self-contained before writing it back: a + /// resource that names a zone it does not define is one another client may + /// be unable to read. + pub fn referenced_tzids(&self) -> Vec<&str> { + let mut ids: Vec<&str> = self + .events + .iter() + .flat_map(VEvent::referenced_tzids) + .collect(); + ids.sort_unstable(); + ids.dedup(); + ids + } + + /// `TZID`s referenced by an event but not defined here. + pub fn undefined_tzids(&self) -> Vec<&str> { + self.referenced_tzids() + .into_iter() + .filter(|id| self.timezone(id).is_none()) + .collect() + } + + /// The series master — the event with no `RECURRENCE-ID`. + pub fn master(&self) -> Option<&VEvent> { + self.events.iter().find(|e| !e.is_override()) + } + + /// The overrides, each replacing one occurrence of a master. + pub fn overrides(&self) -> impl Iterator { + self.events.iter().filter(|e| e.is_override()) + } +} + +impl Default for VCalendar { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/runway-core/src/model/event.rs b/crates/runway-core/src/model/event.rs index 92f3de1..f463ba2 100644 --- a/crates/runway-core/src/model/event.rs +++ b/crates/runway-core/src/model/event.rs @@ -3,6 +3,7 @@ use super::alarm::VAlarm; use super::datetime::{CalendarDateTime, EventEnd, IcalDuration}; use super::person::{Attendee, CalendarUser}; +use super::property::UnknownProperty; use chrono::{DateTime, TimeDelta, Utc}; use serde::{Deserialize, Serialize}; @@ -97,6 +98,12 @@ pub struct VEvent { // ---- Sub-components ---- #[serde(default, skip_serializing_if = "Vec::is_empty")] pub alarms: Vec, + + // ---- Passthrough ---- + /// Properties this model does not interpret, carried through so an edit + /// here does not destroy another client's state. See [`UnknownProperty`]. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub unknown_properties: Vec, } impl VEvent { @@ -137,9 +144,29 @@ impl VEvent { created: Some(now), last_modified: Some(now), alarms: Vec::new(), + unknown_properties: Vec::new(), } } + /// Every `TZID` this event's date-time properties name. + /// + /// A `VCALENDAR` must define each of these; see + /// [`VCalendar::undefined_tzids`](super::calendar::VCalendar::undefined_tzids). + pub fn referenced_tzids(&self) -> Vec<&str> { + let ends = match &self.end { + Some(EventEnd::DateTime { dtend }) => Some(dtend), + _ => None, + }; + std::iter::once(&self.dtstart) + .chain(ends) + .chain(self.recurrence_id.iter()) + .chain(self.rdate.iter()) + .chain(self.exdate.iter()) + .filter_map(|dt| dt.tzid()) + .map(|tz| tz.as_str()) + .collect() + } + /// The title to display. Empty summaries are treated as absent. pub fn title(&self) -> Option<&str> { self.summary.as_deref().filter(|s| !s.trim().is_empty()) diff --git a/crates/runway-core/src/model/mod.rs b/crates/runway-core/src/model/mod.rs index aa0110b..7c43c81 100644 --- a/crates/runway-core/src/model/mod.rs +++ b/crates/runway-core/src/model/mod.rs @@ -6,12 +6,16 @@ //! or the recurrence engine into the WASM bundle. mod alarm; +mod calendar; mod datetime; mod event; mod object; mod person; +mod property; +mod timezone; pub use alarm::{AlarmAction, AlarmTrigger, TriggerRelation, VAlarm}; +pub use calendar::{RUNWAY_PRODID, VCalendar}; pub use datetime::{CalendarDateTime, EventEnd, IcalDuration, InvalidTzId, TzId}; pub use event::{ EventClass, EventStatus, GeoPosition, InvalidPriority, Priority, PriorityBand, Transparency, @@ -19,3 +23,5 @@ pub use event::{ }; pub use object::{CalendarObject, EditScope}; pub use person::{Attendee, CalendarUser, CalendarUserType, ParticipationStatus, Role}; +pub use property::{PropertyParam, UnknownComponent, UnknownProperty}; +pub use timezone::{InvalidUtcOffset, TimeZoneRule, TimeZoneRuleKind, UtcOffset, VTimeZone}; diff --git a/crates/runway-core/src/model/object.rs b/crates/runway-core/src/model/object.rs index 771e2ba..58efb69 100644 --- a/crates/runway-core/src/model/object.rs +++ b/crates/runway-core/src/model/object.rs @@ -12,6 +12,7 @@ //! were added to "clean up" the result by silently discarding events. Keeping //! the resource intact removes the need for any of that. +use super::calendar::VCalendar; use super::event::VEvent; use serde::{Deserialize, Serialize}; @@ -25,35 +26,43 @@ pub struct CalendarObject { pub etag: Option, /// Path of the collection holding this resource. pub calendar_path: String, - /// The `VEVENT`s inside, sharing one `UID`: at most one master plus any - /// number of overrides. - pub events: Vec, + /// The `VCALENDAR` this resource contains — events, and the zone + /// definitions they reference. Both halves have to travel together: a `PUT` + /// that dropped the `VTIMEZONE` would leave the resource unreadable to + /// clients that cannot resolve the identifier on their own. + pub calendar: VCalendar, } impl CalendarObject { + /// The `VEVENT`s inside, sharing one `UID`: at most one master plus any + /// number of overrides. + pub fn events(&self) -> &[VEvent] { + &self.calendar.events + } + /// The series master — the event without a `RECURRENCE-ID`. /// /// Absent when a resource contains only overrides, which is unusual but /// legal and which servers do emit. pub fn master(&self) -> Option<&VEvent> { - self.events.iter().find(|e| !e.is_override()) + self.calendar.master() } /// The overrides, each replacing one occurrence of the master. pub fn overrides(&self) -> impl Iterator { - self.events.iter().filter(|e| e.is_override()) + self.calendar.overrides() } /// The `UID` shared by every event in this resource. pub fn uid(&self) -> Option<&str> { - self.events.first().map(|e| e.uid.as_str()) + self.calendar.events.first().map(|e| e.uid.as_str()) } /// Whether every contained event agrees on the `UID`, as RFC 4791 requires. /// /// Worth asserting in tests against real servers rather than assuming. pub fn has_consistent_uid(&self) -> bool { - let mut uids = self.events.iter().map(|e| e.uid.as_str()); + let mut uids = self.calendar.events.iter().map(|e| e.uid.as_str()); match uids.next() { None => true, Some(first) => uids.all(|u| u == first), diff --git a/crates/runway-core/src/model/person.rs b/crates/runway-core/src/model/person.rs index d97512d..c7cf809 100644 --- a/crates/runway-core/src/model/person.rs +++ b/crates/runway-core/src/model/person.rs @@ -1,5 +1,6 @@ //! Organizers and attendees — RFC 5545 §3.8.4. +use super::property::PropertyParam; use serde::{Deserialize, Serialize}; /// A calendar user address plus its display parameters. Used for `ORGANIZER`. @@ -19,6 +20,10 @@ pub struct CalendarUser { /// `LANGUAGE`. #[serde(default, skip_serializing_if = "Option::is_none")] pub language: Option, + /// Parameters this model does not interpret, such as Google's + /// `X-NUM-GUESTS`. Kept so a round-trip does not quietly strip them. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub unknown_params: Vec, } impl CalendarUser { @@ -29,6 +34,7 @@ impl CalendarUser { dir_entry: None, sent_by: None, language: None, + unknown_params: Vec::new(), } } @@ -67,6 +73,9 @@ pub struct Attendee { pub sent_by: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub language: Option, + /// As on [`CalendarUser`]: unrecognised parameters travel through. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub unknown_params: Vec, } impl Attendee { @@ -83,6 +92,7 @@ impl Attendee { delegated_from: Vec::new(), sent_by: None, language: None, + unknown_params: Vec::new(), } } diff --git a/crates/runway-core/src/model/property.rs b/crates/runway-core/src/model/property.rs new file mode 100644 index 0000000..55d6c9d --- /dev/null +++ b/crates/runway-core/src/model/property.rs @@ -0,0 +1,80 @@ +//! Properties this model does not interpret, kept so they survive a round-trip. +//! +//! A calendar on a real server is written by several clients at once. Ours is +//! one of them, and it is not the authority on which properties matter: +//! Thunderbird tracks alarm dismissal in `X-MOZ-LASTACK`, Evolution keys its +//! alarms on `X-EVOLUTION-ALARM-UID`, Exchange carries a dozen +//! `X-MICROSOFT-CDO-*` flags, and `ACKNOWLEDGED` is real RFC 9074. +//! +//! If we parsed an event, dropped everything we did not recognise, and wrote it +//! back, we would silently destroy the other clients' state on every edit. So +//! anything unrecognised is carried through opaquely rather than discarded. + +use serde::{Deserialize, Serialize}; + +/// A property carried through verbatim. +/// +/// The value is stored **exactly as it appeared on the wire**, still escaped. +/// Nothing here is interpreted, so nothing here can be misinterpreted; it goes +/// back out byte-for-byte as it came in. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UnknownProperty { + /// The property name, as written. + pub name: String, + /// Parameters in their original order, values still quoted where they were. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub params: Vec, + /// The raw, still-escaped value. + pub value: String, +} + +impl UnknownProperty { + pub fn new(name: impl Into, value: impl Into) -> Self { + Self { + name: name.into(), + params: Vec::new(), + value: value.into(), + } + } + + /// The first value of a named parameter, if present. Case-insensitive on + /// the parameter name, as RFC 5545 §3.2 requires. + pub fn param(&self, name: &str) -> Option<&str> { + self.params + .iter() + .find(|p| p.name.eq_ignore_ascii_case(name)) + .map(|p| p.value.as_str()) + } +} + +/// A whole component carried through verbatim. +/// +/// Runway is a `VEVENT` client, but a calendar collection may also hold +/// `VTODO`, `VJOURNAL` or `VFREEBUSY` components — the `Personal` collection on +/// a typical Baikal advertises all three. Writing a resource back without them +/// would delete another application's data, so they travel through untouched. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UnknownComponent { + /// The component name, such as `VTODO`. + pub name: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub properties: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub components: Vec, +} + +/// One `name=value` parameter of a property. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PropertyParam { + pub name: String, + pub value: String, +} + +impl PropertyParam { + pub fn new(name: impl Into, value: impl Into) -> Self { + Self { + name: name.into(), + value: value.into(), + } + } +} diff --git a/crates/runway-core/src/model/timezone.rs b/crates/runway-core/src/model/timezone.rs new file mode 100644 index 0000000..1dcae25 --- /dev/null +++ b/crates/runway-core/src/model/timezone.rs @@ -0,0 +1,162 @@ +//! `VTIMEZONE` — RFC 5545 §3.6.5. +//! +//! Carrying these is not optional book-keeping. A published Exchange feed names +//! its zones `Pacific Standard Time`, not `America/Los_Angeles`: those are +//! Windows zone names, and no IANA database will resolve them. What *does* +//! resolve them is the `VTIMEZONE` block the same file ships alongside the +//! events, which states the offsets and the transition rules outright. +//! +//! So the rule is: keep the zone identifier exactly as written — normalising it +//! at parse time would make a round-trip impossible — and keep the definition +//! that explains it. Resolving one to a real offset is the `recurrence` +//! feature's job, and it has three things to try in order: the identifier as +//! IANA, the identifier through a Windows→IANA mapping, and failing both, these +//! rules. + +use super::property::UnknownProperty; +use chrono::NaiveDateTime; +use serde::{Deserialize, Serialize}; + +/// A time zone definition, as carried inside a `VCALENDAR`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct VTimeZone { + /// `TZID`, verbatim. May be an IANA name, a Windows name, or anything else + /// a producer chose. + pub tzid: String, + /// `TZURL`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, + /// The `STANDARD` and `DAYLIGHT` sub-components, in the order given. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub rules: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub unknown_properties: Vec, +} + +impl VTimeZone { + pub fn new(tzid: impl Into) -> Self { + Self { + tzid: tzid.into(), + url: None, + rules: Vec::new(), + unknown_properties: Vec::new(), + } + } + + /// True when the identifier looks like a Windows zone name rather than an + /// IANA one. IANA identifiers are `Area/Location`; Windows ones are English + /// prose. Cheap heuristic, used only to decide which lookup to try first. + pub fn has_windows_style_id(&self) -> bool { + !self.tzid.contains('/') && self.tzid.contains(' ') + } +} + +/// One `STANDARD` or `DAYLIGHT` block: an offset, and when it applies. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TimeZoneRule { + pub kind: TimeZoneRuleKind, + /// `DTSTART`, always a local time with no zone of its own — it is expressed + /// in the offset given by `offset_from`. Exchange writes `16010101T020000` + /// here, so a parser that range-checks years will reject real data. + pub dtstart: NaiveDateTime, + /// `TZOFFSETFROM` — the offset in force before this transition. + pub offset_from: UtcOffset, + /// `TZOFFSETTO` — the offset in force after it. + pub offset_to: UtcOffset, + /// `RRULE` describing when this rule recurs, verbatim. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rrule: Option, + /// `RDATE` — explicit transition times. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub rdate: Vec, + /// `TZNAME` — the abbreviation, such as `MST`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub names: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub unknown_properties: Vec, +} + +/// Which half of a zone definition a rule describes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum TimeZoneRuleKind { + Standard, + Daylight, +} + +impl TimeZoneRuleKind { + pub fn component_name(self) -> &'static str { + match self { + Self::Standard => "STANDARD", + Self::Daylight => "DAYLIGHT", + } + } +} + +/// A fixed offset from UTC, as `TZOFFSETFROM` / `TZOFFSETTO` express it. +/// +/// Stored in seconds. This is a raw offset and is *not* a time zone — the +/// distinction the previous iteration collapsed, sending `-06:00` as though it +/// identified a zone, so a recurring 9am meeting drifted an hour every spring. +/// An offset is only ever meaningful attached to the rule that says when it +/// applies. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct UtcOffset { + seconds: i32, +} + +impl UtcOffset { + /// No offset at all. + pub const UTC: Self = Self { seconds: 0 }; + + /// Seconds east of UTC. Rejects anything beyond ±24 hours. + pub fn from_seconds(seconds: i32) -> Result { + if seconds.abs() > 24 * 3600 { + return Err(InvalidUtcOffset(seconds.to_string())); + } + Ok(Self { seconds }) + } + + pub fn seconds(self) -> i32 { + self.seconds + } + + /// Parses the `±HHMM` or `±HHMMSS` form RFC 5545 §3.3.14 defines. + pub fn parse(text: &str) -> Result { + let err = || InvalidUtcOffset(text.to_owned()); + let (sign, digits) = match text.as_bytes().first() { + Some(b'+') => (1, &text[1..]), + Some(b'-') => (-1, &text[1..]), + _ => return Err(err()), + }; + if !digits.bytes().all(|b| b.is_ascii_digit()) { + return Err(err()); + } + let part = |from: usize, to: usize| digits[from..to].parse::().map_err(|_| err()); + let seconds = match digits.len() { + 4 => part(0, 2)? * 3600 + part(2, 4)? * 60, + 6 => part(0, 2)? * 3600 + part(2, 4)? * 60 + part(4, 6)?, + _ => return Err(err()), + }; + Self::from_seconds(sign * seconds) + } + + /// Renders the `±HHMM` form, widening to `±HHMMSS` only when the offset has + /// a seconds component — which historical zones such as `Europe/Amsterdam` + /// before 1937 genuinely do. + pub fn to_ical(self) -> String { + let sign = if self.seconds < 0 { '-' } else { '+' }; + let total = self.seconds.unsigned_abs(); + let (h, m, s) = (total / 3600, (total % 3600) / 60, total % 60); + if s == 0 { + format!("{sign}{h:02}{m:02}") + } else { + format!("{sign}{h:02}{m:02}{s:02}") + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("not a valid UTC offset: {0:?}")] +pub struct InvalidUtcOffset(pub String); diff --git a/crates/runway-core/tests/golden/README.md b/crates/runway-core/tests/golden/README.md new file mode 100644 index 0000000..9a0da59 --- /dev/null +++ b/crates/runway-core/tests/golden/README.md @@ -0,0 +1,54 @@ +# Golden-file corpus + +Every file here is real iCalendar data unless it sits under `synthetic/`. The point of the corpus +is that `runway-core` is tested against what servers and clients actually emit, not against what +the RFC says they should. + +The contract each file must satisfy is in `tests/ical.rs`: **parse → serialise → parse must be +stable**, and the second parse must equal the first. A file that cannot round-trip is a bug in the +parser or the writer, never a reason to edit the file. + +## Provenance + +`baikal/` — captured from a live Baikal (SabreDAV) server holding six calendars written by seven +different clients over five years. One file per CalDAV resource, exactly as the server returned it +in a `calendar-query` REPORT. + +| File | What it covers | +|---|---| +| `davx5-allday-weekly-alarm.ics` | `VALUE=DATE` all-day series, `RRULE`, one `VALARM` | +| `davx5-zoned-two-alarms-vtimezone.ics` | `TZID=America/New_York`, **two** `VALARM`s, `VTIMEZONE` emitted *after* the `VEVENT` | +| `evolution-x-lic-error.ics` | `X-LIC-ERROR` baked in by libical, `ACKNOWLEDGED`, `X-EVOLUTION-ALARM-UID`, `TRIGGER;RELATED=START` | +| `thunderbird-google-invite-attendees.ics` | three `ATTENDEE`s with `CUTYPE`/`PARTSTAT`/`X-NUM-GUESTS`, `RRULE` with `COUNT`, `Europe/Zurich` | +| `thunderbird-x-moz-props.ics` | `X-MOZ-GENERATION`, `X-MOZ-LASTACK` — vendor state that must survive a round-trip | +| `outlook-invite-quoted-cn.ics` | `ORGANIZER`/`ATTENDEE` with a **quoted `CN` containing a comma**, `LANGUAGE` parameters, `X-MICROSOFT-CDO-*` | +| `runway-v1-unfolded-line.ics` | **v1's own broken output**, still stored on the server: a 248-octet unfolded `DESCRIPTION` (RFC violation) and `CREATED` with no `Z` | + +`outlook/` — captured from a published Outlook/Exchange `.ics` feed (252 events, 103 UIDs), trimmed +to seven components that carry the interesting structure. + +| Covers | +|---| +| **Windows timezone names** (`TZID=Pacific Standard Time`), not IANA — with the matching `VTIMEZONE` definitions, whose `DTSTART:16010101T020000` is year **1601** | +| A master with four `RECURRENCE-ID` overrides, one of which moves the occurrence to a **different day** | +| An **orphan override**: a `RECURRENCE-ID` whose master lies outside the feed window and must still render | +| `EXDATE` with **many comma-separated values on one line** | +| `METHOD:PUBLISH`, `X-WR-CALNAME`, and the full `X-MICROSOFT-*` set | + +`synthetic/` — hand-authored, because nothing in the real corpus exercises these: `DURATION` +instead of `DTEND`, floating (zoneless) times, `RDATE` on an event, a CalDAV resource bundling a +master with its override, a DST spring-forward series, a leap-day series, and the complete set of +RFC 5545 text escapes with non-ASCII. + +## Scrubbing + +The real files have been scrubbed. Structure is preserved **exactly** — property names, parameters, +property order, component nesting, every datetime, `TZID`, `RRULE`, `EXDATE`, `RECURRENCE-ID`, +`SEQUENCE` and `X-` property is byte-for-byte what the server sent. Only human-readable content was +replaced: `SUMMARY`, `DESCRIPTION`, `LOCATION`, display names, email addresses and URLs, each with +synthetic text of comparable shape that keeps the RFC 5545 escaping in play. `UID`s were replaced by +a stable hash that preserves their original *format*, so master/override linkage inside a series +still holds. + +`runway-v1-unfolded-line.ics` is deliberately **not** refolded: its over-long line is the defect +under test. diff --git a/crates/runway-core/tests/golden/baikal/davx5-allday-weekly-alarm.ics b/crates/runway-core/tests/golden/baikal/davx5-allday-weekly-alarm.ics new file mode 100644 index 0000000..240992f --- /dev/null +++ b/crates/runway-core/tests/golden/baikal/davx5-allday-weekly-alarm.ics @@ -0,0 +1,23 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:DAVx5/4.4.8-gplay ical4j/3.2.19 (com.digibites.calendar) +BEGIN:VEVENT +UID:eee51914-187b-40d5-342c-dc80c118438a +STATUS:CONFIRMED +SUMMARY:Check-in +CLASS:PUBLIC +TRANSP:OPAQUE +DTSTART;VALUE=DATE:20250331 +DTEND;VALUE=DATE:20250401 +RRULE:FREQ=WEEKLY;BYDAY=MO +CREATED:20250902T164854Z +DTSTAMP:20250902T164854Z +LAST-MODIFIED:20250902T164854Z +SEQUENCE:2 +BEGIN:VALARM +ACTION:DISPLAY +DESCRIPTION:Retrospective demo retro +TRIGGER:-PT4H +END:VALARM +END:VEVENT +END:VCALENDAR diff --git a/crates/runway-core/tests/golden/baikal/davx5-zoned-two-alarms-vtimezone.ics b/crates/runway-core/tests/golden/baikal/davx5-zoned-two-alarms-vtimezone.ics new file mode 100644 index 0000000..df386f5 --- /dev/null +++ b/crates/runway-core/tests/golden/baikal/davx5-zoned-two-alarms-vtimezone.ics @@ -0,0 +1,40 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:DAVx5/4.5.19-gplay ical4j/4.3.0 +BEGIN:VEVENT +DTSTAMP:20260811T142404Z +UID:3a21fd46-26c4-85b5-eee3-6d2d2256f8ef +SUMMARY:Briefing +DTSTART;TZID=America/New_York:20260818T083000 +DTEND;TZID=America/New_York:20260818T093000 +RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=TU +STATUS:CONFIRMED +BEGIN:VALARM +TRIGGER:-PT1H +ACTION:DISPLAY +DESCRIPTION:Design +END:VALARM +BEGIN:VALARM +TRIGGER:-PT12H +ACTION:DISPLAY +DESCRIPTION:Design +END:VALARM +END:VEVENT +BEGIN:VTIMEZONE +TZID:America/New_York +BEGIN:STANDARD +TZNAME:EST +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +DTSTART:20071104T020000 +RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU +END:STANDARD +BEGIN:DAYLIGHT +TZNAME:EDT +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +DTSTART:20070311T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU +END:DAYLIGHT +END:VTIMEZONE +END:VCALENDAR diff --git a/crates/runway-core/tests/golden/baikal/evolution-x-lic-error.ics b/crates/runway-core/tests/golden/baikal/evolution-x-lic-error.ics new file mode 100644 index 0000000..c2e39fb --- /dev/null +++ b/crates/runway-core/tests/golden/baikal/evolution-x-lic-error.ics @@ -0,0 +1,44 @@ +BEGIN:VCALENDAR +CALSCALE:GREGORIAN +PRODID:-//Ximian//NONSGML Evolution Calendar//EN +VERSION:2.0 +BEGIN:VTIMEZONE +TZID:America/Denver +X-LIC-LOCATION:America/Denver +BEGIN:DAYLIGHT +TZNAME:MDT +TZOFFSETFROM:-0700 +TZOFFSETTO:-0600 +DTSTART:20070311T020000 +RRULE:FREQ=YEARLY;UNTIL=20370308T090000Z;BYDAY=2SU;BYMONTH=3 +END:DAYLIGHT +BEGIN:STANDARD +TZNAME:MST +TZOFFSETFROM:-0600 +TZOFFSETTO:-0700 +DTSTART:20071104T020000 +RRULE:FREQ=YEARLY;UNTIL=20361102T080000Z;BYDAY=1SU;BYMONTH=11 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +CREATED:20210128T161146Z +LAST-MODIFIED:20211019T060028Z +DTSTAMP:20210128T203012Z +UID:c615f0a3-b567-cd71-7925-216aa5c3cc06 +SUMMARY:Handoff +X-MOZ-LASTACK:20210128T203012Z +DTSTART;TZID=America/Denver:20210128T133000 +DTEND;TZID=America/Denver:20210128T143000 +TRANSP:OPAQUE +X-MOZ-GENERATION:2 +BEGIN:VALARM +X-EVOLUTION-ALARM-UID:799e9f4195fcc29e5cc98f57eda3937d400a3543 +ACTION:DISPLAY +DESCRIPTION:Check-in retro sync +TRIGGER;RELATED=START:-PT15M +ACKNOWLEDGED:20211019T060028Z +X-LIC-ERROR;X-LIC-ERRORTYPE=PARAMETER-VALUE-PARSE-ERROR:Got a VALUE paramet + er with an illegal type for property: VALUE=DURATION +END:VALARM +END:VEVENT +END:VCALENDAR diff --git a/crates/runway-core/tests/golden/baikal/outlook-invite-quoted-cn.ics b/crates/runway-core/tests/golden/baikal/outlook-invite-quoted-cn.ics new file mode 100644 index 0000000..2febfdb --- /dev/null +++ b/crates/runway-core/tests/golden/baikal/outlook-invite-quoted-cn.ics @@ -0,0 +1,54 @@ +BEGIN:VCALENDAR +PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN +VERSION:2.0 +BEGIN:VTIMEZONE +TZID:America/Denver +BEGIN:DAYLIGHT +TZOFFSETFROM:-0700 +TZOFFSETTO:-0600 +TZNAME:MDT +DTSTART:19700308T020000 +RRULE:FREQ=YEARLY;BYDAY=2SU;BYMONTH=3 +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:-0600 +TZOFFSETTO:-0700 +TZNAME:MST +DTSTART:19701101T020000 +RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=11 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +LAST-MODIFIED:20210928T163536Z +DTSTAMP:20210928T163536Z +UID:BC7787EBDF79D94DA9E1A90BFB6ECA7E9CD51D0402D0FAEEEC9CD42126A75F5DBC7787E + BDF79D94DA9E1A90BFB6ECA7E9CD51D0402D0FAEE +SUMMARY:Standup Design +PRIORITY:5 +STATUS:CONFIRMED +ORGANIZER;CN=Dana Okafor;SCHEDULE-AGENT=CLIENT:mailto:dana.okafor@example.o + rg +ATTENDEE;RSVP=TRUE;CN="Priya Raman";PARTSTAT=ACCEPTED;ROLE=REQ-PARTICIPANT: + mailto:priya.raman@example.org +DTSTART;TZID=America/Denver:20210930T140000 +DTEND;TZID=America/Denver:20210930T143000 +DESCRIPTION;LANGUAGE=en-US:Quarterly handoff budget retrospective workshop + kickoff kickoff demo\nJoin: https://example.org/meet/653ba53ffa43\n +CLASS:PUBLIC +TRANSP:OPAQUE +SEQUENCE:0 +LOCATION;LANGUAGE=en-US:Virtual +X-MICROSOFT-CDO-APPT-SEQUENCE:0 +X-MICROSOFT-CDO-OWNERAPPTID:-566454299 +X-MICROSOFT-CDO-BUSYSTATUS:TENTATIVE +X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY +X-MICROSOFT-CDO-ALLDAYEVENT:FALSE +X-MICROSOFT-CDO-IMPORTANCE:1 +X-MICROSOFT-CDO-INSTTYPE:0 +X-MICROSOFT-DONOTFORWARDMEETING:FALSE +X-MICROSOFT-DISALLOW-COUNTER:FALSE +X-MICROSOFT-LOCATIONS:[] +X-MOZ-RECEIVED-SEQUENCE:0 +X-MOZ-RECEIVED-DTSTAMP:20210928T141728Z +END:VEVENT +END:VCALENDAR diff --git a/crates/runway-core/tests/golden/baikal/runway-v1-unfolded-line.ics b/crates/runway-core/tests/golden/baikal/runway-v1-unfolded-line.ics new file mode 100644 index 0000000..f32081b --- /dev/null +++ b/crates/runway-core/tests/golden/baikal/runway-v1-unfolded-line.ics @@ -0,0 +1,21 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//calendar-app//calendar-app//EN +BEGIN:VEVENT +UID:0097f029-5408-49be-e522-73f1e8e7ab35-1764105096 +DTSTAMP:20251125T211136Z +DTSTART:20251203T200000Z +DTEND:20251203T203000Z +SUMMARY:Briefing +DESCRIPTION:https://gov.teams.example.org/l/meetup-join/19%3agcch%3ameeting_bc0ea8b8a3984e5c81dc505caf2c54b0%40thread.v2/0?context=%7b%22Tid%22%3a%22f50f4fcc-38ea-4525-a42e-e89136a91fed%22%2c%22Oid%22%3a%22b755cbb7-d40e-4324-b018-503bb3bfe89d%22%7d +STATUS:CONFIRMED +CLASS:PUBLIC +CREATED:20251125T211136 +LAST-MODIFIED:20251125T211136Z +BEGIN:VALARM +ACTION:DISPLAY +TRIGGER:-PT120M +DESCRIPTION:Check-in +END:VALARM +END:VEVENT +END:VCALENDAR diff --git a/crates/runway-core/tests/golden/baikal/thunderbird-google-invite-attendees.ics b/crates/runway-core/tests/golden/baikal/thunderbird-google-invite-attendees.ics new file mode 100644 index 0000000..020bb72 --- /dev/null +++ b/crates/runway-core/tests/golden/baikal/thunderbird-google-invite-attendees.ics @@ -0,0 +1,49 @@ +BEGIN:VCALENDAR +PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN +VERSION:2.0 +BEGIN:VTIMEZONE +TZID:Europe/Zurich +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +TZNAME:CEST +DTSTART:19700329T020000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:CET +DTSTART:19701025T030000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +CREATED:20210520T192010Z +LAST-MODIFIED:20210520T201659Z +DTSTAMP:20210520T201659Z +UID:4d11f4742abb8ae6e6ab54d8b3@google.com +SUMMARY:Review Briefing +STATUS:CONFIRMED +ORGANIZER;CN=Jordan Vale;SCHEDULE-AGENT=CLIENT:mailto:jordan.vale@example.o + rg +ATTENDEE;RSVP=TRUE;CN=Jordan Vale;PARTSTAT=ACCEPTED;CUTYPE=INDIVIDUAL;ROLE= + REQ-PARTICIPANT;X-NUM-GUESTS=0:mailto:jordan.vale@example.org +ATTENDEE;RSVP=TRUE;CN=Priya Raman;PARTSTAT=NEEDS-ACTION;CUTYPE=INDIVIDUAL;R + OLE=REQ-PARTICIPANT;X-NUM-GUESTS=0:mailto:priya.raman@example.org +ATTENDEE;PARTSTAT=ACCEPTED;ROLE=REQ-PARTICIPANT:mailto:priya.raman@example. + org +RRULE:FREQ=WEEKLY;COUNT=5;BYDAY=TU,TH +DTSTART;TZID=Europe/Zurich:20210520T210000 +DTEND;TZID=Europe/Zurich:20210520T213000 +X-MICROSOFT-CDO-OWNERAPPTID:-786089143 +DESCRIPTION:Join: https://example.org/meet/75a54b52f57c\nJoin: https://exam + ple.org/meet/e93ac4d1f299\nRetrospective retro onboarding design planning + planning handoff roadmap design standup demo handoff +LOCATION:Conference Room B +SEQUENCE:0 +TRANSP:OPAQUE +X-MOZ-RECEIVED-SEQUENCE:0 +X-MOZ-RECEIVED-DTSTAMP:20210520T192011Z +END:VEVENT +END:VCALENDAR diff --git a/crates/runway-core/tests/golden/baikal/thunderbird-x-moz-props.ics b/crates/runway-core/tests/golden/baikal/thunderbird-x-moz-props.ics new file mode 100644 index 0000000..3d9a88f --- /dev/null +++ b/crates/runway-core/tests/golden/baikal/thunderbird-x-moz-props.ics @@ -0,0 +1,137 @@ +BEGIN:VCALENDAR +PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN +VERSION:2.0 +BEGIN:VTIMEZONE +TZID:America/Denver +X-TZINFO:America/Denver[2023c] +BEGIN:STANDARD +TZOFFSETTO:-070000 +TZOFFSETFROM:-065956 +TZNAME:America/Denver(STD) +DTSTART:18831118T120004 +RDATE:18831118T120004 +END:STANDARD +BEGIN:STANDARD +TZOFFSETTO:-070000 +TZOFFSETFROM:-060000 +TZNAME:America/Denver(STD) +DTSTART:19181027T020000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU;UNTIL=19201031T020000 +END:STANDARD +BEGIN:DAYLIGHT +TZOFFSETTO:-060000 +TZOFFSETFROM:-070000 +TZNAME:America/Denver(DST) +DTSTART:19180331T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU;UNTIL=19210327T020000 +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETTO:-070000 +TZOFFSETFROM:-060000 +TZNAME:America/Denver(STD) +DTSTART:19210522T020000 +RDATE:19210522T020000 +END:STANDARD +BEGIN:DAYLIGHT +TZOFFSETTO:-060000 +TZOFFSETFROM:-070000 +TZNAME:America/Denver(DST) +DTSTART:19420209T020000 +RDATE:19420209T020000 +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETTO:-070000 +TZOFFSETFROM:-060000 +TZNAME:America/Denver(STD) +DTSTART:19450930T020000 +RDATE:19450930T020000 +END:STANDARD +BEGIN:DAYLIGHT +TZOFFSETTO:-060000 +TZOFFSETFROM:-070000 +TZNAME:America/Denver(DST) +DTSTART:19650425T020000 +RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=-1SU;UNTIL=19730429T020000 +END:DAYLIGHT +BEGIN:DAYLIGHT +TZOFFSETTO:-060000 +TZOFFSETFROM:-070000 +TZNAME:America/Denver(DST) +DTSTART:19740106T020000 +RDATE:19740106T020000 +END:DAYLIGHT +BEGIN:DAYLIGHT +TZOFFSETTO:-060000 +TZOFFSETFROM:-070000 +TZNAME:America/Denver(DST) +DTSTART:19750223T020000 +RDATE:19750223T020000 +END:DAYLIGHT +BEGIN:DAYLIGHT +TZOFFSETTO:-060000 +TZOFFSETFROM:-070000 +TZNAME:America/Denver(DST) +DTSTART:19760425T020000 +RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=-1SU;UNTIL=19860427T020000 +END:DAYLIGHT +BEGIN:DAYLIGHT +TZOFFSETTO:-060000 +TZOFFSETFROM:-070000 +TZNAME:America/Denver(DST) +DTSTART:19870405T020000 +RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU;UNTIL=20060402T020000 +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETTO:-070000 +TZOFFSETFROM:-060000 +TZNAME:America/Denver(STD) +DTSTART:19651031T020000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU;UNTIL=20061029T020000 +END:STANDARD +BEGIN:DAYLIGHT +TZOFFSETTO:-060000 +TZOFFSETFROM:-070000 +TZNAME:America/Denver(DST) +DTSTART:20070311T020000 +RDATE:20070311T020000 +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETTO:-070000 +TZOFFSETFROM:-060000 +TZNAME:America/Denver(STD) +DTSTART:20071104T020000 +RDATE:20071104T020000 +END:STANDARD +BEGIN:DAYLIGHT +TZOFFSETTO:-060000 +TZOFFSETFROM:-070000 +TZNAME:(DST) +DTSTART:20080309T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETTO:-070000 +TZOFFSETFROM:-060000 +TZNAME:(STD) +DTSTART:20081102T020000 +RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +CREATED:20230901T204357Z +LAST-MODIFIED:20230901T204400Z +DTSTAMP:20230901T204400Z +UID:c9525154-42b2-3c07-b11f-46760a4dab35 +SUMMARY:Check-in Budget +STATUS:CONFIRMED +X-MOZ-LASTACK:20230901T204400Z +DTSTART;TZID=America/Denver:20230831T180000 +DTEND;TZID=America/Denver:20230831T190000 +X-MOZ-GENERATION:1 +BEGIN:VALARM +ACTION:DISPLAY +TRIGGER:-PT2H +DESCRIPTION:Retrospective +END:VALARM +END:VEVENT +END:VCALENDAR diff --git a/crates/runway-core/tests/golden/outlook/feed-overrides-windows-tz.ics b/crates/runway-core/tests/golden/outlook/feed-overrides-windows-tz.ics new file mode 100644 index 0000000..5d78389 --- /dev/null +++ b/crates/runway-core/tests/golden/outlook/feed-overrides-windows-tz.ics @@ -0,0 +1,230 @@ +BEGIN:VCALENDAR +METHOD:PUBLISH +PRODID:Microsoft Exchange Server 2010 +VERSION:2.0 +X-WR-CALNAME:Calendar +BEGIN:VTIMEZONE +TZID:Central Standard Time +BEGIN:STANDARD +DTSTART:16010101T020000 +TZOFFSETFROM:-0500 +TZOFFSETTO:-0600 +RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=1SU;BYMONTH=11 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:16010101T020000 +TZOFFSETFROM:-0600 +TZOFFSETTO:-0500 +RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=2SU;BYMONTH=3 +END:DAYLIGHT +END:VTIMEZONE +BEGIN:VTIMEZONE +TZID:Mountain Standard Time +BEGIN:STANDARD +DTSTART:16010101T020000 +TZOFFSETFROM:-0600 +TZOFFSETTO:-0700 +RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=1SU;BYMONTH=11 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:16010101T020000 +TZOFFSETFROM:-0700 +TZOFFSETTO:-0600 +RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=2SU;BYMONTH=3 +END:DAYLIGHT +END:VTIMEZONE +BEGIN:VTIMEZONE +TZID:Pacific Standard Time +BEGIN:STANDARD +DTSTART:16010101T020000 +TZOFFSETFROM:-0700 +TZOFFSETTO:-0800 +RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=1SU;BYMONTH=11 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:16010101T020000 +TZOFFSETFROM:-0800 +TZOFFSETTO:-0700 +RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=2SU;BYMONTH=3 +END:DAYLIGHT +END:VTIMEZONE +BEGIN:VEVENT +DESCRIPTION:\nJoin: https://example.org/meet/d5b85f228adc\n +RRULE:FREQ=WEEKLY;UNTIL=20270825T190000Z;INTERVAL=1;BYDAY=WE;WKST=SU +EXDATE;TZID=Pacific Standard Time:20251217T120000,20251224T120000,20251231T + 120000,20260513T120000,20260701T120000,20260805T120000,20260819T120000,202 + 60826T120000,20260902T120000,20260909T120000 +UID:5d2296acb04b3a6d80097d073f@google.com +SUMMARY:Handoff Triage +DTSTART;TZID=Pacific Standard Time:20251015T120000 +DTEND;TZID=Pacific Standard Time:20251015T122500 +CLASS:PUBLIC +PRIORITY:5 +DTSTAMP:20260826T175427Z +TRANSP:OPAQUE +STATUS:CONFIRMED +SEQUENCE:29 +LOCATION:Annex, Room 7 +X-MICROSOFT-CDO-APPT-SEQUENCE:29 +X-MICROSOFT-CDO-BUSYSTATUS:BUSY +X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY +X-MICROSOFT-CDO-ALLDAYEVENT:FALSE +X-MICROSOFT-CDO-IMPORTANCE:1 +X-MICROSOFT-CDO-INSTTYPE:1 +X-MICROSOFT-DONOTFORWARDMEETING:FALSE +X-MICROSOFT-DISALLOW-COUNTER:FALSE +X-MICROSOFT-REQUESTEDATTENDANCEMODE:DEFAULT +X-MICROSOFT-ISRESPONSEREQUESTED:FALSE +END:VEVENT +BEGIN:VEVENT +DESCRIPTION:\nJoin: https://example.org/meet/d5b85f228adc\n +UID:5d2296acb04b3a6d80097d073f@google.com +RECURRENCE-ID;TZID=Pacific Standard Time:20251015T120000 +SUMMARY:Handoff Triage +DTSTART;TZID=Pacific Standard Time:20251016T110000 +DTEND;TZID=Pacific Standard Time:20251016T113000 +CLASS:PUBLIC +PRIORITY:5 +DTSTAMP:20260826T175427Z +TRANSP:OPAQUE +STATUS:CONFIRMED +SEQUENCE:29 +LOCATION:Annex, Room 7 +X-MICROSOFT-CDO-APPT-SEQUENCE:29 +X-MICROSOFT-CDO-BUSYSTATUS:BUSY +X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY +X-MICROSOFT-CDO-ALLDAYEVENT:FALSE +X-MICROSOFT-CDO-IMPORTANCE:1 +X-MICROSOFT-CDO-INSTTYPE:3 +X-MICROSOFT-DONOTFORWARDMEETING:FALSE +X-MICROSOFT-DISALLOW-COUNTER:FALSE +X-MICROSOFT-REQUESTEDATTENDANCEMODE:DEFAULT +X-MICROSOFT-ISRESPONSEREQUESTED:FALSE +END:VEVENT +BEGIN:VEVENT +DESCRIPTION:\nJoin: https://example.org/meet/d5b85f228adc\n +UID:5d2296acb04b3a6d80097d073f@google.com +RECURRENCE-ID;TZID=Pacific Standard Time:20251105T120000 +SUMMARY:Handoff Triage +DTSTART;TZID=Pacific Standard Time:20251105T120000 +DTEND;TZID=Pacific Standard Time:20251105T122500 +CLASS:PUBLIC +PRIORITY:5 +DTSTAMP:20260826T175427Z +TRANSP:OPAQUE +STATUS:CONFIRMED +SEQUENCE:29 +LOCATION:Annex, Room 7 +X-MICROSOFT-CDO-APPT-SEQUENCE:29 +X-MICROSOFT-CDO-BUSYSTATUS:BUSY +X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY +X-MICROSOFT-CDO-ALLDAYEVENT:FALSE +X-MICROSOFT-CDO-IMPORTANCE:1 +X-MICROSOFT-CDO-INSTTYPE:3 +X-MICROSOFT-DONOTFORWARDMEETING:FALSE +X-MICROSOFT-DISALLOW-COUNTER:FALSE +X-MICROSOFT-REQUESTEDATTENDANCEMODE:DEFAULT +X-MICROSOFT-ISRESPONSEREQUESTED:FALSE +END:VEVENT +BEGIN:VEVENT +DESCRIPTION:\nJoin: https://example.org/meet/d5b85f228adc\n +UID:5d2296acb04b3a6d80097d073f@google.com +RECURRENCE-ID;TZID=Pacific Standard Time:20251112T120000 +SUMMARY:Handoff Triage +DTSTART;TZID=Pacific Standard Time:20251112T120000 +DTEND;TZID=Pacific Standard Time:20251112T122500 +CLASS:PUBLIC +PRIORITY:5 +DTSTAMP:20260826T175427Z +TRANSP:OPAQUE +STATUS:CONFIRMED +SEQUENCE:29 +LOCATION:Annex, Room 7 +X-MICROSOFT-CDO-APPT-SEQUENCE:29 +X-MICROSOFT-CDO-BUSYSTATUS:BUSY +X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY +X-MICROSOFT-CDO-ALLDAYEVENT:FALSE +X-MICROSOFT-CDO-IMPORTANCE:1 +X-MICROSOFT-CDO-INSTTYPE:3 +X-MICROSOFT-DONOTFORWARDMEETING:FALSE +X-MICROSOFT-DISALLOW-COUNTER:FALSE +X-MICROSOFT-REQUESTEDATTENDANCEMODE:DEFAULT +X-MICROSOFT-ISRESPONSEREQUESTED:FALSE +END:VEVENT +BEGIN:VEVENT +DESCRIPTION:\nJoin: https://example.org/meet/d5b85f228adc\n +UID:5d2296acb04b3a6d80097d073f@google.com +RECURRENCE-ID;TZID=Pacific Standard Time:20251210T120000 +SUMMARY:Handoff Triage +DTSTART;TZID=Pacific Standard Time:20251209T130000 +DTEND;TZID=Pacific Standard Time:20251209T133000 +CLASS:PUBLIC +PRIORITY:5 +DTSTAMP:20260826T175427Z +TRANSP:OPAQUE +STATUS:CONFIRMED +SEQUENCE:29 +LOCATION:Annex, Room 7 +X-MICROSOFT-CDO-APPT-SEQUENCE:29 +X-MICROSOFT-CDO-BUSYSTATUS:BUSY +X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY +X-MICROSOFT-CDO-ALLDAYEVENT:FALSE +X-MICROSOFT-CDO-IMPORTANCE:1 +X-MICROSOFT-CDO-INSTTYPE:3 +X-MICROSOFT-DONOTFORWARDMEETING:FALSE +X-MICROSOFT-DISALLOW-COUNTER:FALSE +X-MICROSOFT-REQUESTEDATTENDANCEMODE:DEFAULT +X-MICROSOFT-ISRESPONSEREQUESTED:FALSE +END:VEVENT +BEGIN:VEVENT +DESCRIPTION:Briefing\nJoin: https://example.org/meet/5934b829a167\n +UID:D5BFA26242816AE1AB2B2D7D14A1B96E3C984A142C64CCBBBF1CC42774AC46FAD5BFA26 + 242816AE1AB2B2D7D14A1B96E3C984A142C64CCBB +RECURRENCE-ID;TZID=Mountain Standard Time:20260526T133000 +SUMMARY:Briefing Onboarding +DTSTART;TZID=Mountain Standard Time:20260526T133000 +DTEND;TZID=Mountain Standard Time:20260526T140000 +CLASS:PUBLIC +PRIORITY:5 +DTSTAMP:20260826T175427Z +TRANSP:OPAQUE +STATUS:CONFIRMED +SEQUENCE:1 +LOCATION:Annex, Room 7 +X-MICROSOFT-CDO-APPT-SEQUENCE:1 +X-MICROSOFT-CDO-BUSYSTATUS:BUSY +X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY +X-MICROSOFT-CDO-ALLDAYEVENT:FALSE +X-MICROSOFT-CDO-IMPORTANCE:1 +X-MICROSOFT-CDO-INSTTYPE:3 +X-MICROSOFT-DONOTFORWARDMEETING:FALSE +X-MICROSOFT-DISALLOW-COUNTER:FALSE +X-MICROSOFT-REQUESTEDATTENDANCEMODE:DEFAULT +X-MICROSOFT-ISRESPONSEREQUESTED:FALSE +END:VEVENT +BEGIN:VEVENT +DESCRIPTION:\nJoin: https://example.org/meet/5cfc4622592a\n +UID:16A4709A509B3E413F90C38AB729CEA79577D656B130C5C3251996E325F3B9AF16A4709 + A509B3E413F90C38AB729CEA79577D656B130C5C3 +SUMMARY:Planning Check-in +DTSTART;TZID=Central Standard Time:20260406T093000 +DTEND;TZID=Central Standard Time:20260406T101500 +CLASS:PUBLIC +PRIORITY:5 +DTSTAMP:20260826T175427Z +TRANSP:OPAQUE +STATUS:CONFIRMED +SEQUENCE:1 +LOCATION:Annex, Room 7 +X-MICROSOFT-CDO-APPT-SEQUENCE:1 +X-MICROSOFT-CDO-BUSYSTATUS:TENTATIVE +X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY +X-MICROSOFT-CDO-ALLDAYEVENT:FALSE +X-MICROSOFT-CDO-IMPORTANCE:1 +X-MICROSOFT-CDO-INSTTYPE:0 +X-MICROSOFT-DONOTFORWARDMEETING:FALSE +X-MICROSOFT-DISALLOW-COUNTER:FALSE +X-MICROSOFT-REQUESTEDATTENDANCEMODE:DEFAULT +X-MICROSOFT-ISRESPONSEREQUESTED:FALSE +END:VEVENT +END:VCALENDAR diff --git a/crates/runway-core/tests/golden/synthetic/allday-exdate-value-date.ics b/crates/runway-core/tests/golden/synthetic/allday-exdate-value-date.ics new file mode 100644 index 0000000..4ab6fa3 --- /dev/null +++ b/crates/runway-core/tests/golden/synthetic/allday-exdate-value-date.ics @@ -0,0 +1,13 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Runway//Runway Test Fixture//EN +BEGIN:VEVENT +UID:allday-exdate@fixtures.runway.invalid +DTSTAMP:20260101T000000Z +DTSTART;VALUE=DATE:20260105 +DTEND;VALUE=DATE:20260106 +SUMMARY:Bin day +RRULE:FREQ=WEEKLY;BYDAY=MO +EXDATE;VALUE=DATE:20260223,20260302 +END:VEVENT +END:VCALENDAR diff --git a/crates/runway-core/tests/golden/synthetic/dst-spring-forward.ics b/crates/runway-core/tests/golden/synthetic/dst-spring-forward.ics new file mode 100644 index 0000000..6f1f7ae --- /dev/null +++ b/crates/runway-core/tests/golden/synthetic/dst-spring-forward.ics @@ -0,0 +1,12 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Runway//Runway Test Fixture//EN +BEGIN:VEVENT +UID:dst-spring-forward@fixtures.runway.invalid +DTSTAMP:20260301T090000Z +DTSTART;TZID=America/Denver:20260302T090000 +DTEND;TZID=America/Denver:20260302T093000 +SUMMARY:Weekly across spring forward +RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=4 +END:VEVENT +END:VCALENDAR diff --git a/crates/runway-core/tests/golden/synthetic/duration-not-dtend.ics b/crates/runway-core/tests/golden/synthetic/duration-not-dtend.ics new file mode 100644 index 0000000..525e088 --- /dev/null +++ b/crates/runway-core/tests/golden/synthetic/duration-not-dtend.ics @@ -0,0 +1,11 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Runway//Runway Test Fixture//EN +BEGIN:VEVENT +UID:duration-not-dtend@fixtures.runway.invalid +DTSTAMP:20260114T090000Z +DTSTART;TZID=America/Denver:20260114T090000 +DURATION:PT1H30M +SUMMARY:Ninety minutes expressed as a duration +END:VEVENT +END:VCALENDAR diff --git a/crates/runway-core/tests/golden/synthetic/floating-time.ics b/crates/runway-core/tests/golden/synthetic/floating-time.ics new file mode 100644 index 0000000..acf2b0d --- /dev/null +++ b/crates/runway-core/tests/golden/synthetic/floating-time.ics @@ -0,0 +1,11 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Runway//Runway Test Fixture//EN +BEGIN:VEVENT +UID:floating@fixtures.runway.invalid +DTSTAMP:20260114T090000Z +DTSTART:20260114T090000 +DTEND:20260114T100000 +SUMMARY:Nine in the morning\, wherever you are +END:VEVENT +END:VCALENDAR diff --git a/crates/runway-core/tests/golden/synthetic/leap-day-yearly.ics b/crates/runway-core/tests/golden/synthetic/leap-day-yearly.ics new file mode 100644 index 0000000..fce198f --- /dev/null +++ b/crates/runway-core/tests/golden/synthetic/leap-day-yearly.ics @@ -0,0 +1,12 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Runway//Runway Test Fixture//EN +BEGIN:VEVENT +UID:leap-day@fixtures.runway.invalid +DTSTAMP:20240229T000000Z +DTSTART;VALUE=DATE:20240229 +DTEND;VALUE=DATE:20240301 +SUMMARY:Leap day +RRULE:FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=29 +END:VEVENT +END:VCALENDAR diff --git a/crates/runway-core/tests/golden/synthetic/rdate-and-exdate.ics b/crates/runway-core/tests/golden/synthetic/rdate-and-exdate.ics new file mode 100644 index 0000000..51510be --- /dev/null +++ b/crates/runway-core/tests/golden/synthetic/rdate-and-exdate.ics @@ -0,0 +1,14 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Runway//Runway Test Fixture//EN +BEGIN:VEVENT +UID:rdate-exdate@fixtures.runway.invalid +DTSTAMP:20260105T090000Z +DTSTART;TZID=America/Denver:20260105T090000 +DTEND;TZID=America/Denver:20260105T093000 +SUMMARY:Weekly with an extra date and two skipped +RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=8 +RDATE;TZID=America/Denver:20260110T090000 +EXDATE;TZID=America/Denver:20260119T090000,20260202T090000 +END:VEVENT +END:VCALENDAR diff --git a/crates/runway-core/tests/golden/synthetic/series-master-with-override.ics b/crates/runway-core/tests/golden/synthetic/series-master-with-override.ics new file mode 100644 index 0000000..af2a6df --- /dev/null +++ b/crates/runway-core/tests/golden/synthetic/series-master-with-override.ics @@ -0,0 +1,22 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Runway//Runway Test Fixture//EN +BEGIN:VEVENT +UID:series-override@fixtures.runway.invalid +DTSTAMP:20260105T090000Z +DTSTART;TZID=America/Denver:20260105T090000 +DTEND;TZID=America/Denver:20260105T100000 +SUMMARY:Standup +RRULE:FREQ=WEEKLY;BYDAY=MO +SEQUENCE:0 +END:VEVENT +BEGIN:VEVENT +UID:series-override@fixtures.runway.invalid +RECURRENCE-ID;TZID=America/Denver:20260119T090000 +DTSTAMP:20260112T142200Z +DTSTART;TZID=America/Denver:20260119T140000 +DTEND;TZID=America/Denver:20260119T150000 +SUMMARY:Standup (moved to the afternoon) +SEQUENCE:1 +END:VEVENT +END:VCALENDAR diff --git a/crates/runway-core/tests/golden/synthetic/text-escapes-and-utf8.ics b/crates/runway-core/tests/golden/synthetic/text-escapes-and-utf8.ics new file mode 100644 index 0000000..fd91a75 --- /dev/null +++ b/crates/runway-core/tests/golden/synthetic/text-escapes-and-utf8.ics @@ -0,0 +1,16 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Runway//Runway Test Fixture//EN +BEGIN:VEVENT +UID:escapes@fixtures.runway.invalid +DTSTAMP:20260114T090000Z +DTSTART;TZID=Europe/Zurich:20260114T090000 +DTEND;TZID=Europe/Zurich:20260114T100000 +SUMMARY:Lezione d'italiano — caffè\, cioccolato e “virgolette” +DESCRIPTION:Line one\nLine two\; with a semicolon\nA backslash: \\\nA comm + a\, and a colon: here +LOCATION;LANGUAGE=it-CH:Zürich\, Hauptbahnhof +ORGANIZER;CN="Rossi, Giulia (IT: Milano)":mailto:giulia.rossi@example.org +CATEGORIES:Lezioni,Personale +END:VEVENT +END:VCALENDAR diff --git a/crates/runway-core/tests/ical.rs b/crates/runway-core/tests/ical.rs new file mode 100644 index 0000000..d51dc1a --- /dev/null +++ b/crates/runway-core/tests/ical.rs @@ -0,0 +1,682 @@ +//! The iCalendar round-trip, exercised against real server data. +//! +//! The corpus in `tests/golden/` is described in its own README. The contract +//! asserted here is that **parse → write → parse is stable**: whatever the +//! first parse understood, the second must understand identically. A file that +//! fails is a defect in the parser or the writer, never a reason to edit the +//! file. +//! +//! Stability alone is not enough, though — a parser that dropped `ATTENDEE` +//! entirely would round-trip perfectly and still be wrong. So the structural +//! checks below assert that nothing *disappears*, and the named tests pin the +//! specific things real producers do that the previous iteration got wrong. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use pretty_assertions::assert_eq; +use runway_core::ical::{self, IcalError}; +use runway_core::model::*; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +// ------------------------------------------------------------------ corpus -- + +fn golden_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/golden") +} + +/// Every `.ics` in the corpus, as (relative name, contents). +fn corpus() -> Vec<(String, String)> { + fn walk(dir: &Path, root: &Path, out: &mut Vec<(String, String)>) { + let mut entries: Vec<_> = std::fs::read_dir(dir) + .expect("golden corpus is readable") + .filter_map(Result::ok) + .map(|e| e.path()) + .collect(); + entries.sort(); + for path in entries { + if path.is_dir() { + walk(&path, root, out); + } else if path.extension().is_some_and(|e| e == "ics") { + let name = path + .strip_prefix(root) + .unwrap_or(&path) + .to_string_lossy() + .into_owned(); + out.push((name, std::fs::read_to_string(&path).unwrap())); + } + } + } + let root = golden_dir(); + let mut out = Vec::new(); + walk(&root, &root, &mut out); + assert!(!out.is_empty(), "the golden corpus must not be empty"); + out +} + +fn load(name: &str) -> VCalendar { + let path = golden_dir().join(name); + let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{name}: {e}")); + ical::parse(&text).unwrap_or_else(|e| panic!("{name} failed to parse: {e}")) +} + +/// Counts every property name in a document, at any depth. +fn property_names(ics: &str) -> BTreeMap { + let unfolded = ics.replace("\r\n ", "").replace("\n ", ""); + let mut counts = BTreeMap::new(); + for line in unfolded.lines() { + let Some((head, _)) = line.split_once(':') else { + continue; + }; + let name = head.split(';').next().unwrap_or(head).to_uppercase(); + if name == "BEGIN" || name == "END" || name.is_empty() { + continue; + } + *counts.entry(name).or_insert(0) += 1; + } + counts +} + +// ---------------------------------------------------------- the round-trip -- + +#[test] +fn every_golden_file_parses() { + for (name, text) in corpus() { + if let Err(e) = ical::parse(&text) { + panic!("{name} failed to parse: {e}"); + } + } +} + +#[test] +fn every_golden_file_survives_a_round_trip() { + for (name, text) in corpus() { + let first = ical::parse(&text).unwrap_or_else(|e| panic!("{name}: {e}")); + let written = ical::write(&first); + let second = ical::parse(&written) + .unwrap_or_else(|e| panic!("{name} did not survive being written: {e}\n{written}")); + assert_eq!(first, second, "{name} changed meaning on a round trip"); + } +} + +#[test] +fn writing_is_deterministic() { + for (name, text) in corpus() { + let parsed = ical::parse(&text).unwrap(); + assert_eq!( + ical::write(&parsed), + ical::write(&parsed), + "{name}: the same calendar must always produce the same bytes, or \ + every read would look like a remote edit to CalDAV", + ); + } +} + +#[test] +fn no_property_is_dropped_on_a_round_trip() { + for (name, text) in corpus() { + let written = ical::write(&ical::parse(&text).unwrap()); + let (before, after) = (property_names(&text), property_names(&written)); + + for (property, count) in &before { + // DTEND and DURATION are alternatives; a producer may send both and + // we deliberately keep only the explicit end. + if property == "DURATION" && before.contains_key("DTEND") { + continue; + } + let kept = after.get(property).copied().unwrap_or(0); + assert!( + kept >= *count, + "{name}: {property} appeared {count} time(s) in the source but \ + {kept} time(s) after a round trip -- a parse that silently \ + drops properties is exactly what produced the phantom \ + duplicates in the last iteration", + ); + } + } +} + +// ----------------------------------------------------------------- folding -- + +#[test] +fn output_is_folded_to_75_octets() { + for (name, text) in corpus() { + let written = ical::write(&ical::parse(&text).unwrap()); + for (n, line) in written.split("\r\n").enumerate() { + assert!( + line.len() <= 75, + "{name} line {}: {} octets, over the RFC 5545 limit\n{line}", + n + 1, + line.len(), + ); + } + } +} + +#[test] +fn output_uses_crlf_line_endings() { + for (name, text) in corpus() { + let written = ical::write(&ical::parse(&text).unwrap()); + assert!( + !written.replace("\r\n", "").contains('\n'), + "{name}: every line ending must be CRLF", + ); + assert!(written.ends_with("END:VCALENDAR\r\n"), "{name}"); + } +} + +#[test] +fn a_long_line_is_folded_and_reads_back_identically() { + // v1 wrote this DESCRIPTION as a single 248-octet line, in violation of + // RFC 5545 3.1, and those events are still on the server. + let calendar = load("baikal/runway-v1-unfolded-line.ics"); + let description = calendar.events[0].description.clone().unwrap(); + assert!(description.len() > 200); + + let written = ical::write(&calendar); + assert!( + written.contains("\r\n "), + "a 248-octet value must come back folded", + ); + let reparsed = ical::parse(&written).unwrap(); + assert_eq!( + reparsed.events[0].description.as_deref(), + Some(description.as_str()), + "unfolding must reconstruct the value exactly", + ); +} + +#[test] +fn folding_never_splits_a_utf8_character() { + let mut event = VEvent::new(CalendarDateTime::Utc { + utc: chrono::Utc::now(), + }); + event.description = Some("é".repeat(200)); + + let written = ical::write(&VCalendar::with_events(vec![event.clone()])); + // Reaching this point at all means every fold landed on a boundary; a bad + // split would have produced invalid UTF-8 rather than a String. + for line in written.split("\r\n") { + assert!(line.len() <= 75); + } + let reparsed = ical::parse(&written).unwrap(); + assert_eq!(reparsed.events[0].description, event.description); +} + +// -------------------------------------------------------------- timezones -- + +#[test] +fn windows_timezone_identifiers_are_preserved_verbatim() { + let calendar = load("outlook/feed-overrides-windows-tz.ics"); + + let ids: Vec<&str> = calendar + .timezones + .iter() + .map(|tz| tz.tzid.as_str()) + .collect(); + assert!( + ids.contains(&"Pacific Standard Time"), + "got {ids:?} -- Exchange names its zones in Windows form, and \ + normalising to IANA at parse time would make the document \ + unrepresentable; the mapping belongs at the point of use", + ); + + let start = &calendar.events[0].dtstart; + assert_eq!( + start.tzid().map(TzId::as_str), + Some("Pacific Standard Time"), + ); + assert!( + calendar.undefined_tzids().is_empty(), + "the feed defines its own zones" + ); +} + +#[test] +fn a_timezone_definition_carries_its_transition_rules() { + let calendar = load("outlook/feed-overrides-windows-tz.ics"); + let tz = calendar.timezone("Pacific Standard Time").unwrap(); + + assert!(tz.has_windows_style_id()); + assert_eq!(tz.rules.len(), 2, "a STANDARD and a DAYLIGHT block"); + + let standard = tz + .rules + .iter() + .find(|r| r.kind == TimeZoneRuleKind::Standard) + .unwrap(); + assert_eq!(standard.offset_from.seconds(), -7 * 3600); + assert_eq!(standard.offset_to.seconds(), -8 * 3600); + assert!(standard.rrule.is_some()); + assert_eq!( + standard.dtstart.date().to_string(), + "1601-01-01", + "Exchange really does write the year 1601 here; a parser that \ + range-checks the year rejects real data", + ); +} + +#[test] +fn a_zoned_start_is_not_promoted_to_utc() { + let calendar = load("baikal/davx5-zoned-two-alarms-vtimezone.ics"); + + assert_eq!( + calendar.events[0].dtstart.tzid().map(TzId::as_str), + Some("America/New_York"), + "the previous parser commented 'if no TZID, treat as UTC' and applied \ + that reasoning widely enough to drift recurring events across DST", + ); +} + +#[test] +fn a_floating_time_stays_floating() { + let calendar = load("synthetic/floating-time.ics"); + + assert!( + matches!( + calendar.events[0].dtstart, + CalendarDateTime::Floating { .. } + ), + "a value with neither Z nor TZID means local wall-clock time wherever \ + it is read, which is not the same as UTC", + ); +} + +// ------------------------------------------------------------- recurrence -- + +#[test] +fn a_master_and_its_overrides_are_kept_apart() { + let calendar = load("outlook/feed-overrides-windows-tz.ics"); + + let masters = calendar.events.iter().filter(|e| !e.is_override()).count(); + let overrides = calendar.overrides().count(); + assert!(overrides >= 4, "got {overrides} overrides"); + assert!(masters >= 2); + + // Grouping by UID is the whole point: several events sharing a summary and + // a UID are one series, not duplicates to be merged away. + let series_uid = calendar + .events + .iter() + .find(|e| e.rrule.is_some()) + .map(|e| e.uid.clone()) + .unwrap(); + let in_series = calendar + .events + .iter() + .filter(|e| e.uid == series_uid) + .count(); + assert!( + in_series > 1, + "the same UID appearing repeatedly is a series with exceptions", + ); +} + +#[test] +fn an_override_can_move_its_occurrence_to_another_day() { + let calendar = load("outlook/feed-overrides-windows-tz.ics"); + + let moved = calendar + .overrides() + .find(|e| { + e.recurrence_id + .as_ref() + .is_some_and(|rid| rid.date() != e.dtstart.date()) + }) + .expect("the corpus contains a rescheduled occurrence"); + + assert_ne!( + moved.recurrence_id.as_ref().map(CalendarDateTime::date), + Some(moved.dtstart.date()), + "RECURRENCE-ID identifies which occurrence is replaced, not when the \ + replacement happens -- conflating the two is how a moved meeting ends \ + up rendered on its original day", + ); +} + +#[test] +fn an_override_without_its_master_is_still_parsed() { + let calendar = load("outlook/feed-overrides-windows-tz.ics"); + + let orphans: Vec<&VEvent> = calendar + .overrides() + .filter(|e| { + !calendar + .events + .iter() + .any(|m| m.uid == e.uid && !m.is_override()) + }) + .collect(); + + assert!( + !orphans.is_empty(), + "a published feed truncates series at the window edge, leaving \ + overrides whose master is outside it; those are real events and \ + discarding them loses data", + ); +} + +#[test] +fn every_exdate_on_one_line_is_kept() { + let calendar = load("outlook/feed-overrides-windows-tz.ics"); + + let master = calendar + .events + .iter() + .find(|e| !e.exdate.is_empty()) + .expect("the corpus has a series with exclusions"); + + assert!( + master.exdate.len() >= 8, + "got {} EXDATEs -- Exchange writes them comma-separated on a single \ + line, and a parser keyed on property name alone keeps only one", + master.exdate.len(), + ); + assert!( + master.exdate.iter().all(|d| d.tzid().is_some()), + "the TZID parameter applies to every value on the line", + ); +} + +#[test] +fn rdate_and_exdate_coexist() { + let calendar = load("synthetic/rdate-and-exdate.ics"); + let event = &calendar.events[0]; + + assert_eq!(event.rdate.len(), 1); + assert_eq!(event.exdate.len(), 2); + assert!(event.is_recurring()); +} + +#[test] +fn an_all_day_series_keeps_date_valued_exdates() { + let calendar = load("synthetic/allday-exdate-value-date.ics"); + let event = &calendar.events[0]; + + assert!(event.is_all_day()); + assert_eq!(event.exdate.len(), 2); + assert!( + event.exdate.iter().all(CalendarDateTime::is_date_only), + "an exclusion from an all-day series is a date, not a date-time", + ); +} + +#[test] +fn a_master_and_override_in_one_resource_stay_together() { + let calendar = load("synthetic/series-master-with-override.ics"); + + assert_eq!(calendar.events.len(), 2); + assert!(calendar.master().is_some()); + assert_eq!(calendar.overrides().count(), 1); + assert_eq!( + calendar.events[0].uid, calendar.events[1].uid, + "one CalDAV resource, one UID", + ); +} + +// ---------------------------------------------------------------- content -- + +#[test] +fn repeated_properties_are_all_kept() { + let calendar = load("baikal/thunderbird-google-invite-attendees.ics"); + + assert_eq!( + calendar.events[0].attendees.len(), + 3, + "three ATTENDEE lines must produce three attendees; collecting \ + properties into a map keeps only the last, which is how the previous \ + backend ended up with a 'TODO: Parse attendees properly'", + ); +} + +#[test] +fn attendee_parameters_survive() { + let calendar = load("baikal/thunderbird-google-invite-attendees.ics"); + let attendee = &calendar.events[0].attendees[0]; + + assert!(attendee.common_name.is_some()); + assert_eq!(attendee.role, Some(Role::ReqParticipant)); + assert_eq!(attendee.rsvp, Some(true)); + assert!( + attendee + .unknown_params + .iter() + .any(|p| p.name.eq_ignore_ascii_case("X-NUM-GUESTS")), + "a parameter we do not model is still somebody's data", + ); +} + +#[test] +fn a_quoted_parameter_containing_a_comma_round_trips() { + let calendar = load("baikal/outlook-invite-quoted-cn.ics"); + let organizer = calendar.events[0].organizer.as_ref().unwrap(); + + let name = organizer.common_name.as_deref().unwrap(); + let written = ical::write(&calendar); + let reparsed = ical::parse(&written).unwrap(); + + assert_eq!( + reparsed.events[0] + .organizer + .as_ref() + .and_then(|o| o.common_name.as_deref()), + Some(name), + "an unquoted CN containing a comma reads back as two parameters", + ); +} + +#[test] +fn two_alarms_on_one_event_are_both_kept() { + let calendar = load("baikal/davx5-zoned-two-alarms-vtimezone.ics"); + + assert_eq!(calendar.events[0].alarms.len(), 2); + assert!( + calendar.events[0] + .alarms + .iter() + .all(|a| a.action == AlarmAction::Display) + ); +} + +#[test] +fn vendor_state_on_an_alarm_is_carried_through() { + let calendar = load("baikal/evolution-x-lic-error.ics"); + let alarm = &calendar.events[0].alarms[0]; + + let names: Vec<&str> = alarm + .unknown_properties + .iter() + .map(|p| p.name.as_str()) + .collect(); + assert!( + names.iter().any(|n| n.eq_ignore_ascii_case("ACKNOWLEDGED")), + "got {names:?} -- ACKNOWLEDGED records that a person dismissed this \ + alarm; dropping it makes the alarm fire again in their other client", + ); + assert!( + names + .iter() + .any(|n| n.eq_ignore_ascii_case("X-EVOLUTION-ALARM-UID")) + ); +} + +#[test] +fn a_libical_error_marker_does_not_stop_the_parse() { + // 22 objects on the real server carry these, written by Evolution. + let calendar = load("baikal/evolution-x-lic-error.ics"); + + assert_eq!(calendar.events.len(), 1); + let written = ical::write(&calendar); + assert!( + written.contains("X-LIC-ERROR"), + "and it is carried, not swallowed" + ); +} + +#[test] +fn vendor_properties_on_an_event_are_carried_through() { + let calendar = load("baikal/thunderbird-x-moz-props.ics"); + + let names: Vec<&str> = calendar.events[0] + .unknown_properties + .iter() + .map(|p| p.name.as_str()) + .collect(); + assert!( + names.iter().any(|n| n.starts_with("X-MOZ")), + "got {names:?}" + ); +} + +#[test] +fn an_alarm_relative_to_the_end_stays_relative_to_the_end() { + let mut event = VEvent::new(CalendarDateTime::Utc { + utc: chrono::Utc::now(), + }); + event.alarms.push(VAlarm { + trigger: AlarmTrigger::Relative { + offset: IcalDuration::minutes(-15).unwrap(), + related: TriggerRelation::End, + }, + ..VAlarm::display_before(IcalDuration::minutes(-15).unwrap(), "Wrap up") + }); + + let written = ical::write(&VCalendar::with_events(vec![event])); + assert!(written.contains("RELATED=END"), "{written}"); + + let reparsed = ical::parse(&written).unwrap(); + assert!(matches!( + reparsed.events[0].alarms[0].trigger, + AlarmTrigger::Relative { + related: TriggerRelation::End, + .. + } + )); +} + +#[test] +fn text_escapes_and_non_ascii_survive() { + let calendar = load("synthetic/text-escapes-and-utf8.ics"); + let event = &calendar.events[0]; + + let summary = event.summary.as_deref().unwrap(); + assert!(summary.contains('—') && summary.contains("caffè")); + assert!( + summary.contains(", cioccolato"), + "an escaped comma is a comma" + ); + + let description = event.description.as_deref().unwrap(); + assert!(description.contains('\n'), "\\n is a newline"); + assert!(description.contains(';'), "\\; is a semicolon"); + assert!(description.contains('\\'), "\\\\ is a backslash"); + + let reparsed = ical::parse(&ical::write(&calendar)).unwrap(); + assert_eq!(reparsed.events[0].summary.as_deref(), Some(summary)); + assert_eq!(reparsed.events[0].description.as_deref(), Some(description)); +} + +#[test] +fn a_multi_value_text_property_is_not_escaped_into_one_value() { + let mut event = VEvent::new(CalendarDateTime::Date { + date: chrono::NaiveDate::from_ymd_opt(2026, 1, 5).unwrap(), + }); + event.categories = vec!["Work".to_owned(), "Personal".to_owned()]; + + let written = ical::write(&VCalendar::with_events(vec![event])); + + assert!( + written.contains("CATEGORIES:Work,Personal"), + "the separator must stay bare. Escaping the whole joined value would \ + still round-trip through our own parser, and every other client would \ + read one category named \"Work,Personal\"\n{written}", + ); +} + +// ------------------------------------------------------------------ timing -- + +#[test] +fn a_duration_is_kept_as_a_duration() { + let calendar = load("synthetic/duration-not-dtend.ics"); + let event = &calendar.events[0]; + + assert!(matches!(event.end, Some(EventEnd::Duration { .. }))); + assert_eq!(event.duration(), chrono::TimeDelta::minutes(90)); + assert!( + ical::write(&calendar).contains("DURATION:PT1H30M"), + "a producer that sent DURATION should get DURATION back", + ); +} + +#[test] +fn an_all_day_value_stays_date_valued() { + let calendar = load("baikal/davx5-allday-weekly-alarm.ics"); + let event = &calendar.events[0]; + + assert!(event.is_all_day()); + assert!(ical::write(&calendar).contains("DTSTART;VALUE=DATE:")); +} + +#[test] +fn a_utc_timestamp_missing_its_z_is_repaired_on_write() { + // Runway v1 wrote CREATED without the Z that RFC 5545 requires. Reading it + // as UTC and writing it back correctly repairs the record instead of + // propagating the defect. + let calendar = load("baikal/runway-v1-unfolded-line.ics"); + assert!(calendar.events[0].created.is_some()); + + let written = ical::write(&calendar); + let created = written + .lines() + .find(|l| l.starts_with("CREATED:")) + .expect("CREATED is written"); + assert!(created.ends_with('Z'), "{created}"); +} + +// ------------------------------------------------------------------ errors -- + +#[test] +fn a_missing_dtstart_is_an_error_not_a_guess() { + let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//x//x//EN\r\n\ + BEGIN:VEVENT\r\nUID:no-start\r\nDTSTAMP:20260101T000000Z\r\n\ + END:VEVENT\r\nEND:VCALENDAR\r\n"; + + assert!(matches!( + ical::parse(ics), + Err(IcalError::MissingProperty { + component: "VEVENT", + property: "DTSTART" + }) + )); +} + +#[test] +fn an_unreadable_datetime_names_the_property_and_the_value() { + let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//x//x//EN\r\n\ + BEGIN:VEVENT\r\nUID:bad\r\nDTSTAMP:20260101T000000Z\r\n\ + DTSTART:not-a-date\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; + + match ical::parse(ics) { + Err(IcalError::InvalidValue { + property, value, .. + }) => { + assert_eq!(property, "DTSTART"); + assert_eq!(value, "not-a-date"); + } + other => panic!("expected a typed error, got {other:?}"), + } +} + +#[test] +fn input_without_a_calendar_is_reported_as_such() { + // Prose is a syntax error; a well-formed component tree that simply has no + // VCALENDAR in it is the NoCalendar case. + assert!(matches!( + ical::parse("not a calendar"), + Err(IcalError::Syntax(_)) + )); + assert!(matches!( + ical::parse("BEGIN:VTODO\r\nUID:x\r\nEND:VTODO\r\n"), + Err(IcalError::NoCalendar), + )); +} diff --git a/crates/runway-core/tests/model.rs b/crates/runway-core/tests/model.rs index 01e9558..c669f2f 100644 --- a/crates/runway-core/tests/model.rs +++ b/crates/runway-core/tests/model.rs @@ -443,7 +443,7 @@ fn series_with_one_override() -> CalendarObject { href: "/calendars/connor/personal/shared-uid.ics".to_owned(), etag: Some("\"abc123\"".to_owned()), calendar_path: "/calendars/connor/personal/".to_owned(), - events: vec![master, moved], + calendar: VCalendar::with_events(vec![master, moved]), } } @@ -454,7 +454,7 @@ fn a_resource_separates_its_master_from_its_overrides() { assert!(object.master().is_some()); assert_eq!(object.overrides().count(), 1); assert_eq!( - object.events.len(), + object.events().len(), 2, "a master and its override are two VEVENTs in one resource, not a \ duplicate to be deduplicated away", @@ -472,7 +472,7 @@ fn every_event_in_a_resource_shares_one_uid() { #[test] fn a_resource_with_mismatched_uids_is_detected() { let mut object = series_with_one_override(); - object.events[1].uid = "different".to_owned(); + object.calendar.events[1].uid = "different".to_owned(); assert!( !object.has_consistent_uid(), @@ -484,7 +484,7 @@ fn a_resource_with_mismatched_uids_is_detected() { #[test] fn a_resource_holding_only_overrides_has_no_master() { let mut object = series_with_one_override(); - object.events.retain(VEvent::is_override); + object.calendar.events.retain(VEvent::is_override); assert!(object.master().is_none()); assert_eq!(object.overrides().count(), 1);