Files
runway/crates/runway-core/tests/ical.rs
T
connor 7043a151f6 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.
2026-08-26 14:32:38 -04:00

683 lines
22 KiB
Rust

//! 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),
));
}