Add craft-ical: iCalendar lines, values, rules and VTIMEZONE from MailCraft (#966)

MailCraft crates/ical at a44a450: content lines, DATE/DATE-TIME values,
durations and text, the common subset of recurrence rules, and VTIMEZONE
definitions read and written, with their tests. Times are chrono values; the
zone database the writer needs is the caller's, through ZoneRules. The event
and to-do mapping onto MailCraft's model stays in MailCraft.

Co-authored-by: Martin Ball <98639962+benandez@users.noreply.github.com>
This commit is contained in:
Dylan M. TaylorandMartin Ball authored and GitHub committed 2026-11-11 19:30:23 -05:00
1 parent 69d812dd7a
commit ed16993b3b
12 files changed
+2173

No files matched your search

Generated
+8
View File
@@ -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"
+1
View File
@@ -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" }
+4
View File
@@ -142,6 +142,10 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
origin, to RGBA8, and a run-length writer whose packets end with their scan line. Bounded on
hostile input.
- [`craft-idml`](crates/idml/README.md): IDML (InDesign Markup Language) packages: the ZIP container read with bounded part sizes and lenient part names, `designmap.xml` with its includes in place, the `<Properties>` 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
+17
View File
@@ -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 }
+56
View File
@@ -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.
@@ -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}"
+33
View File
@@ -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};
+321
View File
@@ -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<String> {
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<String>,
}
/// 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<Param>,
/// 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<ContentLine> {
// 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<String> {
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<_>>(), vec![line.strip_prefix('\u{feff}').unwrap_or(&line).to_string()]);
}
#[test]
fn any_line_parses_or_is_refused(line in proptest::prelude::any::<String>()) {
let _ = ContentLine::parse(&line);
let _ = Lines::new(&line).count();
}
}
}
+516
View File
@@ -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<Freq> {
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<Weekday>,
/// 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<i8>,
/// Ends after this many occurrences.
pub count: Option<u32>,
/// Ends on this day (inclusive).
pub until: Option<NaiveDate>,
}
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<Weekday> {
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<Recurrence> {
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<Recurrence> {
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<i32>, 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::<u32>().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::<i32>().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<Weekday> = 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<i32>, 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::<i32>().ok()?) }, day))
}
/// A position the model holds: the first to the fifth, or the last.
fn position(n: i32) -> Option<i8> {
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<Timestamp>) -> String {
format(r, Some((valid_time(start), false)), until)
}
fn format(r: &Recurrence, start: Option<(NaiveDateTime, bool)>, until_at: Option<Timestamp>) -> 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<Weekday> = 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::<Vec<_>>().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<Recurrence> {
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<i8>] = 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<Value = Recurrence> {
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::<u32>()), 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::<u32>(), days in proptest::collection::vec(0usize..7, 0..12), nth in proptest::option::of(any::<i8>()), count in proptest::option::of(any::<u32>())) {
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::<String>(), 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));
}
}
}
+419
View File
@@ -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<NaiveDate> {
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<When> {
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::<u32>().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<i64> {
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::<i64>, 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::<String>()) {
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::<String>(), 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::<i64>()) {
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());
}
}
}
+498
View File
@@ -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<NaiveDate> {
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<NaiveDate> {
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<Yearly>,
/// The rule holds up to this instant, and for this many beginnings.
until: Option<i64>,
count: Option<u32>,
dates: Vec<NaiveDateTime>,
}
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<NaiveDateTime> {
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<i64> {
let mut best: Option<i64> = 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<Part>,
}
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<i32> {
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::<i32>().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<i64>, Option<u32>)> {
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::<u32>().ok().filter(|m| (1..=12).contains(m)),
"BYMONTHDAY" => month_day = value.parse::<u32>().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::<i8>().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::<u32>().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<NaiveDateTime>,
from: Option<i32>,
to: Option<i32>,
rule: Option<String>,
dates: Vec<NaiveDateTime>,
}
impl PartBuilder {
fn finish(self) -> Option<Part> {
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<VTimezone>,
/// Work spent so far (see [`WORK_BUDGET`]), spends granted so far, and whether the budget ran out.
spent: Cell<u64>,
granted: Cell<u32>,
stopped: Cell<bool>,
}
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<VTimezone> = None;
let mut part: Option<PartBuilder> = 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<Item = &VTimezone> {
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<u32> {
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<Transition>;
}
/// 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<Yearly> {
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<Yearly>) {
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;
+293
View File
@@ -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<Transition>,
}
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<Transition> {
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<i32>, 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<i32>) -> 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<i32>) -> 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<Value = (usize, i32)> {
(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::<String>(), 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);
}
}