Skip to main content

timezone_data/
posix.rs

1//! Parser and evaluator for POSIX-style `TZ` strings.
2//!
3//! A TZif file may carry a trailing POSIX TZ rule (the "footer") describing how
4//! daylight-saving transitions continue past the last stored transition. This
5//! module parses such strings (e.g. `EST5EDT,M3.2.0,M11.1.0`) and computes the
6//! offset in effect at any instant, all without allocating.
7
8use core::fmt;
9
10use crate::error::Error;
11
12/// Identifies the type of transition rule in a POSIX TZ string.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum RuleKind {
15    /// `Jn` format: Julian day (1-365); February 29 is never counted.
16    Julian,
17    /// `n` format: zero-based day of year (0-365); February 29 is counted.
18    DayOfYear,
19    /// `Mm.w.d` format: month, week, and day-of-week.
20    MonthWeekDay,
21}
22
23/// Specifies when a DST transition occurs within a year.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub struct TransitionRule {
26    /// Which interpretation [`day`](Self::day) takes.
27    pub kind: RuleKind,
28    /// Julian day (1-365), day-of-year (0-365), or day-of-week (0 = Sunday).
29    pub day: i32,
30    /// Week of month (1-5); only meaningful for [`RuleKind::MonthWeekDay`].
31    pub week: i32,
32    /// Month (1-12); only meaningful for [`RuleKind::MonthWeekDay`].
33    pub mon: i32,
34    /// Seconds after midnight (default 7200 = 02:00:00).
35    pub time: i32,
36}
37
38/// A parsed POSIX-style `TZ` string, e.g. `EST5EDT,M3.2.0,M11.1.0`.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct PosixTz<'a> {
41    /// Standard time abbreviation.
42    pub std_abbrev: &'a str,
43    /// Standard time UTC offset in seconds (positive = east of UTC).
44    pub std_offset: i32,
45    /// DST abbreviation; empty if the rule defines no daylight saving time.
46    pub dst_abbrev: &'a str,
47    /// DST UTC offset in seconds (positive = east of UTC).
48    pub dst_offset: i32,
49    /// Rule for the standard → DST transition.
50    pub start: TransitionRule,
51    /// Rule for the DST → standard transition.
52    pub end: TransitionRule,
53}
54
55impl<'a> PosixTz<'a> {
56    /// Reports whether the rule defines daylight saving time.
57    pub fn has_dst(&self) -> bool {
58        !self.dst_abbrev.is_empty()
59    }
60
61    /// Returns the abbreviation, UTC offset, and DST flag in effect at the given
62    /// Unix timestamp according to this rule.
63    pub fn lookup(&self, unix: i64) -> (&'a str, i32, bool) {
64        if !self.has_dst() {
65            return (self.std_abbrev, self.std_offset, false);
66        }
67
68        let (year, yday, sec) = unix_to_yday_sec(unix);
69        let year_sec = yday * 86400 + sec;
70
71        let start_sec = rule_to_year_sec(self.start, year, self.std_offset);
72        let end_sec = rule_to_year_sec(self.end, year, self.dst_offset);
73
74        let in_dst = if start_sec < end_sec {
75            // Northern hemisphere: DST between start and end.
76            year_sec >= start_sec && year_sec < end_sec
77        } else {
78            // Southern hemisphere: DST outside [end, start).
79            year_sec >= start_sec || year_sec < end_sec
80        };
81
82        if in_dst {
83            (self.dst_abbrev, self.dst_offset, true)
84        } else {
85            (self.std_abbrev, self.std_offset, false)
86        }
87    }
88
89    /// Returns the DST start and end times as Unix timestamps for `year`,
90    /// or `None` if the rule defines no DST.
91    pub fn transitions_for_year(&self, year: i32) -> Option<(i64, i64)> {
92        if !self.has_dst() {
93            return None;
94        }
95        let year_start = year_to_unix(year);
96        let start_sec = rule_to_year_sec(self.start, year, self.std_offset);
97        let end_sec = rule_to_year_sec(self.end, year, self.dst_offset);
98        Some((year_start + start_sec as i64, year_start + end_sec as i64))
99    }
100}
101
102impl fmt::Display for PosixTz<'_> {
103    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104        write_name(f, self.std_abbrev)?;
105        write_offset(f, -self.std_offset)?;
106
107        if !self.has_dst() {
108            return Ok(());
109        }
110
111        write_name(f, self.dst_abbrev)?;
112        if self.dst_offset != self.std_offset + 3600 {
113            write_offset(f, -self.dst_offset)?;
114        }
115
116        f.write_str(",")?;
117        write_rule(f, self.start)?;
118        f.write_str(",")?;
119        write_rule(f, self.end)
120    }
121}
122
123/// Parses a POSIX-style `TZ` string.
124pub fn parse_posix_tz(s: &str) -> Result<PosixTz<'_>, Error> {
125    // Standard time name.
126    let (std_abbrev, rest) = parse_tz_name(s)?;
127    if std_abbrev.is_empty() {
128        return Err(Error::BadPosixTz("empty standard timezone name"));
129    }
130
131    // Standard time offset. POSIX offsets are positive *west* of UTC (opposite
132    // of ISO), so we negate to store seconds east of UTC.
133    let (off, rest) = parse_tz_offset(rest)?;
134    let std_offset = -off;
135
136    let mut p = PosixTz {
137        std_abbrev,
138        std_offset,
139        dst_abbrev: "",
140        dst_offset: 0,
141        start: DEFAULT_RULE,
142        end: DEFAULT_RULE,
143    };
144
145    if rest.is_empty() {
146        return Ok(p); // No DST.
147    }
148
149    // DST name.
150    let (dst_abbrev, rest) = parse_tz_name(rest)?;
151    if dst_abbrev.is_empty() {
152        return Err(Error::BadPosixTz("empty DST timezone name"));
153    }
154    p.dst_abbrev = dst_abbrev;
155
156    // Optional DST offset (default: std offset + 1 hour).
157    let rest = if !rest.is_empty() && !rest.starts_with(',') {
158        let (off, rest) = parse_tz_offset(rest)?;
159        p.dst_offset = -off;
160        rest
161    } else {
162        p.dst_offset = p.std_offset + 3600;
163        rest
164    };
165
166    // Transition rules.
167    if rest.is_empty() {
168        // Default US rules: M3.2.0,M11.1.0
169        p.start = TransitionRule {
170            kind: RuleKind::MonthWeekDay,
171            mon: 3,
172            week: 2,
173            day: 0,
174            time: 7200,
175        };
176        p.end = TransitionRule {
177            kind: RuleKind::MonthWeekDay,
178            mon: 11,
179            week: 1,
180            day: 0,
181            time: 7200,
182        };
183        return Ok(p);
184    }
185
186    let rest = rest
187        .strip_prefix(',')
188        .ok_or(Error::BadPosixTz("expected ',' before transition rules"))?;
189
190    let (start, rest) = parse_tz_rule(rest)?;
191    p.start = start;
192
193    let rest = rest
194        .strip_prefix(',')
195        .ok_or(Error::BadPosixTz("expected ',' between transition rules"))?;
196
197    let (end, _rest) = parse_tz_rule(rest)?;
198    p.end = end;
199
200    Ok(p)
201}
202
203const DEFAULT_RULE: TransitionRule = TransitionRule {
204    kind: RuleKind::MonthWeekDay,
205    day: 0,
206    week: 0,
207    mon: 0,
208    time: 7200,
209};
210
211// --- Parsing helpers ---
212
213fn parse_tz_name(s: &str) -> Result<(&str, &str), Error> {
214    if s.is_empty() {
215        return Ok(("", ""));
216    }
217    let b = s.as_bytes();
218    if b[0] == b'<' {
219        // Quoted name: <...>
220        let end = s
221            .find('>')
222            .ok_or(Error::BadPosixTz("unterminated '<' in TZ name"))?;
223        return Ok((&s[1..end], &s[end + 1..]));
224    }
225    // Unquoted: letters only.
226    let mut i = 0;
227    while i < b.len() && is_alpha(b[i]) {
228        i += 1;
229    }
230    Ok((&s[..i], &s[i..]))
231}
232
233fn parse_tz_offset(s: &str) -> Result<(i32, &str), Error> {
234    if s.is_empty() {
235        return Err(Error::BadPosixTz("expected offset"));
236    }
237    let mut rest = s;
238    let mut neg = false;
239    if let Some(r) = rest.strip_prefix('-') {
240        neg = true;
241        rest = r;
242    } else if let Some(r) = rest.strip_prefix('+') {
243        rest = r;
244    }
245
246    let (hours, mut rest) = parse_tz_num(rest, 0, 167)?;
247    let mut mins = 0;
248    let mut secs = 0;
249    if let Some(r) = rest.strip_prefix(':') {
250        let (m, r) = parse_tz_num(r, 0, 59)?;
251        mins = m;
252        rest = r;
253        if let Some(r) = rest.strip_prefix(':') {
254            let (sx, r) = parse_tz_num(r, 0, 59)?;
255            secs = sx;
256            rest = r;
257        }
258    }
259
260    let mut offset = hours * 3600 + mins * 60 + secs;
261    if neg {
262        offset = -offset;
263    }
264    Ok((offset, rest))
265}
266
267fn parse_tz_rule(s: &str) -> Result<(TransitionRule, &str), Error> {
268    if s.is_empty() {
269        return Err(Error::BadPosixTz("empty transition rule"));
270    }
271    let mut r = TransitionRule {
272        kind: RuleKind::DayOfYear,
273        day: 0,
274        week: 0,
275        mon: 0,
276        time: 7200,
277    };
278
279    let b = s.as_bytes();
280    let mut rest;
281    if b[0] == b'M' {
282        // Mm.w.d
283        r.kind = RuleKind::MonthWeekDay;
284        let (mon, after) = parse_tz_num(&s[1..], 1, 12)?;
285        r.mon = mon;
286        rest = after
287            .strip_prefix('.')
288            .ok_or(Error::BadPosixTz("expected '.' after month in rule"))?;
289        let (week, after) = parse_tz_num(rest, 1, 5)?;
290        r.week = week;
291        rest = after
292            .strip_prefix('.')
293            .ok_or(Error::BadPosixTz("expected '.' after week in rule"))?;
294        let (day, after) = parse_tz_num(rest, 0, 6)?;
295        r.day = day;
296        rest = after;
297    } else if b[0] == b'J' {
298        // Jn (1-365, no leap day)
299        r.kind = RuleKind::Julian;
300        let (day, after) = parse_tz_num(&s[1..], 1, 365)?;
301        r.day = day;
302        rest = after;
303    } else {
304        // n (0-365, with leap day)
305        r.kind = RuleKind::DayOfYear;
306        let (day, after) = parse_tz_num(s, 0, 365)?;
307        r.day = day;
308        rest = after;
309    }
310
311    // Optional time component: /time
312    if let Some(after) = rest.strip_prefix('/') {
313        let (off, after) = parse_tz_offset(after)?;
314        r.time = off;
315        rest = after;
316    }
317
318    Ok((r, rest))
319}
320
321fn parse_tz_num(s: &str, min: i32, max: i32) -> Result<(i32, &str), Error> {
322    let b = s.as_bytes();
323    if b.is_empty() || !is_digit(b[0]) {
324        return Err(Error::BadPosixTz("expected digit"));
325    }
326    let mut n: i32 = 0;
327    let mut i = 0;
328    while i < b.len() && is_digit(b[i]) {
329        n = n * 10 + (b[i] - b'0') as i32;
330        i += 1;
331    }
332    if n < min || n > max {
333        return Err(Error::BadPosixTz("number out of range"));
334    }
335    Ok((n, &s[i..]))
336}
337
338fn is_alpha(c: u8) -> bool {
339    c.is_ascii_alphabetic()
340}
341
342fn is_digit(c: u8) -> bool {
343    c.is_ascii_digit()
344}
345
346// --- Time computation helpers ---
347
348fn is_leap_year(year: i32) -> bool {
349    year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
350}
351
352const DAYS_IN_MONTH: [i32; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
353
354/// Returns the Unix timestamp for January 1 00:00:00 UTC of `year`.
355fn year_to_unix(year: i32) -> i64 {
356    let y = year as i64 - 1970;
357    let mut days = 365 * y;
358
359    // Add leap days. Count leap years in [1970, year).
360    if year > 1970 {
361        days += (y + 1) / 4;
362        days -= (y + 69) / 100;
363        days += (y + 369) / 400;
364    } else if year < 1970 {
365        days += (y - 2) / 4;
366        days -= (y - 30) / 100;
367        days += (y - 30) / 400;
368    }
369
370    days * 86400
371}
372
373/// Returns the calendar year (UTC) containing the given Unix timestamp.
374pub(crate) fn year_of(unix: i64) -> i32 {
375    unix_to_yday_sec(unix).0
376}
377
378/// Converts a Unix timestamp to (year, day-of-year [0-based], second-of-day).
379fn unix_to_yday_sec(unix: i64) -> (i32, i32, i32) {
380    let mut unix = unix;
381    let mut sec = (unix % 86400) as i32;
382    if sec < 0 {
383        sec += 86400;
384        unix -= 86400;
385    }
386    let days = (unix / 86400) as i32;
387
388    // Compute year from days since epoch, adjusting from an estimate.
389    let mut year = 1970 + days / 365;
390    loop {
391        let year_start = (year_to_unix(year) / 86400) as i32;
392        if year_start <= days {
393            let mut year_end = year_start + 365;
394            if is_leap_year(year) {
395                year_end += 1;
396            }
397            if days < year_end {
398                return (year, days - year_start, sec);
399            }
400            year += 1;
401        } else {
402            year -= 1;
403        }
404    }
405}
406
407/// Converts a [`TransitionRule`] to seconds since the start of the year in UTC
408/// (wall-clock seconds adjusted by `offset`).
409fn rule_to_year_sec(r: TransitionRule, year: i32, offset: i32) -> i32 {
410    let leap = is_leap_year(year);
411
412    let yday = match r.kind {
413        RuleKind::Julian => {
414            // Jn: 1-365, Feb 29 is never counted.
415            let mut d = r.day - 1;
416            if leap && d >= 59 {
417                d += 1; // after Feb 28
418            }
419            d
420        }
421        RuleKind::DayOfYear => {
422            // n: 0-365.
423            r.day
424        }
425        RuleKind::MonthWeekDay => {
426            // Mm.w.d: month, week (1-5), day-of-week (0 = Sunday).
427            let m = (r.mon - 1) as usize; // 0-indexed
428
429            // Day of year for the 1st of the month.
430            let mut first_yday = 0;
431            for (i, &dim) in DAYS_IN_MONTH.iter().enumerate().take(m) {
432                first_yday += dim;
433                if i == 1 && leap {
434                    first_yday += 1;
435                }
436            }
437
438            // Day of week for Jan 1 of this year (0 = Sunday).
439            // 1970-01-01 was a Thursday (4).
440            let jan1_wday = (((year_to_unix(year) / 86400) % 7 + 4 + 7 * 53) % 7) as i32;
441
442            // Day of week for the 1st of the month.
443            let first_wday = (jan1_wday + first_yday) % 7;
444
445            // Days until the target day-of-week from the 1st.
446            let days_until = (r.day - first_wday + 7) % 7;
447
448            // Advance to the target week.
449            let mut y = first_yday + days_until + (r.week - 1) * 7;
450
451            // week=5 means "last in month". Clamp to the month's length.
452            let mut month_days = DAYS_IN_MONTH[m];
453            if m == 1 && leap {
454                month_days += 1;
455            }
456            while y - first_yday >= month_days {
457                y -= 7;
458            }
459            y
460        }
461    };
462
463    // Seconds from the start of the year, plus the transition time, then adjust
464    // from wall time to UTC.
465    yday * 86400 + r.time - offset
466}
467
468// --- String formatting helpers ---
469
470fn write_name(f: &mut fmt::Formatter<'_>, name: &str) -> fmt::Result {
471    let needs_quote = name.bytes().any(|c| !is_alpha(c));
472    if needs_quote {
473        write!(f, "<{name}>")
474    } else {
475        f.write_str(name)
476    }
477}
478
479fn write_offset(f: &mut fmt::Formatter<'_>, posix_off: i32) -> fmt::Result {
480    let mut v = posix_off;
481    if v < 0 {
482        f.write_str("-")?;
483        v = -v;
484    }
485    let hours = v / 3600;
486    let mins = (v % 3600) / 60;
487    let secs = v % 60;
488
489    write!(f, "{hours}")?;
490    if mins != 0 || secs != 0 {
491        write!(f, ":{mins:02}")?;
492        if secs != 0 {
493            write!(f, ":{secs:02}")?;
494        }
495    }
496    Ok(())
497}
498
499fn write_rule(f: &mut fmt::Formatter<'_>, r: TransitionRule) -> fmt::Result {
500    match r.kind {
501        RuleKind::Julian => write!(f, "J{}", r.day)?,
502        RuleKind::DayOfYear => write!(f, "{}", r.day)?,
503        RuleKind::MonthWeekDay => write!(f, "M{}.{}.{}", r.mon, r.week, r.day)?,
504    }
505    if r.time != 7200 {
506        f.write_str("/")?;
507        write_offset(f, r.time)?;
508    }
509    Ok(())
510}