Skip to main content

rosetta_date/
timezone.rs

1//! Timezone handling: abbreviation mapping, offset parsing, and conversion.
2
3use crate::error::{Result, RosettaError};
4
5/// A resolved timezone offset in seconds from UTC.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub struct TzOffset {
8    /// Total offset from UTC in seconds (e.g. +28800 for UTC+8).
9    pub total_seconds: i32,
10}
11
12impl TzOffset {
13    pub const UTC: TzOffset = TzOffset { total_seconds: 0 };
14
15    pub fn from_hm(hours: i32, minutes: i32) -> Self {
16        Self {
17            total_seconds: hours * 3600 + minutes.signum() * minutes.abs() * 60,
18        }
19    }
20
21    pub fn hours(&self) -> i32 {
22        self.total_seconds / 3600
23    }
24
25    pub fn minutes(&self) -> i32 {
26        (self.total_seconds % 3600) / 60
27    }
28}
29
30impl std::fmt::Display for TzOffset {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        let sign = if self.total_seconds >= 0 { '+' } else { '-' };
33        let abs = self.total_seconds.unsigned_abs();
34        let h = abs / 3600;
35        let m = (abs % 3600) / 60;
36        write!(f, "{}{:02}:{:02}", sign, h, m)
37    }
38}
39
40/// Common timezone abbreviation → offset mapping.
41/// Note: some abbreviations are ambiguous; we pick the most common interpretation.
42static TZ_ABBREVIATIONS: &[(&str, i32)] = &[
43    // UTC variants
44    ("utc", 0),
45    ("gmt", 0),
46    ("z", 0),
47    // North America
48    ("est", -5 * 3600),
49    ("edt", -4 * 3600),
50    ("cst", -6 * 3600),
51    ("cdt", -5 * 3600),
52    ("mst", -7 * 3600),
53    ("mdt", -6 * 3600),
54    ("pst", -8 * 3600),
55    ("pdt", -7 * 3600),
56    ("akst", -9 * 3600),
57    ("akdt", -8 * 3600),
58    ("hst", -10 * 3600),
59    // Europe
60    ("wet", 0),
61    ("west", 3600),
62    ("cet", 3600),
63    ("cest", 2 * 3600),
64    ("eet", 2 * 3600),
65    ("eest", 3 * 3600),
66    ("msk", 3 * 3600),
67    // Asia
68    ("ist", 5 * 3600 + 1800), // India
69    ("pkt", 5 * 3600),        // Pakistan
70    ("bst", 6 * 3600),        // Bangladesh
71    ("ict", 7 * 3600),        // Indochina
72    ("cst_cn", 8 * 3600),     // China
73    ("hkt", 8 * 3600),        // Hong Kong
74    ("sgt", 8 * 3600),        // Singapore
75    ("kst", 9 * 3600),        // Korea
76    ("jst", 9 * 3600),        // Japan
77    // Oceania
78    ("awst", 8 * 3600),
79    ("acst", 9 * 3600 + 1800),
80    ("aest", 10 * 3600),
81    ("nzst", 12 * 3600),
82    ("nzdt", 13 * 3600),
83];
84
85/// IANA-style timezone name → offset mapping (a subset of common ones).
86static IANA_ZONES: &[(&str, i32)] = &[
87    ("asia/shanghai", 8 * 3600),
88    ("asia/hong_kong", 8 * 3600),
89    ("asia/tokyo", 9 * 3600),
90    ("asia/seoul", 9 * 3600),
91    ("asia/kolkata", 5 * 3600 + 1800),
92    ("asia/singapore", 8 * 3600),
93    ("asia/dubai", 4 * 3600),
94    ("europe/london", 0),
95    ("europe/paris", 3600),
96    ("europe/berlin", 3600),
97    ("europe/moscow", 3 * 3600),
98    ("america/new_york", -5 * 3600),
99    ("america/chicago", -6 * 3600),
100    ("america/denver", -7 * 3600),
101    ("america/los_angeles", -8 * 3600),
102    ("america/sao_paulo", -3 * 3600),
103    ("pacific/auckland", 12 * 3600),
104    ("australia/sydney", 10 * 3600),
105];
106
107/// Parse a timezone string and return the offset.
108///
109/// Supports:
110/// - Abbreviations: "UTC", "PST", "JST", etc.
111/// - IANA names: "Asia/Shanghai", "America/New_York"
112/// - Numeric offsets: "+0800", "+08:00", "-07:00", "+08", "Z"
113pub fn parse_timezone(input: &str) -> Result<TzOffset> {
114    let trimmed = input.trim();
115
116    // "Z" → UTC
117    if trimmed.eq_ignore_ascii_case("z") {
118        return Ok(TzOffset::UTC);
119    }
120
121    // Try numeric offset: +0800, +08:00, -07, etc.
122    if let Some(offset) = try_parse_numeric_offset(trimmed) {
123        return Ok(offset);
124    }
125
126    let lower = trimmed.to_lowercase();
127
128    // Try abbreviation
129    for &(abbr, secs) in TZ_ABBREVIATIONS {
130        if lower == abbr {
131            return Ok(TzOffset {
132                total_seconds: secs,
133            });
134        }
135    }
136
137    // Try IANA name (normalize separators)
138    let normalized = lower.replace(' ', "_").replace("//", "/");
139    for &(name, secs) in IANA_ZONES {
140        if normalized == name {
141            return Ok(TzOffset {
142                total_seconds: secs,
143            });
144        }
145    }
146
147    Err(RosettaError::TimezoneError(format!(
148        "Unknown timezone: '{}'",
149        trimmed
150    )))
151}
152
153/// Try to parse a numeric offset string like "+0800", "+08:00", "-07", "+08".
154fn try_parse_numeric_offset(s: &str) -> Option<TzOffset> {
155    let bytes = s.as_bytes();
156    if bytes.is_empty() {
157        return None;
158    }
159
160    let (sign, rest) = match bytes[0] {
161        b'+' => (1i32, &s[1..]),
162        b'-' => (-1i32, &s[1..]),
163        _ => return None,
164    };
165
166    // Remove optional colon: "+08:00" → "0800"
167    let digits: String = rest.chars().filter(|c| c.is_ascii_digit()).collect();
168
169    let (hours, minutes) = match digits.len() {
170        1 | 2 => {
171            // "+8" or "+08"
172            let h: i32 = digits.parse().ok()?;
173            (h, 0)
174        }
175        3 => {
176            // "+830" → 8h 30m
177            let h: i32 = digits[..1].parse().ok()?;
178            let m: i32 = digits[1..].parse().ok()?;
179            (h, m)
180        }
181        4 => {
182            // "+0800"
183            let h: i32 = digits[..2].parse().ok()?;
184            let m: i32 = digits[2..].parse().ok()?;
185            (h, m)
186        }
187        _ => return None,
188    };
189
190    if hours > 14 || minutes > 59 {
191        return None;
192    }
193
194    Some(TzOffset {
195        total_seconds: sign * (hours * 3600 + minutes * 60),
196    })
197}
198
199/// Try to extract a timezone offset from the tail of a date string.
200/// Returns `(remaining_input, offset)` if found.
201pub fn extract_trailing_timezone(input: &str) -> Option<(&str, TzOffset)> {
202    let trimmed = input.trim_end();
203
204    // Try "Z" at end
205    if trimmed.ends_with('Z') || trimmed.ends_with('z') {
206        let rest = &trimmed[..trimmed.len() - 1];
207        return Some((rest.trim_end(), TzOffset::UTC));
208    }
209
210    // Try numeric offset at end: "+08:00", "+0800", "-07:00"
211    // Look for the last '+' or '-' that could start an offset
212    for search_len in [6, 5, 3, 2] {
213        if trimmed.len() > search_len && trimmed.is_char_boundary(trimmed.len() - search_len) {
214            let tail = &trimmed[trimmed.len() - search_len..];
215            // Only consider it an offset if the preceding char is a space, 'T', or 't'
216            // (prevents mismatching date components like "-10-15" as offsets)
217            let preceding_idx = trimmed.len() - search_len;
218            let preceding_ok = if preceding_idx == 0 {
219                true
220            } else {
221                let prev = trimmed.as_bytes()[preceding_idx - 1];
222                // Always allow space/T. For '+', allow after digits too
223                // ('+' is unambiguous, only '-' can be confused with date separators)
224                let starts_with_plus = tail.starts_with('+');
225                prev == b' '
226                    || prev == b'T'
227                    || prev == b't'
228                    || (starts_with_plus && prev.is_ascii_digit())
229            };
230            if preceding_ok && let Some(offset) = try_parse_numeric_offset(tail) {
231                let rest = &trimmed[..trimmed.len() - search_len];
232                return Some((rest.trim_end(), offset));
233            }
234        }
235    }
236
237    // Try abbreviation at end (split on last space)
238    if let Some(last_space) = trimmed.rfind(' ') {
239        let tail = &trimmed[last_space + 1..];
240        if let Ok(offset) = parse_timezone(tail) {
241            let rest = &trimmed[..last_space];
242            return Some((rest.trim_end(), offset));
243        }
244    }
245
246    None
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn test_parse_utc() {
255        assert_eq!(parse_timezone("UTC").unwrap().total_seconds, 0);
256        assert_eq!(parse_timezone("Z").unwrap().total_seconds, 0);
257        assert_eq!(parse_timezone("gmt").unwrap().total_seconds, 0);
258    }
259
260    #[test]
261    fn test_parse_numeric_offsets() {
262        assert_eq!(parse_timezone("+08:00").unwrap().total_seconds, 28800);
263        assert_eq!(parse_timezone("+0800").unwrap().total_seconds, 28800);
264        assert_eq!(parse_timezone("-05:00").unwrap().total_seconds, -18000);
265        assert_eq!(parse_timezone("+08").unwrap().total_seconds, 28800);
266    }
267
268    #[test]
269    fn test_parse_abbreviations() {
270        assert_eq!(parse_timezone("PST").unwrap().total_seconds, -28800);
271        assert_eq!(parse_timezone("JST").unwrap().total_seconds, 32400);
272        assert_eq!(parse_timezone("EST").unwrap().total_seconds, -18000);
273    }
274
275    #[test]
276    fn test_parse_iana() {
277        assert_eq!(
278            parse_timezone("Asia/Shanghai").unwrap().total_seconds,
279            28800
280        );
281        assert_eq!(
282            parse_timezone("America/New_York").unwrap().total_seconds,
283            -18000
284        );
285    }
286
287    #[test]
288    fn test_extract_trailing_tz() {
289        let (rest, tz) = extract_trailing_timezone("2023-10-01 12:30:00+08:00").unwrap();
290        assert_eq!(rest, "2023-10-01 12:30:00");
291        assert_eq!(tz.total_seconds, 28800);
292
293        let (rest, tz) = extract_trailing_timezone("2023-10-01 12:30:00Z").unwrap();
294        assert_eq!(rest, "2023-10-01 12:30:00");
295        assert_eq!(tz.total_seconds, 0);
296
297        let (rest, tz) = extract_trailing_timezone("2023-10-01 12:30:00 PST").unwrap();
298        assert_eq!(rest, "2023-10-01 12:30:00");
299        assert_eq!(tz.total_seconds, -28800);
300    }
301
302    #[test]
303    fn test_tz_display() {
304        assert_eq!(format!("{}", TzOffset::from_hm(8, 0)), "+08:00");
305        assert_eq!(format!("{}", TzOffset::from_hm(-5, 0)), "-05:00");
306        assert_eq!(format!("{}", TzOffset::UTC), "+00:00");
307    }
308}