1use std::cmp::Ordering;
11use std::fmt;
12
13use chrono::FixedOffset;
14use chrono_tz::Tz;
15use itertools::Itertools;
16use serde::{Deserialize, Serialize};
17use uncased::UncasedStr;
18
19use crate::abbrev::TIMEZONE_ABBREVS;
20
21pub const MZ_CATALOG_TIMEZONE_NAMES_SQL: &str =
23 include_str!(concat!(env!("OUT_DIR"), "/timezone.gen.sql"));
24
25#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
27pub enum Timezone {
28 #[serde(with = "fixed_offset_serde")]
29 FixedOffset(FixedOffset),
30 Tz(Tz),
31}
32
33impl Timezone {
34 pub fn parse(tz: &str, spec: TimezoneSpec) -> Result<Self, String> {
35 build_timezone_offset_second(&tokenize_timezone(tz)?, tz, spec)
36 }
37}
38
39mod fixed_offset_serde {
42 use serde::de::Error;
43 use serde::{Deserializer, Serializer};
44
45 use super::*;
46
47 pub fn deserialize<'de, D: Deserializer<'de>>(
48 deserializer: D,
49 ) -> Result<FixedOffset, D::Error> {
50 let offset = i32::deserialize(deserializer)?;
51 FixedOffset::east_opt(offset).ok_or_else(|| {
52 Error::custom(format!("Invalid timezone offset: |{}| >= 86_400", offset))
53 })
54 }
55
56 pub fn serialize<S: Serializer>(
57 offset: &FixedOffset,
58 serializer: S,
59 ) -> Result<S::Ok, S::Error> {
60 serializer.serialize_i32(offset.local_minus_utc())
61 }
62}
63
64impl PartialOrd for Timezone {
65 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
66 Some(self.cmp(other))
67 }
68}
69
70impl Ord for Timezone {
74 fn cmp(&self, other: &Self) -> Ordering {
75 use Timezone::*;
76 match (self, other) {
77 (FixedOffset(a), FixedOffset(b)) => a.local_minus_utc().cmp(&b.local_minus_utc()),
78 (Tz(a), Tz(b)) => a.name().cmp(b.name()),
79 (FixedOffset(_), Tz(_)) => Ordering::Less,
80 (Tz(_), FixedOffset(_)) => Ordering::Greater,
81 }
82 }
83}
84
85impl Default for Timezone {
86 fn default() -> Self {
87 Self::FixedOffset(FixedOffset::east_opt(0).unwrap())
88 }
89}
90
91impl fmt::Display for Timezone {
92 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93 match self {
94 Timezone::FixedOffset(offset) => offset.fmt(f),
95 Timezone::Tz(tz) => tz.fmt(f),
96 }
97 }
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
103enum TimeStrToken {
104 Dash,
105 Colon,
106 Plus,
107 Zulu,
108 Num(u64, usize),
109 TzName(String),
110 Delim,
111}
112
113impl std::fmt::Display for TimeStrToken {
114 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115 use TimeStrToken::*;
116 match self {
117 Dash => write!(f, "-"),
118 Colon => write!(f, ":"),
119 Plus => write!(f, "+"),
120 Zulu => write!(f, "Z"),
121 Num(i, digits) => write!(f, "{:01$}", i, digits - 1),
122 TzName(n) => write!(f, "{}", n),
123 Delim => write!(f, " "),
124 }
125 }
126}
127
128fn tokenize_timezone(value: &str) -> Result<Vec<TimeStrToken>, String> {
129 let mut toks: Vec<TimeStrToken> = vec![];
130 let mut num_buf = String::with_capacity(4);
131 let split_nums: bool = !value.contains(':');
134
135 let value = value.trim_matches(|c: char| {
136 (c.is_ascii_whitespace() || c.is_ascii_punctuation()) && (c != '+' && c != '-')
137 });
138
139 fn parse_num(
142 toks: &mut Vec<TimeStrToken>,
143 n: &str,
144 split_nums: bool,
145 idx: usize,
146 ) -> Result<(), String> {
147 if n.is_empty() {
148 return Ok(());
149 }
150
151 let (first, second) = if n.len() > 2 && split_nums {
152 let (first, second) = n.split_at(n.len() - 2);
153 (first, Some(second))
154 } else {
155 (n, None)
156 };
157
158 toks.push(TimeStrToken::Num(
159 first.parse().map_err(|e| {
160 format!(
161 "Unable to tokenize value {} as a number at index {}: {}",
162 first, idx, e
163 )
164 })?,
165 first.len(),
166 ));
167
168 if let Some(second) = second {
169 toks.push(TimeStrToken::Num(
170 second.parse().map_err(|e| {
171 format!(
172 "Unable to tokenize value {} as a number at index {}: {}",
173 second, idx, e
174 )
175 })?,
176 second.len(),
177 ));
178 }
179
180 Ok(())
181 }
182
183 let mut space_skip_mode = false;
186 for (i, chr) in value.char_indices() {
187 if space_skip_mode && chr.is_ascii_whitespace() {
189 continue;
190 } else {
191 space_skip_mode = false;
192 }
193
194 match chr {
195 ':' => {
196 parse_num(&mut toks, &num_buf, split_nums, i)?;
197 num_buf.clear();
198 toks.push(TimeStrToken::Colon);
199 }
200 '-' => {
201 parse_num(&mut toks, &num_buf, split_nums, i)?;
202 num_buf.clear();
203 toks.push(TimeStrToken::Dash);
204 space_skip_mode = true;
205 }
206 '+' => {
207 parse_num(&mut toks, &num_buf, split_nums, i)?;
208 num_buf.clear();
209 toks.push(TimeStrToken::Plus);
210 space_skip_mode = true;
211 }
212 chr if (chr == 'z' || chr == 'Z') && (i == value.len() - 1) => {
213 parse_num(&mut toks, &num_buf, split_nums, i)?;
214 num_buf.clear();
215 toks.push(TimeStrToken::Zulu);
216 }
217 chr if chr.is_digit(10) => num_buf.push(chr),
218 chr if chr.is_ascii_alphabetic() => {
219 parse_num(&mut toks, &num_buf, split_nums, i)?;
220 let substring = &value[i..];
221 toks.push(TimeStrToken::TzName(substring.to_string()));
222 return Ok(toks);
223 }
224 chr if chr.is_ascii_whitespace() || chr.is_ascii_punctuation() => {
226 parse_num(&mut toks, &num_buf, split_nums, i)?;
227 num_buf.clear();
228 toks.push(TimeStrToken::Delim);
229 }
230 chr => {
231 return Err(format!(
232 "Error tokenizing timezone string ('{}'): invalid character {:?} at offset {}",
233 value, chr, i
234 ));
235 }
236 }
237 }
238 parse_num(&mut toks, &num_buf, split_nums, 0)?;
239 Ok(toks)
240}
241
242#[derive(Debug, Clone, Copy)]
243pub enum TimezoneSpec {
244 Iso,
246 Posix,
248}
249
250fn build_timezone_offset_second(
251 tokens: &[TimeStrToken],
252 value: &str,
253 spec: TimezoneSpec,
254) -> Result<Timezone, String> {
255 use TimeStrToken::*;
256 static ALL_FORMATS: [&[TimeStrToken]; 12] = [
257 &[Plus, Num(0, 1), Colon, Num(0, 1), Colon, Num(0, 1)],
258 &[Dash, Num(0, 1), Colon, Num(0, 1), Colon, Num(0, 1)],
259 &[Plus, Num(0, 1), Colon, Num(0, 1)],
260 &[Dash, Num(0, 1), Colon, Num(0, 1)],
261 &[Plus, Num(0, 1), Num(0, 1), Num(0, 1)],
262 &[Dash, Num(0, 1), Num(0, 1), Num(0, 1)],
263 &[Plus, Num(0, 1), Num(0, 1)],
264 &[Dash, Num(0, 1), Num(0, 1)],
265 &[Plus, Num(0, 1)],
266 &[Dash, Num(0, 1)],
267 &[TzName(String::new())],
268 &[Zulu],
269 ];
270
271 let mut is_positive = true;
272 let mut hour_offset: Option<i32> = None;
273 let mut minute_offset: Option<i32> = None;
274 let mut second_offset: Option<i32> = None;
275
276 for format in ALL_FORMATS {
277 let actual = tokens.iter();
278
279 if actual.len() != format.len() {
280 continue;
281 }
282
283 for (i, (atok, etok)) in actual.zip_eq(format).enumerate() {
284 match (atok, etok) {
285 (Colon, Colon) | (Plus, Plus) => { }
286 (Dash, Dash) => {
287 is_positive = false;
288 }
289 (Num(val, _), Num(_, _)) => {
290 let val = *val;
291 match (hour_offset, minute_offset, second_offset) {
292 (None, None, None) => {
293 if val <= 15 {
295 hour_offset = Some(i32::try_from(val).expect(
296 "number between 0 and 15 should fit in signed 32-bit integer",
297 ));
298 } else {
299 return Err(format!(
300 "Invalid timezone string ({}): timezone hour invalid {}",
301 value, val
302 ));
303 }
304 }
305 (Some(_), None, None) => {
306 if val < 60 {
307 minute_offset = Some(i32::try_from(val).expect(
308 "number between 0 and 59 should fit in signed 32-bit integer",
309 ));
310 } else {
311 return Err(format!(
312 "Invalid timezone string ({}): timezone minute invalid {}",
313 value, val
314 ));
315 }
316 }
317 (Some(_), Some(_), None) => {
318 if val < 60 {
319 second_offset = Some(i32::try_from(val).expect(
320 "number between 0 and 59 should fit in signed 32-bit integer",
321 ));
322 } else {
323 return Err(format!(
324 "Invalid timezone string ({}): timezone second invalid {}",
325 value, val
326 ));
327 }
328 }
329 (Some(_), Some(_), Some(_)) => {
332 return Err(format!(
333 "Invalid timezone string ({}): invalid value {} at token index {}",
334 value, val, i
335 ));
336 }
337 _ => unreachable!("parsed a minute before an hour!"),
338 }
339 }
340 (Zulu, Zulu) => return Ok(Default::default()),
341 (TzName(val), TzName(_)) => {
342 if let Some(abbrev) = TIMEZONE_ABBREVS.get(UncasedStr::new(val)) {
343 return Ok(abbrev.timezone());
344 }
345
346 return match Tz::from_str_insensitive(val) {
347 Ok(tz) => Ok(Timezone::Tz(tz)),
348 Err(_) => Err(format!(
352 "Invalid timezone string ({value}): '{val}' is not a valid timezone. \
353 Failed to parse {val} at token index {i}"
354 )),
355 };
356 }
357 (_, _) => {
358 is_positive = true;
362 hour_offset = None;
363 minute_offset = None;
364 second_offset = None;
365 break;
366 }
367 }
368 }
369
370 if let Some(hour_offset) = hour_offset {
372 let mut tz_offset_second = hour_offset * 60 * 60;
373
374 if let Some(minute_offset) = minute_offset {
375 tz_offset_second += minute_offset * 60;
376 }
377
378 if let Some(second_offset) = second_offset {
379 tz_offset_second += second_offset;
380 }
381
382 let offset = match (is_positive, spec) {
383 (true, TimezoneSpec::Iso) | (false, TimezoneSpec::Posix) => {
384 FixedOffset::east_opt(tz_offset_second).unwrap()
385 }
386 (false, TimezoneSpec::Iso) | (true, TimezoneSpec::Posix) => {
387 FixedOffset::west_opt(tz_offset_second).unwrap()
388 }
389 };
390
391 return Ok(Timezone::FixedOffset(offset));
392 }
393 }
394
395 Err(format!("Cannot parse timezone offset {}", value))
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401
402 #[mz_ore::test]
403 fn test_parse_timezone_offset_second() {
404 use Timezone::{FixedOffset as F, Tz as T};
405 let test_cases = [
406 ("+0:00", F(FixedOffset::east_opt(0).unwrap())),
407 ("-0:00", F(FixedOffset::east_opt(0).unwrap())),
408 ("+0:000000", F(FixedOffset::east_opt(0).unwrap())),
409 ("+000000:00", F(FixedOffset::east_opt(0).unwrap())),
410 ("+000000:000000", F(FixedOffset::east_opt(0).unwrap())),
411 ("+0", F(FixedOffset::east_opt(0).unwrap())),
412 ("+00", F(FixedOffset::east_opt(0).unwrap())),
413 ("+000", F(FixedOffset::east_opt(0).unwrap())),
414 ("+0000", F(FixedOffset::east_opt(0).unwrap())),
415 ("+00000000", F(FixedOffset::east_opt(0).unwrap())),
416 ("+0000001:000000", F(FixedOffset::east_opt(3600).unwrap())),
417 ("+0000000:000001", F(FixedOffset::east_opt(60).unwrap())),
418 ("+0000001:000001", F(FixedOffset::east_opt(3660).unwrap())),
419 (
420 "+0000001:000001:000001",
421 F(FixedOffset::east_opt(3661).unwrap()),
422 ),
423 ("+4:00", F(FixedOffset::east_opt(14400).unwrap())),
424 ("-4:00", F(FixedOffset::west_opt(14400).unwrap())),
425 ("+2:30", F(FixedOffset::east_opt(9000).unwrap())),
426 ("-5:15", F(FixedOffset::west_opt(18900).unwrap())),
427 ("+0:20", F(FixedOffset::east_opt(1200).unwrap())),
428 ("-0:20", F(FixedOffset::west_opt(1200).unwrap())),
429 ("+0:0:20", F(FixedOffset::east_opt(20).unwrap())),
430 ("+5", F(FixedOffset::east_opt(18000).unwrap())),
431 ("-5", F(FixedOffset::west_opt(18000).unwrap())),
432 ("+05", F(FixedOffset::east_opt(18000).unwrap())),
433 ("-05", F(FixedOffset::west_opt(18000).unwrap())),
434 ("+500", F(FixedOffset::east_opt(18000).unwrap())),
435 ("-500", F(FixedOffset::west_opt(18000).unwrap())),
436 ("+530", F(FixedOffset::east_opt(19800).unwrap())),
437 ("-530", F(FixedOffset::west_opt(19800).unwrap())),
438 ("+050", F(FixedOffset::east_opt(3000).unwrap())),
439 ("-050", F(FixedOffset::west_opt(3000).unwrap())),
440 ("+15", F(FixedOffset::east_opt(54000).unwrap())),
441 ("-15", F(FixedOffset::west_opt(54000).unwrap())),
442 ("+1515", F(FixedOffset::east_opt(54900).unwrap())),
443 ("+15:15:15", F(FixedOffset::east_opt(54915).unwrap())),
444 ("+015", F(FixedOffset::east_opt(900).unwrap())),
445 ("-015", F(FixedOffset::west_opt(900).unwrap())),
446 ("+0015", F(FixedOffset::east_opt(900).unwrap())),
447 ("-0015", F(FixedOffset::west_opt(900).unwrap())),
448 ("+00015", F(FixedOffset::east_opt(900).unwrap())),
449 ("-00015", F(FixedOffset::west_opt(900).unwrap())),
450 ("+005", F(FixedOffset::east_opt(300).unwrap())),
451 ("-005", F(FixedOffset::west_opt(300).unwrap())),
452 ("+0000005", F(FixedOffset::east_opt(300).unwrap())),
453 ("+00000100", F(FixedOffset::east_opt(3600).unwrap())),
454 ("Z", F(FixedOffset::east_opt(0).unwrap())),
455 ("z", F(FixedOffset::east_opt(0).unwrap())),
456 ("UTC", F(FixedOffset::east_opt(0).unwrap())),
457 ("Pacific/Auckland", T(Tz::Pacific__Auckland)),
458 ("America/New_York", T(Tz::America__New_York)),
459 ("America/Los_Angeles", T(Tz::America__Los_Angeles)),
460 ("utc", F(FixedOffset::east_opt(0).unwrap())),
461 ("pAcIfIc/AUcKlAnD", T(Tz::Pacific__Auckland)),
462 ("AMERICA/NEW_YORK", T(Tz::America__New_York)),
463 ("america/los_angeles", T(Tz::America__Los_Angeles)),
464 ("+5:", F(FixedOffset::east_opt(18000).unwrap())),
466 ("-5:15:", F(FixedOffset::west_opt(18900).unwrap())),
467 ("- 5:15:", F(FixedOffset::west_opt(18900).unwrap())),
468 (
469 " ! ? ! - 5:15 ? ! ? ",
470 F(FixedOffset::west_opt(18900).unwrap()),
471 ),
472 (" UTC", F(FixedOffset::east_opt(0).unwrap())),
473 (" UTC ", F(FixedOffset::east_opt(0).unwrap())),
474 (" ? UTC ! ", F(FixedOffset::east_opt(0).unwrap())),
475 ];
476
477 for (timezone, expected) in test_cases.iter() {
478 match Timezone::parse(timezone, TimezoneSpec::Iso) {
479 Ok(tz) => assert_eq!(&tz, expected),
480 Err(e) => panic!(
481 "Test failed when expected to pass test case: {} error: {}",
482 timezone, e
483 ),
484 }
485 }
486
487 let failure_test_cases = [
488 "+25:00", "+120:00", "+0:61", "+0:500", " 12:30", "+-12:30", "+2525", "+2561",
489 "+255900", "+25", "+5::30", "++5:00", "--5:00", "a", "zzz", "ZZZ", "ZZ Top", " +",
490 " -", " ", "1", "12", "1234", "+16", "-17", "-14:60", "1:30:60",
491 ];
492
493 for test in failure_test_cases.iter() {
494 match Timezone::parse(test, TimezoneSpec::Iso) {
495 Ok(t) => panic!(
496 "Test passed when expected to fail test case: {} parsed tz offset (seconds): {}",
497 test, t
498 ),
499 Err(e) => println!("{}", e),
500 }
501 }
502 }
503}