1use crate::error::{Result, RosettaError};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub struct TzOffset {
8 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
40static TZ_ABBREVIATIONS: &[(&str, i32)] = &[
43 ("utc", 0),
45 ("gmt", 0),
46 ("z", 0),
47 ("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 ("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 ("ist", 5 * 3600 + 1800), ("pkt", 5 * 3600), ("bst", 6 * 3600), ("ict", 7 * 3600), ("cst_cn", 8 * 3600), ("hkt", 8 * 3600), ("sgt", 8 * 3600), ("kst", 9 * 3600), ("jst", 9 * 3600), ("awst", 8 * 3600),
79 ("acst", 9 * 3600 + 1800),
80 ("aest", 10 * 3600),
81 ("nzst", 12 * 3600),
82 ("nzdt", 13 * 3600),
83];
84
85static 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
107pub fn parse_timezone(input: &str) -> Result<TzOffset> {
114 let trimmed = input.trim();
115
116 if trimmed.eq_ignore_ascii_case("z") {
118 return Ok(TzOffset::UTC);
119 }
120
121 if let Some(offset) = try_parse_numeric_offset(trimmed) {
123 return Ok(offset);
124 }
125
126 let lower = trimmed.to_lowercase();
127
128 for &(abbr, secs) in TZ_ABBREVIATIONS {
130 if lower == abbr {
131 return Ok(TzOffset {
132 total_seconds: secs,
133 });
134 }
135 }
136
137 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
153fn 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 let digits: String = rest.chars().filter(|c| c.is_ascii_digit()).collect();
168
169 let (hours, minutes) = match digits.len() {
170 1 | 2 => {
171 let h: i32 = digits.parse().ok()?;
173 (h, 0)
174 }
175 3 => {
176 let h: i32 = digits[..1].parse().ok()?;
178 let m: i32 = digits[1..].parse().ok()?;
179 (h, m)
180 }
181 4 => {
182 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
199pub fn extract_trailing_timezone(input: &str) -> Option<(&str, TzOffset)> {
202 let trimmed = input.trim_end();
203
204 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 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 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 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 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}