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.
This commit is contained in:
2026-08-26 14:32:38 -04:00
parent 52cd5a961d
commit 7043a151f6
35 changed files with 3397 additions and 11 deletions
Generated
+1
View File
@@ -2082,6 +2082,7 @@ dependencies = [
"icalendar",
"pretty_assertions",
"rrule",
"runway-core",
"serde",
"serde_json",
"thiserror 2.0.20",
+3
View File
@@ -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
+48
View File
@@ -0,0 +1,48 @@
//! Errors from the iCalendar layer.
//!
//! These are typed rather than `String` on purpose. The previous iteration
//! returned `Result<T, String>` 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<String>,
value: impl Into<String>,
reason: impl Into<String>,
) -> Self {
Self::InvalidValue {
property: property.into(),
value: value.into(),
reason: reason.into(),
}
}
}
+51
View File
@@ -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<String>,
calendar_path: impl Into<String>,
etag: Option<String>,
body: &str,
) -> Result<CalendarObject, IcalError> {
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()]))
}
+584
View File
@@ -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<String, String>`, 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<VCalendar, IcalError> {
parse_all(input)?
.into_iter()
.next()
.ok_or(IcalError::NoCalendar)
}
/// Parses every `VCALENDAR` in a document.
pub fn parse_all(input: &str) -> Result<Vec<VCalendar>, 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<VCalendar, IcalError> {
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<VEvent, IcalError> {
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::<Utc>::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::<u8>()
.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<GeoPosition, IcalError> {
let (lat, lon) = value
.split_once(';')
.ok_or_else(|| IcalError::invalid("GEO", value, "expected 'latitude;longitude'"))?;
let read = |s: &str| {
s.trim()
.parse::<f64>()
.map_err(|e| IcalError::invalid("GEO", value, e.to_string()))
};
Ok(GeoPosition {
latitude: read(lat)?,
longitude: read(lon)?,
})
}
fn parse_status(value: &str) -> Option<EventStatus> {
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<EventClass> {
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<Transparency> {
match value.trim().to_uppercase().as_str() {
"OPAQUE" => Some(Transparency::Opaque),
"TRANSPARENT" => Some(Transparency::Transparent),
_ => None,
}
}
// ------------------------------------------------------------------ VALARM --
fn parse_alarm(raw: &RawComponent<'_>) -> Result<VAlarm, IcalError> {
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<AlarmAction> {
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<AlarmTrigger, IcalError> {
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<VTimeZone, IcalError> {
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<TimeZoneRule, IcalError> {
let mut dtstart = None;
let mut offset_from = None;
let mut offset_to = None;
let mut rule = TimeZoneRule {
kind,
dtstart: DateTime::<Utc>::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, IcalError> {
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, IcalError> {
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<String> {
// 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<crate::model::CalendarDateTime, IcalError> {
read_datetime(
&self.name,
self.value,
self.param("VALUE"),
self.param("TZID"),
)
}
fn datetime_list(&self) -> Result<Vec<crate::model::CalendarDateTime>, 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<PropertyParam> {
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<Role> {
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<ParticipationStatus> {
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<CalendarUserType> {
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),
}
}
+365
View File
@@ -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<CalendarDateTime, IcalError> {
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<DateTime<Utc>, 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<IcalDuration, IcalError> {
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<Item = &'a str>) -> String {
values
.into_iter()
.map(escape_text)
.collect::<Vec<_>>()
.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<Utc>) -> 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
}
+469
View File
@@ -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(&params), &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(&params), &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", &params, &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<String> = 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, &params, &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(&quote_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, &params, &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<String>,
}
let mut groups: Vec<Group> = 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",
}
}
+3
View File
@@ -13,4 +13,7 @@
pub mod model;
#[cfg(feature = "ical")]
pub mod ical;
pub use model::*;
+7
View File
@@ -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<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub attendees: Vec<Attendee>,
/// 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<UnknownProperty>,
}
impl VAlarm {
@@ -46,6 +52,7 @@ impl VAlarm {
description: Some(description.into()),
summary: None,
attendees: Vec::new(),
unknown_properties: Vec::new(),
}
}
}
+116
View File
@@ -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<String>,
/// `METHOD` — `PUBLISH` on a subscribed feed, absent on a CalDAV resource.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub method: Option<String>,
/// 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<VEvent>,
/// Zone definitions referenced by those events' `TZID` parameters.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub timezones: Vec<VTimeZone>,
/// Calendar-level properties we do not interpret, such as `X-WR-CALNAME`.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub unknown_properties: Vec<UnknownProperty>,
/// `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<UnknownComponent>,
}
/// 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<VEvent>) -> 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<Item = &VEvent> {
self.events.iter().filter(|e| e.is_override())
}
}
impl Default for VCalendar {
fn default() -> Self {
Self::new()
}
}
+27
View File
@@ -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<VAlarm>,
// ---- 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<UnknownProperty>,
}
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())
+6
View File
@@ -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};
+16 -7
View File
@@ -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<String>,
/// 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<VEvent>,
/// 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<Item = &VEvent> {
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),
+10
View File
@@ -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<String>,
/// 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<PropertyParam>,
}
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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
/// As on [`CalendarUser`]: unrecognised parameters travel through.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub unknown_params: Vec<PropertyParam>,
}
impl Attendee {
@@ -83,6 +92,7 @@ impl Attendee {
delegated_from: Vec::new(),
sent_by: None,
language: None,
unknown_params: Vec::new(),
}
}
+80
View File
@@ -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<PropertyParam>,
/// The raw, still-escaped value.
pub value: String,
}
impl UnknownProperty {
pub fn new(name: impl Into<String>, value: impl Into<String>) -> 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<UnknownProperty>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub components: Vec<UnknownComponent>,
}
/// 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<String>, value: impl Into<String>) -> Self {
Self {
name: name.into(),
value: value.into(),
}
}
}
+162
View File
@@ -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<String>,
/// The `STANDARD` and `DAYLIGHT` sub-components, in the order given.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub rules: Vec<TimeZoneRule>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub unknown_properties: Vec<UnknownProperty>,
}
impl VTimeZone {
pub fn new(tzid: impl Into<String>) -> 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<String>,
/// `RDATE` — explicit transition times.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub rdate: Vec<NaiveDateTime>,
/// `TZNAME` — the abbreviation, such as `MST`.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub names: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub unknown_properties: Vec<UnknownProperty>,
}
/// 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<Self, InvalidUtcOffset> {
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<Self, InvalidUtcOffset> {
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::<i32>().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);
+54
View File
@@ -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.
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
+682
View File
@@ -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<String, usize> {
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),
));
}
+4 -4
View File
@@ -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);