1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::fmt;
5
6use use_time_zone_id::{TimeZoneId, parse_time_zone_id};
7
8const MAX_OFFSET_MINUTES: i16 = 14 * 60;
9
10#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
12pub enum TimeZone {
13 Iana(TimeZoneId),
15 FixedOffset(TimeZoneOffset),
17}
18
19impl TimeZone {
20 #[must_use]
22 pub fn new(input: &str) -> Option<Self> {
23 parse_time_zone(input)
24 }
25
26 pub fn try_new(input: &str) -> Result<Self, TimeZoneParseError> {
33 try_parse_time_zone(input)
34 }
35
36 #[must_use]
38 pub const fn iana(identifier: TimeZoneId) -> Self {
39 Self::Iana(identifier)
40 }
41
42 #[must_use]
44 pub const fn fixed_offset(offset: TimeZoneOffset) -> Self {
45 Self::FixedOffset(offset)
46 }
47
48 #[must_use]
50 pub const fn as_time_zone_id(&self) -> Option<&TimeZoneId> {
51 match self {
52 Self::Iana(identifier) => Some(identifier),
53 Self::FixedOffset(_) => None,
54 }
55 }
56
57 #[must_use]
59 pub const fn offset(&self) -> Option<TimeZoneOffset> {
60 match self {
61 Self::Iana(_) => None,
62 Self::FixedOffset(offset) => Some(*offset),
63 }
64 }
65
66 #[must_use]
68 pub const fn is_iana(&self) -> bool {
69 matches!(self, Self::Iana(_))
70 }
71
72 #[must_use]
74 pub const fn is_fixed_offset(&self) -> bool {
75 matches!(self, Self::FixedOffset(_))
76 }
77}
78
79impl fmt::Display for TimeZone {
80 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
81 match self {
82 Self::Iana(identifier) => formatter.write_str(identifier.as_str()),
83 Self::FixedOffset(offset) => fmt::Display::fmt(offset, formatter),
84 }
85 }
86}
87
88#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
90pub struct TimeZoneOffset {
91 minutes: i16,
92}
93
94impl TimeZoneOffset {
95 pub const UTC: Self = Self { minutes: 0 };
97
98 pub const MIN: Self = Self {
100 minutes: -MAX_OFFSET_MINUTES,
101 };
102
103 pub const MAX: Self = Self {
105 minutes: MAX_OFFSET_MINUTES,
106 };
107
108 #[must_use]
110 pub fn new(input: &str) -> Option<Self> {
111 parse_time_zone_offset(input)
112 }
113
114 pub fn try_new(input: &str) -> Result<Self, TimeZoneParseError> {
121 try_parse_time_zone_offset(input)
122 }
123
124 #[must_use]
126 pub const fn from_minutes(minutes: i16) -> Option<Self> {
127 if minutes < -MAX_OFFSET_MINUTES || minutes > MAX_OFFSET_MINUTES {
128 None
129 } else {
130 Some(Self { minutes })
131 }
132 }
133
134 #[must_use]
136 pub const fn total_minutes(self) -> i16 {
137 self.minutes
138 }
139
140 #[must_use]
142 pub const fn is_utc(self) -> bool {
143 self.minutes == 0
144 }
145}
146
147impl fmt::Display for TimeZoneOffset {
148 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
149 if self.is_utc() {
150 return formatter.write_str("UTC");
151 }
152
153 let sign = if self.minutes.is_negative() { '-' } else { '+' };
154 let absolute_minutes = self.minutes.unsigned_abs();
155 let hours = absolute_minutes / 60;
156 let minutes = absolute_minutes % 60;
157
158 write!(formatter, "UTC{sign}{hours:02}:{minutes:02}")
159 }
160}
161
162#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
164pub enum TimeZoneParseError {
165 Empty,
167 ContainsWhitespace,
169 InvalidOffsetFormat,
171 InvalidOffsetHour,
173 InvalidOffsetMinute,
175 OffsetOutOfRange,
177 InvalidTimeZoneId,
179}
180
181impl fmt::Display for TimeZoneParseError {
182 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
183 let message = match self {
184 Self::Empty => "time zone input is empty",
185 Self::ContainsWhitespace => "time zone input contains whitespace",
186 Self::InvalidOffsetFormat => "fixed time zone offset is malformed",
187 Self::InvalidOffsetHour => "fixed time zone offset hour is malformed",
188 Self::InvalidOffsetMinute => "fixed time zone offset minute is malformed",
189 Self::OffsetOutOfRange => "fixed time zone offset is outside -14:00..=+14:00",
190 Self::InvalidTimeZoneId => "time zone identifier is malformed",
191 };
192
193 formatter.write_str(message)
194 }
195}
196
197impl std::error::Error for TimeZoneParseError {}
198
199#[must_use]
201pub fn parse_time_zone(input: &str) -> Option<TimeZone> {
202 try_parse_time_zone(input).ok()
203}
204
205pub fn try_parse_time_zone(input: &str) -> Result<TimeZone, TimeZoneParseError> {
212 reject_empty_or_whitespace(input)?;
213
214 if is_offset_candidate(input) {
215 return try_parse_time_zone_offset(input).map(TimeZone::FixedOffset);
216 }
217
218 parse_time_zone_id(input)
219 .map(TimeZone::Iana)
220 .ok_or(TimeZoneParseError::InvalidTimeZoneId)
221}
222
223#[must_use]
225pub fn is_time_zone(input: &str) -> bool {
226 parse_time_zone(input).is_some()
227}
228
229#[must_use]
231pub fn parse_time_zone_offset(input: &str) -> Option<TimeZoneOffset> {
232 try_parse_time_zone_offset(input).ok()
233}
234
235pub fn try_parse_time_zone_offset(input: &str) -> Result<TimeZoneOffset, TimeZoneParseError> {
242 reject_empty_or_whitespace(input)?;
243
244 if matches!(input, "Z" | "UTC") {
245 return Ok(TimeZoneOffset::UTC);
246 }
247
248 let signed_offset =
249 strip_offset_prefix(input).ok_or(TimeZoneParseError::InvalidOffsetFormat)?;
250
251 parse_signed_offset(signed_offset)
252}
253
254#[must_use]
256pub fn is_time_zone_offset(input: &str) -> bool {
257 parse_time_zone_offset(input).is_some()
258}
259
260fn reject_empty_or_whitespace(input: &str) -> Result<(), TimeZoneParseError> {
261 if input.is_empty() {
262 return Err(TimeZoneParseError::Empty);
263 }
264
265 if input.chars().any(char::is_whitespace) {
266 return Err(TimeZoneParseError::ContainsWhitespace);
267 }
268
269 Ok(())
270}
271
272fn is_offset_candidate(input: &str) -> bool {
273 matches!(input, "Z" | "UTC")
274 || input.starts_with('+')
275 || input.starts_with('-')
276 || has_signed_prefix(input, "UTC")
277 || has_signed_prefix(input, "GMT")
278}
279
280fn has_signed_prefix(input: &str, prefix: &str) -> bool {
281 input
282 .strip_prefix(prefix)
283 .is_some_and(|remainder| remainder.starts_with('+') || remainder.starts_with('-'))
284}
285
286fn strip_offset_prefix(input: &str) -> Option<&str> {
287 if input.starts_with('+') || input.starts_with('-') {
288 return Some(input);
289 }
290
291 input
292 .strip_prefix("UTC")
293 .filter(|remainder| remainder.starts_with('+') || remainder.starts_with('-'))
294 .or_else(|| {
295 input
296 .strip_prefix("GMT")
297 .filter(|remainder| remainder.starts_with('+') || remainder.starts_with('-'))
298 })
299}
300
301fn parse_signed_offset(input: &str) -> Result<TimeZoneOffset, TimeZoneParseError> {
302 let (is_negative, body) = split_offset_sign(input)?;
303 let bytes = body.as_bytes();
304 let (hours, minutes) = match bytes.len() {
305 2 => (
306 parse_digit_pair(bytes, TimeZoneParseError::InvalidOffsetHour)?,
307 0,
308 ),
309 4 => (
310 parse_digit_pair(&bytes[..2], TimeZoneParseError::InvalidOffsetHour)?,
311 parse_digit_pair(&bytes[2..], TimeZoneParseError::InvalidOffsetMinute)?,
312 ),
313 5 if bytes[2] == b':' => (
314 parse_digit_pair(&bytes[..2], TimeZoneParseError::InvalidOffsetHour)?,
315 parse_digit_pair(&bytes[3..], TimeZoneParseError::InvalidOffsetMinute)?,
316 ),
317 _ => return Err(TimeZoneParseError::InvalidOffsetFormat),
318 };
319
320 if minutes > 59 {
321 return Err(TimeZoneParseError::InvalidOffsetMinute);
322 }
323
324 let unsigned_minutes = (hours * 60) + minutes;
325 let signed_minutes = if is_negative {
326 -unsigned_minutes
327 } else {
328 unsigned_minutes
329 };
330
331 TimeZoneOffset::from_minutes(signed_minutes).ok_or(TimeZoneParseError::OffsetOutOfRange)
332}
333
334fn split_offset_sign(input: &str) -> Result<(bool, &str), TimeZoneParseError> {
335 match (input.strip_prefix('+'), input.strip_prefix('-')) {
336 (Some(body), _) => Ok((false, body)),
337 (None, Some(body)) => Ok((true, body)),
338 (None, None) => Err(TimeZoneParseError::InvalidOffsetFormat),
339 }
340}
341
342fn parse_digit_pair(bytes: &[u8], error: TimeZoneParseError) -> Result<i16, TimeZoneParseError> {
343 let [tens, ones] = bytes else {
344 return Err(error);
345 };
346
347 if !tens.is_ascii_digit() || !ones.is_ascii_digit() {
348 return Err(error);
349 }
350
351 Ok((i16::from(*tens - b'0') * 10) + i16::from(*ones - b'0'))
352}
353
354#[cfg(test)]
355mod tests {
356 use super::{
357 TimeZone, TimeZoneOffset, TimeZoneParseError, is_time_zone, is_time_zone_offset,
358 parse_time_zone, parse_time_zone_offset, try_parse_time_zone, try_parse_time_zone_offset,
359 };
360
361 #[test]
362 fn parses_iana_time_zone_ids() {
363 let zone = parse_time_zone("America/New_York");
364
365 assert!(matches!(zone, Some(TimeZone::Iana(_))));
366
367 if let Some(TimeZone::Iana(identifier)) = zone {
368 assert_eq!(identifier.area(), "America");
369 assert_eq!(identifier.location(), Some("New_York"));
370 } else {
371 panic!("expected IANA time zone");
372 }
373 }
374
375 #[test]
376 fn parses_fixed_offset_shapes() {
377 for (input, minutes, display) in [
378 ("Z", 0, "UTC"),
379 ("UTC", 0, "UTC"),
380 ("+05:30", 330, "UTC+05:30"),
381 ("-08:00", -480, "UTC-08:00"),
382 ("+0530", 330, "UTC+05:30"),
383 ("-0800", -480, "UTC-08:00"),
384 ("+05", 300, "UTC+05:00"),
385 ("-08", -480, "UTC-08:00"),
386 ("UTC+05:30", 330, "UTC+05:30"),
387 ("GMT-08:00", -480, "UTC-08:00"),
388 ] {
389 let offset = parse_time_zone_offset(input);
390
391 assert_eq!(offset.map(TimeZoneOffset::total_minutes), Some(minutes));
392 assert_eq!(
393 offset.map(|value| value.to_string()),
394 Some(display.to_string())
395 );
396 assert!(is_time_zone_offset(input));
397 }
398 }
399
400 #[test]
401 fn parses_time_zone_offsets_as_time_zones() {
402 let zone = parse_time_zone("UTC+05:30");
403
404 assert!(matches!(zone, Some(TimeZone::FixedOffset(_))));
405 assert_eq!(
406 zone.map(|value| value.to_string()),
407 Some("UTC+05:30".to_string())
408 );
409 assert!(is_time_zone("UTC+05:30"));
410 }
411
412 #[test]
413 fn keeps_offsets_in_the_civil_range() {
414 assert_eq!(
415 TimeZoneOffset::from_minutes(-840),
416 Some(TimeZoneOffset::MIN)
417 );
418 assert_eq!(TimeZoneOffset::from_minutes(840), Some(TimeZoneOffset::MAX));
419 assert_eq!(parse_time_zone_offset("-14:00"), Some(TimeZoneOffset::MIN));
420 assert_eq!(parse_time_zone_offset("+14:00"), Some(TimeZoneOffset::MAX));
421 assert_eq!(parse_time_zone_offset("-14:01"), None);
422 assert_eq!(parse_time_zone_offset("+14:01"), None);
423 }
424
425 #[test]
426 fn rejects_invalid_fixed_offset_shapes() {
427 for input in [
428 "",
429 " +05:00",
430 "+05:00 ",
431 "UTC +05:00",
432 "PST",
433 "+5",
434 "+05:3",
435 "+05:60",
436 "+15:00",
437 "UTC+99:00",
438 "UT+05:00",
439 ] {
440 assert!(!is_time_zone_offset(input), "{input}");
441 assert_eq!(parse_time_zone_offset(input), None, "{input}");
442 }
443 }
444
445 #[test]
446 fn reports_diagnostic_errors() {
447 assert_eq!(
448 try_parse_time_zone_offset(""),
449 Err(TimeZoneParseError::Empty)
450 );
451 assert_eq!(
452 try_parse_time_zone_offset("+05:00 "),
453 Err(TimeZoneParseError::ContainsWhitespace)
454 );
455 assert_eq!(
456 try_parse_time_zone_offset("+05:60"),
457 Err(TimeZoneParseError::InvalidOffsetMinute)
458 );
459 assert_eq!(
460 try_parse_time_zone_offset("+14:01"),
461 Err(TimeZoneParseError::OffsetOutOfRange)
462 );
463 assert_eq!(
464 try_parse_time_zone("America/@Home"),
465 Err(TimeZoneParseError::InvalidTimeZoneId)
466 );
467 }
468}