Skip to main content

ical/
timezone.rs

1//! # Time zones
2//!
3//! Turning a civil date-time into a UTC offset, using only the `VTIMEZONE` that
4//! travels inside the calendar.
5//!
6//! Expansion is civil by design ([`recur`](crate::recur)) and that boundary
7//! stays: nothing here changes what a rule denotes. What it adds is the step
8//! after, the one nobody could take before. A caller holding an occurrence and
9//! the calendar it came from can ask what offset was in force, with no time-zone
10//! database and no new dependency, because RFC 5545 3.6.5 makes a calendar carry
11//! its own rules: a `VTIMEZONE` is a list of observances, each with the offset
12//! before it, the offset after it, and a recurrence rule saying when it takes
13//! effect.
14//!
15//! ## The two hard cases are answered, not guessed
16//!
17//! A local clock is not a bijection. When it springs forward, the times it jumps
18//! over never happen; when it falls back, the times it repeats happen twice.
19//! [`resolve`](IcalTimezone::resolve) reports both as what they are, with the
20//! offsets either side, rather than picking one and calling it the answer.
21//! Choosing belongs to the caller, who knows whether a skipped alarm should fire
22//! early, late or not at all.
23//!
24//! ## What is read, and what is not
25//!
26//! An observance contributes its `DTSTART`, its `RRULE`s and its `RDATE`s
27//! through the same [`IcalRecurSet`] every other component uses, so the
28//! transitions of a zone are just another recurrence set. `TZNAME`, `TZURL` and
29//! `LAST-MODIFIED` are not read: they name a zone, they do not place it. Every
30//! `DTSTART` inside an observance is local to the offset *before* the transition
31//! (RFC 5545 3.6.5), which is what makes a transition expressible in two local
32//! times at once, and what the gap and the fold are made of.
33
34use alloc::{
35    string::{String, ToString},
36    vec::Vec,
37};
38
39use crate::{
40    component::{IcalComponent, IcalComponentKind, IcalComponentName},
41    ical::Ical,
42    prop::{IcalPropKind, IcalPropName},
43    recur::{IcalRecurDateTime, set::IcalRecurSet},
44    value::IcalValue,
45};
46
47/// What offset is in force at one civil local time.
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49pub enum IcalOffset {
50    /// The local time is unambiguous, and this offset is in force. Seconds east
51    /// of UTC, so `-0500` is `-18000`.
52    One(i32),
53    /// The local time never happens: a transition jumped over it.
54    Gap {
55        /// The offset in force before the transition.
56        before: i32,
57        /// The offset in force after it.
58        after: i32,
59    },
60    /// The local time happens twice.
61    Fold {
62        /// The offset of its first occurrence.
63        earlier: i32,
64        /// The offset of its second.
65        later: i32,
66    },
67}
68
69impl IcalOffset {
70    /// The offset, when the local time has exactly one. `None` for a gap or a
71    /// fold, which is the whole point of distinguishing them.
72    pub fn unambiguous(&self) -> Option<i32> {
73        match self {
74            Self::One(offset) => Some(*offset),
75            _ => None,
76        }
77    }
78}
79
80/// One `STANDARD` or `DAYLIGHT` observance: when it takes effect, and the
81/// offsets either side of it.
82#[derive(Clone, Debug, PartialEq, Eq)]
83pub struct IcalObservance {
84    /// Whether this is a `DAYLIGHT` observance rather than a `STANDARD` one.
85    pub daylight: bool,
86    /// The offset in force before this observance takes effect
87    /// (`TZOFFSETFROM`), in seconds east of UTC.
88    pub from: i32,
89    /// The offset in force after it (`TZOFFSETTO`), in seconds east of UTC.
90    pub to: i32,
91    /// When it takes effect, as a recurrence set. Every date in it is local to
92    /// [`from`](Self::from).
93    pub onsets: IcalRecurSet,
94}
95
96/// A `VTIMEZONE`, read into the observances that place it.
97#[derive(Clone, Debug, PartialEq, Eq)]
98pub struct IcalTimezone {
99    /// The `TZID` this zone answers to, verbatim.
100    pub id: String,
101    /// Its observances, in source order.
102    pub observances: Vec<IcalObservance>,
103}
104
105/// One transition of a zone: the instant it happens, expressed in the local
106/// time before it, and the offsets either side.
107#[derive(Clone, Copy, Debug, PartialEq, Eq)]
108struct Transition {
109    /// The onset in the local time *before* the transition, as `DTSTART`
110    /// spells it.
111    before_local: IcalRecurDateTime,
112    from: i32,
113    to: i32,
114}
115
116impl Transition {
117    /// The same instant in the local time *after* the transition.
118    fn after_local(&self) -> IcalRecurDateTime {
119        IcalRecurDateTime::from_seconds(
120            self.before_local.seconds() + i64::from(self.to) - i64::from(self.from),
121        )
122    }
123}
124
125impl IcalTimezone {
126    /// Read a `VTIMEZONE` component into its observances.
127    ///
128    /// `None` when the component is not a `VTIMEZONE` or carries no `TZID`: a
129    /// zone nobody can name is a zone nobody can ask about.
130    pub fn of_component(component: &IcalComponent<'_>) -> Option<Self> {
131        if !matches!(
132            component.name,
133            IcalComponentName::Kind(IcalComponentKind::VTimezone)
134        ) {
135            return None;
136        }
137
138        let id = component
139            .props
140            .iter()
141            .find_map(|prop| match (&prop.name, &prop.value) {
142                (IcalPropName::Kind(IcalPropKind::TzId), IcalValue::Text(text)) => {
143                    Some(text.0.to_string())
144                }
145                _ => None,
146            })?;
147
148        let observances = component
149            .components
150            .iter()
151            .filter_map(IcalObservance::of_component)
152            .collect();
153
154        Some(Self { id, observances })
155    }
156
157    /// The zone a calendar defines under `tzid`, if it defines one.
158    pub fn of_calendar(ical: &Ical<'_>, tzid: &str) -> Option<Self> {
159        ical.components
160            .iter()
161            .filter_map(Self::of_component)
162            .find(|zone| zone.id == tzid)
163    }
164
165    /// The offset in force at a civil local time, or the gap or fold it falls
166    /// in.
167    ///
168    /// A zone with no observance at all resolves everything to UTC, since it
169    /// states no offset to apply. A local time before the first transition
170    /// takes the offset that transition says came before it, which is what
171    /// `TZOFFSETFROM` is for.
172    pub fn resolve(&self, local: IcalRecurDateTime) -> IcalOffset {
173        let mut previous: Option<Transition> = None;
174        let mut next: Option<Transition> = None;
175        let mut first: Option<Transition> = None;
176
177        for observance in &self.observances {
178            for onset in observance.onsets.expand() {
179                let transition = Transition {
180                    before_local: onset.start,
181                    from: observance.from,
182                    to: observance.to,
183                };
184
185                if first.is_none_or(|held| transition.before_local < held.before_local) {
186                    first = Some(transition);
187                }
188
189                if transition.before_local <= local {
190                    if previous.is_none_or(|held| held.before_local < transition.before_local) {
191                        previous = Some(transition);
192                    }
193                } else {
194                    // NOTE: Onsets come out in order, so the first one past the
195                    // query is this observance's only candidate for the next
196                    // transition, and there is no reason to walk an endless
197                    // rule any further.
198                    if next.is_none_or(|held| transition.before_local < held.before_local) {
199                        next = Some(transition);
200                    }
201                    break;
202                }
203            }
204        }
205
206        // NOTE: A local time the last transition jumped over never happened.
207        if let Some(transition) = previous
208            && transition.to > transition.from
209            && local < transition.after_local()
210        {
211            return IcalOffset::Gap {
212                before: transition.from,
213                after: transition.to,
214            };
215        }
216
217        // NOTE: A local time the next transition is about to repeat happens
218        // twice.
219        if let Some(transition) = next
220            && transition.to < transition.from
221            && local >= transition.after_local()
222        {
223            return IcalOffset::Fold {
224                earlier: transition.from,
225                later: transition.to,
226            };
227        }
228
229        match (previous, first) {
230            (Some(transition), _) => IcalOffset::One(transition.to),
231            (None, Some(transition)) => IcalOffset::One(transition.from),
232            (None, None) => IcalOffset::One(0),
233        }
234    }
235}
236
237impl IcalObservance {
238    /// Read a `STANDARD` or `DAYLIGHT` component into an observance. `None` for
239    /// anything else, and for one that states no offsets.
240    pub fn of_component(component: &IcalComponent<'_>) -> Option<Self> {
241        let daylight = match component.name {
242            IcalComponentName::Kind(IcalComponentKind::Daylight) => true,
243            IcalComponentName::Kind(IcalComponentKind::Standard) => false,
244            _ => return None,
245        };
246
247        let mut from = None;
248        let mut to = None;
249
250        for prop in &component.props {
251            let IcalPropName::Kind(kind) = prop.name else {
252                continue;
253            };
254
255            let IcalValue::UtcOffset(offset) = &prop.value else {
256                continue;
257            };
258
259            match kind {
260                IcalPropKind::TzOffsetFrom => from = parse_offset(&offset.0),
261                IcalPropKind::TzOffsetTo => to = parse_offset(&offset.0),
262                _ => {}
263            }
264        }
265
266        Some(Self {
267            daylight,
268            from: from?,
269            to: to?,
270            onsets: IcalRecurSet::of_component(component),
271        })
272    }
273}
274
275/// Parse the RFC 5545 3.3.14 `+/-hhmm[ss]` form into seconds east of UTC.
276fn parse_offset(text: &str) -> Option<i32> {
277    let (sign, digits) = match text.as_bytes().first()? {
278        b'+' => (1, &text[1..]),
279        b'-' => (-1, &text[1..]),
280        _ => (1, text),
281    };
282
283    if !matches!(digits.len(), 4 | 6) || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
284        return None;
285    }
286
287    let part = |range: core::ops::Range<usize>| digits[range].parse::<i32>().ok();
288
289    let hours = part(0..2)?;
290    let minutes = part(2..4)?;
291    let seconds = if digits.len() == 6 { part(4..6)? } else { 0 };
292
293    Some(sign * (hours * 3600 + minutes * 60 + seconds))
294}
295
296#[cfg(test)]
297mod tests {
298    use crate::timezone::parse_offset;
299
300    #[test]
301    fn reads_every_offset_spelling() {
302        assert_eq!(parse_offset("-0500"), Some(-18_000));
303        assert_eq!(parse_offset("+0100"), Some(3_600));
304        assert_eq!(parse_offset("+053045"), Some(19_845));
305        assert_eq!(parse_offset("0000"), Some(0));
306    }
307
308    #[test]
309    fn refuses_what_is_not_an_offset() {
310        assert_eq!(parse_offset(""), None);
311        assert_eq!(parse_offset("+5"), None);
312        assert_eq!(parse_offset("+05:00"), None);
313        assert_eq!(parse_offset("+0h00"), None);
314    }
315}