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}