Skip to main content

datetime/
tz.rs

1// tz -- time zones from POSIX TZ rules ("CET-1CEST,M3.5.0,M10.5.0/3")
2//
3//   Zone::utc() / Zone::by_name("Europe/Berlin") / Zone::from_windows("W. Europe Standard Time")
4//   Zone::from_posix(name, rule) / Zone::fixed(name, offset_secs)
5//   Zone::names() -> every IANA name of the table (sorted)
6//   zone.offset_at(secs) -> UTC offset (seconds east), zone.abbrev_at(secs), zone.is_dst(secs)
7//   zone.to_local(ts) -> Civil;  zone.from_local(&civil) -> LocalResult:
8//        Single(ts) | Ambiguous(first, second) (the repeated hour) | Gap { after } (the skipped
9//        hour: `after` is the instant the clocks jumped, the first valid time after the gap)
10//
11// A rule describes the zone from its last change on (the TZif footer of tools/gen_tz.py), so
12// times before that change use today's rule.
13
14use alloc::string::String;
15
16use crate::zones::{WINDOWS, ZONES};
17use crate::{days_in_month, weekday, Civil, Timestamp};
18
19/// a DST transition rule: Mm.w.d (month, week 1..5 (5 = last), weekday 0 = Sunday), Jn (day
20/// 1..365 without Feb 29) or n (day 0..365 with it); time in seconds after local midnight
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22enum Rule {
23    M { month: u8, week: u8, wday: u8, time: i32 },
24    J { day: u16, time: i32 },
25    D { day: u16, time: i32 },
26}
27
28#[derive(Clone, Debug, PartialEq, Eq)]
29struct Dst {
30    abbr: String,
31    off: i32,
32    start: Rule,
33    end: Rule,
34}
35
36/// a time zone: its name, standard offset and an optional daylight saving rule
37#[derive(Clone, Debug, PartialEq, Eq)]
38pub struct Zone {
39    name: String,
40    abbr: String,
41    off: i32,
42    dst: Option<Dst>,
43}
44
45/// the instants a local wall time stands for
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub enum LocalResult {
48    Single(Timestamp),
49    /// the wall time occurs twice (the clocks went back): earlier and later instant
50    Ambiguous(Timestamp, Timestamp),
51    /// the wall time does not exist (the clocks jumped forward): the jump's instant
52    Gap { after: Timestamp },
53}
54
55impl LocalResult {
56    /// the earliest instant that stands for the wall time (a gap: the jump)
57    pub fn earliest(&self) -> Timestamp {
58        match *self {
59            LocalResult::Single(t) | LocalResult::Ambiguous(t, _) => t,
60            LocalResult::Gap { after } => after,
61        }
62    }
63}
64
65// ------------------------------------------------------------------ POSIX TZ parsing
66
67struct P<'a> {
68    s: &'a [u8],
69    i: usize,
70}
71
72impl P<'_> {
73    fn peek(&self) -> Option<u8> {
74        self.s.get(self.i).copied()
75    }
76    fn name(&mut self) -> Option<String> {
77        if self.peek() == Some(b'<') {
78            let start = self.i + 1;
79            while self.peek()? != b'>' {
80                self.i += 1;
81            }
82            let n = core::str::from_utf8(&self.s[start..self.i]).ok()?;
83            self.i += 1;
84            return Some(String::from(n));
85        }
86        let start = self.i;
87        while matches!(self.peek(), Some(c) if c.is_ascii_alphabetic()) {
88            self.i += 1;
89        }
90        if self.i - start < 3 {
91            return None;
92        }
93        Some(String::from(core::str::from_utf8(&self.s[start..self.i]).ok()?))
94    }
95    fn num(&mut self) -> Option<i32> {
96        let start = self.i;
97        let mut v = 0i32;
98        while let Some(c) = self.peek() {
99            if !c.is_ascii_digit() {
100                break;
101            }
102            v = v.checked_mul(10)?.checked_add((c - b'0') as i32)?;
103            self.i += 1;
104        }
105        if self.i == start { None } else { Some(v) }
106    }
107    // [+-]hh[:mm[:ss]] -> seconds
108    fn time(&mut self) -> Option<i32> {
109        let neg = match self.peek() {
110            Some(b'-') => {
111                self.i += 1;
112                true
113            }
114            Some(b'+') => {
115                self.i += 1;
116                false
117            }
118            _ => false,
119        };
120        let h = self.num()?;
121        if h > 167 {
122            return None; // POSIX / TZif: at most +-167 hours
123        }
124        let mut t = h * 3600;
125        if self.peek() == Some(b':') {
126            self.i += 1;
127            let m = self.num()?;
128            if m > 59 {
129                return None;
130            }
131            t += m * 60;
132            if self.peek() == Some(b':') {
133                self.i += 1;
134                let s = self.num()?;
135                if s > 59 {
136                    return None;
137                }
138                t += s;
139            }
140        }
141        Some(if neg { -t } else { t })
142    }
143    fn rule(&mut self) -> Option<Rule> {
144        let r = match self.peek()? {
145            b'M' => {
146                self.i += 1;
147                let month = u8::try_from(self.num()?).ok()?;
148                if self.peek()? != b'.' {
149                    return None;
150                }
151                self.i += 1;
152                let week = u8::try_from(self.num()?).ok()?;
153                if self.peek()? != b'.' {
154                    return None;
155                }
156                self.i += 1;
157                let wday = u8::try_from(self.num()?).ok()?;
158                if !(1..=12).contains(&month) || !(1..=5).contains(&week) || wday > 6 {
159                    return None;
160                }
161                Rule::M { month, week, wday, time: 7200 }
162            }
163            b'J' => {
164                self.i += 1;
165                let day = u16::try_from(self.num()?).ok().filter(|d| (1..=365).contains(d))?;
166                Rule::J { day, time: 7200 }
167            }
168            _ => Rule::D { day: u16::try_from(self.num()?).ok().filter(|&d| d <= 365)?, time: 7200 },
169        };
170        let time = if self.peek() == Some(b'/') {
171            self.i += 1;
172            self.time()?
173        } else {
174            7200
175        };
176        Some(match r {
177            Rule::M { month, week, wday, .. } => Rule::M { month, week, wday, time },
178            Rule::J { day, .. } => Rule::J { day, time },
179            Rule::D { day, .. } => Rule::D { day, time },
180        })
181    }
182}
183
184fn parse_posix(rule: &str) -> Option<(String, i32, Option<Dst>)> {
185    let mut p = P { s: rule.as_bytes(), i: 0 };
186    let abbr = p.name()?;
187    // POSIX offsets are west of UTC: "CET-1" = UTC+1
188    let off = -p.time()?;
189    if p.peek().is_none() {
190        return Some((abbr, off, None));
191    }
192    let dabbr = p.name()?;
193    let doff = match p.peek() {
194        Some(b',') | None => off + 3600,
195        _ => -p.time()?,
196    };
197    let (start, end) = if p.peek() == Some(b',') {
198        p.i += 1;
199        let s = p.rule()?;
200        if p.peek()? != b',' {
201            return None;
202        }
203        p.i += 1;
204        let e = p.rule()?;
205        (s, e)
206    } else {
207        // the US default of POSIX
208        (Rule::M { month: 3, week: 2, wday: 0, time: 7200 }, Rule::M { month: 11, week: 1, wday: 0, time: 7200 })
209    };
210    if p.peek().is_some() {
211        return None;
212    }
213    Some((abbr, off, Some(Dst { abbr: dabbr, off: doff, start, end })))
214}
215
216// the local seconds (since the epoch, read as UTC) of a rule in year y, at its time
217fn rule_local(y: i32, r: Rule) -> i64 {
218    let (date, time) = match r {
219        Rule::M { month, week, wday, time } => {
220            // weekday of the 1st: crate weekday is 0 = Monday; the rule's 0 = Sunday
221            let first = (weekday(y, month, 1) + 1) % 7;
222            let mut day = 1 + (wday as i32 - first as i32 + 7) % 7 + 7 * (week as i32 - 1);
223            let dim = days_in_month(y, month) as i32;
224            while day > dim {
225                day -= 7;
226            }
227            (crate::days_from_civil(y, month, day as u8), time)
228        }
229        Rule::J { day, time } => {
230            // 1..365, February 29 never counted
231            let mut d = day as i64 - 1;
232            if crate::is_leap(y) && day >= 60 {
233                d += 1;
234            }
235            (crate::days_from_civil(y, 1, 1) + d, time)
236        }
237        Rule::D { day, time } => (crate::days_from_civil(y, 1, 1) + day as i64, time),
238    };
239    date * 86_400 + time as i64
240}
241
242impl Zone {
243    /// UTC
244    pub fn utc() -> Zone {
245        Zone { name: String::from("UTC"), abbr: String::from("UTC"), off: 0, dst: None }
246    }
247
248    /// a fixed offset (seconds east of UTC)
249    pub fn fixed(name: &str, offset_secs: i32) -> Zone {
250        Zone { name: String::from(name), abbr: String::from(name), off: offset_secs, dst: None }
251    }
252
253    /// a zone from a POSIX TZ rule
254    pub fn from_posix(name: &str, rule: &str) -> Option<Zone> {
255        let (abbr, off, dst) = parse_posix(rule)?;
256        Some(Zone { name: String::from(name), abbr, off, dst })
257    }
258
259    /// an IANA zone of the table ("Europe/Berlin"; "UTC", "Etc/UTC", "Z" also work)
260    pub fn by_name(name: &str) -> Option<Zone> {
261        if matches!(name, "UTC" | "Z" | "Etc/UTC" | "Etc/Universal" | "Universal" | "Zulu" | "GMT" | "Etc/GMT") {
262            return Some(Zone { name: String::from(if name == "Z" { "UTC" } else { name }), ..Zone::utc() });
263        }
264        let i = ZONES.binary_search_by(|(n, _)| (*n).cmp(name)).ok()?;
265        Zone::from_posix(ZONES[i].0, ZONES[i].1)
266    }
267
268    /// the IANA zone of a Windows time zone key ("W. Europe Standard Time")
269    pub fn from_windows(key: &str) -> Option<Zone> {
270        let i = WINDOWS.binary_search_by(|(k, _)| (*k).cmp(key)).ok()?;
271        Zone::by_name(WINDOWS[i].1)
272    }
273
274    /// the IANA name of a Windows time zone key
275    pub fn windows_to_iana(key: &str) -> Option<&'static str> {
276        WINDOWS.binary_search_by(|(k, _)| (*k).cmp(key)).ok().map(|i| WINDOWS[i].1)
277    }
278
279    /// every IANA zone name of the table, sorted
280    pub fn names() -> impl Iterator<Item = &'static str> {
281        ZONES.iter().map(|(n, _)| *n)
282    }
283
284    /// a zone from a TZif file (its footer rule; /etc/localtime), named `name`
285    pub fn from_tzif(name: &str, data: &[u8]) -> Option<Zone> {
286        if !data.starts_with(b"TZif") || data.last() != Some(&b'\n') {
287            return None;
288        }
289        let body = &data[..data.len() - 1];
290        let i = body.iter().rposition(|&c| c == b'\n')?;
291        let rule = core::str::from_utf8(&body[i + 1..]).ok()?;
292        if rule.is_empty() {
293            return None;
294        }
295        Zone::from_posix(name, rule)
296    }
297
298    pub fn name(&self) -> &str {
299        &self.name
300    }
301
302    /// the standard offset (seconds east of UTC)
303    pub fn std_offset(&self) -> i32 {
304        self.off
305    }
306
307    // (start, end) of DST in the UTC seconds of year y (by the standard-time year)
308    fn dst_window(&self, y: i32) -> Option<(i64, i64, i32)> {
309        let d = self.dst.as_ref()?;
310        // the start time is given in standard time, the end time in daylight time
311        let start = rule_local(y, d.start) - self.off as i64;
312        let end = rule_local(y, d.end) - d.off as i64;
313        Some((start, end, d.off))
314    }
315
316    /// whether daylight saving time is in effect at an instant
317    pub fn is_dst(&self, secs: i64) -> bool {
318        let Some(_) = self.dst else { return false };
319        let y = Civil::from_unix(secs + self.off as i64).year;
320        let Some((s, e, _)) = self.dst_window(y) else { return false };
321        if s < e { secs >= s && secs < e } else { secs < e || secs >= s }
322    }
323
324    /// the UTC offset (seconds east) in effect at an instant
325    pub fn offset_at(&self, secs: i64) -> i32 {
326        match &self.dst {
327            Some(d) if self.is_dst(secs) => d.off,
328            _ => self.off,
329        }
330    }
331
332    /// the abbreviation in effect at an instant ("CET" / "CEST")
333    pub fn abbrev_at(&self, secs: i64) -> &str {
334        match &self.dst {
335            Some(d) if self.is_dst(secs) => &d.abbr,
336            _ => &self.abbr,
337        }
338    }
339
340    /// the wall time of an instant
341    pub fn to_local(&self, ts: Timestamp) -> Civil {
342        let mut c = Civil::from_unix(ts.secs + self.offset_at(ts.secs) as i64);
343        c.nanos = ts.nanos;
344        c
345    }
346
347    /// the instants of a wall time (seconds; nanos kept)
348    pub fn from_local(&self, c: &Civil) -> LocalResult {
349        let l = c.to_unix();
350        let ns = c.nanos;
351        let Some(d) = &self.dst else {
352            return LocalResult::Single(Timestamp { secs: l - self.off as i64, nanos: ns });
353        };
354        let t1 = l - self.off as i64; // read as standard time
355        let t2 = l - d.off as i64; // read as daylight time
356        let ok1 = self.offset_at(t1) == self.off;
357        let ok2 = self.offset_at(t2) == d.off;
358        let (a, b) = (t1.min(t2), t1.max(t2));
359        match (ok1, ok2) {
360            (true, true) if t1 != t2 => LocalResult::Ambiguous(Timestamp { secs: a, nanos: ns }, Timestamp { secs: b, nanos: ns }),
361            (true, _) => LocalResult::Single(Timestamp { secs: t1, nanos: ns }),
362            (_, true) => LocalResult::Single(Timestamp { secs: t2, nanos: ns }),
363            _ => {
364                // in the gap: the transition between a and b
365                let y = c.year;
366                let mut jump = b;
367                for yy in [y - 1, y, y + 1] {
368                    if let Some((s, e, _)) = self.dst_window(yy) {
369                        for t in [s, e] {
370                            if t >= a && t <= b {
371                                jump = t;
372                            }
373                        }
374                    }
375                }
376                LocalResult::Gap { after: Timestamp { secs: jump, nanos: 0 } }
377            }
378        }
379    }
380}