Skip to main content

rich_interact/
zone.rs

1//! Time zones for [`datetime`](crate::datetime): how far a zone's clocks
2//! are from UTC at a moment, summer time included. The local zone is the
3//! platform's (`localtime_r` on Unix, `SystemTimeToTzSpecificLocalTime`
4//! on Windows unless `TZ` is set); a zone can also be read from a TZif file (the zoneinfo
5//! database's format) or a POSIX `TZ` rule, with no dependency.
6
7use std::sync::Arc;
8
9/// A time zone: how many seconds its clocks are ahead of UTC at each
10/// moment. [`Zone::Local`] is the platform's local zone, which follows
11/// summer time; [`Zone::posix`], [`Zone::tzif`] and [`Zone::named`] read
12/// a zone's rules; [`Zone::Fixed`] never changes.
13///
14/// ```
15/// use rich_interact::datetime::{Date, DateTime, Time, Zone};
16///
17/// // Central Europe: an hour ahead in winter, two in summer.
18/// let cet = Zone::posix("CET-1CEST,M3.5.0,M10.5.0/3").unwrap();
19/// let winter = DateTime::new(Date::new(2026, 1, 15), Time::new(12, 0, 0));
20/// let summer = DateTime::new(Date::new(2026, 7, 15), Time::new(12, 0, 0));
21/// assert_eq!(cet.offset_at(winter.to_unix(&Zone::Utc)), 3600);
22/// assert_eq!(cet.offset_at(summer.to_unix(&Zone::Utc)), 7200);
23/// ```
24#[derive(Clone, Debug, PartialEq)]
25pub enum Zone {
26    /// Coordinated Universal Time.
27    Utc,
28    /// The platform's local zone, as it is now configured (`TZ`,
29    /// `/etc/localtime`, the system's setting).
30    Local,
31    /// Always this many seconds ahead of UTC (behind if negative).
32    Fixed(i32),
33    /// A zone's rules, read by [`Zone::posix`], [`Zone::tzif`] or
34    /// [`Zone::named`].
35    Rules(Arc<Rules>),
36}
37
38/// A zone's rules: the transitions it has had, and the POSIX rule for
39/// the years after them.
40#[derive(Debug, PartialEq)]
41pub struct Rules {
42    /// Each transition: from when (seconds since 1970 UTC), the offset.
43    transitions: Vec<(i64, i32)>,
44    /// The offset before the first transition.
45    before: i32,
46    /// After the last transition (or with none): the rule.
47    rule: Option<Posix>,
48}
49
50/// A POSIX `TZ` rule: a standard offset, and a summer time with the days
51/// it starts and ends.
52#[derive(Clone, Debug, PartialEq)]
53struct Posix {
54    /// Seconds ahead of UTC (POSIX writes it the other way round).
55    std: i32,
56    dst: Option<Summer>,
57}
58
59#[derive(Clone, Debug, PartialEq)]
60struct Summer {
61    offset: i32,
62    start: (Day, i32),
63    end: (Day, i32),
64}
65
66/// The day of the year a summer time starts or ends.
67#[derive(Clone, Copy, Debug, PartialEq)]
68enum Day {
69    /// `Jn`: day 1 to 365, the 29th of February never counted.
70    Julian(u32),
71    /// `n`: day 0 to 365, counting it.
72    Zero(u32),
73    /// `Mm.w.d`: day `d` (0 Sunday) of week `w` (5 the last) of month `m`.
74    Month(u32, u32, u32),
75}
76
77impl Zone {
78    /// How many seconds the zone's clocks are ahead of UTC at `unix`
79    /// (seconds since 1970-01-01 00:00 UTC).
80    pub fn offset_at(&self, unix: i64) -> i32 {
81        match self {
82            Zone::Utc => 0,
83            Zone::Fixed(offset) => *offset,
84            Zone::Local => local_offset(unix),
85            Zone::Rules(rules) => rules.offset_at(unix),
86        }
87    }
88
89    /// The zone a POSIX `TZ` rule describes: `"CET-1CEST,M3.5.0,M10.5.0/3"`,
90    /// `"EST5EDT,M3.2.0,M11.1.0"`, `"<+0330>-3:30"`, `"UTC0"`. A summer
91    /// time with no days given starts and ends on the United States'.
92    pub fn posix(rule: &str) -> Result<Zone, String> {
93        let posix = parse_posix(rule).ok_or_else(|| format!("{rule:?} is not a POSIX TZ rule"))?;
94        Ok(Zone::Rules(Arc::new(Rules {
95            transitions: Vec::new(),
96            before: posix.std,
97            rule: Some(posix),
98        })))
99    }
100
101    /// The zone in a TZif file (`/usr/share/zoneinfo/Europe/Berlin`):
102    /// its transitions, and the rule in its footer for the years after.
103    pub fn tzif(bytes: &[u8]) -> Result<Zone, String> {
104        parse_tzif(bytes)
105            .map(|rules| Zone::Rules(Arc::new(rules)))
106            .ok_or_else(|| "not a TZif file".to_string())
107    }
108
109    /// The zone named `name` in the zoneinfo database (`"Europe/London"`),
110    /// read from `$TZDIR` or `/usr/share/zoneinfo`; an error where there is
111    /// no such file (as on Windows).
112    pub fn named(name: &str) -> Result<Zone, String> {
113        if name.split('/').any(|part| part == ".." || part.is_empty()) {
114            return Err(format!("{name:?} is not a zone name"));
115        }
116        let dir = std::env::var_os("TZDIR")
117            .filter(|d| !d.is_empty())
118            .map(std::path::PathBuf::from)
119            .unwrap_or_else(|| "/usr/share/zoneinfo".into());
120        let bytes = std::fs::read(dir.join(name)).map_err(|e| format!("{name}: {e}"))?;
121        Zone::tzif(&bytes)
122    }
123}
124
125impl Rules {
126    fn offset_at(&self, unix: i64) -> i32 {
127        match self.transitions.partition_point(|&(at, _)| at <= unix) {
128            0 if !self.transitions.is_empty() => self.before,
129            n if n < self.transitions.len() => self.transitions[n - 1].1,
130            n => match &self.rule {
131                Some(rule) => rule.offset_at(unix),
132                None => self
133                    .transitions
134                    .get(n.wrapping_sub(1))
135                    .map_or(self.before, |t| t.1),
136            },
137        }
138    }
139}
140
141impl Posix {
142    fn offset_at(&self, unix: i64) -> i32 {
143        let Some(dst) = &self.dst else {
144            return self.std;
145        };
146        // The year as the zone's standard time has it.
147        let year =
148            crate::datetime::Date::from_days((unix + self.std as i64).div_euclid(86_400)).year;
149        // Summer time starts at a standard-time hour and ends at a summer one.
150        let start = day_start(year, dst.start.0) + dst.start.1 as i64 - self.std as i64;
151        let end = day_start(year, dst.end.0) + dst.end.1 as i64 - dst.offset as i64;
152        let summer = if start < end {
153            unix >= start && unix < end
154        } else {
155            // The southern hemisphere: summer over the new year.
156            !(unix >= end && unix < start)
157        };
158        if summer { dst.offset } else { self.std }
159    }
160}
161
162/// Seconds from 1970 to the start of `day` of `year` (as if UTC).
163fn day_start(year: i32, day: Day) -> i64 {
164    use crate::datetime::Date;
165    let jan1 = Date::new(year, 1, 1).days();
166    let days = match day {
167        Day::Julian(n) => {
168            let n = n.clamp(1, 365) as i64;
169            // The 29th of February is not counted: day 60 is 1 March.
170            jan1 + n - 1 + (Date::is_leap_year(year) && n >= 60) as i64
171        }
172        Day::Zero(n) => jan1 + n.min(365) as i64,
173        Day::Month(month, week, weekday) => {
174            let month = month.clamp(1, 12);
175            let first = Date::new(year, month, 1);
176            // `Date::weekday` is 0 for Monday; POSIX's 0 is Sunday.
177            let first_wd = (first.weekday() + 1) % 7;
178            let mut day = 1 + (weekday + 7 - first_wd) % 7 + (week.clamp(1, 5) - 1) * 7;
179            let last = Date::days_in_month(year, month);
180            while day > last {
181                day -= 7;
182            }
183            Date::new(year, month, day).days()
184        }
185    };
186    days * 86_400
187}
188
189/// A POSIX TZ rule.
190fn parse_posix(text: &str) -> Option<Posix> {
191    let mut s = text.trim().trim_start_matches(':');
192    let _std_name = take_name(&mut s)?;
193    let std = -take_offset(&mut s)?;
194    if s.is_empty() {
195        return Some(Posix { std, dst: None });
196    }
197    let _dst_name = take_name(&mut s)?;
198    let offset = if s.starts_with(',') || s.is_empty() {
199        std + 3600
200    } else {
201        -take_offset(&mut s)?
202    };
203    let (start, end) = if s.is_empty() {
204        // The United States' rule since 2007.
205        ((Day::Month(3, 2, 0), 7200), (Day::Month(11, 1, 0), 7200))
206    } else {
207        s = s.strip_prefix(',')?;
208        let start = take_rule_day(&mut s)?;
209        s = s.strip_prefix(',')?;
210        let end = take_rule_day(&mut s)?;
211        if !s.is_empty() {
212            return None;
213        }
214        (start, end)
215    };
216    Some(Posix {
217        std,
218        dst: Some(Summer { offset, start, end }),
219    })
220}
221
222/// A zone's abbreviation: three letters or more, or anything in `<>`.
223fn take_name<'a>(s: &mut &'a str) -> Option<&'a str> {
224    if let Some(rest) = s.strip_prefix('<') {
225        let end = rest.find('>')?;
226        let name = &rest[..end];
227        *s = &rest[end + 1..];
228        return Some(name);
229    }
230    let end = s
231        .find(|c: char| !c.is_ascii_alphabetic())
232        .unwrap_or(s.len());
233    if end < 3 {
234        return None;
235    }
236    let name = &s[..end];
237    *s = &s[end..];
238    Some(name)
239}
240
241/// `[+-]hh[:mm[:ss]]`, in seconds.
242fn take_offset(s: &mut &str) -> Option<i32> {
243    let (sign, rest) = match s.as_bytes().first() {
244        Some(b'-') => (-1, &s[1..]),
245        Some(b'+') => (1, &s[1..]),
246        _ => (1, *s),
247    };
248    let end = rest
249        .find(|c: char| !(c.is_ascii_digit() || c == ':'))
250        .unwrap_or(rest.len());
251    let mut total = 0i32;
252    for (i, part) in rest[..end].split(':').enumerate() {
253        if i > 2 || part.is_empty() || part.len() > 3 {
254            return None;
255        }
256        let n: i32 = part.parse().ok()?;
257        total += n * [3600, 60, 1][i];
258    }
259    *s = &rest[end..];
260    Some(sign * total)
261}
262
263/// A rule's day and the time on it (`M3.5.0/3`; 02:00 by default).
264fn take_rule_day(s: &mut &str) -> Option<(Day, i32)> {
265    let end = s.find([',', '/']).unwrap_or(s.len());
266    let part = &s[..end];
267    let day = if let Some(n) = part.strip_prefix('J') {
268        Day::Julian(n.parse().ok().filter(|n| (1..=365).contains(n))?)
269    } else if let Some(m) = part.strip_prefix('M') {
270        let mut it = m.split('.').map(|p| p.parse::<u32>().ok());
271        let (month, week, day) = (it.next()??, it.next()??, it.next()??);
272        if it.next().is_some() || !(1..=12).contains(&month) || !(1..=5).contains(&week) || day > 6
273        {
274            return None;
275        }
276        Day::Month(month, week, day)
277    } else {
278        Day::Zero(part.parse().ok().filter(|n| *n <= 365)?)
279    };
280    *s = &s[end..];
281    let time = match s.strip_prefix('/') {
282        Some(rest) => {
283            *s = rest;
284            take_offset(s)?
285        }
286        None => 7200,
287    };
288    Some((day, time))
289}
290
291/// The rules in a TZif file, version 1 to 4.
292fn parse_tzif(bytes: &[u8]) -> Option<Rules> {
293    struct Header {
294        version: u8,
295        isut: usize,
296        isstd: usize,
297        leap: usize,
298        time: usize,
299        types: usize,
300        chars: usize,
301    }
302    fn header(b: &[u8]) -> Option<Header> {
303        if b.len() < 44 || &b[..4] != b"TZif" {
304            return None;
305        }
306        let count =
307            |i: usize| u32::from_be_bytes(b[20 + i * 4..24 + i * 4].try_into().unwrap()) as usize;
308        Some(Header {
309            version: b[4],
310            isut: count(0),
311            isstd: count(1),
312            leap: count(2),
313            time: count(3),
314            types: count(4),
315            chars: count(5),
316        })
317    }
318    fn block_len(h: &Header, time_size: usize) -> usize {
319        h.time * time_size
320            + h.time
321            + h.types * 6
322            + h.chars
323            + h.leap * (time_size + 4)
324            + h.isstd
325            + h.isut
326    }
327    let first = header(bytes)?;
328    let (h, data, time_size) = if first.version >= b'2' {
329        let second_at = 44 + block_len(&first, 4);
330        let second = header(bytes.get(second_at..)?)?;
331        (second, bytes.get(second_at + 44..)?, 8)
332    } else {
333        (first, &bytes[44..], 4)
334    };
335    let len = block_len(&h, time_size);
336    if data.len() < len || h.types == 0 {
337        return None;
338    }
339    let times = &data[..h.time * time_size];
340    let indices = &data[h.time * time_size..h.time * time_size + h.time];
341    let types_at = h.time * time_size + h.time;
342    let utoff = |i: usize| -> Option<i32> {
343        let at = types_at + i * 6;
344        Some(i32::from_be_bytes(data.get(at..at + 4)?.try_into().ok()?))
345    };
346    let mut transitions = Vec::with_capacity(h.time);
347    for i in 0..h.time {
348        let at = if time_size == 8 {
349            i64::from_be_bytes(times[i * 8..i * 8 + 8].try_into().ok()?)
350        } else {
351            i32::from_be_bytes(times[i * 4..i * 4 + 4].try_into().ok()?) as i64
352        };
353        let index = indices[i] as usize;
354        if index >= h.types {
355            return None;
356        }
357        transitions.push((at, utoff(index)?));
358    }
359    // RFC 8536: before the first transition, the first type.
360    let before = utoff(0)?;
361    // The footer: a POSIX rule between line breaks (version 2 on).
362    let rule = (time_size == 8)
363        .then(|| {
364            let footer = data.get(len..)?;
365            let text = std::str::from_utf8(footer).ok()?;
366            let text = text.strip_prefix('\n')?;
367            let line = &text[..text.find('\n')?];
368            (!line.is_empty()).then(|| parse_posix(line)).flatten()
369        })
370        .flatten();
371    Some(Rules {
372        transitions,
373        before,
374        rule,
375    })
376}
377
378/// The local zone's offset at `unix`, from the platform.
379pub(crate) fn local_offset(unix: i64) -> i32 {
380    platform::offset(unix).unwrap_or_else(|| fallback().offset_at(unix))
381}
382
383/// The local zone where the platform has no answer: `TZ` (a POSIX rule,
384/// or a zone name), else `/etc/localtime`, else UTC.
385fn fallback() -> &'static Zone {
386    static ZONE: std::sync::OnceLock<Zone> = std::sync::OnceLock::new();
387    ZONE.get_or_init(|| {
388        if let Some(tz) = std::env::var("TZ").ok().filter(|t| !t.is_empty()) {
389            let tz = tz.trim_start_matches(':');
390            if let Ok(zone) = Zone::named(tz) {
391                return zone;
392            }
393            if let Ok(zone) = Zone::posix(tz) {
394                return zone;
395            }
396        }
397        std::fs::read("/etc/localtime")
398            .ok()
399            .and_then(|bytes| Zone::tzif(&bytes).ok())
400            .unwrap_or(Zone::Utc)
401    })
402}
403
404#[cfg(any(
405    target_os = "linux",
406    target_os = "android",
407    target_os = "macos",
408    target_os = "ios",
409    target_os = "freebsd",
410    target_os = "netbsd",
411    target_os = "openbsd",
412    target_os = "dragonfly"
413))]
414mod platform {
415    #[allow(unsafe_code)]
416    unsafe extern "C" {
417        fn tzset();
418    }
419
420    /// `localtime_r`'s `tm_gmtoff`: the C library reads `TZ` and the
421    /// zoneinfo database, summer time included.
422    #[allow(unsafe_code)]
423    pub(super) fn offset(unix: i64) -> Option<i32> {
424        static TZSET: std::sync::Once = std::sync::Once::new();
425        // SAFETY: `tzset` takes no arguments and only reads the
426        // environment; `localtime_r` writes only to `tm`, which is ours,
427        // and reads `time`, which lives across the call.
428        unsafe {
429            TZSET.call_once(|| tzset());
430            // `time_t` is 32 bits on some targets.
431            #[allow(clippy::useless_conversion)]
432            let time: libc::time_t = unix.try_into().ok()?;
433            let mut tm: libc::tm = std::mem::zeroed();
434            if libc::localtime_r(&time, &mut tm).is_null() {
435                return None;
436            }
437            i32::try_from(tm.tm_gmtoff).ok()
438        }
439    }
440}
441
442#[cfg(windows)]
443mod platform {
444    use windows_sys::Win32::Foundation::SYSTEMTIME;
445    use windows_sys::Win32::System::Time::SystemTimeToTzSpecificLocalTime;
446
447    use crate::datetime::{Date, DateTime, Time};
448
449    /// The system's zone, through `SystemTimeToTzSpecificLocalTime`: the
450    /// local time at `unix`, less `unix`. A `TZ` that is set wins, as it
451    /// does for the C runtime's `localtime`: the fallback reads it.
452    #[allow(unsafe_code)]
453    pub(super) fn offset(unix: i64) -> Option<i32> {
454        if std::env::var_os("TZ").is_some_and(|tz| !tz.is_empty()) {
455            return None;
456        }
457        let utc = DateTime::from_unix(unix, &super::Zone::Utc);
458        let st = |at: DateTime| SYSTEMTIME {
459            wYear: at.date.year as u16,
460            wMonth: at.date.month as u16,
461            wDayOfWeek: 0,
462            wDay: at.date.day as u16,
463            wHour: at.time.hour as u16,
464            wMinute: at.time.minute as u16,
465            wSecond: at.time.second as u16,
466            wMilliseconds: 0,
467        };
468        let universal = st(utc);
469        let mut local = st(utc);
470        // SAFETY: both pointers are to SYSTEMTIMEs that live across the
471        // call; a null zone asks for the system's current one.
472        let ok =
473            unsafe { SystemTimeToTzSpecificLocalTime(std::ptr::null(), &universal, &mut local) };
474        if ok == 0 {
475            return None;
476        }
477        let local = DateTime::new(
478            Date::new(local.wYear as i32, local.wMonth as u32, local.wDay as u32),
479            Time::new(
480                local.wHour as u32,
481                local.wMinute as u32,
482                local.wSecond as u32,
483            ),
484        );
485        i32::try_from(local.to_unix(&super::Zone::Utc) - unix).ok()
486    }
487}
488
489#[cfg(not(any(
490    windows,
491    target_os = "linux",
492    target_os = "android",
493    target_os = "macos",
494    target_os = "ios",
495    target_os = "freebsd",
496    target_os = "netbsd",
497    target_os = "openbsd",
498    target_os = "dragonfly"
499)))]
500mod platform {
501    pub(super) fn offset(_unix: i64) -> Option<i32> {
502        None
503    }
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509    use crate::datetime::{Date, DateTime, Time};
510
511    fn at(y: i32, m: u32, d: u32, h: u32, min: u32) -> i64 {
512        DateTime::new(Date::new(y, m, d), Time::new(h, min, 0)).to_unix(&Zone::Utc)
513    }
514
515    #[test]
516    fn posix_rules_follow_summer_time() {
517        let cet = Zone::posix("CET-1CEST,M3.5.0,M10.5.0/3").unwrap();
518        // 2026: summer time from 29 March 01:00 UTC to 25 October 01:00 UTC.
519        assert_eq!(cet.offset_at(at(2026, 3, 29, 0, 59)), 3600);
520        assert_eq!(cet.offset_at(at(2026, 3, 29, 1, 0)), 7200);
521        assert_eq!(cet.offset_at(at(2026, 10, 25, 0, 59)), 7200);
522        assert_eq!(cet.offset_at(at(2026, 10, 25, 1, 0)), 3600);
523        let us = Zone::posix("EST5EDT").unwrap();
524        // 8 March 2026 07:00 UTC, and 1 November 06:00 UTC.
525        assert_eq!(us.offset_at(at(2026, 3, 8, 6, 59)), -5 * 3600);
526        assert_eq!(us.offset_at(at(2026, 3, 8, 7, 0)), -4 * 3600);
527        assert_eq!(us.offset_at(at(2026, 11, 1, 5, 59)), -4 * 3600);
528        assert_eq!(us.offset_at(at(2026, 11, 1, 6, 0)), -5 * 3600);
529        // The south: summer over the new year.
530        let nz = Zone::posix("NZST-12NZDT,M9.5.0,M4.1.0/3").unwrap();
531        assert_eq!(nz.offset_at(at(2026, 1, 10, 0, 0)), 13 * 3600);
532        assert_eq!(nz.offset_at(at(2026, 7, 10, 0, 0)), 12 * 3600);
533        let tehran = Zone::posix("<+0330>-3:30").unwrap();
534        assert_eq!(tehran.offset_at(0), 3 * 3600 + 1800);
535        for bad in [
536            "",
537            "X",
538            "CET",
539            "CET-1CEST,M13.5.0,M10.5.0",
540            "CET-1CEST,M3.5.0",
541        ] {
542            assert!(Zone::posix(bad).is_err(), "{bad:?}");
543        }
544    }
545
546    /// A version-2 TZif file: one transition to summer time, then the rule.
547    fn tzif() -> Vec<u8> {
548        fn header(out: &mut Vec<u8>, version: u8, time: u32, types: u32, chars: u32) {
549            out.extend(b"TZif");
550            out.push(version);
551            out.extend([0; 15]);
552            for n in [0, 0, 0, time, types, chars] {
553                out.extend(n.to_be_bytes());
554            }
555        }
556        let mut out = Vec::new();
557        header(&mut out, b'2', 0, 1, 4);
558        out.extend(3600i32.to_be_bytes());
559        out.extend([0, 0]);
560        out.extend(b"CET\0");
561        header(&mut out, b'2', 1, 2, 9);
562        out.extend(at(2026, 3, 29, 1, 0).to_be_bytes());
563        out.push(1);
564        out.extend(3600i32.to_be_bytes());
565        out.extend([0, 0]);
566        out.extend(7200i32.to_be_bytes());
567        out.extend([1, 4]);
568        out.extend(b"CET\0CEST\0");
569        out.extend(b"\nCET-1CEST,M3.5.0,M10.5.0/3\n");
570        out
571    }
572
573    #[test]
574    fn tzif_files_read_with_their_footer() {
575        let zone = Zone::tzif(&tzif()).unwrap();
576        assert_eq!(zone.offset_at(at(2026, 3, 1, 0, 0)), 3600);
577        assert_eq!(zone.offset_at(at(2026, 3, 29, 1, 0)), 7200);
578        // After the last transition, the footer's rule.
579        assert_eq!(zone.offset_at(at(2026, 10, 25, 1, 0)), 3600);
580        assert_eq!(zone.offset_at(at(2030, 7, 1, 0, 0)), 7200);
581        assert!(Zone::tzif(b"nope").is_err());
582        // The system's database, where there is one.
583        if let Ok(london) = Zone::named("Europe/London") {
584            assert_eq!(london.offset_at(at(2026, 1, 15, 12, 0)), 0);
585            assert_eq!(london.offset_at(at(2026, 7, 15, 12, 0)), 3600);
586            assert_eq!(london.offset_at(at(2040, 7, 15, 12, 0)), 3600);
587        }
588        assert!(Zone::named("../etc/passwd").is_err());
589    }
590}