mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 22:12:12 +00:00
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:
1 parent
69d812dd7a
commit
ed16993b3b
12 files changed
+2173
No files matched your search
Generated
+8
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
@@ -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}"
|
||||
@@ -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};
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user