Skip to main content

tzcompile/model/
time.rs

1//! Parsing of tzdata time-ish fields: UT offsets (`STDOFF`), saved amounts (`SAVE` and
2//! inline rules), and times-of-day (`AT`, and the time part of `UNTIL`).
3//!
4//! tzdata times are *signed, colon-separated* and can exceed 24 hours or go negative
5//! (e.g. `-2:30`, `24:00`, `25:00`). They are stored as a whole number of seconds.
6//! Fractional seconds are accepted and rounded to the nearest second (ties away from
7//! zero), matching modern `zic`.
8
9use crate::diagnostics::DiagnosticCode;
10
11/// A signed UT offset in seconds (east of UTC positive), as used by `STDOFF`.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub struct Offset(pub i32);
14
15/// Which clock an `AT`/`UNTIL` time is measured against. (Consumed by the T2 transition
16/// compiler; parsed now so the model is complete.)
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum TimeRef {
19    /// Wall-clock / local time (suffix `w`, or none).
20    Wall,
21    /// Standard time (suffix `s`).
22    Standard,
23    /// Universal time (suffix `u`, `g`, or `z`).
24    Universal,
25}
26
27/// A parsed time-of-day: signed seconds plus the clock it is measured against.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub struct TimeOfDay {
30    pub seconds: i32,
31    pub reference: TimeRef,
32}
33
34impl TimeOfDay {
35    /// `-`, i.e. zero wall time.
36    pub fn zero() -> Self {
37        TimeOfDay {
38            seconds: 0,
39            reference: TimeRef::Wall,
40        }
41    }
42}
43
44/// A parsed `SAVE` value: the offset added during this rule, plus whether it counts as DST.
45///
46/// The DST flag follows `zic`: an explicit `s`/`d` suffix wins; otherwise zero is standard
47/// and non-zero is daylight.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub struct Save {
50    pub seconds: i32,
51    pub is_dst: bool,
52}
53
54/// Parse a colon-separated `[-+]hh[:mm[:ss[.frac]]]` magnitude into seconds.
55///
56/// Returns `(seconds, rest)` where `rest` is whatever trailed the numeric part (a suffix
57/// letter such as `w`/`s`/`u`/`d`), so callers can interpret field-specific suffixes.
58fn parse_hms(input: &str) -> std::result::Result<(i32, &str), String> {
59    let s = input;
60    let (neg, body) = match s.strip_prefix('-') {
61        Some(rest) => (true, rest),
62        None => (false, s.strip_prefix('+').unwrap_or(s)),
63    };
64
65    // Split the numeric prefix from a trailing non-digit/non-colon/non-dot suffix.
66    let split = body
67        .find(|c: char| !(c.is_ascii_digit() || c == ':' || c == '.'))
68        .unwrap_or(body.len());
69    let (num, rest) = body.split_at(split);
70    if num.is_empty() {
71        return Err(format!("missing time value in {input:?}"));
72    }
73
74    let mut parts = num.split(':');
75    let h: i64 = parse_u(parts.next().unwrap_or(""))?;
76    let m: i64 = match parts.next() {
77        Some(p) => parse_u(p)?,
78        None => 0,
79    };
80    // The seconds field may carry a fractional part.
81    let (sec, frac) = match parts.next() {
82        Some(p) => match p.split_once('.') {
83            Some((whole, frac)) => (parse_u(whole)?, frac),
84            None => (parse_u(p)?, ""),
85        },
86        None => (0, ""),
87    };
88    if parts.next().is_some() {
89        return Err(format!("too many ':' groups in time {input:?}"));
90    }
91    if m >= 60 || sec >= 60 {
92        // zic is lenient about the hour count (it may exceed 24) but not minutes/seconds.
93        return Err(format!("minutes/seconds out of range in {input:?}"));
94    }
95
96    let mut total = h * 3600 + m * 60 + sec;
97    // Round fractional seconds to nearest, ties away from zero (matches modern zic).
98    if !frac.is_empty() {
99        if !frac.bytes().all(|b| b.is_ascii_digit()) {
100            return Err(format!("invalid fractional seconds in {input:?}"));
101        }
102        let half = frac.as_bytes()[0] >= b'5';
103        if half {
104            total += 1;
105        }
106    }
107
108    let total = if neg { -total } else { total };
109    let total = i32::try_from(total).map_err(|_| format!("time {input:?} out of range"))?;
110    Ok((total, rest))
111}
112
113fn parse_u(s: &str) -> std::result::Result<i64, String> {
114    if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
115        return Err(format!("invalid number {s:?}"));
116    }
117    s.parse::<i64>()
118        .map_err(|_| format!("number {s:?} out of range"))
119}
120
121/// Parse a `STDOFF`/`UNTIL`-offset value (no DST flag, no clock suffix expected).
122pub fn parse_offset(input: &str) -> std::result::Result<Offset, (DiagnosticCode, String)> {
123    if input == "-" {
124        return Ok(Offset(0));
125    }
126    let (sec, rest) = parse_hms(input).map_err(|m| (DiagnosticCode::InvalidValue, m))?;
127    if !rest.is_empty() {
128        return Err((
129            DiagnosticCode::InvalidValue,
130            format!("unexpected suffix {rest:?} in offset {input:?}"),
131        ));
132    }
133    Ok(Offset(sec))
134}
135
136/// Parse an `AT` time (or the time part of `UNTIL`), including a `w`/`s`/`u`/`g`/`z` suffix.
137pub fn parse_time_of_day(input: &str) -> std::result::Result<TimeOfDay, (DiagnosticCode, String)> {
138    if input == "-" {
139        return Ok(TimeOfDay::zero());
140    }
141    let (sec, rest) = parse_hms(input).map_err(|m| (DiagnosticCode::InvalidValue, m))?;
142    let reference = match rest {
143        "" | "w" => TimeRef::Wall,
144        "s" => TimeRef::Standard,
145        "u" | "g" | "z" => TimeRef::Universal,
146        other => {
147            return Err((
148                DiagnosticCode::InvalidTimeSuffix,
149                format!("invalid time suffix {other:?} in {input:?}"),
150            ))
151        }
152    };
153    Ok(TimeOfDay {
154        seconds: sec,
155        reference,
156    })
157}
158
159/// Parse a `SAVE` value (or an inline zone `RULES` offset), honouring the `s`/`d` suffix.
160pub fn parse_save(input: &str) -> std::result::Result<Save, (DiagnosticCode, String)> {
161    if input == "-" {
162        return Ok(Save {
163            seconds: 0,
164            is_dst: false,
165        });
166    }
167    let (sec, rest) = parse_hms(input).map_err(|m| (DiagnosticCode::InvalidValue, m))?;
168    let is_dst = match rest {
169        // Explicit suffix wins over the zero/non-zero heuristic.
170        "s" => false,
171        "d" => true,
172        "" => sec != 0,
173        other => {
174            return Err((
175                DiagnosticCode::InvalidTimeSuffix,
176                format!("invalid save suffix {other:?} in {input:?}"),
177            ))
178        }
179    };
180    Ok(Save {
181        seconds: sec,
182        is_dst,
183    })
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn offsets() {
192        assert_eq!(parse_offset("0").unwrap(), Offset(0));
193        assert_eq!(parse_offset("-").unwrap(), Offset(0));
194        assert_eq!(parse_offset("-5:00").unwrap(), Offset(-18000));
195        assert_eq!(parse_offset("5:30:15").unwrap(), Offset(19815));
196        assert_eq!(parse_offset("1").unwrap(), Offset(3600));
197        assert!(parse_offset("5:00s").is_err());
198        assert!(parse_offset("1:99").is_err());
199    }
200
201    #[test]
202    fn times_with_suffix() {
203        assert_eq!(parse_time_of_day("2:00").unwrap().reference, TimeRef::Wall);
204        assert_eq!(
205            parse_time_of_day("2:00s").unwrap().reference,
206            TimeRef::Standard
207        );
208        assert_eq!(
209            parse_time_of_day("2:00u").unwrap().reference,
210            TimeRef::Universal
211        );
212        assert_eq!(parse_time_of_day("24:00").unwrap().seconds, 86400);
213        assert_eq!(parse_time_of_day("-2:30").unwrap().seconds, -9000);
214        assert!(parse_time_of_day("2:00x").is_err());
215    }
216
217    #[test]
218    fn save_dst_flag() {
219        assert!(!parse_save("0").unwrap().is_dst);
220        assert!(parse_save("1:00").unwrap().is_dst);
221        assert!(!parse_save("1:00s").unwrap().is_dst);
222        assert!(parse_save("0d").unwrap().is_dst);
223        assert_eq!(parse_save("-1:00").unwrap().seconds, -3600);
224    }
225
226    #[test]
227    fn fractional_rounds_to_nearest() {
228        assert_eq!(parse_time_of_day("0:00:00.4").unwrap().seconds, 0);
229        assert_eq!(parse_time_of_day("0:00:00.5").unwrap().seconds, 1);
230    }
231}