ppoppo-clock 0.1.0

Universal Clock + Timer port for ppoppo workspace — chat-as-infrastructure primitive
Documentation
use thiserror::Error;

/// Validated IANA timezone identifier (e.g. `"Asia/Seoul"`, `"UTC"`).
///
/// The inner string is always a non-empty, validated IANA name. Validation
/// is feature-gated: `native` uses `time_tz` embedded tzdata; `mock` accepts
/// any non-empty string so tests don't depend on a tz database.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Tz(String);

#[derive(Debug, Error, PartialEq, Eq)]
pub enum TzParseError {
    #[error("timezone name must not be empty")]
    Empty,
    #[error("unknown IANA timezone: {0}")]
    Unknown(String),
}

impl Tz {
    /// Parse a validated IANA timezone name.
    pub fn parse(s: &str) -> Result<Self, TzParseError> {
        if s.is_empty() {
            return Err(TzParseError::Empty);
        }
        if !Self::is_valid(s) {
            return Err(TzParseError::Unknown(s.to_owned()));
        }
        Ok(Self(s.to_owned()))
    }

    pub fn as_iana(&self) -> &str {
        &self.0
    }

    pub fn utc() -> Self {
        // "UTC" is always valid — skip the validation branch.
        Self("UTC".to_owned())
    }

    pub fn seoul() -> Self {
        Self("Asia/Seoul".to_owned())
    }

    #[cfg(feature = "native")]
    fn is_valid(s: &str) -> bool {
        time_tz::timezones::get_by_name(s).is_some()
    }

    // Mock feature: accept any non-empty string (tests don't need tz database).
    #[cfg(all(feature = "mock", not(feature = "native")))]
    fn is_valid(_s: &str) -> bool {
        true
    }

    // Neither native nor mock: reject everything (forces feature selection at build).
    #[cfg(not(any(feature = "native", feature = "mock")))]
    fn is_valid(_s: &str) -> bool {
        false
    }
}

impl std::fmt::Display for Tz {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_valid_roundtrips() {
        let tz = Tz::parse("Asia/Seoul").expect("Asia/Seoul should be valid");
        assert_eq!(tz.as_iana(), "Asia/Seoul");
    }

    #[test]
    fn parse_empty_is_error() {
        assert_eq!(Tz::parse(""), Err(TzParseError::Empty));
    }

    #[test]
    #[cfg(feature = "native")]
    fn parse_unknown_is_error_on_native() {
        assert!(matches!(
            Tz::parse("Mars/Olympus"),
            Err(TzParseError::Unknown(_))
        ));
    }

    #[test]
    fn seoul_roundtrips() {
        assert_eq!(Tz::seoul().as_iana(), "Asia/Seoul");
    }

    #[test]
    fn utc_roundtrips() {
        assert_eq!(Tz::utc().as_iana(), "UTC");
    }

    #[test]
    fn display_matches_iana() {
        let tz = Tz::parse("Asia/Seoul").expect("valid");
        assert_eq!(tz.to_string(), "Asia/Seoul");
    }

    #[cfg(feature = "native")]
    mod proptest_tz {
        use super::*;
        use proptest::prelude::*;

        proptest! {
            #[test]
            fn parse_never_panics(s in ".*") {
                let _ = Tz::parse(&s);
            }
        }
    }
}