1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::fmt;
5
6#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
8pub struct TimeZoneId {
9 value: String,
10}
11
12impl TimeZoneId {
13 #[must_use]
15 pub fn new(input: &str) -> Option<Self> {
16 parse_time_zone_id(input)
17 }
18
19 #[must_use]
21 pub fn as_str(&self) -> &str {
22 &self.value
23 }
24
25 #[must_use]
27 pub fn into_string(self) -> String {
28 self.value
29 }
30
31 #[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 #[must_use]
41 pub fn location(&self) -> Option<&str> {
42 self.value.split_once('/').map(|(_, location)| location)
43 }
44
45 #[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#[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#[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#[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}