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