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}