Skip to main content

zdump_rs/
posix.rs

1//! POSIX TZ-string parsing + evaluation — the TZif **footer**, used to project offset/is_dst/abbreviation
2//! for instants *beyond the last explicit transition* (Phase 2).
3//!
4//! A TZif file's footer is a POSIX TZ string such as `EST5EDT,M3.2.0,M11.1.0` or
5//! `GMT0BST,M3.5.0/1,M10.5.0` or `<-03>3<-02>,M3.5.0/-2,M10.5.0/-1`. RFC 9636 says a reader uses it to
6//! determine local time after the final recorded transition. This module parses that grammar and answers
7//! offset/is_dst/abbreviation at an arbitrary instant by computing the DST start/end transitions for the
8//! surrounding years (the same approach glibc/Python's `zoneinfo` use).
9//!
10//! Grammar (POSIX, with the common extensions tzcode emits):
11//! ```text
12//!   std offset [dst [offset] [,start[/time],end[/time]]]
13//!   name   := alpha{3,} | '<' [+-]?[A-Za-z0-9]{3,} '>'
14//!   offset := [+-]hh[:mm[:ss]]        (POSIX sign: POSITIVE means WEST of UTC)
15//!   rule   := 'J'n | n | 'M'm.w.d     (Jn=1..365 skip Feb29; n=0..365; Mm.w.d month.week.weekday)
16//!   time   := [+-]hh[:mm[:ss]]        (LOCAL wall clock; default 02:00:00; may be negative or >24h)
17//! ```
18
19#![forbid(unsafe_code)]
20
21use crate::civil::{civil_from_days, days_from_civil};
22use crate::tzif::Observation;
23
24/// A transition rule (when DST starts or ends within a year).
25#[derive(Debug, Clone, PartialEq, Eq)]
26enum Rule {
27    /// `Jn` — Julian day 1..365, never counting Feb 29.
28    Julian1(i64),
29    /// `n` — zero-based day of year 0..365, counting Feb 29.
30    ZeroBased(i64),
31    /// `Mm.w.d` — month (1..12), week (1..5, 5 = last), weekday (0=Sunday..6).
32    MonthWeekDay { m: i64, w: i64, d: i64 },
33}
34
35/// A parsed POSIX TZ string. `utoff_*` are seconds EAST of UTC (so `local = UTC + utoff`), already
36/// sign-flipped from the POSIX west-positive convention.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct TzString {
39    pub std_abbr: String,
40    pub std_utoff: i32,
41    pub dst: Option<Dst>,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct Dst {
46    pub dst_abbr: String,
47    pub dst_utoff: i32,
48    start: Rule,
49    start_time: i64,
50    end: Rule,
51    end_time: i64,
52}
53
54/// Parse a POSIX TZ string. Returns `None` if it is not a well-formed footer (the witness then falls back
55/// to the explicit table and flags the instant as footer-governed-but-unprojected).
56pub fn parse(s: &str) -> Option<TzString> {
57    let b = s.as_bytes();
58    let mut i = 0usize;
59    let (std_abbr, ni) = parse_name(b, i)?;
60    i = ni;
61    let (std_posix, ni) = parse_offset(b, i)?; // required for std
62    i = ni;
63    let std_utoff = -std_posix;
64    if i >= b.len() {
65        return Some(TzString {
66            std_abbr,
67            std_utoff,
68            dst: None,
69        });
70    }
71    // DST abbreviation
72    let (dst_abbr, ni) = parse_name(b, i)?;
73    i = ni;
74    // optional DST offset; default = std one hour east (utoff_dst = utoff_std + 3600)
75    let (dst_utoff, ni) = if i < b.len() && b[i] != b',' {
76        let (p, ni) = parse_offset(b, i)?;
77        (-p, ni)
78    } else {
79        (std_utoff + 3600, i)
80    };
81    i = ni;
82    // ,start[/time],end[/time]
83    if i >= b.len() || b[i] != b',' {
84        return None; // a DST name with no rules is not usable
85    }
86    i += 1;
87    let (start, ni) = parse_rule(b, i)?;
88    i = ni;
89    let (start_time, ni) = parse_opt_time(b, i);
90    i = ni;
91    if i >= b.len() || b[i] != b',' {
92        return None;
93    }
94    i += 1;
95    let (end, ni) = parse_rule(b, i)?;
96    i = ni;
97    let (end_time, _ni) = parse_opt_time(b, i);
98    Some(TzString {
99        std_abbr,
100        std_utoff,
101        dst: Some(Dst {
102            dst_abbr,
103            dst_utoff,
104            start,
105            start_time,
106            end,
107            end_time,
108        }),
109    })
110}
111
112fn parse_name(b: &[u8], mut i: usize) -> Option<(String, usize)> {
113    if i >= b.len() {
114        return None;
115    }
116    if b[i] == b'<' {
117        i += 1;
118        let start = i;
119        while i < b.len() && b[i] != b'>' {
120            i += 1;
121        }
122        if i >= b.len() {
123            return None;
124        }
125        let name = std::str::from_utf8(&b[start..i]).ok()?.to_string();
126        Some((name, i + 1))
127    } else {
128        let start = i;
129        while i < b.len() && b[i].is_ascii_alphabetic() {
130            i += 1;
131        }
132        if i - start < 3 {
133            return None;
134        }
135        Some((std::str::from_utf8(&b[start..i]).ok()?.to_string(), i))
136    }
137}
138
139/// Parse `[+-]hh[:mm[:ss]]` into seconds (POSIX west-positive sign, returned as-is).
140fn parse_offset(b: &[u8], mut i: usize) -> Option<(i32, usize)> {
141    let mut sign = 1i32;
142    if i < b.len() && (b[i] == b'+' || b[i] == b'-') {
143        if b[i] == b'-' {
144            sign = -1;
145        }
146        i += 1;
147    }
148    let (hh, ni) = parse_uint(b, i)?;
149    i = ni;
150    let mut secs = hh * 3600;
151    if i < b.len() && b[i] == b':' {
152        let (mm, ni) = parse_uint(b, i + 1)?;
153        i = ni;
154        secs += mm * 60;
155        if i < b.len() && b[i] == b':' {
156            let (ss, ni) = parse_uint(b, i + 1)?;
157            i = ni;
158            secs += ss;
159        }
160    }
161    Some((sign * secs as i32, i))
162}
163
164/// `/time` with default 02:00:00; the time is LOCAL wall and may be signed / exceed 24h.
165fn parse_opt_time(b: &[u8], i: usize) -> (i64, usize) {
166    if i < b.len() && b[i] == b'/' {
167        if let Some((t, ni)) = parse_signed_time(b, i + 1) {
168            return (t, ni);
169        }
170    }
171    (7200, i)
172}
173
174fn parse_signed_time(b: &[u8], mut i: usize) -> Option<(i64, usize)> {
175    let mut sign = 1i64;
176    if i < b.len() && (b[i] == b'+' || b[i] == b'-') {
177        if b[i] == b'-' {
178            sign = -1;
179        }
180        i += 1;
181    }
182    let (hh, ni) = parse_uint(b, i)?;
183    i = ni;
184    let mut secs = (hh as i64) * 3600;
185    if i < b.len() && b[i] == b':' {
186        let (mm, ni) = parse_uint(b, i + 1)?;
187        i = ni;
188        secs += (mm as i64) * 60;
189        if i < b.len() && b[i] == b':' {
190            let (ss, ni) = parse_uint(b, i + 1)?;
191            i = ni;
192            secs += ss as i64;
193        }
194    }
195    Some((sign * secs, i))
196}
197
198fn parse_rule(b: &[u8], i: usize) -> Option<(Rule, usize)> {
199    if i >= b.len() {
200        return None;
201    }
202    match b[i] {
203        b'J' => {
204            let (n, ni) = parse_uint(b, i + 1)?;
205            Some((Rule::Julian1(n as i64), ni))
206        }
207        b'M' => {
208            let (m, i1) = parse_uint(b, i + 1)?;
209            if b.get(i1) != Some(&b'.') {
210                return None;
211            }
212            let (w, i2) = parse_uint(b, i1 + 1)?;
213            if b.get(i2) != Some(&b'.') {
214                return None;
215            }
216            let (d, i3) = parse_uint(b, i2 + 1)?;
217            Some((
218                Rule::MonthWeekDay {
219                    m: m as i64,
220                    w: w as i64,
221                    d: d as i64,
222                },
223                i3,
224            ))
225        }
226        _ => {
227            let (n, ni) = parse_uint(b, i)?;
228            Some((Rule::ZeroBased(n as i64), ni))
229        }
230    }
231}
232
233fn parse_uint(b: &[u8], mut i: usize) -> Option<(u64, usize)> {
234    let start = i;
235    let mut v = 0u64;
236    while i < b.len() && b[i].is_ascii_digit() {
237        v = v * 10 + (b[i] - b'0') as u64;
238        i += 1;
239    }
240    if i == start {
241        None
242    } else {
243        Some((v, i))
244    }
245}
246
247/// Weekday of a day-count since the epoch, 0=Sunday..6=Saturday (1970-01-01 was a Thursday = 4).
248fn weekday(days: i64) -> i64 {
249    (days.rem_euclid(7) + 4).rem_euclid(7)
250}
251
252fn days_in_month(y: i64, m: i64) -> i64 {
253    let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
254    match m {
255        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
256        4 | 6 | 9 | 11 => 30,
257        2 => {
258            if leap {
259                29
260            } else {
261                28
262            }
263        }
264        _ => 30,
265    }
266}
267
268/// Convert a `Jn` (1..365, no Feb 29) to (month, day).
269fn julian1_to_md(mut n: i64) -> (i64, i64) {
270    const ML: [i64; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
271    let mut m = 1;
272    while m <= 12 && n > ML[(m - 1) as usize] {
273        n -= ML[(m - 1) as usize];
274        m += 1;
275    }
276    (m, n)
277}
278
279/// The UTC instant of a rule's transition in `year`, given the offset in effect *just before* it.
280fn rule_to_unix(r: &Rule, time: i64, year: i64, utoff_before: i32) -> i64 {
281    let (m, d) = match *r {
282        Rule::Julian1(n) => julian1_to_md(n),
283        Rule::ZeroBased(n) => {
284            // 0-based day of year, leap-aware
285            let mut rem = n;
286            let mut mm = 1;
287            loop {
288                let dim = days_in_month(year, mm);
289                if rem < dim {
290                    break;
291                }
292                rem -= dim;
293                mm += 1;
294                if mm > 12 {
295                    mm = 12;
296                    rem = days_in_month(year, 12) - 1;
297                    break;
298                }
299            }
300            (mm, rem + 1)
301        }
302        Rule::MonthWeekDay { m, w, d } => {
303            let first = days_from_civil(year, m, 1);
304            let wd_first = weekday(first);
305            // day-of-month of the first weekday `d`
306            let mut dom = 1 + (d - wd_first).rem_euclid(7);
307            dom += (w - 1) * 7;
308            let dim = days_in_month(year, m);
309            while dom > dim {
310                dom -= 7; // w == 5 ("last") overshoot
311            }
312            (m, dom)
313        }
314    };
315    // local wall instant (civil date at midnight + the rule's local time), then to UTC.
316    let local = days_from_civil(year, m, d) * 86400 + time;
317    local - utoff_before as i64
318}
319
320impl TzString {
321    fn std_obs(&self) -> Observation {
322        Observation {
323            utoff: self.std_utoff,
324            is_dst: false,
325            abbr: self.std_abbr.clone(),
326        }
327    }
328    fn dst_obs(&self, d: &Dst) -> Observation {
329        Observation {
330            utoff: d.dst_utoff,
331            is_dst: true,
332            abbr: d.dst_abbr.clone(),
333        }
334    }
335
336    /// offset/is_dst/abbreviation at UTC instant `t`, via the POSIX rule. Robust across the year boundary:
337    /// it materialises the start/end transitions for the years around `t`, sorts them, and takes the state
338    /// set by the latest transition at-or-before `t`.
339    pub fn observe(&self, t: i64) -> Observation {
340        let Some(d) = &self.dst else {
341            return self.std_obs();
342        };
343        // local year of t (std offset is a fine approximation for picking which years to materialise)
344        let (y, _, _) = civil_from_days((t + self.std_utoff as i64).div_euclid(86400));
345        // (instant, becomes_dst) for the surrounding years
346        let mut tr: Vec<(i64, bool)> = Vec::with_capacity(6);
347        for yr in (y - 1)..=(y + 1) {
348            tr.push((
349                rule_to_unix(&d.start, d.start_time, yr, self.std_utoff),
350                true,
351            ));
352            tr.push((rule_to_unix(&d.end, d.end_time, yr, d.dst_utoff), false));
353        }
354        tr.sort_by_key(|x| x.0);
355        // state set by the latest transition <= t
356        let mut state_dst = None;
357        for (inst, becomes_dst) in &tr {
358            if *inst <= t {
359                state_dst = Some(*becomes_dst);
360            } else {
361                break;
362            }
363        }
364        // before the earliest materialised transition, the state is the opposite of that earliest one
365        let dst_now = match state_dst {
366            Some(s) => s,
367            None => !tr.first().map(|x| x.1).unwrap_or(false),
368        };
369        if dst_now {
370            self.dst_obs(d)
371        } else {
372            self.std_obs()
373        }
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    #[test]
382    fn parses_est5edt() {
383        let z = parse("EST5EDT,M3.2.0,M11.1.0").unwrap();
384        assert_eq!(z.std_abbr, "EST");
385        assert_eq!(z.std_utoff, -5 * 3600);
386        let d = z.dst.as_ref().unwrap();
387        assert_eq!(d.dst_abbr, "EDT");
388        assert_eq!(d.dst_utoff, -4 * 3600); // default std+1h east
389    }
390
391    #[test]
392    fn est_winter_summer() {
393        let z = parse("EST5EDT,M3.2.0,M11.1.0").unwrap();
394        // 2027-01-15 UTC -> EST; 2027-07-15 UTC -> EDT (far beyond any explicit table)
395        let jan = crate::civil::parse_iso_utc("2027-01-15T12:00:00Z").unwrap();
396        let jul = crate::civil::parse_iso_utc("2027-07-15T12:00:00Z").unwrap();
397        let a = z.observe(jan);
398        assert_eq!((a.utoff, a.is_dst, a.abbr.as_str()), (-18000, false, "EST"));
399        let b = z.observe(jul);
400        assert_eq!((b.utoff, b.is_dst, b.abbr.as_str()), (-14400, true, "EDT"));
401    }
402
403    #[test]
404    fn london_and_angle_names() {
405        let z = parse("GMT0BST,M3.5.0/1,M10.5.0").unwrap();
406        assert_eq!(z.std_utoff, 0);
407        assert_eq!(z.dst.as_ref().unwrap().dst_utoff, 3600);
408        // angle-bracket numeric names (e.g. -03/-02)
409        let z2 = parse("<-03>3<-02>,M3.5.0/-2,M10.5.0/-1").unwrap();
410        assert_eq!(z2.std_abbr, "-03");
411        assert_eq!(z2.std_utoff, -3 * 3600);
412        assert_eq!(z2.dst.as_ref().unwrap().dst_utoff, -2 * 3600);
413    }
414
415    #[test]
416    fn fixed_zone_no_dst() {
417        let z = parse("UTC0").unwrap();
418        assert!(z.dst.is_none());
419        let o = z.observe(0);
420        assert_eq!((o.utoff, o.is_dst), (0, false));
421    }
422
423    #[test]
424    fn southern_hemisphere_wraps_year() {
425        // Sydney-like: DST Oct..Apr (start month > end month)
426        let z = parse("AEST-10AEDT,M10.1.0,M4.1.0/3").unwrap();
427        let jan = crate::civil::parse_iso_utc("2030-01-15T00:00:00Z").unwrap(); // summer (DST)
428        let jul = crate::civil::parse_iso_utc("2030-07-15T00:00:00Z").unwrap(); // winter (std)
429        assert!(z.observe(jan).is_dst);
430        assert!(!z.observe(jul).is_dst);
431    }
432}