qsv_dateparser/
timezone.rs

1use anyhow::{Result, anyhow};
2use chrono::offset::FixedOffset;
3
4/// Tries to parse `[-+]\d\d` continued by `\d\d`. Return `FixedOffset` if possible.
5/// It can parse RFC 2822 legacy timezones. If offset cannot be determined, -0000 will be returned.
6///
7/// The additional `colon` may be used to parse a mandatory or optional `:` between hours and minutes,
8/// and should return a valid `FixedOffset` or `Err` when parsing fails.
9#[inline]
10pub fn parse(s: &str) -> Result<FixedOffset> {
11    FixedOffset::east_opt(if s.contains(':') {
12        parse_offset_internal(s, colon_or_space, false)?
13    } else {
14        parse_offset_2822(s)?
15    })
16    .ok_or_else(|| anyhow!("input is out of range"))
17}
18
19#[inline]
20fn parse_offset_2822(s: &str) -> Result<i32> {
21    // tries to parse legacy time zone names
22    let upto = s
23        .as_bytes()
24        .iter()
25        .position(|&c| !c.is_ascii_alphabetic())
26        .unwrap_or(s.len());
27    if upto > 0 {
28        let name = &s[..upto];
29        let offset_hours = |o| Ok(o * 3600);
30        if equals(name, "gmt") || equals(name, "ut") || equals(name, "utc") {
31            offset_hours(0)
32        } else if equals(name, "edt") {
33            offset_hours(-4)
34        } else if equals(name, "est") || equals(name, "cdt") {
35            offset_hours(-5)
36        } else if equals(name, "cst") || equals(name, "mdt") {
37            offset_hours(-6)
38        } else if equals(name, "mst") || equals(name, "pdt") {
39            offset_hours(-7)
40        } else if equals(name, "pst") {
41            offset_hours(-8)
42        } else {
43            Ok(0) // recommended by RFC 2822: consume but treat it as -0000
44        }
45    } else {
46        let offset = parse_offset_internal(s, |s| Ok(s), false)?;
47        Ok(offset)
48    }
49}
50
51#[inline]
52fn parse_offset_internal<F>(
53    mut s: &str,
54    mut consume_colon: F,
55    allow_missing_minutes: bool,
56) -> Result<i32>
57where
58    F: FnMut(&str) -> Result<&str>,
59{
60    let err_out_of_range = "input is out of range";
61    let err_invalid = "input contains invalid characters";
62    let err_too_short = "premature end of input";
63
64    let digits = |s: &str| -> Result<(u8, u8)> {
65        let b = s.as_bytes();
66        if b.len() < 2 {
67            Err(anyhow!(err_too_short))
68        } else {
69            Ok((b[0], b[1]))
70        }
71    };
72    let negative = match s.as_bytes().first() {
73        Some(&b'+') => false,
74        Some(&b'-') => true,
75        Some(_) => return Err(anyhow!(err_invalid)),
76        None => return Err(anyhow!(err_too_short)),
77    };
78    s = &s[1..];
79
80    // hours (00--99)
81    let hours = match digits(s)? {
82        (h1 @ b'0'..=b'9', h2 @ b'0'..=b'9') => i32::from((h1 - b'0') * 10 + (h2 - b'0')),
83        _ => return Err(anyhow!(err_invalid)),
84    };
85    s = &s[2..];
86
87    // colons (and possibly other separators)
88    s = consume_colon(s)?;
89
90    // minutes (00--59)
91    // if the next two items are digits then we have to add minutes
92    let minutes = match digits(s) {
93        Ok(ds) => match ds {
94            (m1 @ b'0'..=b'5', m2 @ b'0'..=b'9') => i32::from((m1 - b'0') * 10 + (m2 - b'0')),
95            (b'6'..=b'9', b'0'..=b'9') => return Err(anyhow!(err_out_of_range)),
96            _ => return Err(anyhow!(err_invalid)),
97        },
98        _ => {
99            if allow_missing_minutes {
100                0
101            } else {
102                return Err(anyhow!(err_too_short));
103            }
104        }
105    };
106
107    let seconds = hours * 3600 + minutes * 60;
108    Ok(if negative { -seconds } else { seconds })
109}
110
111/// Returns true when two slices are equal case-insensitively (in ASCII).
112/// Assumes that the `pattern` is already converted to lower case.
113#[inline]
114fn equals(s: &str, pattern: &str) -> bool {
115    let mut xs = s.as_bytes().iter().map(|&c| match c {
116        b'A'..=b'Z' => c + 32,
117        _ => c,
118    });
119    let mut ys = pattern.as_bytes().iter().copied();
120    loop {
121        match (xs.next(), ys.next()) {
122            (None, None) => return true,
123            (None, _) | (_, None) => return false,
124            (Some(x), Some(y)) if x != y => return false,
125            _ => (),
126        }
127    }
128}
129
130/// Consumes any number (including zero) of colon or spaces.
131#[inline]
132fn colon_or_space(s: &str) -> Result<&str> {
133    Ok(s.trim_start_matches(|c: char| c == ':' || c.is_whitespace()))
134}
135
136#[cfg(test)]
137#[allow(deprecated)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn parse() {
143        let test_cases = [
144            ("-0800", FixedOffset::west(8 * 3600)),
145            ("+10:00", FixedOffset::east(10 * 3600)),
146            ("PST", FixedOffset::west(8 * 3600)),
147            ("PDT", FixedOffset::west(7 * 3600)),
148            ("UTC", FixedOffset::west(0)),
149            ("GMT", FixedOffset::west(0)),
150        ];
151
152        for &(input, want) in test_cases.iter() {
153            assert_eq!(super::parse(input).unwrap(), want, "parse/{}", input)
154        }
155    }
156}