diff --git a/Cargo.lock b/Cargo.lock index 0b52a4503..857ed807c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1180,6 +1180,14 @@ dependencies = [ "writeable", ] +[[package]] +name = "craft-ical" +version = "0.1.0" +dependencies = [ + "chrono", + "proptest", +] + [[package]] name = "craft-idml" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 3f361200d..d7a54cda3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,7 @@ craft-fig = { path = "crates/fig" } craft-fonts-build = { path = "crates/fonts-build" } craft-i18n = { path = "crates/i18n" } craft-idml = { path = "crates/idml" } +craft-ical = { path = "crates/ical" } craft-jpeg = { path = "crates/jpeg" } craft-kiwi = { path = "crates/kiwi" } craft-keymap = { path = "crates/keymap" } diff --git a/README.md b/README.md index 951fc1ab9..678bf7f1c 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,10 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = "` form on craft-xml elements, value codecs and the package writer; and InDesign Tagged Text, read and written. +- [`craft-ical`](crates/ical/README.md): iCalendar (RFC 5545) building blocks, from MailCraft: content + lines, DATE/DATE-TIME values, durations, TEXT escaping, the common subset of recurrence rules, + and `VTIMEZONE` definitions read and written. No time-zone database; what events and to-dos + mean stays in each app. Bounded and panic-free on hostile input. - [`craft-stream-codecs`](crates/stream-codecs/README.md): byte-stream and bi-level image codecs that PDF, PostScript and TIFF share: LZW, ASCII85, ASCIIHex, RunLength, TIFF and PNG predictors, CCITT Group 3 and 4 decoding and Group 4 encoding, lossless JBIG2 encoding. Every diff --git a/crates/ical/Cargo.toml b/crates/ical/Cargo.toml new file mode 100644 index 000000000..b533c1386 --- /dev/null +++ b/crates/ical/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "craft-ical" +description = "iCalendar (RFC 5545) building blocks: content lines, values, recurrence rules and VTIMEZONE definitions, read and written." +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +chrono = { workspace = true } + +[dev-dependencies] +proptest = { workspace = true } diff --git a/crates/ical/README.md b/crates/ical/README.md new file mode 100644 index 000000000..1781ffc3b --- /dev/null +++ b/crates/ical/README.md @@ -0,0 +1,56 @@ +# craft-ical + +iCalendar (RFC 5545) building blocks, read and written. Times are `chrono` values; instants are +`i64` seconds since 1970-01-01T00:00:00Z. + +- **`lines`**: `Lines` unfolds a text (CRLF, LF or CR; a byte order mark is skipped); + `ContentLine::parse` splits `NAME;PARAM="quoted",x:value` into an upper-cased name, + parameters (RFC 6868 caret escapes undone) and the value as written; `fold` writes a line in + 75-octet pieces without splitting a character; `param_value` quotes and escapes a parameter. +- **`values`**: `When` (`DATE`, floating or zoned `DATE-TIME`, UTC `DATE-TIME`, with the ISO 8601 + punctuation some writers add), `parse_duration`, `escape_text` / `unescape_text`, `split_list`, + `format_date` / `format_local` / `format_utc`, and the wall-clock helpers they rest on. +- **`rrule`**: `Recurrence`, the subset of RRULE calendars show and edit (daily, weekly, monthly, + yearly; interval; weekdays; the nth or last weekday; count; until), `parse_rrule` and + `format_rrule`, and `format_for` / `format_zoned`, which spell out the start's weekday and + month and give `UNTIL` the start's value type. A rule outside the subset is read as the nearest + it holds. +- **`zones`**: `TimeZones::collect` reads a text's `VTIMEZONE`s (yearly rules by weekday or day + of the month, `UNTIL`, `COUNT`, `RDATE`s); a `VTimezone` answers `offset_at(instant)`, + `offset_for_wall(wall)` and `instant(wall)`, with RFC 5545's reading of skipped and repeated + hours. `write_vtimezone` writes a zone from the caller's `ZoneRules` (offset at an instant, + transitions): one `DAYLIGHT` and one `STANDARD` part with yearly rules where the zone keeps one + rule, else change by change. + +No time-zone database is here: matching a `TZID` to an IANA or Windows zone, and which zone an +event is shown in, are the caller's. So is what events and to-dos mean: an app reads them from +`ContentLine`s with these values and maps them onto its own model. + +## Hostile input + +Nothing panics on any text. + +| Limit | Value | +|---|---| +| unfolded line (`lines::MAX_LINE`) | 1 MiB | +| parameters on a line, values of a parameter | 64 each | +| recurrence rule | 4096 bytes, 64 parts | +| durations | about 10,000 years | +| zones per text (`zones::MAX_ZONES`) | 64 | +| parts per zone, dates per part | 64 each | +| `TZID` kept (`zones::MAX_TZID`) | 128 bytes | +| offsets | at most 18 hours | +| work on zone definitions per text (`zones::WORK_BUDGET`) | 20,000,000 units, spent by the caller through `TimeZones::spend` | + +Every date and time written is in the years 1 to 9999; a value outside them is moved to the +nearest end. + +## Origin + +MailCraft `crates/ical` (`lines.rs`, `values.rs`, `rrule.rs`, `zones.rs`) at +[a44a450](https://github.com/UCS-Care-Group/mailcraft/commit/a44a45013d1c6396229c40ab60172d1882b368d5), +by Martin Ball and the MailCraft contributors (MIT OR Apache-2.0), with their tests. MailCraft's +own date types are replaced by `chrono`, its time-zone database by the `ZoneRules` trait (the +writer's tests use rule zones built in the test), and its recurrence model by `Recurrence`. +MailCraft's event and to-do reader and writer (`read.rs`, `write.rs`), and the matching of a +`TZID` against its zone database, stay in MailCraft as the mapping onto its model. diff --git a/crates/ical/proptest-regressions/lines.txt b/crates/ical/proptest-regressions/lines.txt new file mode 100644 index 000000000..bd55905b3 --- /dev/null +++ b/crates/ical/proptest-regressions/lines.txt @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 960cb45b7c8511f655aea177ca4d647864fc5cf713e5fbdd611ddba6ed077047 # shrinks to line = "\u{feff}\u{feff}" diff --git a/crates/ical/src/lib.rs b/crates/ical/src/lib.rs new file mode 100644 index 000000000..be77be9e7 --- /dev/null +++ b/crates/ical/src/lib.rs @@ -0,0 +1,33 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +//! iCalendar (RFC 5545): the layers a calendar program reads and writes `.ics` text with. +//! +//! - [`lines`]: content lines. [`Lines`] unfolds a text, [`ContentLine::parse`] splits a line +//! into its name, parameters (RFC 6868 caret escapes undone) and value, [`fold`] and +//! [`param_value`] write them. +//! - [`values`]: DATE and DATE-TIME values ([`When`]), durations, TEXT escaping, lists. +//! - [`rrule`]: the common subset of recurrence rules ([`Recurrence`]), read and written, with the +//! start's weekday and month spelled out where a rule leans on them. +//! - [`zones`]: `VTIMEZONE` definitions a text brings ([`TimeZones`]), which answer the offset at +//! an instant and the instant of a wall-clock time, and [`write_vtimezone`], which writes a +//! definition from the caller's zone rules ([`ZoneRules`]). +//! +//! Times are `chrono` values; instants are seconds since 1970-01-01T00:00:00Z. There is no +//! time-zone database here: what a `TZID` means beyond the text is the caller's. +//! +//! What events and to-dos mean (which properties become which fields, which zone a time is shown +//! in) is the caller's too: it reads them from [`ContentLine`]s with these values. +//! +//! Input is hostile. Nothing here panics on any text: lines, parameters, rules, zones, parts and +//! dates are capped, and work on zone definitions is budgeted ([`TimeZones::spend`]). + +pub mod lines; +pub mod rrule; +pub mod values; +pub mod zones; + +pub use lines::{ContentLine, Lines, Param, fold, param_value}; +pub use rrule::{Freq, Recurrence, format_rrule, parse_rrule}; +pub use values::When; +pub use zones::{TimeZones, Transition, VTimezone, ZoneRules, write_vtimezone}; diff --git a/crates/ical/src/lines.rs b/crates/ical/src/lines.rs new file mode 100644 index 000000000..9c82a7c94 --- /dev/null +++ b/crates/ical/src/lines.rs @@ -0,0 +1,321 @@ +//! Content lines (RFC 5545 §3.1–3.2): unfolding on the way in, folding on the way out, and the +//! `NAME;PARAM=value;PARAM="quoted":value` shape in between. + +/// Longest logical line kept, in bytes; the rest of a longer line is dropped. +pub const MAX_LINE: usize = 1 << 20; +/// Most parameters read from one line, and most values of one parameter. +const MAX_PARAMS: usize = 64; +/// Octets per physical line when writing. +const FOLD_AT: usize = 75; + +/// The longest prefix of `s` of at most `max` bytes that ends on a char boundary. +pub fn prefix(s: &str, max: usize) -> &str { + if s.len() <= max { + return s; + } + let mut end = max; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + s.get(..end).unwrap_or("") +} + +/// The logical (unfolded) lines of a text. Lines end at CRLF, LF or CR; a line that starts with a +/// space or a tab continues the one before. +pub struct Lines<'a> { + rest: &'a str, +} + +impl<'a> Lines<'a> { + pub fn new(text: &'a str) -> Lines<'a> { + Lines { rest: text.strip_prefix('\u{feff}').unwrap_or(text) } + } + + /// The next physical line, without its line break. + fn physical(&mut self) -> Option<&'a str> { + if self.rest.is_empty() { + return None; + } + let bytes = self.rest.as_bytes(); + let end = bytes.iter().position(|b| matches!(b, b'\r' | b'\n')).unwrap_or(bytes.len()); + let skip = match (bytes.get(end), bytes.get(end.saturating_add(1))) { + (Some(b'\r'), Some(b'\n')) => 2, + (Some(_), _) => 1, + (None, _) => 0, + }; + let line = self.rest.get(..end).unwrap_or(""); + self.rest = self.rest.get(end.saturating_add(skip)..).unwrap_or(""); + Some(line) + } +} + +impl Iterator for Lines<'_> { + type Item = String; + + fn next(&mut self) -> Option { + let mut line = String::new(); + push_capped(&mut line, self.physical()?); + while self.rest.starts_with([' ', '\t']) { + let Some(more) = self.physical() else { break }; + push_capped(&mut line, more.get(1..).unwrap_or("")); + } + Some(line) + } +} + +fn push_capped(line: &mut String, more: &str) { + line.push_str(prefix(more, MAX_LINE.saturating_sub(line.len()))); +} + +/// One property parameter: `CN="Ada Lovelace"`, `ROLE=OPT-PARTICIPANT`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Param { + /// Upper-cased. + pub name: String, + pub values: Vec, +} + +/// One property: `ATTENDEE;CN=Ada:mailto:ada@example.org`. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ContentLine { + /// Upper-cased. + pub name: String, + pub params: Vec, + /// As written: still escaped. + pub value: String, +} + +impl ContentLine { + /// Parse an unfolded line; `None` for a line without a name or without a `:`. + pub fn parse(line: &str) -> Option { + // Every delimiter is ASCII, so the byte positions found here are char boundaries. + let bytes = line.as_bytes(); + let stop = |from: usize, at: &[u8]| { + bytes.get(from..).and_then(|b| b.iter().position(|c| at.contains(c))).map_or(bytes.len(), |i| from.saturating_add(i)) + }; + let mut pos = stop(0, b";:"); + let name = line.get(..pos)?.trim(); + if name.is_empty() { + return None; + } + let mut params = Vec::new(); + while bytes.get(pos) == Some(&b';') { + let start = pos.saturating_add(1); + pos = stop(start, b"=;:"); + let param = line.get(start..pos)?.trim().to_ascii_uppercase(); + let mut values = Vec::new(); + while matches!(bytes.get(pos), Some(b'=' | b',')) { + let from = pos.saturating_add(1); + let value = if bytes.get(from) == Some(&b'"') { + let open = from.saturating_add(1); + let close = stop(open, b"\""); + // A quote that never closes: the line has no value. + bytes.get(close)?; + pos = stop(close, b",;:"); + line.get(open..close)? + } else { + pos = stop(from, b",;:"); + line.get(from..pos)? + }; + if values.len() < MAX_PARAMS { + values.push(caret_decode(value)); + } + } + if !param.is_empty() && params.len() < MAX_PARAMS { + params.push(Param { name: param, values }); + } + } + if bytes.get(pos) != Some(&b':') { + return None; + } + Some(ContentLine { name: name.to_ascii_uppercase(), params, value: line.get(pos.saturating_add(1)..)?.to_string() }) + } + + /// The first value of the parameter `name` (upper case). + pub fn param(&self, name: &str) -> Option<&str> { + self.params.iter().find(|p| p.name == name).and_then(|p| p.values.first()).map(String::as_str) + } +} + +/// Undo RFC 6868: `^n` is a line break, `^'` a double quote, `^^` a caret. +fn caret_decode(s: &str) -> String { + if !s.contains('^') { + return s.to_string(); + } + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + let decoded = match (c, chars.peek()) { + ('^', Some('n')) => Some('\n'), + ('^', Some('\'')) => Some('"'), + ('^', Some('^')) => Some('^'), + _ => None, + }; + match decoded { + Some(d) => { + out.push(d); + chars.next(); + } + None => out.push(c), + } + } + out +} + +/// A parameter value ready to write: RFC 6868 escapes for what a parameter can't hold, other +/// control characters dropped, and quotes around it when it needs them. +pub fn param_value(s: &str) -> String { + let mut out = String::with_capacity(s.len().saturating_add(2)); + for c in s.chars() { + match c { + '^' => out.push_str("^^"), + '"' => out.push_str("^'"), + '\n' => out.push_str("^n"), + '\t' => out.push(c), + c if c.is_control() => {} + c => out.push(c), + } + } + if out.contains([';', ':', ',']) || out.starts_with(' ') || out.ends_with(' ') { format!("\"{out}\"") } else { out } +} + +/// Append `line` to `out` with CRLF, folded so no physical line is longer than 75 octets and no +/// character is split. +pub fn fold(out: &mut String, line: &str) { + let mut rest = line; + let mut room = FOLD_AT; + while rest.len() > room { + let head = prefix(rest, room); + if head.is_empty() { + break; + } + out.push_str(head); + out.push_str("\r\n "); + rest = rest.get(head.len()..).unwrap_or(""); + // The leading space counts. + room = FOLD_AT - 1; + } + out.push_str(rest); + out.push_str("\r\n"); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn lines(text: &str) -> Vec { + Lines::new(text).collect() + } + + #[test] + fn unfolds_every_kind_of_line_break() { + assert_eq!(lines("A:1\r\nB:2\nC:3\rD:4"), ["A:1", "B:2", "C:3", "D:4"]); + assert_eq!(lines("SUMMARY:Lunch \r\n with Ada\r\n"), ["SUMMARY:Lunch with Ada"]); + assert_eq!(lines("SUMMARY:Lun\n\tch\n twice\n"), ["SUMMARY:Lunch twice"]); + // Only the one space is the fold. + assert_eq!(lines("X:a\r\n b"), ["X:a b"]); + assert_eq!(lines("X:a \r\nY:b"), ["X:a ", "Y:b"]); + assert!(lines("").is_empty()); + } + + #[test] + fn a_byte_order_mark_and_blank_lines_are_harmless() { + assert_eq!(lines("\u{feff}BEGIN:VCALENDAR\r\n\r\nEND:VCALENDAR"), ["BEGIN:VCALENDAR", "", "END:VCALENDAR"]); + assert_eq!(ContentLine::parse(""), None); + } + + #[test] + fn parses_names_parameters_and_values() { + let l = ContentLine::parse("dtstart;tzid=Example/Riverton;Value=DATE-TIME:20261012T090000").unwrap(); + assert_eq!(l.name, "DTSTART"); + assert_eq!(l.param("TZID"), Some("Example/Riverton")); + assert_eq!(l.param("VALUE"), Some("DATE-TIME")); + assert_eq!(l.param("CN"), None); + assert_eq!(l.value, "20261012T090000"); + let plain = ContentLine::parse("SUMMARY:Budget: phase two; hiring").unwrap(); + assert_eq!((plain.name.as_str(), plain.params.len(), plain.value.as_str()), ("SUMMARY", 0, "Budget: phase two; hiring")); + assert_eq!(ContentLine::parse("DESCRIPTION:").unwrap().value, ""); + } + + #[test] + fn quoted_parameters_hide_delimiters() { + let l = ContentLine::parse("ATTENDEE;CN=\"Okafor, Nadia: lead; planning\";ROLE=REQ-PARTICIPANT:mailto:nadia@example.org").unwrap(); + assert_eq!(l.param("CN"), Some("Okafor, Nadia: lead; planning")); + assert_eq!(l.param("ROLE"), Some("REQ-PARTICIPANT")); + assert_eq!(l.value, "mailto:nadia@example.org"); + let several = ContentLine::parse("X-TEST;MEMBER=\"mailto:a@example.org\",\"mailto:b@example.org\",c:v").unwrap(); + assert_eq!(several.params[0].values, ["mailto:a@example.org", "mailto:b@example.org", "c"]); + // A parameter without a value, and an empty one. + let bare = ContentLine::parse("X-TEST;FLAG;EMPTY=:v").unwrap(); + assert_eq!(bare.params.len(), 2); + assert_eq!(bare.param("FLAG"), None); + assert_eq!(bare.param("EMPTY"), Some("")); + } + + #[test] + fn broken_lines_are_refused() { + for bad in ["no colon here", ":value without a name", ";CN=x:y", "ATTENDEE;CN=\"never closed:mailto:a@example.org", "ATTENDEE;CN=x", " "] { + assert_eq!(ContentLine::parse(bad), None, "{bad}"); + } + } + + #[test] + fn parameter_values_escape_and_quote() { + assert_eq!(param_value("Ada Lovelace"), "Ada Lovelace"); + assert_eq!(param_value("Lovelace, Ada"), "\"Lovelace, Ada\""); + assert_eq!(param_value("Ada \"the Countess\" ^1\nLine\r\u{7}"), "Ada ^'the Countess^' ^^1^nLine"); + for name in ["Ada", "Lovelace, Ada", "a\"b^c\nd", "x:y;z", "^n^'^^", "Zoë — 東京"] { + let l = ContentLine::parse(&format!("ORGANIZER;CN={}:mailto:a@example.org", param_value(name))).unwrap(); + assert_eq!(l.param("CN"), Some(name)); + } + } + + #[test] + fn folds_at_75_octets_without_splitting_characters() { + let line = format!("SUMMARY:{}", "é東🎉x".repeat(40)); + let mut out = String::new(); + fold(&mut out, &line); + assert!(out.ends_with("\r\n")); + let physical: Vec<&str> = out.trim_end_matches("\r\n").split("\r\n").collect(); + assert!(physical.len() > 3); + assert!(physical.iter().all(|l| l.len() <= 75), "{physical:?}"); + assert!(physical.iter().skip(1).all(|l| l.starts_with(' '))); + assert_eq!(lines(&out), [line]); + // A short line is left alone, and an exact fit isn't folded. + let mut short = String::new(); + fold(&mut short, &"x".repeat(75)); + assert_eq!(short, format!("{}\r\n", "x".repeat(75))); + let mut one_more = String::new(); + fold(&mut one_more, &"x".repeat(76)); + assert_eq!(one_more, format!("{}\r\n x\r\n", "x".repeat(75))); + } + + #[test] + fn enormous_lines_are_capped() { + let text = format!("SUMMARY:{}\r\n {}\r\nUID:after", "é".repeat(MAX_LINE), "x".repeat(1000)); + let got = lines(&text); + assert_eq!(got.len(), 2); + assert!(got[0].len() <= MAX_LINE); + assert_eq!(got[1], "UID:after"); + assert_eq!(prefix("héllo", 2), "h"); + assert_eq!(prefix("héllo", 0), ""); + assert_eq!(prefix("héllo", 99), "héllo"); + } + + proptest::proptest! { + #[test] + fn folding_then_unfolding_gives_the_line_back(line in "[^\r\n]{0,400}") { + let mut out = String::new(); + fold(&mut out, &line); + proptest::prop_assert!(out.split("\r\n").all(|l| l.len() <= 75)); + // A byte order mark at the very start of a text isn't part of its first line. + proptest::prop_assert_eq!(Lines::new(&out).collect::>(), vec![line.strip_prefix('\u{feff}').unwrap_or(&line).to_string()]); + } + + #[test] + fn any_line_parses_or_is_refused(line in proptest::prelude::any::()) { + let _ = ContentLine::parse(&line); + let _ = Lines::new(&line).count(); + } + } +} diff --git a/crates/ical/src/rrule.rs b/crates/ical/src/rrule.rs new file mode 100644 index 000000000..2833e5a29 --- /dev/null +++ b/crates/ical/src/rrule.rs @@ -0,0 +1,516 @@ +//! Recurrence rules (RFC 5545 §3.3.10): the common subset a calendar keeps. + +use chrono::{Datelike, NaiveDate, NaiveDateTime, Weekday}; + +use crate::lines::prefix; +use crate::values::{When, format_date, valid_time}; + +type Timestamp = i64; + +/// How often a rule repeats. `SECONDLY`, `MINUTELY` and `HOURLY` are not held. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum Freq { + Daily, + #[default] + Weekly, + Monthly, + Yearly, +} + +impl Freq { + /// From `DAILY`, `weekly`…: any letter case. + pub fn parse(s: &str) -> Option { + match s.trim().to_ascii_uppercase().as_str() { + "DAILY" => Some(Freq::Daily), + "WEEKLY" => Some(Freq::Weekly), + "MONTHLY" => Some(Freq::Monthly), + "YEARLY" => Some(Freq::Yearly), + _ => None, + } + } + + /// `DAILY`, `WEEKLY`, `MONTHLY`, `YEARLY`. + pub fn as_str(self) -> &'static str { + match self { + Freq::Daily => "DAILY", + Freq::Weekly => "WEEKLY", + Freq::Monthly => "MONTHLY", + Freq::Yearly => "YEARLY", + } + } +} + +/// How an event repeats: the subset of RRULE that calendars show and edit. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Recurrence { + pub freq: Freq, + /// Every `interval` days/weeks/months/years; 0 reads as 1. + pub interval: u32, + /// Weekly: the days of the week (none: the start's own). Daily: only on these days. Monthly and + /// yearly with `nth`: the weekday(s) counted. + pub by_day: Vec, + /// Monthly and yearly: the `nth` `by_day` weekday of the month (1–5, or -1 for the last) + /// instead of the start's day of the month. + pub nth: Option, + /// Ends after this many occurrences. + pub count: Option, + /// Ends on this day (inclusive). + pub until: Option, +} + +impl Recurrence { + /// Every day, week, month or year. + pub fn every(freq: Freq) -> Recurrence { + Recurrence { freq, interval: 1, ..Default::default() } + } +} + +/// The days of the week, Monday first. +pub const WEEKDAYS: [Weekday; 7] = [Weekday::Mon, Weekday::Tue, Weekday::Wed, Weekday::Thu, Weekday::Fri, Weekday::Sat, Weekday::Sun]; + +/// `MO`, `TU`… as iCalendar writes a weekday. +pub fn weekday_code(day: Weekday) -> &'static str { + match day { + Weekday::Mon => "MO", + Weekday::Tue => "TU", + Weekday::Wed => "WE", + Weekday::Thu => "TH", + Weekday::Fri => "FR", + Weekday::Sat => "SA", + Weekday::Sun => "SU", + } +} + +/// The weekday of `MO`, `tu`…; `None` for anything else. +pub fn weekday_of_code(s: &str) -> Option { + WEEKDAYS.into_iter().find(|d| weekday_code(*d).eq_ignore_ascii_case(s.trim())) +} + +/// Longest rule read, in bytes, and most parts (and most `BYDAY` days) of one rule. +const MAX_RULE: usize = 4096; +const MAX_PARTS: usize = 64; + +/// `FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE` as a [`Recurrence`]; `None` for a rule the model can't hold +/// (`SECONDLY`, `MINUTELY`, `HOURLY`, junk). +/// +/// `UNTIL` gives its date, whether it is written as a date or a date-time. `BYDAY=2TU`, `BYDAY=-1FR` +/// and `BYDAY=TU;BYSETPOS=2` give `nth`. Parts that only restate the start (`BYMONTH`, +/// `BYMONTHDAY`, `WKST`) and parts the model has no place for are ignored. +pub fn parse_rrule(rule: &str) -> Option { + parse_with(rule, None) +} + +/// [`parse_rrule`] for a rule read from a file: with `offset_at` (minutes east of UTC at an +/// instant), a UTC `UNTIL` gives its local day. +pub fn parse_with(rule: &str, offset_at: Option<&dyn Fn(Timestamp) -> i32>) -> Option { + let rule = prefix(rule.trim(), MAX_RULE); + let rule = match rule.get(..6) { + Some(name) if name.eq_ignore_ascii_case("RRULE:") => rule.get(6..).unwrap_or(""), + _ => rule, + }; + let mut freq = None; + let mut interval = 1u32; + let (mut count, mut until, mut set_pos) = (None, None, None); + let mut days: Vec<(Option, Weekday)> = Vec::new(); + // Parts that pick days in a way the model can't: with them, a bare BYDAY means something else. + let mut limited = false; + for part in rule.split(';').take(MAX_PARTS) { + let Some((key, value)) = part.split_once('=') else { continue }; + let value = value.trim(); + match key.trim().to_ascii_uppercase().as_str() { + "FREQ" => freq = Some(Freq::parse(value)?), + "INTERVAL" => interval = value.parse().ok().filter(|n| *n > 0).unwrap_or(1), + "COUNT" => count = value.parse::().ok(), + "UNTIL" => { + until = When::parse(value).map(|w| match offset_at { + Some(offset_at) => w.local(offset_at).date(), + None => w.wall().date(), + }) + } + "BYDAY" => days = value.split(',').take(MAX_PARTS).filter_map(by_day).collect(), + "BYSETPOS" => set_pos = value.parse::().ok(), + "BYMONTH" | "BYMONTHDAY" | "BYYEARDAY" | "BYWEEKNO" => limited = true, + _ => {} + } + } + let mut freq = freq?; + let by_month = matches!(freq, Freq::Monthly | Freq::Yearly); + // `1MO,3MO` isn't one position: the first one is kept. + let ordinal = days.iter().find_map(|(ordinal, _)| *ordinal); + if by_month && ordinal.is_some() { + days.retain(|(o, _)| *o == ordinal); + } + let mut by_day: Vec = Vec::new(); + for (_, day) in &days { + if !by_day.contains(day) { + by_day.push(*day); + } + } + let mut nth = None; + if by_month { + nth = match (ordinal, set_pos) { + (Some(ordinal), _) => position(ordinal), + // A position in a set of several weekdays ("the last working day") isn't a weekday's position. + (None, Some(pos)) if by_day.len() == 1 || (by_day.is_empty() && !limited) => position(pos), + _ => None, + }; + if nth.is_none() { + if freq == Freq::Monthly && interval == 1 && !by_day.is_empty() && ordinal.is_none() && set_pos.is_none() && !limited { + // Every Monday of every month is every Monday. + freq = Freq::Weekly; + } else { + by_day.clear(); + } + } + } + Some(Recurrence { freq, interval, by_day, nth, count, until }) +} + +/// `MO`, `2TU`, `-1FR`: the position, when there is one, and the day. +fn by_day(item: &str) -> Option<(Option, Weekday)> { + let item = item.trim(); + let split = item.len().checked_sub(2)?; + let day = weekday_of_code(item.get(split..)?)?; + let ordinal = item.get(..split)?; + Some((if ordinal.is_empty() { None } else { Some(ordinal.parse::().ok()?) }, day)) +} + +/// A position the model holds: the first to the fifth, or the last. +fn position(n: i32) -> Option { + if (1..=5).contains(&n) || n == -1 { i8::try_from(n).ok() } else { None } +} + +/// A [`Recurrence`] as an RRULE value: `FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE`. +/// +/// An `nth` without a `by_day` means "the start's weekday", which a rule alone can't say: it is +/// written as a bare `BYSETPOS`, which [`parse_rrule`] reads back but other programs may not. +/// [`format_for`] knows the start and writes the weekday (and the month of a yearly rule) out. For monthly and yearly rules without an `nth` the model ignores `by_day`, so it isn't +/// written. +pub fn format_rrule(r: &Recurrence) -> String { + format(r, None, None) +} + +/// The rule of an event that starts at `start`: the start's weekday and month are spelled out +/// where the rule leans on them, and `UNTIL` has the start's value type. +pub fn format_for(r: &Recurrence, start: NaiveDateTime, all_day: bool) -> String { + format(r, Some((valid_time(start), all_day)), None) +} + +/// The rule of a timed event whose start is written in a zone: as [`format_for`], with `UNTIL` +/// as the instant `until` in UTC, which is how RFC 5545 has it beside such a start. +pub fn format_zoned(r: &Recurrence, start: NaiveDateTime, until: Option) -> String { + format(r, Some((valid_time(start), false)), until) +} + +fn format(r: &Recurrence, start: Option<(NaiveDateTime, bool)>, until_at: Option) -> String { + let mut out = format!("FREQ={}", r.freq.as_str()); + if r.interval > 1 { + out.push_str(&format!(";INTERVAL={}", r.interval)); + } + let mut days: Vec = Vec::new(); + for day in &r.by_day { + if !days.contains(day) { + days.push(*day); + } + } + let list = |days: &[Weekday], position: &str| days.iter().map(|d| format!("{position}{}", weekday_code(*d))).collect::>().join(","); + match (r.freq, r.nth.filter(|n| *n != 0)) { + (Freq::Daily | Freq::Weekly, _) => { + if !days.is_empty() { + out.push_str(&format!(";BYDAY={}", list(&days, ""))); + } + } + (_, None) => {} + (freq, Some(nth)) => { + // Any negative position is "the last" to the model. + let nth = nth.max(-1); + if let Some((start, _)) = start { + if days.is_empty() { + days.push(start.date().weekday()); + } + if freq == Freq::Yearly { + out.push_str(&format!(";BYMONTH={}", start.date().month())); + } + } + if days.is_empty() { + out.push_str(&format!(";BYSETPOS={nth}")); + } else { + out.push_str(&format!(";BYDAY={}", list(&days, &nth.to_string()))); + } + } + } + if let Some(count) = r.count { + out.push_str(&format!(";COUNT={count}")); + } + if let (Some(_), Some(at)) = (r.until, until_at) { + out.push_str(&format!(";UNTIL={}", crate::values::format_utc(at))); + } else if let Some(until) = r.until { + out.push_str(&format!(";UNTIL={}", format_date(until))); + // The whole last day of a timed series. + if matches!(start, Some((_, false))) { + out.push_str("T235959"); + } + } + out +} + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + + use super::*; + use crate::values::at_midnight; + + fn d(y: i32, m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, day).unwrap() + } + + fn rule(freq: Freq) -> Recurrence { + Recurrence::every(freq) + } + + #[test] + fn parses_the_common_rules() { + assert_eq!(parse_rrule("FREQ=DAILY"), Some(rule(Freq::Daily))); + assert_eq!( + parse_rrule("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE"), + Some(Recurrence { interval: 2, by_day: vec![Weekday::Mon, Weekday::Wed], ..rule(Freq::Weekly) }) + ); + assert_eq!( + parse_rrule("freq=weekly;byday=fr,mo;count=10;wkst=su"), + Some(Recurrence { by_day: vec![Weekday::Fri, Weekday::Mon], count: Some(10), ..rule(Freq::Weekly) }) + ); + assert_eq!( + parse_rrule("FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR"), + Some(Recurrence { by_day: vec![Weekday::Mon, Weekday::Tue, Weekday::Wed, Weekday::Thu, Weekday::Fri], ..rule(Freq::Daily) }) + ); + assert_eq!(parse_rrule("FREQ=MONTHLY;INTERVAL=3"), Some(Recurrence { interval: 3, ..rule(Freq::Monthly) })); + assert_eq!(parse_rrule("FREQ=YEARLY"), Some(rule(Freq::Yearly))); + // The property name and stray spaces are let through. + assert_eq!(parse_rrule(" RRULE:FREQ=DAILY; INTERVAL = 4 ;"), Some(Recurrence { interval: 4, ..rule(Freq::Daily) })); + } + + #[test] + fn positions_come_from_ordinals_or_bysetpos() { + let second_tue = Recurrence { by_day: vec![Weekday::Tue], nth: Some(2), ..rule(Freq::Monthly) }; + assert_eq!(parse_rrule("FREQ=MONTHLY;BYDAY=2TU"), Some(second_tue.clone())); + assert_eq!(parse_rrule("FREQ=MONTHLY;BYDAY=+2TU"), Some(second_tue.clone())); + assert_eq!(parse_rrule("FREQ=MONTHLY;BYDAY=TU;BYSETPOS=2"), Some(second_tue)); + let last_fri = Recurrence { by_day: vec![Weekday::Fri], nth: Some(-1), ..rule(Freq::Monthly) }; + assert_eq!(parse_rrule("FREQ=MONTHLY;BYDAY=-1FR"), Some(last_fri.clone())); + assert_eq!(parse_rrule("FREQ=MONTHLY;BYSETPOS=-1;BYDAY=FR"), Some(last_fri)); + // The fourth Thursday of November: the month restates the start. + assert_eq!( + parse_rrule("FREQ=YEARLY;BYMONTH=11;BYDAY=4TH"), + Some(Recurrence { by_day: vec![Weekday::Thu], nth: Some(4), ..rule(Freq::Yearly) }) + ); + assert_eq!( + parse_rrule("FREQ=MONTHLY;BYDAY=1MO,1FR"), + Some(Recurrence { by_day: vec![Weekday::Mon, Weekday::Fri], nth: Some(1), ..rule(Freq::Monthly) }) + ); + // Positions mean nothing in a weekly rule. + assert_eq!(parse_rrule("FREQ=WEEKLY;BYDAY=2TU"), Some(Recurrence { by_day: vec![Weekday::Tue], ..rule(Freq::Weekly) })); + } + + #[test] + fn rules_the_model_cannot_hold_fall_back() { + // Two positions: the first is kept. + assert_eq!( + parse_rrule("FREQ=MONTHLY;BYDAY=1MO,3MO,1WE"), + Some(Recurrence { by_day: vec![Weekday::Mon, Weekday::Wed], nth: Some(1), ..rule(Freq::Monthly) }) + ); + // The last working day, the second-to-last Friday, Friday the 13th and the like: plain monthly. + for r in [ + "FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1", + "FREQ=MONTHLY;BYDAY=-2FR", + "FREQ=MONTHLY;BYDAY=FR;BYMONTHDAY=13", + "FREQ=MONTHLY;BYMONTHDAY=15", + "FREQ=MONTHLY;BYMONTHDAY=1,2,3;BYSETPOS=1", + ] { + assert_eq!(parse_rrule(r), Some(rule(Freq::Monthly)), "{r}"); + } + // The 20th Monday of the year. + assert_eq!(parse_rrule("FREQ=YEARLY;BYDAY=20MO"), Some(rule(Freq::Yearly))); + // Every Monday and Thursday of every month is weekly; every other month isn't. + assert_eq!(parse_rrule("FREQ=MONTHLY;BYDAY=MO,TH"), Some(Recurrence { by_day: vec![Weekday::Mon, Weekday::Thu], ..rule(Freq::Weekly) })); + assert_eq!(parse_rrule("FREQ=MONTHLY;INTERVAL=2;BYDAY=MO"), Some(Recurrence { interval: 2, ..rule(Freq::Monthly) })); + assert_eq!(parse_rrule("FREQ=YEARLY;BYMONTH=3,6,9"), Some(rule(Freq::Yearly))); + } + + #[test] + fn until_is_a_date_whatever_its_form() { + let until = |r: &str| parse_rrule(r).and_then(|r| r.until); + assert_eq!(until("FREQ=DAILY;UNTIL=20261231"), Some(d(2026, 12, 31))); + assert_eq!(until("FREQ=DAILY;UNTIL=20261231T235959"), Some(d(2026, 12, 31))); + assert_eq!(until("FREQ=DAILY;UNTIL=20261231T030000Z"), Some(d(2026, 12, 31))); + assert_eq!(until("FREQ=DAILY;UNTIL=someday"), None); + // Read from a file, a UTC time is a local day. + let west = parse_with("FREQ=DAILY;UNTIL=20261231T030000Z", Some(&|_| -300)).unwrap(); + assert_eq!(west.until, Some(d(2026, 12, 30))); + let east = parse_with("FREQ=DAILY;UNTIL=20261230T220000Z", Some(&|_| 600)).unwrap(); + assert_eq!(east.until, Some(d(2026, 12, 31))); + assert_eq!(parse_with("FREQ=DAILY;UNTIL=20261231", Some(&|_| -300)).unwrap().until, Some(d(2026, 12, 31))); + } + + #[test] + fn junk_and_fast_rules_are_none() { + for bad in [ + "", + "FREQ=", + "FREQ=HOURLY", + "FREQ=MINUTELY;INTERVAL=5", + "FREQ=SECONDLY", + "FREQ=SOMETIMES", + "INTERVAL=2;BYDAY=MO", + "weekly", + ";;;===", + "RRULE:", + ] { + assert_eq!(parse_rrule(bad), None, "{bad}"); + } + } + + #[test] + fn junk_parts_are_ignored() { + let got = parse_rrule("FREQ=WEEKLY;INTERVAL=0;COUNT=many;BYDAY=MO,XX,,7,MO,é;BYSETPOS=x;X-THING=1;novalue").unwrap(); + assert_eq!(got, Recurrence { by_day: vec![Weekday::Mon], ..rule(Freq::Weekly) }); + assert_eq!(parse_rrule("FREQ=DAILY;INTERVAL=99999999999;COUNT=99999999999").unwrap(), rule(Freq::Daily)); + assert_eq!(parse_rrule("FREQ=DAILY;COUNT=0").unwrap().count, Some(0)); + let long = format!("FREQ=WEEKLY;BYDAY={}", "MO,".repeat(100_000)); + assert_eq!(parse_rrule(&long).unwrap().by_day, [Weekday::Mon]); + } + + #[test] + fn formats_rules() { + assert_eq!(format_rrule(&rule(Freq::Daily)), "FREQ=DAILY"); + assert_eq!( + format_rrule(&Recurrence { interval: 2, by_day: vec![Weekday::Mon, Weekday::Wed, Weekday::Mon], ..rule(Freq::Weekly) }), + "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE" + ); + assert_eq!(format_rrule(&Recurrence { count: Some(10), ..rule(Freq::Yearly) }), "FREQ=YEARLY;COUNT=10"); + assert_eq!(format_rrule(&Recurrence { until: Some(d(2026, 12, 31)), ..rule(Freq::Monthly) }), "FREQ=MONTHLY;UNTIL=20261231"); + assert_eq!(format_rrule(&Recurrence { by_day: vec![Weekday::Tue], nth: Some(2), ..rule(Freq::Monthly) }), "FREQ=MONTHLY;BYDAY=2TU"); + assert_eq!( + format_rrule(&Recurrence { by_day: vec![Weekday::Mon, Weekday::Fri], nth: Some(-1), ..rule(Freq::Monthly) }), + "FREQ=MONTHLY;BYDAY=-1MO,-1FR" + ); + assert_eq!(format_rrule(&Recurrence { nth: Some(3), ..rule(Freq::Monthly) }), "FREQ=MONTHLY;BYSETPOS=3"); + // Interval 0 reads as 1, and the model ignores the days of a monthly rule without a position. + assert_eq!(format_rrule(&Recurrence { interval: 0, by_day: vec![Weekday::Mon], nth: Some(0), ..rule(Freq::Monthly) }), "FREQ=MONTHLY"); + } + + #[test] + fn an_event_s_rule_spells_out_what_the_start_implies() { + let tuesday = d(2026, 10, 13).and_hms_opt(9, 0, 0).unwrap(); + let r = Recurrence { nth: Some(2), until: Some(d(2027, 6, 30)), ..rule(Freq::Monthly) }; + assert_eq!(format_for(&r, tuesday, false), "FREQ=MONTHLY;BYDAY=2TU;UNTIL=20270630T235959"); + assert_eq!(format_for(&r, tuesday, true), "FREQ=MONTHLY;BYDAY=2TU;UNTIL=20270630"); + let thanksgiving = Recurrence { by_day: vec![Weekday::Thu], nth: Some(4), ..rule(Freq::Yearly) }; + assert_eq!(format_for(&thanksgiving, at_midnight(d(2026, 11, 26)), true), "FREQ=YEARLY;BYMONTH=11;BYDAY=4TH"); + assert_eq!(format_for(&Recurrence { nth: Some(i8::MIN), ..rule(Freq::Yearly) }, tuesday, false), "FREQ=YEARLY;BYMONTH=10;BYDAY=-1TU"); + // What it writes reads back as the same series. + assert_eq!(parse_rrule("FREQ=YEARLY;BYMONTH=11;BYDAY=4TH"), Some(thanksgiving)); + // A start in a year iCalendar can't write is moved into the years it can. + let written = format_for(&Recurrence { nth: Some(1), until: Some(NaiveDate::MAX), ..rule(Freq::Yearly) }, NaiveDateTime::MAX, true); + assert_eq!(written, "FREQ=YEARLY;BYMONTH=12;BYDAY=1FR;UNTIL=99991231"); + } + + /// Every shape the model gives a meaning: weekly and daily rules with any days, monthly and + /// yearly rules with a position (and then any days) or without. + fn shapes() -> Vec { + let day_sets: [&[Weekday]; 5] = [&[], &[Weekday::Tue], &[Weekday::Sun, Weekday::Mon], &[Weekday::Fri, Weekday::Mon, Weekday::Wed], &WEEKDAYS]; + let mut out = Vec::new(); + for freq in [Freq::Daily, Freq::Weekly, Freq::Monthly, Freq::Yearly] { + for interval in [1, 2, 13, u32::MAX] { + for count in [None, Some(0), Some(1), Some(u32::MAX)] { + for until in [None, Some(d(2030, 1, 31)), Some(d(1, 1, 1)), Some(d(9999, 12, 31))] { + for days in day_sets { + let positions: &[Option] = if matches!(freq, Freq::Monthly | Freq::Yearly) { + &[None, Some(1), Some(2), Some(3), Some(4), Some(5), Some(-1)] + } else { + &[None] + }; + for nth in positions { + if nth.is_none() && !days.is_empty() && matches!(freq, Freq::Monthly | Freq::Yearly) { + continue; + } + out.push(Recurrence { freq, interval, by_day: days.to_vec(), nth: *nth, count, until }); + } + } + } + } + } + } + out + } + + #[test] + fn every_shape_the_model_supports_round_trips() { + let shapes = shapes(); + assert!(shapes.len() > 1000); + for r in shapes { + let text = format_rrule(&r); + assert_eq!(parse_rrule(&text), Some(r.clone()), "{text}"); + // And as an event writes it: the start is a Tuesday, so a missing day reads back as Tuesday. + for all_day in [false, true] { + let text = format_for(&r, d(2026, 10, 13).and_hms_opt(9, 30, 0).unwrap(), all_day); + let mut want = r.clone(); + if want.nth.is_some() && want.by_day.is_empty() { + want.by_day = vec![Weekday::Tue]; + } + assert_eq!(parse_rrule(&text), Some(want), "{text}"); + } + } + } + + #[test] + fn shapes_without_a_meaning_are_normalised() { + let normal = |r: &Recurrence| parse_rrule(&format_rrule(r)).unwrap(); + // Interval 0 is 1, duplicate days are one, position 0 is none, any negative position is the last. + assert_eq!(normal(&Recurrence { interval: 0, ..rule(Freq::Weekly) }).interval, 1); + assert_eq!(normal(&Recurrence { by_day: vec![Weekday::Mon; 100], ..rule(Freq::Weekly) }).by_day, [Weekday::Mon]); + assert_eq!(normal(&Recurrence { nth: Some(0), by_day: vec![Weekday::Mon], ..rule(Freq::Monthly) }), rule(Freq::Monthly)); + assert_eq!(normal(&Recurrence { nth: Some(-7), by_day: vec![Weekday::Mon], ..rule(Freq::Monthly) }).nth, Some(-1)); + // A position no month has isn't read back. + assert_eq!(normal(&Recurrence { nth: Some(9), by_day: vec![Weekday::Mon], ..rule(Freq::Monthly) }), rule(Freq::Monthly)); + // A position means nothing in a weekly rule. + assert_eq!(normal(&Recurrence { nth: Some(2), ..rule(Freq::Weekly) }), rule(Freq::Weekly)); + } + + fn recurrence() -> impl Strategy { + let freq = prop_oneof![Just(Freq::Daily), Just(Freq::Weekly), Just(Freq::Monthly), Just(Freq::Yearly)]; + let days = proptest::sample::subsequence(WEEKDAYS.to_vec(), 0..=7).prop_shuffle(); + let nth = proptest::option::of(prop_oneof![1i8..=5, Just(-1i8)]); + let until = proptest::option::of((1i32..=9999, 1u32..=12, 1u32..=28).prop_map(|(y, m, day)| d(y, m, day))); + (freq, 1u32.., days, nth, proptest::option::of(any::()), until).prop_map(|(freq, interval, by_day, nth, count, until)| { + let monthly = matches!(freq, Freq::Monthly | Freq::Yearly); + let nth = if monthly { nth } else { None }; + let by_day = if monthly && nth.is_none() { Vec::new() } else { by_day }; + Recurrence { freq, interval, by_day, nth, count, until } + }) + } + + proptest! { + #[test] + fn any_supported_rule_round_trips(r in recurrence()) { + prop_assert_eq!(parse_rrule(&format_rrule(&r)), Some(r)); + } + + #[test] + fn any_rule_at_all_formats_and_reads_back_stably(freq in 0u8..4, interval in any::(), days in proptest::collection::vec(0usize..7, 0..12), nth in proptest::option::of(any::()), count in proptest::option::of(any::())) { + let freq = [Freq::Daily, Freq::Weekly, Freq::Monthly, Freq::Yearly][usize::from(freq)]; + let r = Recurrence { freq, interval, by_day: days.into_iter().filter_map(|i| WEEKDAYS.get(i).copied()).collect(), nth, count, until: None }; + let text = format_rrule(&r); + let back = parse_rrule(&text).unwrap(); + // Reading normalises; a normal rule then round-trips. + prop_assert_eq!(parse_rrule(&format_rrule(&back)), Some(back)); + } + + #[test] + fn rule_text_never_panics(s in any::(), parts in proptest::collection::vec("(FREQ|INTERVAL|COUNT|UNTIL|BYDAY|BYSETPOS|BYMONTH|X)=[-+A-Z0-9,]{0,12}", 0..8)) { + let _ = parse_rrule(&s); + let _ = parse_rrule(&parts.join(";")); + let _ = parse_with(&parts.join(";"), Some(&|_| i32::MIN)); + } + } +} diff --git a/crates/ical/src/values.rs b/crates/ical/src/values.rs new file mode 100644 index 000000000..1f75a804d --- /dev/null +++ b/crates/ical/src/values.rs @@ -0,0 +1,419 @@ +//! Property values: dates and times, durations and escaped text. +//! +//! Times are `chrono` values. An instant is an `i64` of seconds since 1970-01-01T00:00:00Z, and an +//! offset of a zone is minutes east of UTC where this crate takes one from a caller and seconds +//! where a `VTIMEZONE` gives one. Every date and time this crate makes lies in the years 1 to 9999, +//! the years iCalendar can write. + +use chrono::{DateTime, NaiveDate, NaiveDateTime}; + +/// Seconds in a day. +pub const DAY: i64 = 86_400; + +/// Longest duration read, in seconds (some 10 000 years): sums of it can't overflow. +const MAX_DURATION: i64 = 10_000 * 366 * DAY; + +/// The first and the last second iCalendar can write, as seconds since 1970 on the same clock. +const MIN_CIVIL: i64 = -62_135_596_800; +const MAX_CIVIL: i64 = 253_402_300_799; + +/// Seconds since 1970-01-01T00:00:00 on the same wall clock (no zone applied). +pub fn civil_seconds(t: NaiveDateTime) -> i64 { + t.and_utc().timestamp() +} + +/// The wall-clock time `secs` seconds after 1970-01-01T00:00:00, kept in the years 1 to 9999. +pub fn from_civil_seconds(secs: i64) -> NaiveDateTime { + DateTime::from_timestamp(secs.clamp(MIN_CIVIL, MAX_CIVIL), 0).map(|t| t.naive_utc()).unwrap_or_default() +} + +/// The instant a wall clock shows in a zone `offset_minutes` east of UTC. +pub fn to_timestamp(t: NaiveDateTime, offset_minutes: i32) -> i64 { + civil_seconds(t).saturating_sub(i64::from(offset_minutes).saturating_mul(60)) +} + +/// The wall clock at the instant `ts` in a zone `offset_minutes` east of UTC. +pub fn from_timestamp(ts: i64, offset_minutes: i32) -> NaiveDateTime { + from_civil_seconds(ts.saturating_add(i64::from(offset_minutes).saturating_mul(60))) +} + +/// Midnight at the start of `d`. +pub fn at_midnight(d: NaiveDate) -> NaiveDateTime { + d.and_time(chrono::NaiveTime::MIN) +} + +/// A date and time in the years iCalendar can write: one outside them is moved to the nearest end. +pub(crate) fn valid_time(t: NaiveDateTime) -> NaiveDateTime { + from_civil_seconds(civil_seconds(t)) +} + +/// A date from `YYYY-MM-DD` or `YYYYMMDD`, years 1 to 9999. +fn parse_iso_date(s: &str) -> Option { + let s = s.trim(); + let digits: String = s.chars().filter(char::is_ascii_digit).collect(); + if digits.len() != 8 || s.chars().any(|c| !c.is_ascii_digit() && c != '-') { + return None; + } + let year: i32 = digits.get(0..4)?.parse().ok()?; + if year < 1 { + return None; + } + NaiveDate::from_ymd_opt(year, digits.get(4..6)?.parse().ok()?, digits.get(6..8)?.parse().ok()?) +} + +/// A DATE or DATE-TIME value. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum When { + /// `20261012`: a whole day. + Date(NaiveDate), + /// `20261012T090000`: a wall-clock time, floating or in a named zone. + Local(NaiveDateTime), + /// `20261012T090000Z`. + Utc(NaiveDateTime), +} + +impl When { + /// From `YYYYMMDD`, `YYYYMMDDTHHMMSS` or `YYYYMMDDTHHMMSSZ`; the seconds may be missing, and + /// the ISO 8601 punctuation some writers add (`2026-10-12T09:00:00Z`) is let through. + pub fn parse(s: &str) -> Option { + let s = s.trim(); + if s.len() > 32 || !s.is_ascii() { + return None; + } + let (date, time) = match s.find(['T', 't']) { + Some(i) => (s.get(..i)?, Some(s.get(i.saturating_add(1)..)?)), + None => (s, None), + }; + let date = parse_iso_date(date)?; + let Some(time) = time else { return Some(When::Date(date)) }; + let (clock, utc) = match time.strip_suffix(['Z', 'z']) { + Some(clock) => (clock, true), + None => (time, false), + }; + let digits: String = clock.chars().filter(|c| *c != ':').collect(); + if !matches!(digits.len(), 4 | 6) || !digits.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + let part = |at: usize| digits.get(at..at.saturating_add(2)).and_then(|d| d.parse::().ok()); + // A leap second (60) is read as the last second of the minute. + let second = if digits.len() == 6 { part(4)?.min(59) } else { 0 }; + let t = date.and_hms_opt(part(0)?, part(2)?, second)?; + Some(if utc { When::Utc(t) } else { When::Local(t) }) + } + + /// The date and time as written, whatever the zone. + pub fn wall(self) -> NaiveDateTime { + match self { + When::Date(d) => at_midnight(d), + When::Local(t) | When::Utc(t) => t, + } + } + + /// The local wall-clock time: a UTC time is converted, anything else is taken as written. + /// `offset_at(instant)` is minutes east of UTC. + pub fn local(self, offset_at: &dyn Fn(i64) -> i32) -> NaiveDateTime { + match self { + When::Utc(t) => { + let ts = to_timestamp(t, 0); + from_timestamp(ts, offset_at(ts)) + } + other => other.wall(), + } + } + + /// The instant: anything but a UTC time is read in the local zone. + pub fn timestamp(self, offset_at: &dyn Fn(i64) -> i32) -> i64 { + match self { + When::Utc(t) => to_timestamp(t, 0), + other => { + let t = other.wall(); + // Asked twice, so a time near a daylight-saving change still gets its own offset. + let guess = to_timestamp(t, offset_at(to_timestamp(t, 0))); + to_timestamp(t, offset_at(guess)) + } + } + } +} + +/// A duration in seconds from `PT15M`, `-PT1H30M`, `P1D`, `P1DT12H`, `P2W`; `None` for junk. +pub fn parse_duration(s: &str) -> Option { + let s = s.trim(); + let (negative, body) = match s.strip_prefix('-') { + Some(body) => (true, body), + None => (false, s.strip_prefix('+').unwrap_or(s)), + }; + let body = body.strip_prefix(['P', 'p'])?; + let (mut total, mut number, mut in_time, mut any) = (0i64, None::, false, false); + for c in body.chars() { + match c.to_ascii_uppercase() { + '0'..='9' => { + let digit = i64::from(c.to_digit(10)?); + number = Some(number.unwrap_or(0).checked_mul(10)?.checked_add(digit)?); + } + 'T' if number.is_none() => in_time = true, + unit => { + let seconds = match (unit, in_time) { + ('W', false) => 7 * DAY, + ('D', false) => DAY, + ('H', true) => 3600, + ('M', true) => 60, + ('S', true) => 1, + _ => return None, + }; + total = total.checked_add(number.take()?.checked_mul(seconds)?)?; + any = true; + } + } + } + if number.is_some() || !any || total > MAX_DURATION { + return None; + } + Some(if negative { -total } else { total }) +} + +/// `20261012`. +pub fn format_date(d: NaiveDate) -> String { + valid_time(at_midnight(d)).format("%Y%m%d").to_string() +} + +/// `20261012T090000` (floating). +pub fn format_local(t: NaiveDateTime) -> String { + valid_time(t).format("%Y%m%dT%H%M%S").to_string() +} + +/// `20261012T130000Z`. +pub fn format_utc(ts: i64) -> String { + format!("{}Z", format_local(from_timestamp(ts, 0))) +} + +/// A TEXT value ready to write: `\`, `;`, `,` and line breaks escaped (`\r\n` and `\r` count as +/// one line break), other control characters except tab dropped. +pub fn escape_text(s: &str) -> String { + let mut out = String::with_capacity(s.len().saturating_add(8)); + let mut after_cr = false; + for c in s.chars() { + let cr = c == '\r'; + match c { + '\n' if after_cr => {} + '\r' | '\n' => out.push_str("\\n"), + '\\' => out.push_str("\\\\"), + ';' => out.push_str("\\;"), + ',' => out.push_str("\\,"), + '\t' => out.push(c), + c if c.is_control() => {} + c => out.push(c), + } + after_cr = cr; + } + out +} + +/// Undo [`escape_text`]: `\n` and `\N` are line breaks, `\\`, `\;`, `\,` (and the `\:` and `\"` +/// some writers add) their second character; any other backslash is kept. Control characters +/// except tab are dropped. +pub fn unescape_text(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars(); + while let Some(c) = chars.next() { + match c { + '\\' => match chars.next() { + Some('n' | 'N') => out.push('\n'), + Some(x @ ('\\' | ';' | ',' | ':' | '"')) => out.push(x), + Some(x) => { + out.push('\\'); + if !x.is_control() || x == '\t' { + out.push(x); + } + } + None => out.push('\\'), + }, + '\t' => out.push(c), + c if c.is_control() => {} + c => out.push(c), + } + } + out +} + +/// The first `max` items of a list value, split on its unescaped commas; the items are still +/// escaped. +pub fn split_list(s: &str, max: usize) -> Vec<&str> { + let mut out = Vec::new(); + let (mut start, mut escaped) = (0usize, false); + for (i, c) in s.char_indices() { + if out.len() >= max { + return out; + } + match c { + _ if escaped => escaped = false, + '\\' => escaped = true, + ',' => { + out.push(s.get(start..i).unwrap_or("")); + start = i.saturating_add(1); + } + _ => {} + } + } + if out.len() < max { + out.push(s.get(start..).unwrap_or("")); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn d(y: i32, m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, day).unwrap() + } + + fn at(y: i32, m: u32, day: u32, h: u32, min: u32, s: u32) -> NaiveDateTime { + d(y, m, day).and_hms_opt(h, min, s).unwrap() + } + + #[test] + fn dates_and_times() { + assert_eq!(When::parse("20261012"), Some(When::Date(d(2026, 10, 12)))); + assert_eq!(When::parse("20261012T090000"), Some(When::Local(at(2026, 10, 12, 9, 0, 0)))); + assert_eq!(When::parse(" 20261012T133015Z "), Some(When::Utc(at(2026, 10, 12, 13, 30, 15)))); + assert_eq!(When::parse("20261012t0900z"), Some(When::Utc(at(2026, 10, 12, 9, 0, 0)))); + assert_eq!(When::parse("2026-10-12T09:00:00Z"), Some(When::Utc(at(2026, 10, 12, 9, 0, 0)))); + // A leap second. + assert_eq!(When::parse("20161231T235960Z"), Some(When::Utc(at(2016, 12, 31, 23, 59, 59)))); + } + + #[test] + fn junk_dates_are_none() { + for bad in [ + "", + "tomorrow", + "2026101", + "202610123", + "20261312", + "20260230", + "00000101", + "20261012T", + "20261012T25", + "20261012T240000", + "20261012T0960", + "20261012T090000+0200", + "20261012", + "20261012T09000000000000000000000000000000000000", + ] { + assert_eq!(When::parse(bad), None, "{bad}"); + } + } + + #[test] + fn utc_times_become_local_and_local_times_become_instants() { + let utc = When::parse("20261012T130000Z").unwrap(); + assert_eq!(utc.local(&|_| -240), at(2026, 10, 12, 9, 0, 0)); + assert_eq!(utc.local(&|_| 330), at(2026, 10, 12, 18, 30, 0)); + assert_eq!(utc.wall(), at(2026, 10, 12, 13, 0, 0)); + // Across midnight. + assert_eq!(When::parse("20261012T020000Z").unwrap().local(&|_| -300).date(), d(2026, 10, 11)); + let local = When::parse("20261012T090000").unwrap(); + assert_eq!(local.local(&|_| -240), at(2026, 10, 12, 9, 0, 0)); + assert_eq!(local.timestamp(&|_| -240), utc.timestamp(&|_| 600)); + assert_eq!(When::Date(d(1970, 1, 2)).timestamp(&|_| 60), DAY - 3600); + // A zone that changes its offset at an instant: each side gets its own. + let change = to_timestamp(at(2026, 11, 1, 6, 0, 0), 0); + let zone = |ts: i64| if ts < change { -240 } else { -300 }; + assert_eq!(When::parse("20261101T000000").unwrap().timestamp(&zone), to_timestamp(at(2026, 11, 1, 4, 0, 0), 0)); + assert_eq!(When::parse("20261101T120000").unwrap().timestamp(&zone), to_timestamp(at(2026, 11, 1, 17, 0, 0), 0)); + // A hostile offset is survived. + let _ = utc.local(&|_| i32::MIN); + let _ = local.timestamp(&|_| i32::MAX); + } + + #[test] + fn durations() { + assert_eq!(parse_duration("PT15M"), Some(900)); + assert_eq!(parse_duration("-PT15M"), Some(-900)); + assert_eq!(parse_duration("+PT1H30M"), Some(5400)); + assert_eq!(parse_duration("P1D"), Some(DAY)); + assert_eq!(parse_duration("P1DT12H"), Some(DAY + 12 * 3600)); + assert_eq!(parse_duration("P2W"), Some(14 * DAY)); + assert_eq!(parse_duration("pt0s"), Some(0)); + assert_eq!(parse_duration("-PT0M"), Some(0)); + assert_eq!(parse_duration("PT1H2M3S"), Some(3723)); + for bad in ["", "P", "PT", "15M", "PT15", "P15M", "PT1D", "PTM", "P1X", "P-1D", "P99999999999999999999D", "P9999999999W", "PT1H T"] { + assert_eq!(parse_duration(bad), None, "{bad}"); + } + } + + #[test] + fn formats_and_keeps_dates_in_writable_years() { + assert_eq!(format_date(d(2026, 1, 2)), "20260102"); + assert_eq!(format_date(d(987, 6, 5)), "09870605"); + assert_eq!(format_local(at(2026, 10, 12, 9, 5, 0)), "20261012T090500"); + assert_eq!(format_utc(0), "19700101T000000Z"); + assert_eq!(format_utc(1_760_082_785), "20251010T075305Z"); + assert_eq!(format_utc(i64::MAX), "99991231T235959Z"); + assert_eq!(format_utc(i64::MIN), "00010101T000000Z"); + // Years iCalendar can't write are moved to the nearest it can. + for hostile in [NaiveDate::MIN, NaiveDate::MAX, d(0, 1, 1), d(-4, 2, 29), d(10_000, 2, 29)] { + assert!(When::parse(&format_date(hostile)).is_some(), "{hostile:?}"); + assert!(When::parse(&format_local(at_midnight(hostile))).is_some(), "{hostile:?}"); + } + assert_eq!(format_date(NaiveDate::MAX), "99991231"); + assert_eq!(format_local(NaiveDateTime::MIN), "00010101T000000"); + // Civil seconds are the same clock read as UTC, and are kept in the years 1 to 9999. + assert_eq!(civil_seconds(at(1970, 1, 2, 0, 0, 1)), DAY + 1); + assert_eq!(from_civil_seconds(DAY + 1), at(1970, 1, 2, 0, 0, 1)); + assert_eq!(from_civil_seconds(i64::MAX), at(9999, 12, 31, 23, 59, 59)); + assert_eq!(from_civil_seconds(i64::MIN), at(1, 1, 1, 0, 0, 0)); + assert_eq!(from_timestamp(0, -300), at(1969, 12, 31, 19, 0, 0)); + assert_eq!(to_timestamp(at(1969, 12, 31, 19, 0, 0), -300), 0); + } + + #[test] + fn text_escapes() { + assert_eq!(escape_text("Budget, phase two; \\ done\nNext"), "Budget\\, phase two\\; \\\\ done\\nNext"); + assert_eq!(escape_text("a\r\nb\rc\n\nd\u{0}\u{7f}\te"), "a\\nb\\nc\\n\\nd\te"); + assert_eq!(unescape_text("Budget\\, phase two\\; \\\\ done\\nNext\\NLast"), "Budget, phase two; \\ done\nNext\nLast"); + // What other writers escape without being asked to, and what isn't an escape at all. + assert_eq!(unescape_text("10\\:30 \\\"quoted\\\" C:\\Users\\x tail\\"), "10:30 \"quoted\" C:\\Users\\x tail\\"); + assert_eq!(unescape_text("a\u{0}b\u{1b}c\td"), "abc\td"); + } + + #[test] + fn lists_split_on_unescaped_commas() { + assert_eq!(split_list("Work,Travel\\, abroad,,Home\\\\,x", 10), ["Work", "Travel\\, abroad", "", "Home\\\\", "x"]); + assert_eq!(split_list("", 10), [""]); + assert_eq!(split_list("a,b,c,d", 2), ["a", "b"]); + assert_eq!(split_list("a,b", 2), ["a", "b"]); + assert!(split_list("a,b", 0).is_empty()); + } + + proptest::proptest! { + #[test] + fn clean_text_survives_escaping(s in "(\\PC|[\n\t;,\\\\:\"]){0,80}") { + proptest::prop_assert_eq!(unescape_text(&escape_text(&s)), s); + } + + #[test] + fn any_text_escapes_to_one_clean_line(s in proptest::prelude::any::()) { + let escaped = escape_text(&s); + proptest::prop_assert!(!escaped.contains(['\r', '\n'])); + // Escaping what was read gives the same line again. + proptest::prop_assert_eq!(escape_text(&unescape_text(&escaped)), escaped); + } + + #[test] + fn values_never_panic(s in proptest::prelude::any::(), max in 0usize..5) { + let _ = When::parse(&s); + let _ = parse_duration(&s); + let _ = unescape_text(&s); + let _ = split_list(&s, max); + } + + #[test] + fn any_civil_second_reads_back_in_writable_years(secs in proptest::prelude::any::()) { + let t = from_civil_seconds(secs); + proptest::prop_assert_eq!(civil_seconds(t), secs.clamp(MIN_CIVIL, MAX_CIVIL)); + proptest::prop_assert!(When::parse(&format_local(t)).is_some()); + } + } +} diff --git a/crates/ical/src/zones.rs b/crates/ical/src/zones.rs new file mode 100644 index 000000000..9d01d93b8 --- /dev/null +++ b/crates/ical/src/zones.rs @@ -0,0 +1,498 @@ +//! Time zones in iCalendar text (RFC 5545 §3.6.5): the `VTIMEZONE` definitions a text brings with +//! it, read ([`TimeZones`], [`VTimezone`]) and written ([`write_vtimezone`]). +//! +//! This crate has no time-zone database. A definition read from a text answers for itself (the +//! offset at an instant, the instant of a wall-clock time); what a `TZID` means beyond the text +//! (an IANA name, a Windows name, which database zone keeps the same clock) is the caller's. To +//! write a definition, the caller hands over its zone's rules through [`ZoneRules`]. +//! +//! Definitions are hostile text: offsets of more than eighteen hours are not offsets, and the +//! zones, their parts and their dates are all capped. [`TimeZones::spend`] keeps a budget of the +//! work a caller does with them. + +use std::cell::Cell; + +use chrono::{Datelike, NaiveDate, NaiveDateTime, Weekday}; + +use crate::lines::{ContentLine, Lines, fold, prefix}; +use crate::rrule::{weekday_code, weekday_of_code}; +use crate::values::{When, at_midnight, civil_seconds, format_local, from_civil_seconds, from_timestamp, to_timestamp}; + +/// Most zones one text defines, most parts (`STANDARD`, `DAYLIGHT`) of one zone, and most dates +/// (`RDATE`) of one part. +pub const MAX_ZONES: usize = 64; +pub const MAX_PARTS: usize = 64; +pub const MAX_DATES: usize = 64; +/// The work one text's times may cost, in units of one look at one part or one date of a +/// definition ([`VTimezone::cost`]). Past it, [`TimeZones::spend`] says no. +pub const WORK_BUDGET: u64 = 20_000_000; +/// The furthest from UTC an offset may be, in seconds. +const MAX_OFFSET: i32 = 18 * 3600; +/// Longest `TZID` (and `X-LIC-LOCATION`) kept, in bytes. +pub const MAX_TZID: usize = 128; + +/// On which day of each year a part of a zone begins. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Yearly { + /// The `nth` (1–5, or negative from the end) `weekday` of `month`. + Nth { month: u32, nth: i8, weekday: Weekday }, + /// Day `day` of `month`. + Day { month: u32, day: u32 }, +} + +/// The `nth` (1–5, or negative from the end) `weekday` of a month, when the month has one. +fn nth_weekday(year: i32, month: u32, weekday: Weekday, nth: i8) -> Option { + let first = NaiveDate::from_ymd_opt(year, month, 1)?; + if nth > 0 { + let offset = (i64::from(weekday.num_days_from_monday()) - i64::from(first.weekday().num_days_from_monday())).rem_euclid(7); + NaiveDate::from_ymd_opt(year, month, u32::try_from(1 + offset + 7 * (i64::from(nth) - 1)).ok()?) + } else { + let last = first.checked_add_months(chrono::Months::new(1))?.pred_opt()?; + let back = (i64::from(last.weekday().num_days_from_monday()) - i64::from(weekday.num_days_from_monday())).rem_euclid(7); + let day = last.checked_sub_days(chrono::Days::new(u64::try_from(back + 7 * (i64::from(nth.saturating_neg()) - 1)).ok()?))?; + (day.month() == month).then_some(day) + } +} + +impl Yearly { + fn date(self, year: i32) -> Option { + match self { + Yearly::Day { month, day } => NaiveDate::from_ymd_opt(year, month, day), + Yearly::Nth { month, nth, weekday } => nth_weekday(year, month, weekday, nth), + } + } +} + +/// One `STANDARD` or `DAYLIGHT` part of a definition: from `start` on (a wall-clock time by the +/// offset `from`), and then again every year or on the listed dates, the offset is `to`. +#[derive(Clone, Debug, PartialEq, Eq)] +struct Part { + start: NaiveDateTime, + /// Seconds east of UTC before and after. + from: i32, + to: i32, + yearly: Option, + /// The rule holds up to this instant, and for this many beginnings. + until: Option, + count: Option, + dates: Vec, +} + +impl Part { + /// The wall-clock second from which `to` holds for a beginning written as `start`: a time + /// the clocks skip is still by the offset before, and so is the first of an hour that comes + /// twice (RFC 5545 §3.3.5). + fn wall_from(&self, start: NaiveDateTime) -> i64 { + civil_seconds(start).saturating_add(i64::from(self.to.saturating_sub(self.from).max(0))) + } + + fn instant_of(&self, start: NaiveDateTime) -> i64 { + civil_seconds(start).saturating_sub(i64::from(self.from)) + } + + /// The beginning of the rule's year `year`, when the rule has one then. + fn in_year(&self, year: i32) -> Option { + let rule = self.yearly?; + let first = self.start.year(); + if year < first || self.count.is_some_and(|count| i64::from(year) - i64::from(first) >= i64::from(count)) { + return None; + } + let begins = rule.date(year)?.and_time(self.start.time()); + (begins >= self.start && self.until.is_none_or(|until| self.instant_of(begins) <= until)).then_some(begins) + } + + /// The last beginning at or before `limit`, as `key` measures beginnings. + fn latest(&self, limit: i64, year: i32, key: &dyn Fn(&Part, NaiveDateTime) -> i64) -> Option { + let mut best: Option = None; + let mut take = |begins: NaiveDateTime| { + let at = key(self, begins); + if at <= limit && best.is_none_or(|have| at > have) { + best = Some(at); + } + }; + take(self.start); + self.dates.iter().copied().for_each(&mut take); + // The year of the time asked about and the years beside it; and where the rule ends. + let last = self.until.map(|until| from_timestamp(until, 0).year()); + let counted = self.count.map(|count| self.start.year().saturating_add(i32::try_from(count).unwrap_or(i32::MAX)).saturating_sub(1)); + for year in [Some(year.saturating_add(1)), Some(year), Some(year.saturating_sub(1)), last, last.map(|y| y.saturating_sub(1)), counted] + .into_iter() + .flatten() + { + if let Some(begins) = self.in_year(year) { + take(begins); + } + } + best + } +} + +/// A zone as a text defines it (`VTIMEZONE`). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct VTimezone { + tzid: String, + /// `X-LIC-LOCATION`: the place the writer says the zone is (an IANA name, as a rule). + place: String, + parts: Vec, +} + +impl VTimezone { + /// The `TZID`, at most [`MAX_TZID`] bytes. + pub fn tzid(&self) -> &str { + &self.tzid + } + + /// `X-LIC-LOCATION`, empty when the text gives none. + pub fn place(&self) -> &str { + &self.place + } + + /// What one look at the definition costs, in units of [`WORK_BUDGET`]. + pub fn cost(&self) -> u64 { + self.parts.iter().fold(0u64, |sum, part| sum.saturating_add(8).saturating_add(part.dates.len() as u64)) + } + + fn offset_by(&self, limit: i64, year: i32, key: &dyn Fn(&Part, NaiveDateTime) -> i64) -> i32 { + let mut best: Option<(i64, i32)> = None; + for part in &self.parts { + if let Some(at) = part.latest(limit, year, key) + && best.is_none_or(|(have, _)| at >= have) + { + best = Some((at, part.to)); + } + } + match best { + Some((_, to)) => to, + // Before the first beginning: the offset the first one changes from. + None => self.parts.iter().min_by_key(|part| part.start).map_or(0, |part| part.from), + } + } + + /// Seconds east of UTC for a wall-clock time written in the zone. A time the clocks skip is + /// read by the offset before the change; a time that comes twice is the first of the two. + pub fn offset_for_wall(&self, wall: NaiveDateTime) -> i32 { + self.offset_by(civil_seconds(wall), wall.year(), &|part, begins| part.wall_from(begins)) + } + + /// Seconds east of UTC at an instant (seconds since 1970-01-01T00:00:00Z). + pub fn offset_at(&self, instant: i64) -> i32 { + self.offset_by(instant, from_timestamp(instant, 0).year(), &|part, begins| part.instant_of(begins)) + } + + /// The instant of a wall-clock time written in the zone. + pub fn instant(&self, wall: NaiveDateTime) -> i64 { + civil_seconds(wall).saturating_sub(i64::from(self.offset_for_wall(wall))) + } +} + +/// `+0100`, `-0500`, `+053000`: seconds east of UTC; `None` for anything else and for more than +/// an offset can be. +pub fn parse_utc_offset(text: &str) -> Option { + let text = text.trim(); + let (sign, digits) = match (text.strip_prefix('+'), text.strip_prefix('-')) { + (Some(rest), _) => (1, rest), + (None, Some(rest)) => (-1, rest), + (None, None) => return None, + }; + let digits: String = digits.chars().filter(|c| *c != ':').collect(); + if !matches!(digits.len(), 4 | 6) || !digits.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + let part = |at: usize| digits.get(at..at.saturating_add(2)).and_then(|d| d.parse::().ok()); + let (hours, minutes, seconds) = (part(0)?, part(2)?, if digits.len() == 6 { part(4)? } else { 0 }); + let total = hours * 3600 + minutes * 60 + seconds; + (minutes < 60 && seconds < 60 && total <= MAX_OFFSET).then_some(sign * total) +} + +/// An offset in seconds as `+0100`, `-0930`, `+045602`. +pub fn format_utc_offset(seconds: i32) -> String { + let sign = if seconds < 0 { '-' } else { '+' }; + let a = seconds.unsigned_abs(); + if a.is_multiple_of(60) { + format!("{sign}{:02}{:02}", a / 3600, a / 60 % 60) + } else { + format!("{sign}{:02}{:02}{:02}", a / 3600, a / 60 % 60, a % 60) + } +} + +/// The rule of a part: `FREQ=YEARLY;BYMONTH=3;BYDAY=2SU`, with the day of the month or the +/// part's own day when no weekday is named. `None` for a rule that isn't yearly. +fn yearly(rule: &str, start: NaiveDateTime) -> Option<(Yearly, Option, Option)> { + let (mut yearly, mut month, mut by_day, mut month_day) = (false, None, None, None); + let (mut until, mut count) = (None, None); + for part in prefix(rule, 512).split(';').take(16) { + let Some((key, value)) = part.split_once('=') else { continue }; + let value = value.trim(); + match key.trim().to_ascii_uppercase().as_str() { + "FREQ" => yearly = value.eq_ignore_ascii_case("YEARLY"), + "INTERVAL" if value != "1" => return None, + "BYMONTH" => month = value.parse::().ok().filter(|m| (1..=12).contains(m)), + "BYMONTHDAY" => month_day = value.parse::().ok().filter(|d| (1..=31).contains(d)), + "BYDAY" => { + let split = value.len().checked_sub(2)?; + let weekday = weekday_of_code(value.get(split..)?)?; + let nth = match value.get(..split)? { + "" => return None, + nth => nth.parse::().ok().filter(|n| (1..=5).contains(n) || (-5..=-1).contains(n))?, + }; + by_day = Some((nth, weekday)); + } + "UNTIL" => until = When::parse(value).map(|at| to_timestamp(at.wall(), 0)), + "COUNT" => count = value.parse::().ok(), + _ => {} + } + } + if !yearly { + return None; + } + let month = month.unwrap_or(start.month()); + let rule = match (by_day, month_day) { + (Some((nth, weekday)), _) => Yearly::Nth { month, nth, weekday }, + (None, day) => Yearly::Day { month, day: day.unwrap_or(start.day()) }, + }; + Some((rule, until, count)) +} + +/// A `STANDARD` or `DAYLIGHT` part being read. +#[derive(Default)] +struct PartBuilder { + start: Option, + from: Option, + to: Option, + rule: Option, + dates: Vec, +} + +impl PartBuilder { + fn finish(self) -> Option { + let (start, from, to) = (self.start?, self.from?, self.to?); + let (yearly, until, count) = match self.rule.as_deref().and_then(|rule| yearly(rule, start)) { + Some((rule, until, count)) => (Some(rule), until, count), + None => (None, None, None), + }; + Some(Part { start, from, to, yearly, until, count, dates: self.dates }) + } +} + +/// The zones a text defines, and the budget of work spent on them. +#[derive(Debug, Default)] +pub struct TimeZones { + zones: Vec, + /// Work spent so far (see [`WORK_BUDGET`]), spends granted so far, and whether the budget ran out. + spent: Cell, + granted: Cell, + stopped: Cell, +} + +impl TimeZones { + /// Read the `VTIMEZONE`s of a text: at most [`MAX_ZONES`], the first of each `TZID`. A zone + /// without a `TZID` or without a part that has a start and both offsets is not one. + pub fn collect(text: &str) -> TimeZones { + let mut out = TimeZones::default(); + // Nothing to look for in most texts. + let named = text.as_bytes().windows(9).any(|bytes| bytes.eq_ignore_ascii_case(b"VTIMEZONE")); + if !named { + return out; + } + let mut zone: Option = None; + let mut part: Option = None; + for raw in Lines::new(text) { + let Some(line) = ContentLine::parse(&raw) else { continue }; + let value = prefix(line.value.trim(), MAX_TZID); + match (line.name.as_str(), value.to_ascii_uppercase().as_str()) { + ("BEGIN", "VTIMEZONE") => { + if out.zones.len() >= MAX_ZONES { + break; + } + zone = Some(VTimezone::default()); + part = None; + } + ("END", "VTIMEZONE") => { + part = None; + if let Some(definition) = zone.take() + && !definition.tzid.is_empty() + && !definition.parts.is_empty() + && !out.zones.iter().any(|have| have.tzid == definition.tzid) + { + out.zones.push(definition); + } + } + ("BEGIN", "STANDARD" | "DAYLIGHT") if zone.is_some() => part = Some(PartBuilder::default()), + ("END", "STANDARD" | "DAYLIGHT") => { + if let (Some(definition), Some(built)) = (&mut zone, part.take().and_then(PartBuilder::finish)) + && definition.parts.len() < MAX_PARTS + { + definition.parts.push(built); + } + } + // Anything else that begins inside a zone is not a part of it. + ("BEGIN", _) => part = None, + (name, _) => match (&mut zone, &mut part) { + (Some(_), Some(part)) => match name { + "DTSTART" => part.start = part.start.or_else(|| When::parse(&line.value).map(When::wall)), + "TZOFFSETFROM" => part.from = part.from.or_else(|| parse_utc_offset(&line.value)), + "TZOFFSETTO" => part.to = part.to.or_else(|| parse_utc_offset(&line.value)), + "RRULE" if part.rule.is_none() => part.rule = Some(prefix(&line.value, 512).to_string()), + "RDATE" => { + let room = MAX_DATES.saturating_sub(part.dates.len()); + part.dates.extend(line.value.split(',').take(room).filter_map(When::parse).map(When::wall)); + } + _ => {} + }, + (Some(definition), None) => match name { + "TZID" if definition.tzid.is_empty() => definition.tzid = value.to_string(), + "X-LIC-LOCATION" if definition.place.is_empty() => definition.place = value.to_string(), + _ => {} + }, + _ => {} + }, + } + } + out + } + + /// The definition of `tzid`: the one of that name, else one whose name differs only in letter + /// case. The name is looked at to [`MAX_TZID`] bytes. + pub fn get(&self, tzid: &str) -> Option<&VTimezone> { + let tzid = prefix(tzid.trim(), MAX_TZID); + self.zones.iter().find(|zone| zone.tzid == tzid).or_else(|| self.zones.iter().find(|zone| zone.tzid.eq_ignore_ascii_case(tzid))) + } + + /// Every definition, in the order of the text. + pub fn iter(&self) -> impl Iterator { + self.zones.iter() + } + + pub fn is_empty(&self) -> bool { + self.zones.is_empty() + } + + /// Spend `units` of the text's work budget; `false` (and the budget is out for good) when + /// there is not enough left. + pub fn spend(&self, units: u64) -> bool { + let total = self.spent.get().saturating_add(units); + if self.stopped.get() || total > WORK_BUDGET { + self.stopped.set(true); + return false; + } + self.spent.set(total); + self.granted.set(self.granted.get().saturating_add(1)); + true + } + + /// When the budget ran out: how many spends were granted before it did. + pub fn stopped_after(&self) -> Option { + self.stopped.get().then(|| self.granted.get()) + } +} + +// ---- writing ---- + +/// A change of a zone's offset: at the instant `at` (seconds since 1970-01-01T00:00:00Z), from +/// `before` to `after` seconds east of UTC. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Transition { + pub at: i64, + pub before: i32, + pub after: i32, +} + +/// What [`write_vtimezone`] needs to know of a zone: the caller's time-zone database. +pub trait ZoneRules { + /// Seconds east of UTC at an instant. + fn offset_at(&self, instant: i64) -> i32; + /// The changes of offset from `from` up to `to` (instants), in order. + fn transitions(&self, from: i64, to: i64) -> Vec; +} + +/// The rule a change of clocks follows when it falls on the same weekday of the same month each +/// year at the same time of day: the `nth` weekday (the last, when it is in the month's last week +/// in each of the years). +fn yearly_rule(changes: &[(NaiveDateTime, i32, i32)]) -> Option { + let (first, first_from, first_to) = changes.first().copied()?; + let alike = changes.iter().all(|(at, from, to)| { + at.month() == first.month() && at.weekday() == first.weekday() && at.time() == first.time() && (*from, *to) == (first_from, first_to) + }); + if !alike { + return None; + } + let nth = |at: &NaiveDateTime| (at.day() - 1) / 7 + 1; + let last = |at: &NaiveDateTime| at.date().checked_add_days(chrono::Days::new(7)).is_none_or(|week_on| week_on.month() != at.month()); + let (month, weekday) = (first.month(), first.weekday()); + if changes.iter().all(|(at, _, _)| nth(at) == nth(&first)) && nth(&first) < 5 && !changes.iter().all(|(at, _, _)| last(at)) { + Some(Yearly::Nth { month, nth: i8::try_from(nth(&first)).unwrap_or(1), weekday }) + } else if changes.iter().all(|(at, _, _)| last(at)) { + Some(Yearly::Nth { month, nth: -1, weekday }) + } else { + None + } +} + +fn write_part(out: &mut String, kind: &str, start: NaiveDateTime, from: i32, to: i32, rule: Option) { + fold(out, &format!("BEGIN:{kind}")); + fold(out, &format!("DTSTART:{}", format_local(start))); + fold(out, &format!("TZOFFSETFROM:{}", format_utc_offset(from))); + fold(out, &format!("TZOFFSETTO:{}", format_utc_offset(to))); + if let Some(Yearly::Nth { month, nth, weekday }) = rule { + fold(out, &format!("RRULE:FREQ=YEARLY;BYMONTH={month};BYDAY={nth}{}", weekday_code(weekday))); + } + fold(out, &format!("END:{kind}")); +} + +/// Write the definition of a zone named `tzid` (control characters dropped, at most [`MAX_TZID`] +/// bytes) for times in the years `from`–`to` (at most 120 +/// years are written; the years are kept in 2 to 9990). +/// +/// A zone that changes its clocks twice a year by one rule is written as most programs write it, +/// and all of them read it: one `STANDARD` and one `DAYLIGHT` part, each with its yearly rule. A +/// zone that doesn't change is one `STANDARD` part. Any other (a year in which the rule changed, +/// a zone that follows the moon) is written out change by change for those years. +pub fn write_vtimezone(out: &mut String, tzid: &str, zone: &dyn ZoneRules, from: i32, to: i32) { + let (from, to) = (from.clamp(2, 9_990), to.clamp(2, 9_990).max(from.clamp(2, 9_990))); + // A few hundred changes at most are looked at. + let to = to.min(from.saturating_add(120)); + let instant = |year: i32| NaiveDate::from_ymd_opt(year, 1, 1).map_or(0, |day| to_timestamp(at_midnight(day), 0)); + let before = zone.offset_at(instant(from - 1)); + // Each change as it is written: the wall-clock time by the offset before it. + let changes: Vec<(NaiveDateTime, i32, i32)> = zone + .transitions(instant(from - 1), instant(to + 1)) + .into_iter() + .map(|change| (from_civil_seconds(change.at.saturating_add(i64::from(change.before))), change.before, change.after)) + .collect(); + let midnight = |year: i32| NaiveDate::from_ymd_opt(year, 1, 1).map(at_midnight).unwrap_or_default(); + fold(out, "BEGIN:VTIMEZONE"); + // A name can't start another line. + let tzid: String = prefix(tzid, MAX_TZID).chars().filter(|c| !c.is_control()).collect(); + fold(out, &format!("TZID:{tzid}")); + if changes.is_empty() { + write_part(out, "STANDARD", midnight(1601), before, before, None); + fold(out, "END:VTIMEZONE"); + return; + } + // Clocks forward is the beginning of daylight time, clocks back the end of it. + let (forward, back): (Vec<_>, Vec<_>) = changes.iter().copied().partition(|(_, from, to)| to > from); + let years = usize::try_from(i64::from(to) - i64::from(from) + 2).unwrap_or(0); + let regular = forward.len() == years && back.len() == years; + match (yearly_rule(&forward).filter(|_| regular), yearly_rule(&back).filter(|_| regular)) { + (Some(spring), Some(autumn)) => { + for (kind, list, rule) in [("DAYLIGHT", &forward, spring), ("STANDARD", &back, autumn)] { + if let Some((start, from, to)) = list.first().copied() { + write_part(out, kind, start, from, to, Some(rule)); + } + } + } + _ => { + // What holds before the first change, then each change. + let first = changes.first().map_or(NaiveDateTime::default(), |(at, _, _)| *at); + write_part(out, "STANDARD", midnight(first.year().saturating_sub(1).max(1)), before, before, None); + for (start, from, to) in changes { + write_part(out, if to > from { "DAYLIGHT" } else { "STANDARD" }, start, from, to, None); + } + } + } + fold(out, "END:VTIMEZONE"); +} + +#[cfg(test)] +#[path = "zones_tests.rs"] +mod tests; diff --git a/crates/ical/src/zones_tests.rs b/crates/ical/src/zones_tests.rs new file mode 100644 index 000000000..5c1ec9eba --- /dev/null +++ b/crates/ical/src/zones_tests.rs @@ -0,0 +1,293 @@ +//! `VTIMEZONE` reading and writing. The zones written are rule zones built here (the United States' +//! rules, the European Union's, Sydney's, a zone without changes, the United States' change of +//! rule in 2007), standing in for a caller's time-zone database. + +use proptest::prelude::*; + +use super::*; + +fn d(y: i32, m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, day).unwrap() +} + +fn at(y: i32, m: u32, day: u32, h: u32, min: u32) -> NaiveDateTime { + d(y, m, day).and_hms_opt(h, min, 0).unwrap() +} + +fn utc(t: NaiveDateTime) -> i64 { + to_timestamp(t, 0) +} + +/// A zone by its list of changes. +struct Rules { + before: i32, + changes: Vec, +} + +impl ZoneRules for Rules { + fn offset_at(&self, instant: i64) -> i32 { + self.changes.iter().rev().find(|c| c.at <= instant).map_or(self.before, |c| c.after) + } + + fn transitions(&self, from: i64, to: i64) -> Vec { + self.changes.iter().copied().filter(|c| c.at >= from && c.at < to).collect() + } +} + +/// The change on the `nth` `weekday` of `month` at `hour` UTC (`day_before`: the evening before). +fn change(year: i32, month: u32, nth: i8, hour: u32, day_before: bool, before: i32, after: i32) -> Transition { + let day = nth_weekday(year, month, Weekday::Sun, nth).unwrap(); + let day = if day_before { day.pred_opt().unwrap() } else { day }; + Transition { at: utc(day.and_hms_opt(hour, 0, 0).unwrap()), before, after } +} + +const H: i32 = 3600; + +/// The United States' rule since 2007 (and, with `history`, the one before it up to 2006). +fn us_eastern(years: std::ops::RangeInclusive, history: bool) -> Rules { + let mut changes = Vec::new(); + for y in years { + if history && y < 2007 { + changes.push(change(y, 4, 1, 7, false, -5 * H, -4 * H)); + changes.push(change(y, 10, -1, 6, false, -4 * H, -5 * H)); + } else { + changes.push(change(y, 3, 2, 7, false, -5 * H, -4 * H)); + changes.push(change(y, 11, 1, 6, false, -4 * H, -5 * H)); + } + } + Rules { before: -5 * H, changes } +} + +fn berlin(years: std::ops::RangeInclusive) -> Rules { + let changes = years.flat_map(|y| [change(y, 3, -1, 1, false, H, 2 * H), change(y, 10, -1, 1, false, 2 * H, H)]).collect(); + Rules { before: H, changes } +} + +fn sydney(years: std::ops::RangeInclusive) -> Rules { + let changes = years.flat_map(|y| [change(y, 4, 1, 16, true, 11 * H, 10 * H), change(y, 10, 1, 16, true, 10 * H, 11 * H)]).collect(); + Rules { before: 11 * H, changes } +} + +fn fixed(seconds: i32) -> Rules { + Rules { before: seconds, changes: Vec::new() } +} + +/// The zone as a big mail program writes it: today's rule, from the year 1601. +const EASTERN: &str = "BEGIN:VTIMEZONE\r\nTZID:Eastern Standard Time\r\nBEGIN:STANDARD\r\nDTSTART:16011104T020000\r\nRRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=11\r\n\ +TZOFFSETFROM:-0400\r\nTZOFFSETTO:-0500\r\nEND:STANDARD\r\nBEGIN:DAYLIGHT\r\nDTSTART:16010311T020000\r\nRRULE:FREQ=YEARLY;BYDAY=2SU;BYMONTH=3\r\n\ +TZOFFSETFROM:-0500\r\nTZOFFSETTO:-0400\r\nEND:DAYLIGHT\r\nEND:VTIMEZONE\r\n"; + +fn only(text: &str) -> VTimezone { + let zones = TimeZones::collect(text); + assert_eq!(zones.iter().count(), 1, "{text}"); + zones.iter().next().unwrap().clone() +} + +#[test] +fn a_definition_gives_each_time_its_offset() { + let eastern = only(EASTERN); + assert_eq!(eastern.tzid(), "Eastern Standard Time"); + assert_eq!(eastern.offset_for_wall(at(2026, 1, 15, 9, 0)), -5 * 3600); + assert_eq!(eastern.offset_for_wall(at(2026, 7, 15, 9, 0)), -4 * 3600); + // March 8, 2026: 1:59 is winter time, 3:00 summer time, and 2:30 (which no clock shows) + // is still read by the offset before. + assert_eq!(eastern.offset_for_wall(at(2026, 3, 8, 1, 59)), -5 * 3600); + assert_eq!(eastern.offset_for_wall(at(2026, 3, 8, 2, 30)), -5 * 3600); + assert_eq!(eastern.offset_for_wall(at(2026, 3, 8, 3, 0)), -4 * 3600); + // November 1: 1:30 comes twice, and is the first of the two. + assert_eq!(eastern.offset_for_wall(at(2026, 11, 1, 1, 30)), -4 * 3600); + assert_eq!(eastern.offset_for_wall(at(2026, 11, 1, 2, 0)), -5 * 3600); + // By instants, every half hour of two years agrees with the rules. + let rules = us_eastern(2025..=2028, false); + let mut wall = at(2026, 1, 1, 0, 0); + while wall < at(2028, 1, 1, 0, 0) { + let instant = utc(wall); + assert_eq!(eastern.offset_at(instant), rules.offset_at(instant), "{wall}"); + wall += chrono::Duration::minutes(30); + } + assert_eq!(eastern.instant(at(2026, 7, 15, 9, 0)), utc(at(2026, 7, 15, 13, 0))); + // Before there were any rules: what the first change changes from. + assert_eq!(eastern.offset_for_wall(at(1500, 6, 1, 0, 0)), -5 * 3600); +} + +#[test] +fn definitions_of_every_shape_are_read() { + // Listed dates, a rule that ends, a rule by the day of the month, a part with no rule. + let text = "BEGIN:VTIMEZONE\nTZID:Example/Odd\nX-LIC-LOCATION:Europe/Berlin\n\ +BEGIN:STANDARD\nDTSTART:19700101T000000\nTZOFFSETFROM:+0100\nTZOFFSETTO:+0100\nEND:STANDARD\n\ +BEGIN:DAYLIGHT\nDTSTART:20200329T020000\nRDATE:20210328T020000,20220327T020000\nTZOFFSETFROM:+0100\nTZOFFSETTO:+0200\nEND:DAYLIGHT\n\ +BEGIN:STANDARD\nDTSTART:20201025T030000\nRRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU;UNTIL=20221030T010000Z\nTZOFFSETFROM:+0200\nTZOFFSETTO:+0100\nEND:STANDARD\n\ +BEGIN:DAYLIGHT\nDTSTART:20240401T000000\nRRULE:FREQ=YEARLY;BYMONTHDAY=1;BYMONTH=4;COUNT=2\nTZOFFSETFROM:+0100\nTZOFFSETTO:+0130\nEND:DAYLIGHT\n\ +BEGIN:STANDARD\nDTSTART:20241001T000000\nRRULE:FREQ=YEARLY\nTZOFFSETFROM:+0130\nTZOFFSETTO:+0100\nEND:STANDARD\n\ +END:VTIMEZONE\n"; + let odd = only(text); + let offset = |wall: NaiveDateTime| odd.offset_for_wall(wall) / 60; + assert_eq!(offset(at(2019, 7, 1, 12, 0)), 60); + assert_eq!(offset(at(2020, 7, 1, 12, 0)), 120); + assert_eq!(offset(at(2021, 1, 1, 12, 0)), 60); + assert_eq!(offset(at(2022, 7, 1, 12, 0)), 120); + assert_eq!(offset(at(2023, 7, 1, 12, 0)), 60); + // Two Aprils of half an hour, and no third. + assert_eq!(offset(at(2024, 7, 1, 12, 0)), 90); + assert_eq!(offset(at(2025, 7, 1, 12, 0)), 90); + assert_eq!(offset(at(2025, 12, 1, 12, 0)), 60); + assert_eq!(offset(at(2026, 7, 1, 12, 0)), 60); + // It says where it is, and is found by its name in any letter case. + let zones = TimeZones::collect(text); + assert_eq!(zones.get("Example/Odd").map(VTimezone::place), Some("Europe/Berlin")); + assert!(zones.get("example/odd").is_some()); + assert!(zones.get("Example/Even").is_none()); +} + +#[test] +fn nonsense_definitions_are_no_definitions() { + for text in [ + "BEGIN:VTIMEZONE\nEND:VTIMEZONE\n", + "BEGIN:VTIMEZONE\nTZID:Empty\nEND:VTIMEZONE\n", + "BEGIN:VTIMEZONE\nBEGIN:STANDARD\nDTSTART:19700101T000000\nTZOFFSETFROM:+0100\nTZOFFSETTO:+0100\nEND:STANDARD\nEND:VTIMEZONE\n", + // Offsets of days, and no offsets at all. + "BEGIN:VTIMEZONE\nTZID:Far\nBEGIN:STANDARD\nDTSTART:19700101T000000\nTZOFFSETFROM:+9900\nTZOFFSETTO:-7200\nEND:STANDARD\nEND:VTIMEZONE\n", + "BEGIN:VTIMEZONE\nTZID:Far\nBEGIN:STANDARD\nDTSTART:19700101T000000\nTZOFFSETFROM:+1801\nTZOFFSETTO:+0100\nEND:STANDARD\nEND:VTIMEZONE\n", + "BEGIN:VTIMEZONE\nTZID:None\nBEGIN:STANDARD\nDTSTART:19700101T000000\nTZOFFSETFROM:soon\nTZOFFSETTO:later\nEND:STANDARD\nEND:VTIMEZONE\n", + "BEGIN:VTIMEZONE\nTZID:NoStart\nBEGIN:STANDARD\nTZOFFSETFROM:+0100\nTZOFFSETTO:+0100\nEND:STANDARD\nEND:VTIMEZONE\n", + // A part outside a zone, and a zone that never ends. + "BEGIN:STANDARD\nDTSTART:19700101T000000\nTZOFFSETFROM:+0100\nTZOFFSETTO:+0100\nEND:STANDARD\n", + "BEGIN:VTIMEZONE\nTZID:Open\nBEGIN:STANDARD\nDTSTART:19700101T000000\nTZOFFSETFROM:+0100\nTZOFFSETTO:+0100\nEND:STANDARD\n", + ] { + assert!(TimeZones::collect(text).is_empty(), "{text}"); + } + assert_eq!(parse_utc_offset("+053000"), Some(5 * 3600 + 30 * 60)); + assert_eq!(parse_utc_offset("-00:30"), Some(-30 * 60)); + for bad in ["", "0100", "+1", "+01", "+0160", "+010060", "+2400", "+١٢٠٠", "++0100"] { + assert_eq!(parse_utc_offset(bad), None, "{bad}"); + } + assert_eq!((format_utc_offset(-570 * 60), format_utc_offset(0), format_utc_offset(16_562)), ("-0930".into(), "+0000".into(), "+043602".into())); + // A megabyte of name, hundreds of zones, thousands of parts and dates: all bounded. + let long = format!( + "BEGIN:VTIMEZONE\nTZID:{}\nBEGIN:STANDARD\nDTSTART:19700101T000000\nTZOFFSETFROM:+0100\nTZOFFSETTO:+0100\nEND:STANDARD\nEND:VTIMEZONE\n", + "Z".repeat(1 << 20) + ); + let zones = TimeZones::collect(&long); + assert!(only(&long).tzid().len() <= MAX_TZID); + assert!(zones.get(&"Z".repeat(1 << 20)).is_some()); + let many: String = (0..500) + .map(|n| format!("BEGIN:VTIMEZONE\nTZID:Zone{n}\nBEGIN:STANDARD\nDTSTART:19700101T000000\nTZOFFSETFROM:+0100\nTZOFFSETTO:+0100\nEND:STANDARD\nEND:VTIMEZONE\n")) + .collect(); + assert_eq!(TimeZones::collect(&many).iter().count(), MAX_ZONES); + let part = format!( + "BEGIN:STANDARD\nDTSTART:19700101T000000\nRDATE:{}\nTZOFFSETFROM:+0100\nTZOFFSETTO:+0100\nEND:STANDARD\n", + "19800101T000000,".repeat(5_000) + ); + let crowded = only(&format!("BEGIN:VTIMEZONE\nTZID:Crowded\n{}END:VTIMEZONE\n", part.repeat(1_000))); + assert_eq!((crowded.parts.len(), crowded.parts[0].dates.len()), (MAX_PARTS, MAX_DATES)); + assert_eq!(crowded.cost(), (MAX_PARTS * (8 + MAX_DATES)) as u64); + let _ = crowded.offset_for_wall(at(2026, 1, 1, 0, 0)); + // Hostile times ask nothing that panics. + let eastern = only(EASTERN); + for wall in [NaiveDateTime::MAX, NaiveDateTime::MIN, NaiveDateTime::default()] { + let _ = (eastern.offset_for_wall(wall), eastern.instant(wall)); + } + let _ = (eastern.offset_at(i64::MIN), eastern.offset_at(i64::MAX)); +} + +#[test] +fn the_budget_runs_out_for_good() { + let zones = TimeZones::collect(EASTERN); + assert_eq!(zones.stopped_after(), None); + assert!(zones.spend(WORK_BUDGET - 10)); + assert!(zones.spend(10)); + assert!(!zones.spend(1)); + // Once out, out: even a spend of nothing is refused. + assert!(!zones.spend(0)); + assert_eq!(zones.stopped_after(), Some(2)); +} + +/// What a written definition says, read back. +fn written(name: &str, zone: &dyn ZoneRules, from: i32, to: i32) -> (String, VTimezone) { + let mut out = String::new(); + write_vtimezone(&mut out, name, zone, from, to); + let read = only(&out); + (out, read) +} + +#[test] +fn written_definitions_are_the_usual_two_rules_where_a_zone_has_them() { + let (text, _) = written("America/New_York", &us_eastern(2025..=2027, false), 2026, 2026); + assert_eq!( + text.replace("\r\n", "\n"), + "BEGIN:VTIMEZONE\nTZID:America/New_York\n\ +BEGIN:DAYLIGHT\nDTSTART:20250309T020000\nTZOFFSETFROM:-0500\nTZOFFSETTO:-0400\nRRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU\nEND:DAYLIGHT\n\ +BEGIN:STANDARD\nDTSTART:20251102T020000\nTZOFFSETFROM:-0400\nTZOFFSETTO:-0500\nRRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU\nEND:STANDARD\n\ +END:VTIMEZONE\n" + ); + let (text, _) = written("Europe/Berlin", &berlin(2025..=2031), 2026, 2030); + assert!(text.contains("RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU") && text.contains("RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU"), "{text}"); + assert!(text.contains("DTSTART:20250330T020000") && text.contains("DTSTART:20251026T030000"), "{text}"); + // The south: summer time begins in October. + let (text, _) = written("Australia/Sydney", &sydney(2025..=2027), 2026, 2026); + assert!(text.contains("RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU") && text.contains("RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU"), "{text}"); + // No changes: one part. An offset is a zone too. + let (text, _) = written("Asia/Kolkata", &fixed(5 * H + 1800), 2026, 2026); + assert_eq!( + text.replace("\r\n", "\n"), + "BEGIN:VTIMEZONE\nTZID:Asia/Kolkata\nBEGIN:STANDARD\nDTSTART:16010101T000000\nTZOFFSETFROM:+0530\nTZOFFSETTO:+0530\nEND:STANDARD\nEND:VTIMEZONE\n" + ); + let (text, _) = written("UTC-09:30", &fixed(-570 * 60), 2026, 2026); + assert!(text.contains("TZID:UTC-09:30") && text.contains("TZOFFSETTO:-0930"), "{text}"); + // The year the United States changed its rule: written out change by change. + let history = us_eastern(2004..=2009, true); + let (text, read) = written("America/New_York", &history, 2006, 2007); + assert!(!text.contains("RRULE") && text.contains("DTSTART:20060402T020000") && text.contains("DTSTART:20070311T020000"), "{text}"); + assert_eq!(read.instant(at(2006, 3, 20, 9, 0)), utc(at(2006, 3, 20, 14, 0))); + assert_eq!(read.instant(at(2007, 3, 20, 9, 0)), utc(at(2007, 3, 20, 13, 0))); + // Hostile years are years, and a hostile name can't start a line. + for (from, to) in [(i32::MIN, i32::MAX), (9_999, 1), (0, 0)] { + let _ = written("Europe/Berlin", &berlin(2020..=2030), from, to); + } + let (text, read) = written("Evil\r\nEND:VTIMEZONE\r\nBEGIN:VEVENT", &fixed(0), 2026, 2026); + assert_eq!(read.tzid(), "EvilEND:VTIMEZONEBEGIN:VEVENT"); + assert_eq!(text.matches("VTIMEZONE").count(), 3, "{text}"); +} + +fn any_zone() -> impl Strategy { + (0usize..6, -12 * 60i32..=14 * 60) +} + +fn zone_of(which: usize, minutes: i32) -> Rules { + match which { + 0 => us_eastern(1960..=2100, false), + 1 => us_eastern(1960..=2100, true), + 2 => berlin(1960..=2100), + 3 => sydney(1960..=2100), + 4 => fixed(minutes * 60), + _ => fixed(0), + } +} + +proptest! { + #[test] + fn a_written_definition_reads_as_the_zone_it_was_written_for((which, minutes_east) in any_zone(), year in 1971i32..2090, span in 0i32..4, minutes in 0i64..365 * 24 * 60) { + let zone = zone_of(which, minutes_east); + let (text, read) = written("Example/Zone", &zone, year, year + span); + // Any time of those years is the instant the rules say. + let wall = at(year, 1, 1, 0, 0) + chrono::Duration::minutes(minutes) + chrono::Duration::days(i64::from(span) * 365); + let instant = utc(wall); + prop_assert_eq!(read.offset_at(instant), zone.offset_at(instant), "{} at {}\n{}", which, wall, text); + // And a wall-clock time is the instant it shows, where the clock shows it once. + let wall_instant = read.instant(wall); + if read.offset_at(wall_instant) == zone.offset_at(wall_instant) && zone.offset_at(wall_instant - 2 * i64::from(H)) == zone.offset_at(wall_instant + 2 * i64::from(H)) { + prop_assert_eq!(wall_instant + i64::from(zone.offset_at(wall_instant)), civil_seconds(wall)); + } + } + + #[test] + fn any_text_is_definitions_or_none(text in proptest::prelude::any::(), parts in proptest::collection::vec("(BEGIN|END|DTSTART|TZOFFSETFROM|TZOFFSETTO|RRULE|RDATE|TZID):[-+A-Z0-9=;,]{0,24}", 0..40), wall in proptest::num::i64::ANY) { + let _ = TimeZones::collect(&text); + let zones = TimeZones::collect(&format!("BEGIN:VTIMEZONE\nTZID:X\n{}\nEND:VTIMEZONE\n", parts.join("\n"))); + let wall = from_civil_seconds(wall); + if let Some(definition) = zones.get("X") { + let _ = (definition.offset_for_wall(wall), definition.offset_at(utc(wall)), definition.instant(wall)); + } + let _ = zones.get(&text); + } +}