//! The iCalendar round-trip, exercised against real server data. //! //! The corpus in `tests/golden/` is described in its own README. The contract //! asserted here is that **parse → write → parse is stable**: whatever the //! first parse understood, the second must understand identically. A file that //! fails is a defect in the parser or the writer, never a reason to edit the //! file. //! //! Stability alone is not enough, though — a parser that dropped `ATTENDEE` //! entirely would round-trip perfectly and still be wrong. So the structural //! checks below assert that nothing *disappears*, and the named tests pin the //! specific things real producers do that the previous iteration got wrong. #![allow(clippy::unwrap_used, clippy::expect_used)] use pretty_assertions::assert_eq; use runway_core::ical::{self, IcalError}; use runway_core::model::*; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; // ------------------------------------------------------------------ corpus -- fn golden_dir() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/golden") } /// Every `.ics` in the corpus, as (relative name, contents). fn corpus() -> Vec<(String, String)> { fn walk(dir: &Path, root: &Path, out: &mut Vec<(String, String)>) { let mut entries: Vec<_> = std::fs::read_dir(dir) .expect("golden corpus is readable") .filter_map(Result::ok) .map(|e| e.path()) .collect(); entries.sort(); for path in entries { if path.is_dir() { walk(&path, root, out); } else if path.extension().is_some_and(|e| e == "ics") { let name = path .strip_prefix(root) .unwrap_or(&path) .to_string_lossy() .into_owned(); out.push((name, std::fs::read_to_string(&path).unwrap())); } } } let root = golden_dir(); let mut out = Vec::new(); walk(&root, &root, &mut out); assert!(!out.is_empty(), "the golden corpus must not be empty"); out } fn load(name: &str) -> VCalendar { let path = golden_dir().join(name); let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{name}: {e}")); ical::parse(&text).unwrap_or_else(|e| panic!("{name} failed to parse: {e}")) } /// Counts every property name in a document, at any depth. fn property_names(ics: &str) -> BTreeMap { let unfolded = ics.replace("\r\n ", "").replace("\n ", ""); let mut counts = BTreeMap::new(); for line in unfolded.lines() { let Some((head, _)) = line.split_once(':') else { continue; }; let name = head.split(';').next().unwrap_or(head).to_uppercase(); if name == "BEGIN" || name == "END" || name.is_empty() { continue; } *counts.entry(name).or_insert(0) += 1; } counts } // ---------------------------------------------------------- the round-trip -- #[test] fn every_golden_file_parses() { for (name, text) in corpus() { if let Err(e) = ical::parse(&text) { panic!("{name} failed to parse: {e}"); } } } #[test] fn every_golden_file_survives_a_round_trip() { for (name, text) in corpus() { let first = ical::parse(&text).unwrap_or_else(|e| panic!("{name}: {e}")); let written = ical::write(&first); let second = ical::parse(&written) .unwrap_or_else(|e| panic!("{name} did not survive being written: {e}\n{written}")); assert_eq!(first, second, "{name} changed meaning on a round trip"); } } #[test] fn writing_is_deterministic() { for (name, text) in corpus() { let parsed = ical::parse(&text).unwrap(); assert_eq!( ical::write(&parsed), ical::write(&parsed), "{name}: the same calendar must always produce the same bytes, or \ every read would look like a remote edit to CalDAV", ); } } #[test] fn no_property_is_dropped_on_a_round_trip() { for (name, text) in corpus() { let written = ical::write(&ical::parse(&text).unwrap()); let (before, after) = (property_names(&text), property_names(&written)); for (property, count) in &before { // DTEND and DURATION are alternatives; a producer may send both and // we deliberately keep only the explicit end. if property == "DURATION" && before.contains_key("DTEND") { continue; } let kept = after.get(property).copied().unwrap_or(0); assert!( kept >= *count, "{name}: {property} appeared {count} time(s) in the source but \ {kept} time(s) after a round trip -- a parse that silently \ drops properties is exactly what produced the phantom \ duplicates in the last iteration", ); } } } // ----------------------------------------------------------------- folding -- #[test] fn output_is_folded_to_75_octets() { for (name, text) in corpus() { let written = ical::write(&ical::parse(&text).unwrap()); for (n, line) in written.split("\r\n").enumerate() { assert!( line.len() <= 75, "{name} line {}: {} octets, over the RFC 5545 limit\n{line}", n + 1, line.len(), ); } } } #[test] fn output_uses_crlf_line_endings() { for (name, text) in corpus() { let written = ical::write(&ical::parse(&text).unwrap()); assert!( !written.replace("\r\n", "").contains('\n'), "{name}: every line ending must be CRLF", ); assert!(written.ends_with("END:VCALENDAR\r\n"), "{name}"); } } #[test] fn a_long_line_is_folded_and_reads_back_identically() { // v1 wrote this DESCRIPTION as a single 248-octet line, in violation of // RFC 5545 3.1, and those events are still on the server. let calendar = load("baikal/runway-v1-unfolded-line.ics"); let description = calendar.events[0].description.clone().unwrap(); assert!(description.len() > 200); let written = ical::write(&calendar); assert!( written.contains("\r\n "), "a 248-octet value must come back folded", ); let reparsed = ical::parse(&written).unwrap(); assert_eq!( reparsed.events[0].description.as_deref(), Some(description.as_str()), "unfolding must reconstruct the value exactly", ); } #[test] fn folding_never_splits_a_utf8_character() { let mut event = VEvent::new(CalendarDateTime::Utc { utc: chrono::Utc::now(), }); event.description = Some("é".repeat(200)); let written = ical::write(&VCalendar::with_events(vec![event.clone()])); // Reaching this point at all means every fold landed on a boundary; a bad // split would have produced invalid UTF-8 rather than a String. for line in written.split("\r\n") { assert!(line.len() <= 75); } let reparsed = ical::parse(&written).unwrap(); assert_eq!(reparsed.events[0].description, event.description); } // -------------------------------------------------------------- timezones -- #[test] fn windows_timezone_identifiers_are_preserved_verbatim() { let calendar = load("outlook/feed-overrides-windows-tz.ics"); let ids: Vec<&str> = calendar .timezones .iter() .map(|tz| tz.tzid.as_str()) .collect(); assert!( ids.contains(&"Pacific Standard Time"), "got {ids:?} -- Exchange names its zones in Windows form, and \ normalising to IANA at parse time would make the document \ unrepresentable; the mapping belongs at the point of use", ); let start = &calendar.events[0].dtstart; assert_eq!( start.tzid().map(TzId::as_str), Some("Pacific Standard Time"), ); assert!( calendar.undefined_tzids().is_empty(), "the feed defines its own zones" ); } #[test] fn a_timezone_definition_carries_its_transition_rules() { let calendar = load("outlook/feed-overrides-windows-tz.ics"); let tz = calendar.timezone("Pacific Standard Time").unwrap(); assert!(tz.has_windows_style_id()); assert_eq!(tz.rules.len(), 2, "a STANDARD and a DAYLIGHT block"); let standard = tz .rules .iter() .find(|r| r.kind == TimeZoneRuleKind::Standard) .unwrap(); assert_eq!(standard.offset_from.seconds(), -7 * 3600); assert_eq!(standard.offset_to.seconds(), -8 * 3600); assert!(standard.rrule.is_some()); assert_eq!( standard.dtstart.date().to_string(), "1601-01-01", "Exchange really does write the year 1601 here; a parser that \ range-checks the year rejects real data", ); } #[test] fn a_zoned_start_is_not_promoted_to_utc() { let calendar = load("baikal/davx5-zoned-two-alarms-vtimezone.ics"); assert_eq!( calendar.events[0].dtstart.tzid().map(TzId::as_str), Some("America/New_York"), "the previous parser commented 'if no TZID, treat as UTC' and applied \ that reasoning widely enough to drift recurring events across DST", ); } #[test] fn a_floating_time_stays_floating() { let calendar = load("synthetic/floating-time.ics"); assert!( matches!( calendar.events[0].dtstart, CalendarDateTime::Floating { .. } ), "a value with neither Z nor TZID means local wall-clock time wherever \ it is read, which is not the same as UTC", ); } // ------------------------------------------------------------- recurrence -- #[test] fn a_master_and_its_overrides_are_kept_apart() { let calendar = load("outlook/feed-overrides-windows-tz.ics"); let masters = calendar.events.iter().filter(|e| !e.is_override()).count(); let overrides = calendar.overrides().count(); assert!(overrides >= 4, "got {overrides} overrides"); assert!(masters >= 2); // Grouping by UID is the whole point: several events sharing a summary and // a UID are one series, not duplicates to be merged away. let series_uid = calendar .events .iter() .find(|e| e.rrule.is_some()) .map(|e| e.uid.clone()) .unwrap(); let in_series = calendar .events .iter() .filter(|e| e.uid == series_uid) .count(); assert!( in_series > 1, "the same UID appearing repeatedly is a series with exceptions", ); } #[test] fn an_override_can_move_its_occurrence_to_another_day() { let calendar = load("outlook/feed-overrides-windows-tz.ics"); let moved = calendar .overrides() .find(|e| { e.recurrence_id .as_ref() .is_some_and(|rid| rid.date() != e.dtstart.date()) }) .expect("the corpus contains a rescheduled occurrence"); assert_ne!( moved.recurrence_id.as_ref().map(CalendarDateTime::date), Some(moved.dtstart.date()), "RECURRENCE-ID identifies which occurrence is replaced, not when the \ replacement happens -- conflating the two is how a moved meeting ends \ up rendered on its original day", ); } #[test] fn an_override_without_its_master_is_still_parsed() { let calendar = load("outlook/feed-overrides-windows-tz.ics"); let orphans: Vec<&VEvent> = calendar .overrides() .filter(|e| { !calendar .events .iter() .any(|m| m.uid == e.uid && !m.is_override()) }) .collect(); assert!( !orphans.is_empty(), "a published feed truncates series at the window edge, leaving \ overrides whose master is outside it; those are real events and \ discarding them loses data", ); } #[test] fn every_exdate_on_one_line_is_kept() { let calendar = load("outlook/feed-overrides-windows-tz.ics"); let master = calendar .events .iter() .find(|e| !e.exdate.is_empty()) .expect("the corpus has a series with exclusions"); assert!( master.exdate.len() >= 8, "got {} EXDATEs -- Exchange writes them comma-separated on a single \ line, and a parser keyed on property name alone keeps only one", master.exdate.len(), ); assert!( master.exdate.iter().all(|d| d.tzid().is_some()), "the TZID parameter applies to every value on the line", ); } #[test] fn rdate_and_exdate_coexist() { let calendar = load("synthetic/rdate-and-exdate.ics"); let event = &calendar.events[0]; assert_eq!(event.rdate.len(), 1); assert_eq!(event.exdate.len(), 2); assert!(event.is_recurring()); } #[test] fn an_all_day_series_keeps_date_valued_exdates() { let calendar = load("synthetic/allday-exdate-value-date.ics"); let event = &calendar.events[0]; assert!(event.is_all_day()); assert_eq!(event.exdate.len(), 2); assert!( event.exdate.iter().all(CalendarDateTime::is_date_only), "an exclusion from an all-day series is a date, not a date-time", ); } #[test] fn a_master_and_override_in_one_resource_stay_together() { let calendar = load("synthetic/series-master-with-override.ics"); assert_eq!(calendar.events.len(), 2); assert!(calendar.master().is_some()); assert_eq!(calendar.overrides().count(), 1); assert_eq!( calendar.events[0].uid, calendar.events[1].uid, "one CalDAV resource, one UID", ); } // ---------------------------------------------------------------- content -- #[test] fn repeated_properties_are_all_kept() { let calendar = load("baikal/thunderbird-google-invite-attendees.ics"); assert_eq!( calendar.events[0].attendees.len(), 3, "three ATTENDEE lines must produce three attendees; collecting \ properties into a map keeps only the last, which is how the previous \ backend ended up with a 'TODO: Parse attendees properly'", ); } #[test] fn attendee_parameters_survive() { let calendar = load("baikal/thunderbird-google-invite-attendees.ics"); let attendee = &calendar.events[0].attendees[0]; assert!(attendee.common_name.is_some()); assert_eq!(attendee.role, Some(Role::ReqParticipant)); assert_eq!(attendee.rsvp, Some(true)); assert!( attendee .unknown_params .iter() .any(|p| p.name.eq_ignore_ascii_case("X-NUM-GUESTS")), "a parameter we do not model is still somebody's data", ); } #[test] fn a_quoted_parameter_containing_a_comma_round_trips() { let calendar = load("baikal/outlook-invite-quoted-cn.ics"); let organizer = calendar.events[0].organizer.as_ref().unwrap(); let name = organizer.common_name.as_deref().unwrap(); let written = ical::write(&calendar); let reparsed = ical::parse(&written).unwrap(); assert_eq!( reparsed.events[0] .organizer .as_ref() .and_then(|o| o.common_name.as_deref()), Some(name), "an unquoted CN containing a comma reads back as two parameters", ); } #[test] fn two_alarms_on_one_event_are_both_kept() { let calendar = load("baikal/davx5-zoned-two-alarms-vtimezone.ics"); assert_eq!(calendar.events[0].alarms.len(), 2); assert!( calendar.events[0] .alarms .iter() .all(|a| a.action == AlarmAction::Display) ); } #[test] fn vendor_state_on_an_alarm_is_carried_through() { let calendar = load("baikal/evolution-x-lic-error.ics"); let alarm = &calendar.events[0].alarms[0]; let names: Vec<&str> = alarm .unknown_properties .iter() .map(|p| p.name.as_str()) .collect(); assert!( names.iter().any(|n| n.eq_ignore_ascii_case("ACKNOWLEDGED")), "got {names:?} -- ACKNOWLEDGED records that a person dismissed this \ alarm; dropping it makes the alarm fire again in their other client", ); assert!( names .iter() .any(|n| n.eq_ignore_ascii_case("X-EVOLUTION-ALARM-UID")) ); } #[test] fn a_libical_error_marker_does_not_stop_the_parse() { // 22 objects on the real server carry these, written by Evolution. let calendar = load("baikal/evolution-x-lic-error.ics"); assert_eq!(calendar.events.len(), 1); let written = ical::write(&calendar); assert!( written.contains("X-LIC-ERROR"), "and it is carried, not swallowed" ); } #[test] fn vendor_properties_on_an_event_are_carried_through() { let calendar = load("baikal/thunderbird-x-moz-props.ics"); let names: Vec<&str> = calendar.events[0] .unknown_properties .iter() .map(|p| p.name.as_str()) .collect(); assert!( names.iter().any(|n| n.starts_with("X-MOZ")), "got {names:?}" ); } #[test] fn an_alarm_relative_to_the_end_stays_relative_to_the_end() { let mut event = VEvent::new(CalendarDateTime::Utc { utc: chrono::Utc::now(), }); event.alarms.push(VAlarm { trigger: AlarmTrigger::Relative { offset: IcalDuration::minutes(-15).unwrap(), related: TriggerRelation::End, }, ..VAlarm::display_before(IcalDuration::minutes(-15).unwrap(), "Wrap up") }); let written = ical::write(&VCalendar::with_events(vec![event])); assert!(written.contains("RELATED=END"), "{written}"); let reparsed = ical::parse(&written).unwrap(); assert!(matches!( reparsed.events[0].alarms[0].trigger, AlarmTrigger::Relative { related: TriggerRelation::End, .. } )); } #[test] fn text_escapes_and_non_ascii_survive() { let calendar = load("synthetic/text-escapes-and-utf8.ics"); let event = &calendar.events[0]; let summary = event.summary.as_deref().unwrap(); assert!(summary.contains('—') && summary.contains("caffè")); assert!( summary.contains(", cioccolato"), "an escaped comma is a comma" ); let description = event.description.as_deref().unwrap(); assert!(description.contains('\n'), "\\n is a newline"); assert!(description.contains(';'), "\\; is a semicolon"); assert!(description.contains('\\'), "\\\\ is a backslash"); let reparsed = ical::parse(&ical::write(&calendar)).unwrap(); assert_eq!(reparsed.events[0].summary.as_deref(), Some(summary)); assert_eq!(reparsed.events[0].description.as_deref(), Some(description)); } #[test] fn a_multi_value_text_property_is_not_escaped_into_one_value() { let mut event = VEvent::new(CalendarDateTime::Date { date: chrono::NaiveDate::from_ymd_opt(2026, 1, 5).unwrap(), }); event.categories = vec!["Work".to_owned(), "Personal".to_owned()]; let written = ical::write(&VCalendar::with_events(vec![event])); assert!( written.contains("CATEGORIES:Work,Personal"), "the separator must stay bare. Escaping the whole joined value would \ still round-trip through our own parser, and every other client would \ read one category named \"Work,Personal\"\n{written}", ); } // ------------------------------------------------------------------ timing -- #[test] fn a_duration_is_kept_as_a_duration() { let calendar = load("synthetic/duration-not-dtend.ics"); let event = &calendar.events[0]; assert!(matches!(event.end, Some(EventEnd::Duration { .. }))); assert_eq!(event.duration(), chrono::TimeDelta::minutes(90)); assert!( ical::write(&calendar).contains("DURATION:PT1H30M"), "a producer that sent DURATION should get DURATION back", ); } #[test] fn an_all_day_value_stays_date_valued() { let calendar = load("baikal/davx5-allday-weekly-alarm.ics"); let event = &calendar.events[0]; assert!(event.is_all_day()); assert!(ical::write(&calendar).contains("DTSTART;VALUE=DATE:")); } #[test] fn a_utc_timestamp_missing_its_z_is_repaired_on_write() { // Runway v1 wrote CREATED without the Z that RFC 5545 requires. Reading it // as UTC and writing it back correctly repairs the record instead of // propagating the defect. let calendar = load("baikal/runway-v1-unfolded-line.ics"); assert!(calendar.events[0].created.is_some()); let written = ical::write(&calendar); let created = written .lines() .find(|l| l.starts_with("CREATED:")) .expect("CREATED is written"); assert!(created.ends_with('Z'), "{created}"); } // ------------------------------------------------------------------ errors -- #[test] fn a_missing_dtstart_is_an_error_not_a_guess() { let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//x//x//EN\r\n\ BEGIN:VEVENT\r\nUID:no-start\r\nDTSTAMP:20260101T000000Z\r\n\ END:VEVENT\r\nEND:VCALENDAR\r\n"; assert!(matches!( ical::parse(ics), Err(IcalError::MissingProperty { component: "VEVENT", property: "DTSTART" }) )); } #[test] fn an_unreadable_datetime_names_the_property_and_the_value() { let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//x//x//EN\r\n\ BEGIN:VEVENT\r\nUID:bad\r\nDTSTAMP:20260101T000000Z\r\n\ DTSTART:not-a-date\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; match ical::parse(ics) { Err(IcalError::InvalidValue { property, value, .. }) => { assert_eq!(property, "DTSTART"); assert_eq!(value, "not-a-date"); } other => panic!("expected a typed error, got {other:?}"), } } #[test] fn input_without_a_calendar_is_reported_as_such() { // Prose is a syntax error; a well-formed component tree that simply has no // VCALENDAR in it is the NoCalendar case. assert!(matches!( ical::parse("not a calendar"), Err(IcalError::Syntax(_)) )); assert!(matches!( ical::parse("BEGIN:VTODO\r\nUID:x\r\nEND:VTODO\r\n"), Err(IcalError::NoCalendar), )); }