Skip to main content

use_timezone/
lib.rs

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/// A time zone represented by either an IANA-shaped identifier or a fixed offset.
11#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
12pub enum TimeZone {
13    /// An IANA-shaped time zone identifier.
14    Iana(TimeZoneId),
15    /// A fixed UTC offset.
16    FixedOffset(TimeZoneOffset),
17}
18
19impl TimeZone {
20    /// Parses a time zone.
21    #[must_use]
22    pub fn new(input: &str) -> Option<Self> {
23        parse_time_zone(input)
24    }
25
26    /// Parses a time zone with diagnostic errors.
27    ///
28    /// # Errors
29    ///
30    /// Returns [`TimeZoneParseError`] when the input is empty, contains whitespace,
31    /// is an invalid fixed offset, or is not an IANA-shaped identifier.
32    pub fn try_new(input: &str) -> Result<Self, TimeZoneParseError> {
33        try_parse_time_zone(input)
34    }
35
36    /// Returns an IANA time zone value.
37    #[must_use]
38    pub const fn iana(identifier: TimeZoneId) -> Self {
39        Self::Iana(identifier)
40    }
41
42    /// Returns a fixed-offset time zone value.
43    #[must_use]
44    pub const fn fixed_offset(offset: TimeZoneOffset) -> Self {
45        Self::FixedOffset(offset)
46    }
47
48    /// Returns the IANA identifier when this time zone is identifier-based.
49    #[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    /// Returns the fixed offset when this time zone is offset-based.
58    #[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    /// Returns `true` when this time zone is an IANA-shaped identifier.
67    #[must_use]
68    pub const fn is_iana(&self) -> bool {
69        matches!(self, Self::Iana(_))
70    }
71
72    /// Returns `true` when this time zone is a fixed offset.
73    #[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/// A fixed UTC offset in signed minutes.
89#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
90pub struct TimeZoneOffset {
91    minutes: i16,
92}
93
94impl TimeZoneOffset {
95    /// The zero UTC offset.
96    pub const UTC: Self = Self { minutes: 0 };
97
98    /// The minimum civil time zone offset.
99    pub const MIN: Self = Self {
100        minutes: -MAX_OFFSET_MINUTES,
101    };
102
103    /// The maximum civil time zone offset.
104    pub const MAX: Self = Self {
105        minutes: MAX_OFFSET_MINUTES,
106    };
107
108    /// Parses a fixed time zone offset.
109    #[must_use]
110    pub fn new(input: &str) -> Option<Self> {
111        parse_time_zone_offset(input)
112    }
113
114    /// Parses a fixed time zone offset with diagnostic errors.
115    ///
116    /// # Errors
117    ///
118    /// Returns [`TimeZoneParseError`] when the input is empty, contains whitespace,
119    /// is malformed, or falls outside the civil `-14:00..=+14:00` range.
120    pub fn try_new(input: &str) -> Result<Self, TimeZoneParseError> {
121        try_parse_time_zone_offset(input)
122    }
123
124    /// Returns an offset from signed minutes when it is in the civil range.
125    #[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    /// Returns the signed offset in minutes.
135    #[must_use]
136    pub const fn total_minutes(self) -> i16 {
137        self.minutes
138    }
139
140    /// Returns `true` when this is the zero UTC offset.
141    #[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/// A time zone or fixed-offset parse error.
163#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
164pub enum TimeZoneParseError {
165    /// The input was empty.
166    Empty,
167    /// The input contained whitespace.
168    ContainsWhitespace,
169    /// The fixed-offset input was malformed.
170    InvalidOffsetFormat,
171    /// The fixed-offset hour field was malformed.
172    InvalidOffsetHour,
173    /// The fixed-offset minute field was malformed or out of range.
174    InvalidOffsetMinute,
175    /// The fixed offset was outside the civil `-14:00..=+14:00` range.
176    OffsetOutOfRange,
177    /// The input was not an IANA-shaped time zone identifier.
178    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/// Parses a time zone from an IANA-shaped identifier or fixed offset.
200#[must_use]
201pub fn parse_time_zone(input: &str) -> Option<TimeZone> {
202    try_parse_time_zone(input).ok()
203}
204
205/// Parses a time zone from an IANA-shaped identifier or fixed offset with diagnostic errors.
206///
207/// # Errors
208///
209/// Returns [`TimeZoneParseError`] when the input is empty, contains whitespace,
210/// is an invalid fixed offset, or is not an IANA-shaped identifier.
211pub 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/// Returns `true` when the input is a valid IANA-shaped identifier or fixed offset.
224#[must_use]
225pub fn is_time_zone(input: &str) -> bool {
226    parse_time_zone(input).is_some()
227}
228
229/// Parses a fixed time zone offset.
230#[must_use]
231pub fn parse_time_zone_offset(input: &str) -> Option<TimeZoneOffset> {
232    try_parse_time_zone_offset(input).ok()
233}
234
235/// Parses a fixed time zone offset with diagnostic errors.
236///
237/// # Errors
238///
239/// Returns [`TimeZoneParseError`] when the input is empty, contains whitespace,
240/// is malformed, or falls outside the civil `-14:00..=+14:00` range.
241pub 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/// Returns `true` when the input is a valid fixed time zone offset.
255#[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}