Skip to main content

ppoppo_clock/
tz.rs

1use thiserror::Error;
2
3/// Validated IANA timezone identifier (e.g. `"Asia/Seoul"`, `"UTC"`).
4///
5/// The inner string is always a non-empty, validated IANA name. Validation
6/// is feature-gated: `native` uses `time_tz` embedded tzdata; `mock` accepts
7/// any non-empty string so tests don't depend on a tz database.
8#[derive(Debug, Clone, PartialEq, Eq, Hash)]
9pub struct Tz(String);
10
11#[derive(Debug, Error, PartialEq, Eq)]
12pub enum TzParseError {
13    #[error("timezone name must not be empty")]
14    Empty,
15    #[error("unknown IANA timezone: {0}")]
16    Unknown(String),
17}
18
19impl Tz {
20    /// Parse a validated IANA timezone name.
21    pub fn parse(s: &str) -> Result<Self, TzParseError> {
22        if s.is_empty() {
23            return Err(TzParseError::Empty);
24        }
25        if !Self::is_valid(s) {
26            return Err(TzParseError::Unknown(s.to_owned()));
27        }
28        Ok(Self(s.to_owned()))
29    }
30
31    pub fn as_iana(&self) -> &str {
32        &self.0
33    }
34
35    pub fn utc() -> Self {
36        // "UTC" is always valid — skip the validation branch.
37        Self("UTC".to_owned())
38    }
39
40    pub fn seoul() -> Self {
41        Self("Asia/Seoul".to_owned())
42    }
43
44    #[cfg(feature = "native")]
45    fn is_valid(s: &str) -> bool {
46        time_tz::timezones::get_by_name(s).is_some()
47    }
48
49    // Mock feature: accept any non-empty string (tests don't need tz database).
50    #[cfg(all(feature = "mock", not(feature = "native")))]
51    fn is_valid(_s: &str) -> bool {
52        true
53    }
54
55    // Neither native nor mock: reject everything (forces feature selection at build).
56    #[cfg(not(any(feature = "native", feature = "mock")))]
57    fn is_valid(_s: &str) -> bool {
58        false
59    }
60}
61
62impl std::fmt::Display for Tz {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        f.write_str(&self.0)
65    }
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71
72    #[test]
73    fn parse_valid_roundtrips() {
74        let tz = Tz::parse("Asia/Seoul").expect("Asia/Seoul should be valid");
75        assert_eq!(tz.as_iana(), "Asia/Seoul");
76    }
77
78    #[test]
79    fn parse_empty_is_error() {
80        assert_eq!(Tz::parse(""), Err(TzParseError::Empty));
81    }
82
83    #[test]
84    #[cfg(feature = "native")]
85    fn parse_unknown_is_error_on_native() {
86        assert!(matches!(
87            Tz::parse("Mars/Olympus"),
88            Err(TzParseError::Unknown(_))
89        ));
90    }
91
92    #[test]
93    fn seoul_roundtrips() {
94        assert_eq!(Tz::seoul().as_iana(), "Asia/Seoul");
95    }
96
97    #[test]
98    fn utc_roundtrips() {
99        assert_eq!(Tz::utc().as_iana(), "UTC");
100    }
101
102    #[test]
103    fn display_matches_iana() {
104        let tz = Tz::parse("Asia/Seoul").expect("valid");
105        assert_eq!(tz.to_string(), "Asia/Seoul");
106    }
107
108    #[cfg(feature = "native")]
109    mod proptest_tz {
110        use super::*;
111        use proptest::prelude::*;
112
113        proptest! {
114            #[test]
115            fn parse_never_panics(s in ".*") {
116                let _ = Tz::parse(&s);
117            }
118        }
119    }
120}