//! `VTIMEZONE` — RFC 5545 §3.6.5. //! //! Carrying these is not optional book-keeping. A published Exchange feed names //! its zones `Pacific Standard Time`, not `America/Los_Angeles`: those are //! Windows zone names, and no IANA database will resolve them. What *does* //! resolve them is the `VTIMEZONE` block the same file ships alongside the //! events, which states the offsets and the transition rules outright. //! //! So the rule is: keep the zone identifier exactly as written — normalising it //! at parse time would make a round-trip impossible — and keep the definition //! that explains it. Resolving one to a real offset is the `recurrence` //! feature's job, and it has three things to try in order: the identifier as //! IANA, the identifier through a Windows→IANA mapping, and failing both, these //! rules. use super::property::UnknownProperty; use chrono::NaiveDateTime; use serde::{Deserialize, Serialize}; /// A time zone definition, as carried inside a `VCALENDAR`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct VTimeZone { /// `TZID`, verbatim. May be an IANA name, a Windows name, or anything else /// a producer chose. pub tzid: String, /// `TZURL`. #[serde(default, skip_serializing_if = "Option::is_none")] pub url: Option, /// The `STANDARD` and `DAYLIGHT` sub-components, in the order given. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub rules: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub unknown_properties: Vec, } impl VTimeZone { pub fn new(tzid: impl Into) -> Self { Self { tzid: tzid.into(), url: None, rules: Vec::new(), unknown_properties: Vec::new(), } } /// True when the identifier looks like a Windows zone name rather than an /// IANA one. IANA identifiers are `Area/Location`; Windows ones are English /// prose. Cheap heuristic, used only to decide which lookup to try first. pub fn has_windows_style_id(&self) -> bool { !self.tzid.contains('/') && self.tzid.contains(' ') } } /// One `STANDARD` or `DAYLIGHT` block: an offset, and when it applies. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TimeZoneRule { pub kind: TimeZoneRuleKind, /// `DTSTART`, always a local time with no zone of its own — it is expressed /// in the offset given by `offset_from`. Exchange writes `16010101T020000` /// here, so a parser that range-checks years will reject real data. pub dtstart: NaiveDateTime, /// `TZOFFSETFROM` — the offset in force before this transition. pub offset_from: UtcOffset, /// `TZOFFSETTO` — the offset in force after it. pub offset_to: UtcOffset, /// `RRULE` describing when this rule recurs, verbatim. #[serde(default, skip_serializing_if = "Option::is_none")] pub rrule: Option, /// `RDATE` — explicit transition times. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub rdate: Vec, /// `TZNAME` — the abbreviation, such as `MST`. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub names: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub unknown_properties: Vec, } /// Which half of a zone definition a rule describes. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum TimeZoneRuleKind { Standard, Daylight, } impl TimeZoneRuleKind { pub fn component_name(self) -> &'static str { match self { Self::Standard => "STANDARD", Self::Daylight => "DAYLIGHT", } } } /// A fixed offset from UTC, as `TZOFFSETFROM` / `TZOFFSETTO` express it. /// /// Stored in seconds. This is a raw offset and is *not* a time zone — the /// distinction the previous iteration collapsed, sending `-06:00` as though it /// identified a zone, so a recurring 9am meeting drifted an hour every spring. /// An offset is only ever meaningful attached to the rule that says when it /// applies. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(transparent)] pub struct UtcOffset { seconds: i32, } impl UtcOffset { /// No offset at all. pub const UTC: Self = Self { seconds: 0 }; /// Seconds east of UTC. Rejects anything beyond ±24 hours. pub fn from_seconds(seconds: i32) -> Result { if seconds.abs() > 24 * 3600 { return Err(InvalidUtcOffset(seconds.to_string())); } Ok(Self { seconds }) } pub fn seconds(self) -> i32 { self.seconds } /// Parses the `±HHMM` or `±HHMMSS` form RFC 5545 §3.3.14 defines. pub fn parse(text: &str) -> Result { let err = || InvalidUtcOffset(text.to_owned()); let (sign, digits) = match text.as_bytes().first() { Some(b'+') => (1, &text[1..]), Some(b'-') => (-1, &text[1..]), _ => return Err(err()), }; if !digits.bytes().all(|b| b.is_ascii_digit()) { return Err(err()); } let part = |from: usize, to: usize| digits[from..to].parse::().map_err(|_| err()); let seconds = match digits.len() { 4 => part(0, 2)? * 3600 + part(2, 4)? * 60, 6 => part(0, 2)? * 3600 + part(2, 4)? * 60 + part(4, 6)?, _ => return Err(err()), }; Self::from_seconds(sign * seconds) } /// Renders the `±HHMM` form, widening to `±HHMMSS` only when the offset has /// a seconds component — which historical zones such as `Europe/Amsterdam` /// before 1937 genuinely do. pub fn to_ical(self) -> String { let sign = if self.seconds < 0 { '-' } else { '+' }; let total = self.seconds.unsigned_abs(); let (h, m, s) = (total / 3600, (total % 3600) / 60, total % 60); if s == 0 { format!("{sign}{h:02}{m:02}") } else { format!("{sign}{h:02}{m:02}{s:02}") } } } #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] #[error("not a valid UTC offset: {0:?}")] pub struct InvalidUtcOffset(pub String);