Add recurrence expansion
Rules come from the rrule crate. What is ours is the layer above it:
reconciling a series master with the RECURRENCE-ID overrides that replace
individual occurrences, which is where the subtlety actually lives. An
override suppresses the occurrence it names, and an override whose master
falls outside the window is still emitted -- a published feed truncates
series at its edge, and those are real events.
Occurrences know their own RECURRENCE-ID. v1 encoded instance identity as
a "{uid}-{timestamp}" string and split it apart again in the view layer.
TZID resolution is a ladder: IANA, then Windows zone names through a CLDR
table generated from windowsZones.xml, then an assumption that is reported
rather than hidden. Exchange writes "Pacific Standard Time" and every zone
in the real feed maps. Deriving a VTIMEZONE's own offsets is not built --
nothing in the corpus needs it, and the definitions are already carried
should that change.
Daylight saving is settled explicitly rather than by unwrap. An ambiguous
time takes the earlier reading; a time inside a spring-forward gap slides
past it by the gap's own length, so 02:15 and 02:45 stay distinct and stay
in order instead of both snapping to 03:00.
Tested against known-good outputs: DST both directions, leap day, nth
weekday, BYMONTHDAY on short months, COUNT against UNTIL, EXDATE against
an override, and the real Outlook feed -- where the assertion is that no
series ever yields two occurrences at one instant, which is what the old
importer's title-matching heuristics were standing in for.
This commit is contained in:
@@ -34,7 +34,7 @@ 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"] }
|
||||
runway-core = { path = ".", features = ["ical", "recurrence"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -16,4 +16,7 @@ pub mod model;
|
||||
#[cfg(feature = "ical")]
|
||||
pub mod ical;
|
||||
|
||||
#[cfg(feature = "recurrence")]
|
||||
pub mod recurrence;
|
||||
|
||||
pub use model::*;
|
||||
|
||||
@@ -10,6 +10,7 @@ mod calendar;
|
||||
mod datetime;
|
||||
mod event;
|
||||
mod object;
|
||||
mod occurrence;
|
||||
mod person;
|
||||
mod property;
|
||||
mod timezone;
|
||||
@@ -22,6 +23,7 @@ pub use event::{
|
||||
VEvent,
|
||||
};
|
||||
pub use object::{CalendarObject, EditScope};
|
||||
pub use occurrence::Occurrence;
|
||||
pub use person::{Attendee, CalendarUser, CalendarUserType, ParticipationStatus, Role};
|
||||
pub use property::{PropertyParam, UnknownComponent, UnknownProperty};
|
||||
pub use timezone::{InvalidUtcOffset, TimeZoneRule, TimeZoneRuleKind, UtcOffset, VTimeZone};
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
//! A single concrete instance of an event.
|
||||
//!
|
||||
//! Expansion happens on the server and the client receives these, already
|
||||
//! discrete. That is the opposite of the previous iteration, which shipped ~650
|
||||
//! lines of hand-rolled RRULE expansion into the browser and then had to invent
|
||||
//! identities for the instances it produced — encoding them as
|
||||
//! `"{uid}-{timestamp}"` strings that the view layer split apart again to work
|
||||
//! out what it was looking at. An occurrence names itself here instead.
|
||||
|
||||
use super::datetime::CalendarDateTime;
|
||||
use super::event::VEvent;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// One instance of an event on a calendar.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Occurrence {
|
||||
/// The event describing this instance: the series master, or the override
|
||||
/// that replaces it. Carried whole so the UI needs nothing else to render.
|
||||
pub event: VEvent,
|
||||
|
||||
/// When this instance starts, in the form the calendar expresses it — a
|
||||
/// date for an all-day event, a zoned wall-clock time otherwise.
|
||||
pub start: CalendarDateTime,
|
||||
/// When it ends, in the same form.
|
||||
pub end: CalendarDateTime,
|
||||
|
||||
/// The same instants, resolved. Ordering, windowing and overlap tests use
|
||||
/// these; rendering uses the pair above. Keeping both means the client
|
||||
/// never has to resolve a zone, and never has to guess one.
|
||||
pub start_utc: DateTime<Utc>,
|
||||
pub end_utc: DateTime<Utc>,
|
||||
|
||||
/// Which instance of the series this is — the `RECURRENCE-ID` that
|
||||
/// addresses it. `None` for a one-off event.
|
||||
///
|
||||
/// This is the identity the previous iteration reconstructed by splitting
|
||||
/// a string on a hyphen. An edit to a single occurrence sends this value
|
||||
/// back, and it is the same value the server would write as
|
||||
/// `RECURRENCE-ID`, so nothing has to be reverse-engineered.
|
||||
pub recurrence_id: Option<CalendarDateTime>,
|
||||
|
||||
/// True when this instance came from a `VEVENT` of its own rather than
|
||||
/// from expanding the master — a modified occurrence.
|
||||
pub is_override: bool,
|
||||
}
|
||||
|
||||
impl Occurrence {
|
||||
/// The `UID` of the series this instance belongs to.
|
||||
pub fn uid(&self) -> &str {
|
||||
&self.event.uid
|
||||
}
|
||||
|
||||
/// The title to display.
|
||||
pub fn title(&self) -> Option<&str> {
|
||||
self.event.title()
|
||||
}
|
||||
|
||||
/// Whether this instance occupies whole days.
|
||||
pub fn is_all_day(&self) -> bool {
|
||||
self.start.is_date_only()
|
||||
}
|
||||
|
||||
/// Whether this instance overlaps a half-open window.
|
||||
///
|
||||
/// Half-open on purpose: an event ending exactly when a window begins does
|
||||
/// not belong to it, and a zero-length event still does.
|
||||
pub fn overlaps(&self, from: DateTime<Utc>, to: DateTime<Utc>) -> bool {
|
||||
self.start_utc < to && (self.end_utc > from || self.start_utc >= from)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//! Errors from expanding recurrence rules.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum RecurrenceError {
|
||||
/// The `RRULE` could not be parsed, or is not valid against its `DTSTART`.
|
||||
#[error("event {uid} has an unusable RRULE {rrule:?}: {reason}")]
|
||||
InvalidRule {
|
||||
uid: String,
|
||||
rrule: String,
|
||||
reason: String,
|
||||
},
|
||||
|
||||
/// The rule would produce more instances in this window than any calendar
|
||||
/// could sensibly show.
|
||||
///
|
||||
/// Reported instead of truncating: a view quietly missing events is worse
|
||||
/// than one that admits it could not build the list.
|
||||
#[error("event {uid} generates more than {limit} occurrences in this window")]
|
||||
TooManyOccurrences { uid: String, limit: u16 },
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
//! Expanding a calendar into the occurrences that fall inside a window.
|
||||
//!
|
||||
//! The rules themselves come from the `rrule` crate. The previous iteration
|
||||
//! hand-wrote this — `generate_weekly_byday_occurrences`,
|
||||
//! `generate_monthly_byday_occurrences`, `add_months`, `days_in_month`,
|
||||
//! `is_leap_year` and the rest, some 650 lines — and shipped it into the
|
||||
//! browser with no tests. `chrono` already had half of it and `rrule` had the
|
||||
//! other half.
|
||||
//!
|
||||
//! What is genuinely ours is the part above the rule engine: reconciling a
|
||||
//! series master with the overrides that replace individual occurrences of it.
|
||||
//! That is where the real subtlety lives, and it is what makes the ~500 lines
|
||||
//! of "duplicate" heuristics in the old ICS importer unnecessary.
|
||||
|
||||
use super::error::RecurrenceError;
|
||||
use super::zone::{Zones, to_instant, to_local};
|
||||
use crate::model::{CalendarDateTime, Occurrence, TzId, VCalendar, VEvent};
|
||||
use chrono::{DateTime, TimeDelta, Utc};
|
||||
use rrule::{RRule, RRuleSet, Tz as RRuleTz, Unvalidated};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// The most occurrences one series may contribute to a single window.
|
||||
///
|
||||
/// A malformed or pathological rule — `FREQ=SECONDLY` with no `COUNT` — must
|
||||
/// not be able to hang the server. Hitting this is reported rather than
|
||||
/// silently truncating the calendar, because a view that is quietly missing
|
||||
/// events is worse than one that says it failed.
|
||||
const MAX_PER_SERIES: u16 = 10_000;
|
||||
|
||||
/// A half-open span of time to expand into.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Window {
|
||||
pub from: DateTime<Utc>,
|
||||
pub to: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl Window {
|
||||
pub fn new(from: DateTime<Utc>, to: DateTime<Utc>) -> Self {
|
||||
Self { from, to }
|
||||
}
|
||||
}
|
||||
|
||||
/// Expands every event in a calendar into the occurrences visible in a window.
|
||||
///
|
||||
/// Results are ordered by start instant, then by `UID`, so the output is
|
||||
/// deterministic for a given input.
|
||||
pub fn expand(
|
||||
calendar: &VCalendar,
|
||||
window: Window,
|
||||
zones: Zones,
|
||||
) -> Result<Vec<Occurrence>, RecurrenceError> {
|
||||
let mut out = Vec::new();
|
||||
for series in group_by_uid(&calendar.events) {
|
||||
expand_series(&series, window, zones, &mut out)?;
|
||||
}
|
||||
out.retain(|o| o.start_utc < window.to && o.end_utc > window.from || touches_start(o, window));
|
||||
out.sort_by(|a, b| {
|
||||
a.start_utc
|
||||
.cmp(&b.start_utc)
|
||||
.then_with(|| a.uid().cmp(b.uid()))
|
||||
.then_with(|| a.is_override.cmp(&b.is_override))
|
||||
});
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A zero-length event exactly on the window's edge still belongs to it.
|
||||
fn touches_start(occurrence: &Occurrence, window: Window) -> bool {
|
||||
occurrence.start_utc == occurrence.end_utc
|
||||
&& occurrence.start_utc >= window.from
|
||||
&& occurrence.start_utc < window.to
|
||||
}
|
||||
|
||||
/// Everything sharing one `UID`, in the order first seen.
|
||||
///
|
||||
/// This grouping is the whole game. A published feed lists a series master and
|
||||
/// its modified occurrences as separate `VEVENT`s with the same `UID` and the
|
||||
/// same `SUMMARY`; read as a flat list they look like duplicates, which is
|
||||
/// exactly what the old importer concluded before it started merging them by
|
||||
/// title and throwing the losers away.
|
||||
fn group_by_uid(events: &[VEvent]) -> Vec<Vec<&VEvent>> {
|
||||
let mut order: Vec<&str> = Vec::new();
|
||||
let mut groups: HashMap<&str, Vec<&VEvent>> = HashMap::new();
|
||||
for event in events {
|
||||
let uid = event.uid.as_str();
|
||||
if !groups.contains_key(uid) {
|
||||
order.push(uid);
|
||||
}
|
||||
groups.entry(uid).or_default().push(event);
|
||||
}
|
||||
order
|
||||
.into_iter()
|
||||
.filter_map(|uid| groups.remove(uid))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn expand_series(
|
||||
series: &[&VEvent],
|
||||
window: Window,
|
||||
zones: Zones,
|
||||
out: &mut Vec<Occurrence>,
|
||||
) -> Result<(), RecurrenceError> {
|
||||
let master = series.iter().copied().find(|e| !e.is_override());
|
||||
let overrides: Vec<&VEvent> = series.iter().copied().filter(|e| e.is_override()).collect();
|
||||
|
||||
// Every override is an occurrence in its own right. Emitted first, and
|
||||
// emitted even when the master is absent: a feed truncated at its window
|
||||
// edge leaves overrides whose master is outside it, and those are real
|
||||
// events. Discarding them is data loss the viewer would never know about.
|
||||
for event in &overrides {
|
||||
out.push(single_occurrence(event, zones));
|
||||
}
|
||||
|
||||
let Some(master) = master else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if !master.is_recurring() {
|
||||
out.push(single_occurrence(master, zones));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// An occurrence the master would generate is suppressed when an override
|
||||
// replaces it. Matched on the instant the RECURRENCE-ID names, not on its
|
||||
// written form, so a producer that writes the same moment a different way
|
||||
// still lines up.
|
||||
let replaced: Vec<DateTime<Utc>> = overrides
|
||||
.iter()
|
||||
.filter_map(|e| e.recurrence_id.as_ref())
|
||||
.map(|rid| zones.instant(rid))
|
||||
.collect();
|
||||
|
||||
for start in rule_starts(master, window, zones)? {
|
||||
if replaced.contains(&zones.instant(&start)) {
|
||||
continue;
|
||||
}
|
||||
out.push(occurrence_at(master, start, zones));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Runs the rule engine for one master, returning starts in the master's own
|
||||
/// representation.
|
||||
fn rule_starts(
|
||||
master: &VEvent,
|
||||
window: Window,
|
||||
zones: Zones,
|
||||
) -> Result<Vec<CalendarDateTime>, RecurrenceError> {
|
||||
let resolved = zones.zone_of(&master.dtstart);
|
||||
let tz = RRuleTz::Tz(resolved.tz);
|
||||
// Built through the same conversion as everything else, so a DTSTART that
|
||||
// lands in a daylight-saving gap is nudged once, here, rather than
|
||||
// differently in each caller.
|
||||
let dt_start = to_instant(resolved.tz, master.dtstart.naive_local()).with_timezone(&tz);
|
||||
|
||||
let mut set = RRuleSet::new(dt_start);
|
||||
if let Some(text) = &master.rrule {
|
||||
let parsed: RRule<Unvalidated> =
|
||||
text.parse()
|
||||
.map_err(|e: rrule::RRuleError| RecurrenceError::InvalidRule {
|
||||
uid: master.uid.clone(),
|
||||
rrule: text.clone(),
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
let validated = parsed
|
||||
.validate(dt_start)
|
||||
.map_err(|e| RecurrenceError::InvalidRule {
|
||||
uid: master.uid.clone(),
|
||||
rrule: text.clone(),
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
set = set.rrule(validated);
|
||||
}
|
||||
for date in &master.rdate {
|
||||
set = set.rdate(zones.instant(date).with_timezone(&tz));
|
||||
}
|
||||
for date in &master.exdate {
|
||||
set = set.exdate(zones.instant(date).with_timezone(&tz));
|
||||
}
|
||||
|
||||
// An event may begin before the window and run into it, so the search
|
||||
// starts far enough back to catch the longest one present.
|
||||
let lead = master.duration().max(TimeDelta::days(1));
|
||||
let result = set
|
||||
.after((window.from - lead).with_timezone(&tz))
|
||||
.before(window.to.with_timezone(&tz))
|
||||
.all(MAX_PER_SERIES);
|
||||
|
||||
if result.limited {
|
||||
return Err(RecurrenceError::TooManyOccurrences {
|
||||
uid: master.uid.clone(),
|
||||
limit: MAX_PER_SERIES,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(result
|
||||
.dates
|
||||
.into_iter()
|
||||
.map(|dt| {
|
||||
rebuild(
|
||||
&master.dtstart,
|
||||
to_local(resolved.tz, dt.with_timezone(&Utc)),
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Puts a generated instant back into the form the master's `DTSTART` used.
|
||||
///
|
||||
/// A series of all-day events yields dates; a zoned series yields the same zone
|
||||
/// it started in, spelled the same way. Neither is normalised, because an
|
||||
/// occurrence that changed shape on the way out would not match the
|
||||
/// `RECURRENCE-ID` a later edit has to send back.
|
||||
fn rebuild(pattern: &CalendarDateTime, local: chrono::NaiveDateTime) -> CalendarDateTime {
|
||||
match pattern {
|
||||
CalendarDateTime::Date { .. } => CalendarDateTime::Date { date: local.date() },
|
||||
CalendarDateTime::Floating { .. } => CalendarDateTime::Floating { local },
|
||||
CalendarDateTime::Utc { .. } => CalendarDateTime::Utc {
|
||||
utc: local.and_utc(),
|
||||
},
|
||||
CalendarDateTime::Zoned { tzid, .. } => CalendarDateTime::Zoned {
|
||||
local,
|
||||
tzid: tzid.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// An event that is not part of a series, or an override standing on its own.
|
||||
fn single_occurrence(event: &VEvent, zones: Zones) -> Occurrence {
|
||||
let start = event.dtstart.clone();
|
||||
let end = event.dtend();
|
||||
Occurrence {
|
||||
start_utc: zones.instant(&start),
|
||||
end_utc: zones.instant(&end),
|
||||
recurrence_id: event.recurrence_id.clone(),
|
||||
is_override: event.is_override(),
|
||||
event: event.clone(),
|
||||
start,
|
||||
end,
|
||||
}
|
||||
}
|
||||
|
||||
/// One generated instance of a series.
|
||||
fn occurrence_at(master: &VEvent, start: CalendarDateTime, zones: Zones) -> Occurrence {
|
||||
// Wall-clock arithmetic, which is what RFC 5545 means: a 09:00-10:00
|
||||
// meeting is 09:00-10:00 local at every occurrence, including the ones
|
||||
// either side of a daylight-saving change.
|
||||
let end = start.shifted(master.duration());
|
||||
Occurrence {
|
||||
start_utc: zones.instant(&start),
|
||||
end_utc: zones.instant(&end),
|
||||
recurrence_id: Some(start.clone()),
|
||||
is_override: false,
|
||||
event: master.clone(),
|
||||
start,
|
||||
end,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every zone identifier the calendar names that could not be resolved.
|
||||
///
|
||||
/// Worth surfacing: an unresolved zone means times may be wrong by hours, and
|
||||
/// the alternative to reporting it is pretending otherwise.
|
||||
pub fn unresolved_zones(calendar: &VCalendar, zones: Zones) -> Vec<String> {
|
||||
let mut names: Vec<String> = calendar
|
||||
.referenced_tzids()
|
||||
.into_iter()
|
||||
.filter(|id| {
|
||||
TzId::new(*id)
|
||||
.is_ok_and(|tzid| zones.resolve(&tzid).source == super::zone::ZoneSource::Assumed)
|
||||
})
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
names.sort();
|
||||
names.dedup();
|
||||
names
|
||||
}
|
||||
|
||||
/// Resolves a single wall-clock time in a named zone. Exposed because the
|
||||
/// CalDAV layer needs the same conversion when it builds time-range queries.
|
||||
pub fn instant_in(tz: chrono_tz::Tz, local: chrono::NaiveDateTime) -> DateTime<Utc> {
|
||||
to_instant(tz, local)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//! Recurrence expansion — RFC 5545 §3.8.5.
|
||||
//!
|
||||
//! Two decisions from the audit shape this module.
|
||||
//!
|
||||
//! **It runs on the server.** The previous iteration expanded rules in the
|
||||
//! browser, which meant one hand-written implementation with no tests, shipped
|
||||
//! into the WASM bundle, producing instances the view layer then had to invent
|
||||
//! identities for. Here the client receives [`Occurrence`]s that already know
|
||||
//! what they are.
|
||||
//!
|
||||
//! **The rules come from `rrule`.** What this module actually implements is the
|
||||
//! layer above: reconciling a series master with the `RECURRENCE-ID` overrides
|
||||
//! that replace individual occurrences of it, and resolving the zones involved.
|
||||
//!
|
||||
//! [`Occurrence`]: crate::model::Occurrence
|
||||
|
||||
mod error;
|
||||
mod expand;
|
||||
mod windows_zones;
|
||||
mod zone;
|
||||
|
||||
pub use error::RecurrenceError;
|
||||
pub use expand::{Window, expand, instant_in, unresolved_zones};
|
||||
pub use windows_zones::iana_for_windows;
|
||||
pub use zone::{ResolvedZone, ZoneSource, Zones, to_instant, to_local};
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Windows time zone names, mapped to IANA identifiers.
|
||||
//!
|
||||
//! Exchange writes `TZID:Pacific Standard Time` rather than
|
||||
//! `America/Los_Angeles`, so a published Outlook feed names zones that no IANA
|
||||
//! database contains. This is the mapping that closes the gap.
|
||||
//!
|
||||
//! Generated from CLDR `common/supplemental/windowsZones.xml` (typeVersion
|
||||
//! 2021a), keeping the `territory="001"` row for each Windows name — the
|
||||
//! territory-independent default. Regenerate rather than hand-editing.
|
||||
//!
|
||||
//! The reverse direction is deliberately absent: many IANA zones share one
|
||||
//! Windows name, so it does not round-trip. That is one more reason the parser
|
||||
//! stores whatever `TZID` the producer wrote and resolves it only here.
|
||||
|
||||
/// The IANA identifier for a Windows zone name, if it is one.
|
||||
///
|
||||
/// Matched case-insensitively; these names are prose and producers vary.
|
||||
pub fn iana_for_windows(name: &str) -> Option<&'static str> {
|
||||
WINDOWS_TO_IANA
|
||||
.iter()
|
||||
.find(|(windows, _)| windows.eq_ignore_ascii_case(name))
|
||||
.map(|(_, iana)| *iana)
|
||||
}
|
||||
|
||||
/// 139 Windows zone names and the IANA zone each stands for.
|
||||
const WINDOWS_TO_IANA: [(&str, &str); 139] = [
|
||||
("AUS Central Standard Time", "Australia/Darwin"),
|
||||
("AUS Eastern Standard Time", "Australia/Sydney"),
|
||||
("Afghanistan Standard Time", "Asia/Kabul"),
|
||||
("Alaskan Standard Time", "America/Anchorage"),
|
||||
("Aleutian Standard Time", "America/Adak"),
|
||||
("Altai Standard Time", "Asia/Barnaul"),
|
||||
("Arab Standard Time", "Asia/Riyadh"),
|
||||
("Arabian Standard Time", "Asia/Dubai"),
|
||||
("Arabic Standard Time", "Asia/Baghdad"),
|
||||
("Argentina Standard Time", "America/Buenos_Aires"),
|
||||
("Astrakhan Standard Time", "Europe/Astrakhan"),
|
||||
("Atlantic Standard Time", "America/Halifax"),
|
||||
("Aus Central W. Standard Time", "Australia/Eucla"),
|
||||
("Azerbaijan Standard Time", "Asia/Baku"),
|
||||
("Azores Standard Time", "Atlantic/Azores"),
|
||||
("Bahia Standard Time", "America/Bahia"),
|
||||
("Bangladesh Standard Time", "Asia/Dhaka"),
|
||||
("Belarus Standard Time", "Europe/Minsk"),
|
||||
("Bougainville Standard Time", "Pacific/Bougainville"),
|
||||
("Canada Central Standard Time", "America/Regina"),
|
||||
("Cape Verde Standard Time", "Atlantic/Cape_Verde"),
|
||||
("Caucasus Standard Time", "Asia/Yerevan"),
|
||||
("Cen. Australia Standard Time", "Australia/Adelaide"),
|
||||
("Central America Standard Time", "America/Guatemala"),
|
||||
("Central Asia Standard Time", "Asia/Bishkek"),
|
||||
("Central Brazilian Standard Time", "America/Cuiaba"),
|
||||
("Central Europe Standard Time", "Europe/Budapest"),
|
||||
("Central European Standard Time", "Europe/Warsaw"),
|
||||
("Central Pacific Standard Time", "Pacific/Guadalcanal"),
|
||||
("Central Standard Time", "America/Chicago"),
|
||||
("Central Standard Time (Mexico)", "America/Mexico_City"),
|
||||
("Chatham Islands Standard Time", "Pacific/Chatham"),
|
||||
("China Standard Time", "Asia/Shanghai"),
|
||||
("Cuba Standard Time", "America/Havana"),
|
||||
("Dateline Standard Time", "Etc/GMT+12"),
|
||||
("E. Africa Standard Time", "Africa/Nairobi"),
|
||||
("E. Australia Standard Time", "Australia/Brisbane"),
|
||||
("E. Europe Standard Time", "Europe/Chisinau"),
|
||||
("E. South America Standard Time", "America/Sao_Paulo"),
|
||||
("Easter Island Standard Time", "Pacific/Easter"),
|
||||
("Eastern Standard Time", "America/New_York"),
|
||||
("Eastern Standard Time (Mexico)", "America/Cancun"),
|
||||
("Egypt Standard Time", "Africa/Cairo"),
|
||||
("Ekaterinburg Standard Time", "Asia/Yekaterinburg"),
|
||||
("FLE Standard Time", "Europe/Kiev"),
|
||||
("Fiji Standard Time", "Pacific/Fiji"),
|
||||
("GMT Standard Time", "Europe/London"),
|
||||
("GTB Standard Time", "Europe/Bucharest"),
|
||||
("Georgian Standard Time", "Asia/Tbilisi"),
|
||||
("Greenland Standard Time", "America/Godthab"),
|
||||
("Greenwich Standard Time", "Atlantic/Reykjavik"),
|
||||
("Haiti Standard Time", "America/Port-au-Prince"),
|
||||
("Hawaiian Standard Time", "Pacific/Honolulu"),
|
||||
("India Standard Time", "Asia/Calcutta"),
|
||||
("Iran Standard Time", "Asia/Tehran"),
|
||||
("Israel Standard Time", "Asia/Jerusalem"),
|
||||
("Jordan Standard Time", "Asia/Amman"),
|
||||
("Kaliningrad Standard Time", "Europe/Kaliningrad"),
|
||||
("Korea Standard Time", "Asia/Seoul"),
|
||||
("Libya Standard Time", "Africa/Tripoli"),
|
||||
("Line Islands Standard Time", "Pacific/Kiritimati"),
|
||||
("Lord Howe Standard Time", "Australia/Lord_Howe"),
|
||||
("Magadan Standard Time", "Asia/Magadan"),
|
||||
("Magallanes Standard Time", "America/Punta_Arenas"),
|
||||
("Marquesas Standard Time", "Pacific/Marquesas"),
|
||||
("Mauritius Standard Time", "Indian/Mauritius"),
|
||||
("Middle East Standard Time", "Asia/Beirut"),
|
||||
("Montevideo Standard Time", "America/Montevideo"),
|
||||
("Morocco Standard Time", "Africa/Casablanca"),
|
||||
("Mountain Standard Time", "America/Denver"),
|
||||
("Mountain Standard Time (Mexico)", "America/Mazatlan"),
|
||||
("Myanmar Standard Time", "Asia/Rangoon"),
|
||||
("N. Central Asia Standard Time", "Asia/Novosibirsk"),
|
||||
("Namibia Standard Time", "Africa/Windhoek"),
|
||||
("Nepal Standard Time", "Asia/Katmandu"),
|
||||
("New Zealand Standard Time", "Pacific/Auckland"),
|
||||
("Newfoundland Standard Time", "America/St_Johns"),
|
||||
("Norfolk Standard Time", "Pacific/Norfolk"),
|
||||
("North Asia East Standard Time", "Asia/Irkutsk"),
|
||||
("North Asia Standard Time", "Asia/Krasnoyarsk"),
|
||||
("North Korea Standard Time", "Asia/Pyongyang"),
|
||||
("Omsk Standard Time", "Asia/Omsk"),
|
||||
("Pacific SA Standard Time", "America/Santiago"),
|
||||
("Pacific Standard Time", "America/Los_Angeles"),
|
||||
("Pacific Standard Time (Mexico)", "America/Tijuana"),
|
||||
("Pakistan Standard Time", "Asia/Karachi"),
|
||||
("Paraguay Standard Time", "America/Asuncion"),
|
||||
("Qyzylorda Standard Time", "Asia/Qyzylorda"),
|
||||
("Romance Standard Time", "Europe/Paris"),
|
||||
("Russia Time Zone 10", "Asia/Srednekolymsk"),
|
||||
("Russia Time Zone 11", "Asia/Kamchatka"),
|
||||
("Russia Time Zone 3", "Europe/Samara"),
|
||||
("Russian Standard Time", "Europe/Moscow"),
|
||||
("SA Eastern Standard Time", "America/Cayenne"),
|
||||
("SA Pacific Standard Time", "America/Bogota"),
|
||||
("SA Western Standard Time", "America/La_Paz"),
|
||||
("SE Asia Standard Time", "Asia/Bangkok"),
|
||||
("Saint Pierre Standard Time", "America/Miquelon"),
|
||||
("Sakhalin Standard Time", "Asia/Sakhalin"),
|
||||
("Samoa Standard Time", "Pacific/Apia"),
|
||||
("Sao Tome Standard Time", "Africa/Sao_Tome"),
|
||||
("Saratov Standard Time", "Europe/Saratov"),
|
||||
("Singapore Standard Time", "Asia/Singapore"),
|
||||
("South Africa Standard Time", "Africa/Johannesburg"),
|
||||
("South Sudan Standard Time", "Africa/Juba"),
|
||||
("Sri Lanka Standard Time", "Asia/Colombo"),
|
||||
("Sudan Standard Time", "Africa/Khartoum"),
|
||||
("Syria Standard Time", "Asia/Damascus"),
|
||||
("Taipei Standard Time", "Asia/Taipei"),
|
||||
("Tasmania Standard Time", "Australia/Hobart"),
|
||||
("Tocantins Standard Time", "America/Araguaina"),
|
||||
("Tokyo Standard Time", "Asia/Tokyo"),
|
||||
("Tomsk Standard Time", "Asia/Tomsk"),
|
||||
("Tonga Standard Time", "Pacific/Tongatapu"),
|
||||
("Transbaikal Standard Time", "Asia/Chita"),
|
||||
("Turkey Standard Time", "Europe/Istanbul"),
|
||||
("Turks And Caicos Standard Time", "America/Grand_Turk"),
|
||||
("US Eastern Standard Time", "America/Indianapolis"),
|
||||
("US Mountain Standard Time", "America/Phoenix"),
|
||||
("UTC", "Etc/UTC"),
|
||||
("UTC+12", "Etc/GMT-12"),
|
||||
("UTC+13", "Etc/GMT-13"),
|
||||
("UTC-02", "Etc/GMT+2"),
|
||||
("UTC-08", "Etc/GMT+8"),
|
||||
("UTC-09", "Etc/GMT+9"),
|
||||
("UTC-11", "Etc/GMT+11"),
|
||||
("Ulaanbaatar Standard Time", "Asia/Ulaanbaatar"),
|
||||
("Venezuela Standard Time", "America/Caracas"),
|
||||
("Vladivostok Standard Time", "Asia/Vladivostok"),
|
||||
("Volgograd Standard Time", "Europe/Volgograd"),
|
||||
("W. Australia Standard Time", "Australia/Perth"),
|
||||
("W. Central Africa Standard Time", "Africa/Lagos"),
|
||||
("W. Europe Standard Time", "Europe/Berlin"),
|
||||
("W. Mongolia Standard Time", "Asia/Hovd"),
|
||||
("West Asia Standard Time", "Asia/Tashkent"),
|
||||
("West Bank Standard Time", "Asia/Hebron"),
|
||||
("West Pacific Standard Time", "Pacific/Port_Moresby"),
|
||||
("Yakutsk Standard Time", "Asia/Yakutsk"),
|
||||
("Yukon Standard Time", "America/Whitehorse"),
|
||||
];
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Turning a `TZID` into a real time zone, and local times into instants.
|
||||
//!
|
||||
//! The rule this whole module exists to enforce: **store the zone, derive the
|
||||
//! offset**. The previous iteration did the reverse — the client computed its
|
||||
//! current UTC offset with `get_timezone_offset()` and sent that as the
|
||||
//! event's `timezone` — and an offset cannot tell January from July. A weekly
|
||||
//! 9am meeting created in winter moved to 8am the moment the clocks changed.
|
||||
//!
|
||||
//! Resolution is a ladder, because `TZID` is an opaque string and producers
|
||||
//! disagree about what goes in it:
|
||||
//!
|
||||
//! 1. an IANA identifier, which is what most clients write;
|
||||
//! 2. a Windows zone name, which is what Exchange writes;
|
||||
//! 3. nothing that resolves — recorded as an assumption rather than hidden.
|
||||
|
||||
use super::windows_zones::iana_for_windows;
|
||||
use crate::model::{CalendarDateTime, TzId};
|
||||
use chrono::{DateTime, LocalResult, NaiveDateTime, Offset, TimeDelta, TimeZone, Utc};
|
||||
use chrono_tz::Tz;
|
||||
|
||||
/// A resolved zone, and how it was arrived at.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ResolvedZone {
|
||||
pub tz: Tz,
|
||||
pub source: ZoneSource,
|
||||
}
|
||||
|
||||
/// Which rung of the ladder produced a zone.
|
||||
///
|
||||
/// Reported rather than swallowed: `Assumed` means a calendar named a zone
|
||||
/// nothing could identify, and times from it may be wrong by hours. That is
|
||||
/// worth a log line at the point of use, not a silent shrug.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ZoneSource {
|
||||
/// The `TZID` was an IANA identifier.
|
||||
Iana,
|
||||
/// The `TZID` was a Windows zone name, mapped through CLDR.
|
||||
WindowsName,
|
||||
/// Nothing matched; the viewer's own zone was substituted.
|
||||
Assumed,
|
||||
}
|
||||
|
||||
/// Resolves the zones named by one calendar, against a viewer's own zone.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Zones {
|
||||
/// The zone to interpret floating times in, and to fall back to.
|
||||
///
|
||||
/// A floating time means "this wall-clock time, wherever it is read", so
|
||||
/// the viewer's zone is not a guess there — it is the definition.
|
||||
pub display: Tz,
|
||||
}
|
||||
|
||||
impl Zones {
|
||||
pub fn new(display: Tz) -> Self {
|
||||
Self { display }
|
||||
}
|
||||
|
||||
/// Walks the ladder for one identifier.
|
||||
pub fn resolve(&self, tzid: &TzId) -> ResolvedZone {
|
||||
let name = tzid.as_str().trim();
|
||||
|
||||
if let Ok(tz) = name.parse::<Tz>() {
|
||||
return ResolvedZone {
|
||||
tz,
|
||||
source: ZoneSource::Iana,
|
||||
};
|
||||
}
|
||||
if let Some(tz) = iana_for_windows(name).and_then(|iana| iana.parse::<Tz>().ok()) {
|
||||
return ResolvedZone {
|
||||
tz,
|
||||
source: ZoneSource::WindowsName,
|
||||
};
|
||||
}
|
||||
ResolvedZone {
|
||||
tz: self.display,
|
||||
source: ZoneSource::Assumed,
|
||||
}
|
||||
}
|
||||
|
||||
/// The zone a value should be interpreted in.
|
||||
pub fn zone_of(&self, dt: &CalendarDateTime) -> ResolvedZone {
|
||||
match dt {
|
||||
CalendarDateTime::Zoned { tzid, .. } => self.resolve(tzid),
|
||||
CalendarDateTime::Utc { .. } => ResolvedZone {
|
||||
tz: Tz::UTC,
|
||||
source: ZoneSource::Iana,
|
||||
},
|
||||
// A date has no time of day and a floating time has no zone; both
|
||||
// are read in the viewer's zone.
|
||||
CalendarDateTime::Date { .. } | CalendarDateTime::Floating { .. } => ResolvedZone {
|
||||
tz: self.display,
|
||||
source: ZoneSource::Iana,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The instant a value denotes.
|
||||
pub fn instant(&self, dt: &CalendarDateTime) -> DateTime<Utc> {
|
||||
match dt {
|
||||
CalendarDateTime::Utc { utc } => *utc,
|
||||
_ => {
|
||||
let tz = self.zone_of(dt).tz;
|
||||
to_instant(tz, dt.naive_local())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a wall-clock time in a zone to the instant it names.
|
||||
///
|
||||
/// Both awkward cases are handled deliberately rather than by `unwrap`:
|
||||
///
|
||||
/// - **Ambiguous** — the hour that repeats when clocks go back. The earlier of
|
||||
/// the two is taken, which is what a person setting an alarm for 01:30 means.
|
||||
/// - **Non-existent** — the hour skipped when clocks go forward. The time is
|
||||
/// read using the offset that was in force just before the gap, which slides
|
||||
/// it past the gap by exactly the gap's own length: an 02:30 meeting on a
|
||||
/// spring-forward Sunday happens at 03:30.
|
||||
///
|
||||
/// The alternative for a gap — snapping to the first valid instant — would put
|
||||
/// an 02:15 and an 02:45 meeting at the same moment, and lose the order they
|
||||
/// were in. Sliding preserves both.
|
||||
pub fn to_instant(tz: Tz, local: NaiveDateTime) -> DateTime<Utc> {
|
||||
match tz.from_local_datetime(&local) {
|
||||
LocalResult::Single(dt) => dt.with_timezone(&Utc),
|
||||
LocalResult::Ambiguous(earliest, _) => earliest.with_timezone(&Utc),
|
||||
LocalResult::None => {
|
||||
// Walk back to the last wall-clock time that does exist and borrow
|
||||
// its offset. Minute steps, so an unusual transition size — Lord
|
||||
// Howe Island moves by 30 minutes — is still handled exactly.
|
||||
for minutes in 1..=(6 * 60) {
|
||||
let before = local - TimeDelta::minutes(minutes);
|
||||
if let Some(dt) = tz.from_local_datetime(&before).earliest() {
|
||||
let offset = dt.offset().fix();
|
||||
return local.and_utc() - TimeDelta::seconds(offset.local_minus_utc().into());
|
||||
}
|
||||
}
|
||||
local.and_utc()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts an instant to the wall-clock time it shows in a zone.
|
||||
pub fn to_local(tz: Tz, instant: DateTime<Utc>) -> NaiveDateTime {
|
||||
instant.with_timezone(&tz).naive_local()
|
||||
}
|
||||
@@ -0,0 +1,806 @@
|
||||
//! Recurrence expansion, checked against known-good outputs.
|
||||
//!
|
||||
//! Most of these are table-driven and deliberately boring: a rule, a window,
|
||||
//! and the exact list of local times it should produce. The interesting ones
|
||||
//! are the boundaries — daylight saving, leap day, the difference between an
|
||||
//! excluded occurrence and a replaced one — because those are where the
|
||||
//! previous iteration's hand-written expander went wrong, and it had no tests
|
||||
//! at all to say so.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use chrono::{DateTime, TimeDelta, Utc};
|
||||
use chrono_tz::Tz;
|
||||
use pretty_assertions::assert_eq;
|
||||
use runway_core::ical;
|
||||
use runway_core::model::*;
|
||||
use runway_core::recurrence::{RecurrenceError, Window, ZoneSource, Zones, expand};
|
||||
use std::path::Path;
|
||||
|
||||
// ------------------------------------------------------------------ helpers --
|
||||
|
||||
fn denver() -> Tz {
|
||||
Tz::America__Denver
|
||||
}
|
||||
|
||||
fn instant(text: &str) -> DateTime<Utc> {
|
||||
text.parse::<DateTime<Utc>>()
|
||||
.unwrap_or_else(|e| panic!("{text}: {e}"))
|
||||
}
|
||||
|
||||
fn window(from: &str, to: &str) -> Window {
|
||||
Window::new(instant(from), instant(to))
|
||||
}
|
||||
|
||||
/// Wraps event bodies in a `VCALENDAR` and parses them.
|
||||
fn calendar(events: &str) -> VCalendar {
|
||||
let ics = format!(
|
||||
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//test//EN\r\n{}END:VCALENDAR\r\n",
|
||||
events.replace('\n', "\r\n"),
|
||||
);
|
||||
ical::parse(&ics).unwrap_or_else(|e| panic!("fixture did not parse: {e}\n{ics}"))
|
||||
}
|
||||
|
||||
/// A recurring event in Denver, 09:00–09:30, starting on the given date.
|
||||
fn denver_series(start: &str, rrule: &str, extra: &str) -> VCalendar {
|
||||
calendar(&format!(
|
||||
"BEGIN:VEVENT
|
||||
UID:series@test
|
||||
DTSTAMP:20260101T000000Z
|
||||
DTSTART;TZID=America/Denver:{start}T090000
|
||||
DTEND;TZID=America/Denver:{start}T093000
|
||||
SUMMARY:Series
|
||||
RRULE:{rrule}
|
||||
{extra}END:VEVENT
|
||||
"
|
||||
))
|
||||
}
|
||||
|
||||
fn occurrences(cal: &VCalendar, window: Window, tz: Tz) -> Vec<Occurrence> {
|
||||
expand(cal, window, Zones::new(tz)).unwrap_or_else(|e| panic!("expansion failed: {e}"))
|
||||
}
|
||||
|
||||
/// The local wall-clock start of each occurrence, as written.
|
||||
fn local_starts(cal: &VCalendar, window: Window, tz: Tz) -> Vec<String> {
|
||||
occurrences(cal, window, tz)
|
||||
.iter()
|
||||
.map(|o| match &o.start {
|
||||
CalendarDateTime::Date { date } => date.to_string(),
|
||||
other => other.naive_local().to_string(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The UTC instant of each occurrence, which is what shows a zone bug.
|
||||
fn utc_starts(cal: &VCalendar, window: Window, tz: Tz) -> Vec<String> {
|
||||
occurrences(cal, window, tz)
|
||||
.iter()
|
||||
.map(|o| o.start_utc.to_rfc3339())
|
||||
.collect()
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- basic rules --
|
||||
|
||||
#[test]
|
||||
fn a_weekly_rule_produces_one_occurrence_a_week() {
|
||||
let cal = denver_series("20260105", "FREQ=WEEKLY;BYDAY=MO", "");
|
||||
|
||||
assert_eq!(
|
||||
local_starts(
|
||||
&cal,
|
||||
window("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"),
|
||||
denver()
|
||||
),
|
||||
vec![
|
||||
"2026-01-05 09:00:00",
|
||||
"2026-01-12 09:00:00",
|
||||
"2026-01-19 09:00:00",
|
||||
"2026-01-26 09:00:00",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_and_until_bound_a_series_the_same_way() {
|
||||
let by_count = denver_series("20260105", "FREQ=DAILY;COUNT=3", "");
|
||||
let by_until = denver_series("20260105", "FREQ=DAILY;UNTIL=20260107T163000Z", "");
|
||||
let span = window("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z");
|
||||
|
||||
let expected = vec![
|
||||
"2026-01-05 09:00:00",
|
||||
"2026-01-06 09:00:00",
|
||||
"2026-01-07 09:00:00",
|
||||
];
|
||||
assert_eq!(local_starts(&by_count, span, denver()), expected);
|
||||
assert_eq!(
|
||||
local_starts(&by_until, span, denver()),
|
||||
expected,
|
||||
"UNTIL is a UTC instant while the occurrences are local; comparing the \
|
||||
two in the wrong frame is how a series gains or loses its last event",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_monthly_nth_weekday_rule_lands_on_the_right_days() {
|
||||
// The case the previous iteration built a form for and then dropped on the
|
||||
// way to the API, so it never reached the server at all.
|
||||
let cal = denver_series("20260120", "FREQ=MONTHLY;BYDAY=3TU", "");
|
||||
|
||||
assert_eq!(
|
||||
local_starts(
|
||||
&cal,
|
||||
window("2026-01-01T00:00:00Z", "2026-05-01T00:00:00Z"),
|
||||
denver()
|
||||
),
|
||||
vec![
|
||||
"2026-01-20 09:00:00",
|
||||
"2026-02-17 09:00:00",
|
||||
"2026-03-17 09:00:00",
|
||||
"2026-04-21 09:00:00",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_monthly_by_monthday_rule_skips_months_that_are_too_short() {
|
||||
let cal = denver_series("20260131", "FREQ=MONTHLY;BYMONTHDAY=31", "");
|
||||
|
||||
assert_eq!(
|
||||
local_starts(
|
||||
&cal,
|
||||
window("2026-01-01T00:00:00Z", "2026-06-01T00:00:00Z"),
|
||||
denver()
|
||||
),
|
||||
vec![
|
||||
"2026-01-31 09:00:00",
|
||||
"2026-03-31 09:00:00",
|
||||
"2026-05-31 09:00:00",
|
||||
],
|
||||
"February, April and June have no 31st, and the rule simply does not \
|
||||
fire -- it does not roll over into the next month",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_leap_day_rule_only_fires_in_leap_years() {
|
||||
let cal = calendar(
|
||||
"BEGIN:VEVENT
|
||||
UID:leap@test
|
||||
DTSTAMP:20240101T000000Z
|
||||
DTSTART;VALUE=DATE:20240229
|
||||
DTEND;VALUE=DATE:20240301
|
||||
SUMMARY:Leap day
|
||||
RRULE:FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=29
|
||||
END:VEVENT
|
||||
",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
local_starts(
|
||||
&cal,
|
||||
window("2024-01-01T00:00:00Z", "2034-01-01T00:00:00Z"),
|
||||
denver()
|
||||
),
|
||||
vec!["2024-02-29", "2028-02-29", "2032-02-29"],
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- daylight saving
|
||||
|
||||
#[test]
|
||||
fn a_weekly_meeting_keeps_its_local_time_across_spring_forward() {
|
||||
// Denver moves to daylight time on 2026-03-08.
|
||||
let cal = denver_series("20260302", "FREQ=WEEKLY;BYDAY=MO", "");
|
||||
let span = window("2026-03-01T00:00:00Z", "2026-03-24T00:00:00Z");
|
||||
|
||||
assert_eq!(
|
||||
local_starts(&cal, span, denver()),
|
||||
vec![
|
||||
"2026-03-02 09:00:00",
|
||||
"2026-03-09 09:00:00",
|
||||
"2026-03-16 09:00:00",
|
||||
"2026-03-23 09:00:00",
|
||||
],
|
||||
"the meeting is at 09:00 every week, before and after the change",
|
||||
);
|
||||
assert_eq!(
|
||||
utc_starts(&cal, span, denver()),
|
||||
vec![
|
||||
"2026-03-02T16:00:00+00:00",
|
||||
"2026-03-09T15:00:00+00:00",
|
||||
"2026-03-16T15:00:00+00:00",
|
||||
"2026-03-23T15:00:00+00:00",
|
||||
],
|
||||
"and the instant moves by an hour, which is the whole point. The last \
|
||||
iteration sent a fixed UTC offset in place of a zone, so this series \
|
||||
drifted to 08:00 for the rest of the year",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_weekly_meeting_keeps_its_local_time_across_fall_back() {
|
||||
// Denver returns to standard time on 2026-11-01.
|
||||
let cal = denver_series("20261026", "FREQ=WEEKLY;BYDAY=MO", "");
|
||||
let span = window("2026-10-20T00:00:00Z", "2026-11-17T00:00:00Z");
|
||||
|
||||
assert_eq!(
|
||||
utc_starts(&cal, span, denver()),
|
||||
vec![
|
||||
"2026-10-26T15:00:00+00:00",
|
||||
"2026-11-02T16:00:00+00:00",
|
||||
"2026-11-09T16:00:00+00:00",
|
||||
"2026-11-16T16:00:00+00:00",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_start_inside_the_spring_forward_gap_moves_past_it() {
|
||||
// 02:30 on 2026-03-08 does not exist in Denver; the clock jumps 01:59 to
|
||||
// 03:00. Something has to happen, and the choice is made once, explicitly:
|
||||
// the time slides past the gap by the gap's own length.
|
||||
let cal = calendar(
|
||||
"BEGIN:VEVENT
|
||||
UID:gap@test
|
||||
DTSTAMP:20260101T000000Z
|
||||
DTSTART;TZID=America/Denver:20260308T023000
|
||||
DTEND;TZID=America/Denver:20260308T033000
|
||||
SUMMARY:In the gap
|
||||
END:VEVENT
|
||||
",
|
||||
);
|
||||
|
||||
let found = occurrences(
|
||||
&cal,
|
||||
window("2026-03-08T00:00:00Z", "2026-03-09T00:00:00Z"),
|
||||
denver(),
|
||||
);
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(
|
||||
found[0].start_utc.to_rfc3339(),
|
||||
"2026-03-08T09:30:00+00:00",
|
||||
"03:30 local -- slid past the gap, not silently dropped, not thrown \
|
||||
back to the previous day, and not snapped to 03:00 alongside every \
|
||||
other lost time that morning",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ambiguous_start_takes_the_earlier_of_the_two() {
|
||||
// 01:30 on 2026-11-01 happens twice in Denver.
|
||||
let cal = calendar(
|
||||
"BEGIN:VEVENT
|
||||
UID:ambiguous@test
|
||||
DTSTAMP:20260101T000000Z
|
||||
DTSTART;TZID=America/Denver:20261101T013000
|
||||
DTEND;TZID=America/Denver:20261101T020000
|
||||
SUMMARY:Twice over
|
||||
END:VEVENT
|
||||
",
|
||||
);
|
||||
|
||||
let found = occurrences(
|
||||
&cal,
|
||||
window("2026-11-01T00:00:00Z", "2026-11-02T00:00:00Z"),
|
||||
denver(),
|
||||
);
|
||||
assert_eq!(
|
||||
found[0].start_utc.to_rfc3339(),
|
||||
"2026-11-01T07:30:00+00:00",
|
||||
"the first 01:30, which is what a person setting that time means",
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- exclusions and extras
|
||||
|
||||
#[test]
|
||||
fn exdate_removes_an_occurrence_the_rule_would_generate() {
|
||||
let cal = denver_series(
|
||||
"20260105",
|
||||
"FREQ=WEEKLY;BYDAY=MO",
|
||||
"EXDATE;TZID=America/Denver:20260112T090000,20260126T090000\n",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
local_starts(
|
||||
&cal,
|
||||
window("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"),
|
||||
denver()
|
||||
),
|
||||
vec!["2026-01-05 09:00:00", "2026-01-19 09:00:00"],
|
||||
"both values on the one EXDATE line have to be honoured",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rdate_adds_an_occurrence_the_rule_would_not() {
|
||||
let cal = denver_series(
|
||||
"20260105",
|
||||
"FREQ=WEEKLY;BYDAY=MO;COUNT=2",
|
||||
"RDATE;TZID=America/Denver:20260108T090000\n",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
local_starts(
|
||||
&cal,
|
||||
window("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"),
|
||||
denver()
|
||||
),
|
||||
vec![
|
||||
"2026-01-05 09:00:00",
|
||||
"2026-01-08 09:00:00",
|
||||
"2026-01-12 09:00:00",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_all_day_series_excludes_by_date() {
|
||||
let cal = calendar(
|
||||
"BEGIN:VEVENT
|
||||
UID:bins@test
|
||||
DTSTAMP:20260101T000000Z
|
||||
DTSTART;VALUE=DATE:20260105
|
||||
DTEND;VALUE=DATE:20260106
|
||||
SUMMARY:Bin day
|
||||
RRULE:FREQ=WEEKLY;BYDAY=MO
|
||||
EXDATE;VALUE=DATE:20260119
|
||||
END:VEVENT
|
||||
",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
local_starts(
|
||||
&cal,
|
||||
window("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"),
|
||||
denver()
|
||||
),
|
||||
vec!["2026-01-05", "2026-01-12", "2026-01-26"],
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- overrides --
|
||||
|
||||
#[test]
|
||||
fn an_override_replaces_the_occurrence_it_names_without_duplicating_it() {
|
||||
let cal = calendar(
|
||||
"BEGIN:VEVENT
|
||||
UID:standup@test
|
||||
DTSTAMP:20260101T000000Z
|
||||
DTSTART;TZID=America/Denver:20260105T090000
|
||||
DTEND;TZID=America/Denver:20260105T093000
|
||||
SUMMARY:Standup
|
||||
RRULE:FREQ=WEEKLY;BYDAY=MO
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:standup@test
|
||||
RECURRENCE-ID;TZID=America/Denver:20260112T090000
|
||||
DTSTAMP:20260106T000000Z
|
||||
DTSTART;TZID=America/Denver:20260112T140000
|
||||
DTEND;TZID=America/Denver:20260112T143000
|
||||
SUMMARY:Standup (moved)
|
||||
END:VEVENT
|
||||
",
|
||||
);
|
||||
|
||||
let found = occurrences(
|
||||
&cal,
|
||||
window("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"),
|
||||
denver(),
|
||||
);
|
||||
let on_the_12th: Vec<&Occurrence> = found
|
||||
.iter()
|
||||
.filter(|o| o.start.date().to_string() == "2026-01-12")
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
on_the_12th.len(),
|
||||
1,
|
||||
"exactly one occurrence that day -- the master must not also generate \
|
||||
its 09:00 version. Emitting both is what made a correct feed look \
|
||||
like it was full of duplicates",
|
||||
);
|
||||
assert!(on_the_12th[0].is_override);
|
||||
assert_eq!(
|
||||
on_the_12th[0].start.naive_local().to_string(),
|
||||
"2026-01-12 14:00:00"
|
||||
);
|
||||
assert_eq!(found.len(), 4, "four Mondays in January 2026");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_override_moved_to_another_day_lands_on_the_new_day() {
|
||||
let cal = calendar(
|
||||
"BEGIN:VEVENT
|
||||
UID:moved@test
|
||||
DTSTAMP:20260101T000000Z
|
||||
DTSTART;TZID=America/Denver:20260105T090000
|
||||
DTEND;TZID=America/Denver:20260105T093000
|
||||
SUMMARY:Weekly
|
||||
RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=3
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:moved@test
|
||||
RECURRENCE-ID;TZID=America/Denver:20260112T090000
|
||||
DTSTAMP:20260106T000000Z
|
||||
DTSTART;TZID=America/Denver:20260114T110000
|
||||
DTEND;TZID=America/Denver:20260114T113000
|
||||
SUMMARY:Weekly (rescheduled)
|
||||
END:VEVENT
|
||||
",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
local_starts(
|
||||
&cal,
|
||||
window("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"),
|
||||
denver()
|
||||
),
|
||||
vec![
|
||||
"2026-01-05 09:00:00",
|
||||
"2026-01-14 11:00:00",
|
||||
"2026-01-19 09:00:00",
|
||||
],
|
||||
"RECURRENCE-ID says which occurrence is replaced; DTSTART says when the \
|
||||
replacement happens. Nothing should appear on the 12th",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_override_carries_the_recurrence_id_needed_to_edit_it_again() {
|
||||
let cal = calendar(
|
||||
"BEGIN:VEVENT
|
||||
UID:edit@test
|
||||
DTSTAMP:20260101T000000Z
|
||||
DTSTART;TZID=America/Denver:20260105T090000
|
||||
DTEND;TZID=America/Denver:20260105T093000
|
||||
SUMMARY:Weekly
|
||||
RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=2
|
||||
END:VEVENT
|
||||
",
|
||||
);
|
||||
|
||||
let found = occurrences(
|
||||
&cal,
|
||||
window("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"),
|
||||
denver(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
found[1].recurrence_id,
|
||||
Some(CalendarDateTime::Zoned {
|
||||
local: chrono::NaiveDate::from_ymd_opt(2026, 1, 12)
|
||||
.unwrap()
|
||||
.and_hms_opt(9, 0, 0)
|
||||
.unwrap(),
|
||||
tzid: TzId::new("America/Denver").unwrap(),
|
||||
}),
|
||||
"a generated occurrence knows its own RECURRENCE-ID, so editing it \
|
||||
sends back the value the server will write. The last iteration \
|
||||
encoded this as \"{{uid}}-{{timestamp}}\" and split the string apart \
|
||||
again at the other end",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_override_without_a_master_is_still_an_occurrence() {
|
||||
let cal = calendar(
|
||||
"BEGIN:VEVENT
|
||||
UID:orphan@test
|
||||
RECURRENCE-ID;TZID=America/Denver:20260112T090000
|
||||
DTSTAMP:20260106T000000Z
|
||||
DTSTART;TZID=America/Denver:20260112T140000
|
||||
DTEND;TZID=America/Denver:20260112T143000
|
||||
SUMMARY:Orphaned exception
|
||||
END:VEVENT
|
||||
",
|
||||
);
|
||||
|
||||
let found = occurrences(
|
||||
&cal,
|
||||
window("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"),
|
||||
denver(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
found.len(),
|
||||
1,
|
||||
"a published feed truncates series at its edge"
|
||||
);
|
||||
assert!(found[0].is_override);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- windows --
|
||||
|
||||
#[test]
|
||||
fn an_event_straddling_the_window_edge_is_included() {
|
||||
let cal = calendar(
|
||||
"BEGIN:VEVENT
|
||||
UID:long@test
|
||||
DTSTAMP:20260101T000000Z
|
||||
DTSTART;TZID=America/Denver:20260104T220000
|
||||
DTEND;TZID=America/Denver:20260105T020000
|
||||
SUMMARY:Across midnight
|
||||
END:VEVENT
|
||||
",
|
||||
);
|
||||
|
||||
let found = occurrences(
|
||||
&cal,
|
||||
window("2026-01-05T00:00:00Z", "2026-01-06T00:00:00Z"),
|
||||
denver(),
|
||||
);
|
||||
assert_eq!(
|
||||
found.len(),
|
||||
1,
|
||||
"it starts before the window but runs into it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_event_wholly_outside_the_window_is_excluded() {
|
||||
let cal = denver_series("20260105", "FREQ=WEEKLY;BYDAY=MO;COUNT=2", "");
|
||||
|
||||
assert!(
|
||||
occurrences(
|
||||
&cal,
|
||||
window("2026-06-01T00:00:00Z", "2026-07-01T00:00:00Z"),
|
||||
denver()
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn results_are_ordered_and_deterministic() {
|
||||
let cal = calendar(
|
||||
"BEGIN:VEVENT
|
||||
UID:b@test
|
||||
DTSTAMP:20260101T000000Z
|
||||
DTSTART;TZID=America/Denver:20260105T140000
|
||||
DTEND;TZID=America/Denver:20260105T150000
|
||||
SUMMARY:Afternoon
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:a@test
|
||||
DTSTAMP:20260101T000000Z
|
||||
DTSTART;TZID=America/Denver:20260105T090000
|
||||
DTEND;TZID=America/Denver:20260105T100000
|
||||
SUMMARY:Morning
|
||||
END:VEVENT
|
||||
",
|
||||
);
|
||||
let span = window("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z");
|
||||
|
||||
let first = occurrences(&cal, span, denver());
|
||||
assert_eq!(
|
||||
first.iter().map(Occurrence::uid).collect::<Vec<_>>(),
|
||||
vec!["a@test", "b@test"],
|
||||
);
|
||||
assert_eq!(first, occurrences(&cal, span, denver()));
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- timezones --
|
||||
|
||||
#[test]
|
||||
fn a_windows_zone_name_resolves_through_cldr() {
|
||||
let zones = Zones::new(denver());
|
||||
|
||||
for (windows, iana) in [
|
||||
("Pacific Standard Time", Tz::America__Los_Angeles),
|
||||
("Eastern Standard Time", Tz::America__New_York),
|
||||
("Mountain Standard Time", Tz::America__Denver),
|
||||
("GTB Standard Time", Tz::Europe__Bucharest),
|
||||
] {
|
||||
let resolved = zones.resolve(&TzId::new(windows).unwrap());
|
||||
assert_eq!(resolved.tz, iana, "{windows}");
|
||||
assert_eq!(resolved.source, ZoneSource::WindowsName);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_iana_identifier_is_preferred_and_reported_as_such() {
|
||||
let resolved = Zones::new(denver()).resolve(&TzId::new("Europe/Zurich").unwrap());
|
||||
|
||||
assert_eq!(resolved.tz, Tz::Europe__Zurich);
|
||||
assert_eq!(resolved.source, ZoneSource::Iana);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unresolvable_zone_is_reported_rather_than_hidden() {
|
||||
let resolved = Zones::new(denver()).resolve(&TzId::new("Customized Time Zone 3").unwrap());
|
||||
|
||||
assert_eq!(resolved.source, ZoneSource::Assumed);
|
||||
assert_eq!(
|
||||
resolved.tz,
|
||||
denver(),
|
||||
"something has to be assumed, but the caller is told it was assumed \
|
||||
rather than being handed a silently wrong time",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_event_in_a_windows_zone_resolves_to_the_right_instant() {
|
||||
let cal = calendar(
|
||||
"BEGIN:VEVENT
|
||||
UID:exchange@test
|
||||
DTSTAMP:20260101T000000Z
|
||||
DTSTART;TZID=Pacific Standard Time:20260115T090000
|
||||
DTEND;TZID=Pacific Standard Time:20260115T093000
|
||||
SUMMARY:From Exchange
|
||||
END:VEVENT
|
||||
",
|
||||
);
|
||||
|
||||
let found = occurrences(
|
||||
&cal,
|
||||
window("2026-01-15T00:00:00Z", "2026-01-16T00:00:00Z"),
|
||||
denver(),
|
||||
);
|
||||
assert_eq!(
|
||||
found[0].start_utc.to_rfc3339(),
|
||||
"2026-01-15T17:00:00+00:00",
|
||||
"09:00 Los Angeles in January is 17:00 UTC",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_floating_time_is_read_in_the_viewers_zone() {
|
||||
let cal = calendar(
|
||||
"BEGIN:VEVENT
|
||||
UID:floating@test
|
||||
DTSTAMP:20260101T000000Z
|
||||
DTSTART:20260115T090000
|
||||
DTEND:20260115T100000
|
||||
SUMMARY:Nine, wherever you are
|
||||
END:VEVENT
|
||||
",
|
||||
);
|
||||
let span = window("2026-01-15T00:00:00Z", "2026-01-16T00:00:00Z");
|
||||
|
||||
assert_eq!(
|
||||
occurrences(&cal, span, denver())[0].start_utc.to_rfc3339(),
|
||||
"2026-01-15T16:00:00+00:00",
|
||||
);
|
||||
assert_eq!(
|
||||
occurrences(&cal, span, Tz::Europe__Zurich)[0]
|
||||
.start_utc
|
||||
.to_rfc3339(),
|
||||
"2026-01-15T08:00:00+00:00",
|
||||
"the same floating value is a different instant for a different reader, \
|
||||
which is exactly what floating means",
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- limits --
|
||||
|
||||
#[test]
|
||||
fn a_runaway_rule_is_reported_not_truncated() {
|
||||
let cal = denver_series("20260101", "FREQ=MINUTELY", "");
|
||||
|
||||
let result = expand(
|
||||
&cal,
|
||||
window("2026-01-01T00:00:00Z", "2027-01-01T00:00:00Z"),
|
||||
Zones::new(denver()),
|
||||
);
|
||||
|
||||
assert!(
|
||||
matches!(result, Err(RecurrenceError::TooManyOccurrences { .. })),
|
||||
"half a million occurrences is not a calendar view; failing loudly \
|
||||
beats returning a list that is quietly missing most of the year",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unusable_rule_names_the_event_and_the_rule() {
|
||||
let mut cal = denver_series("20260105", "FREQ=WEEKLY", "");
|
||||
cal.events[0].rrule = Some("FREQ=NONSENSE".to_owned());
|
||||
|
||||
match expand(
|
||||
&cal,
|
||||
window("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"),
|
||||
Zones::new(denver()),
|
||||
) {
|
||||
Err(RecurrenceError::InvalidRule { uid, rrule, .. }) => {
|
||||
assert_eq!(uid, "series@test");
|
||||
assert_eq!(rrule, "FREQ=NONSENSE");
|
||||
}
|
||||
other => panic!("expected a typed error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- against real data ---
|
||||
|
||||
fn outlook_feed() -> VCalendar {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests/golden/outlook/feed-overrides-windows-tz.ics");
|
||||
ical::parse(&std::fs::read_to_string(path).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_outlook_feed_expands_without_duplicating_a_single_day() {
|
||||
let cal = outlook_feed();
|
||||
let found = occurrences(
|
||||
&cal,
|
||||
window("2025-10-01T00:00:00Z", "2026-01-01T00:00:00Z"),
|
||||
denver(),
|
||||
);
|
||||
|
||||
assert!(!found.is_empty());
|
||||
|
||||
// Within one series, no two occurrences may claim the same start. This is
|
||||
// the assertion the old importer's title-matching heuristics were standing
|
||||
// in for -- and it holds here because overrides suppress the occurrence
|
||||
// they replace, not because anything was merged away.
|
||||
let mut seen: Vec<(&str, DateTime<Utc>)> =
|
||||
found.iter().map(|o| (o.uid(), o.start_utc)).collect();
|
||||
let before = seen.len();
|
||||
seen.sort();
|
||||
seen.dedup();
|
||||
assert_eq!(
|
||||
before,
|
||||
seen.len(),
|
||||
"a series produced two occurrences at one instant"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_zone_in_the_outlook_feed_resolves() {
|
||||
let cal = outlook_feed();
|
||||
|
||||
assert_eq!(
|
||||
runway_core::recurrence::unresolved_zones(&cal, Zones::new(denver())),
|
||||
Vec::<String>::new(),
|
||||
"the feed names its zones in Windows form and every one of them maps",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_exchange_series_honours_its_exclusions() {
|
||||
let cal = outlook_feed();
|
||||
let master = cal
|
||||
.events
|
||||
.iter()
|
||||
.find(|e| !e.exdate.is_empty() && e.rrule.is_some())
|
||||
.expect("the feed has a series with exclusions");
|
||||
let excluded: Vec<DateTime<Utc>> = master
|
||||
.exdate
|
||||
.iter()
|
||||
.map(|d| Zones::new(denver()).instant(d))
|
||||
.collect();
|
||||
|
||||
let found = occurrences(
|
||||
&cal,
|
||||
window("2025-10-01T00:00:00Z", "2027-01-01T00:00:00Z"),
|
||||
denver(),
|
||||
);
|
||||
|
||||
for instant in excluded {
|
||||
assert!(
|
||||
!found
|
||||
.iter()
|
||||
.any(|o| o.uid() == master.uid && o.start_utc == instant && !o.is_override),
|
||||
"an EXDATE'd occurrence at {instant} was still generated",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expanding_the_whole_feed_stays_cheap() {
|
||||
// Not a benchmark, a guard: the old client re-expanded every rule in the
|
||||
// browser on every view change.
|
||||
let cal = outlook_feed();
|
||||
let started = std::time::Instant::now();
|
||||
let found = occurrences(
|
||||
&cal,
|
||||
window("2025-01-01T00:00:00Z", "2028-01-01T00:00:00Z"),
|
||||
denver(),
|
||||
);
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert!(!found.is_empty());
|
||||
assert!(
|
||||
elapsed < TimeDelta::seconds(5).to_std().unwrap(),
|
||||
"three years of a real feed took {elapsed:?}",
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user