Skip to main content

use_time_zone_id/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::fmt;
5
6/// A syntactically valid IANA-shaped time zone identifier.
7#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
8pub struct TimeZoneId {
9    value: String,
10}
11
12impl TimeZoneId {
13    /// Parses a time zone identifier.
14    #[must_use]
15    pub fn new(input: &str) -> Option<Self> {
16        parse_time_zone_id(input)
17    }
18
19    /// Returns the identifier text.
20    #[must_use]
21    pub fn as_str(&self) -> &str {
22        &self.value
23    }
24
25    /// Consumes the time zone identifier and returns the string.
26    #[must_use]
27    pub fn into_string(self) -> String {
28        self.value
29    }
30
31    /// Returns the first segment, such as `America` or `UTC`.
32    #[must_use]
33    pub fn area(&self) -> &str {
34        self.value
35            .split_once('/')
36            .map_or(self.as_str(), |(area, _)| area)
37    }
38
39    /// Returns the remaining location path after the area segment.
40    #[must_use]
41    pub fn location(&self) -> Option<&str> {
42        self.value.split_once('/').map(|(_, location)| location)
43    }
44
45    /// Returns the identifier segments.
46    #[must_use]
47    pub fn segments(&self) -> Vec<&str> {
48        self.value.split('/').collect()
49    }
50}
51
52impl AsRef<str> for TimeZoneId {
53    fn as_ref(&self) -> &str {
54        self.as_str()
55    }
56}
57
58impl fmt::Display for TimeZoneId {
59    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
60        formatter.write_str(self.as_str())
61    }
62}
63
64/// Parses a syntactically valid IANA-shaped time zone identifier.
65#[must_use]
66pub fn parse_time_zone_id(input: &str) -> Option<TimeZoneId> {
67    is_time_zone_id(input).then(|| TimeZoneId {
68        value: input.to_string(),
69    })
70}
71
72/// Returns `true` when the input is a syntactically valid IANA-shaped identifier.
73#[must_use]
74pub fn is_time_zone_id(input: &str) -> bool {
75    let trimmed = input.trim();
76    if trimmed.is_empty()
77        || trimmed != input
78        || trimmed.starts_with('/')
79        || trimmed.ends_with('/')
80        || trimmed.contains("//")
81        || trimmed.chars().any(char::is_whitespace)
82    {
83        return false;
84    }
85
86    trimmed.split('/').all(is_time_zone_segment)
87}
88
89/// Splits a valid time zone identifier into owned segments.
90#[must_use]
91pub fn split_time_zone_id(input: &str) -> Option<Vec<String>> {
92    parse_time_zone_id(input).map(|zone| {
93        zone.as_str()
94            .split('/')
95            .map(ToOwned::to_owned)
96            .collect::<Vec<_>>()
97    })
98}
99
100fn is_time_zone_segment(segment: &str) -> bool {
101    !segment.is_empty()
102        && !matches!(segment, "." | "..")
103        && segment
104            .bytes()
105            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b'+'))
106}
107
108#[cfg(test)]
109mod tests {
110    use super::{TimeZoneId, is_time_zone_id, parse_time_zone_id, split_time_zone_id};
111
112    #[test]
113    fn accepts_common_time_zone_id_shapes() {
114        for zone in [
115            "UTC",
116            "America/New_York",
117            "America/Indiana/Indianapolis",
118            "Europe/London",
119            "Asia/Tokyo",
120        ] {
121            assert!(is_time_zone_id(zone));
122            assert_eq!(parse_time_zone_id(zone).unwrap().as_str(), zone);
123        }
124    }
125
126    #[test]
127    fn splits_area_and_location() {
128        let zone = TimeZoneId::new("America/Indiana/Indianapolis").unwrap();
129
130        assert_eq!(zone.area(), "America");
131        assert_eq!(zone.location(), Some("Indiana/Indianapolis"));
132        assert_eq!(zone.segments(), vec!["America", "Indiana", "Indianapolis"]);
133        assert_eq!(split_time_zone_id("UTC"), Some(vec!["UTC".to_string()]));
134    }
135
136    #[test]
137    fn rejects_invalid_time_zone_id_shapes() {
138        for zone in [
139            "",
140            " America/New_York",
141            "America/New_York ",
142            "America//Indianapolis",
143            "/America/New_York",
144            "America/New_York/",
145            "America/New York",
146            "America/..",
147            "America/@Home",
148        ] {
149            assert!(!is_time_zone_id(zone));
150            assert!(parse_time_zone_id(zone).is_none());
151        }
152    }
153}