Skip to main content

rusty_ts/time/
tz.rs

1//! Timezone-source resolution.
2//!
3//! Per `spec.md` FR-017, FR-018, FR-019 and `plan.md` AD-001:
4//!
5//! - Default: system local time, honoring the `TZ` env var (handled by
6//!   `chrono::Local` via the OS).
7//! - `-u` / `--utc`: rendering in UTC.
8//! - `--tz=<IANA>`: a named IANA zone resolved via `chrono-tz`. The lookup
9//!   is paid once at startup; per-line render is a fixed-offset conversion.
10
11use crate::error::Error;
12use chrono::{DateTime, Utc};
13use chrono_tz::Tz;
14
15/// Resolved timezone source. Built once at startup; used for every per-line
16/// render. `#[non_exhaustive]` so a future variant (e.g., explicit FixedOffset)
17/// can be added in minor releases.
18///
19/// # Example
20///
21/// ```
22/// use rusty_ts::TimezoneSource;
23///
24/// // Three ways to construct a timezone source:
25/// let local = TimezoneSource::Local;
26/// let utc = TimezoneSource::Utc;
27/// let tokyo = TimezoneSource::named("Asia/Tokyo").expect("valid IANA");
28///
29/// // Unknown IANA names return Error::InvalidIanaName.
30/// assert!(TimezoneSource::named("Atlantis/Atlantica").is_err());
31/// # let _ = (local, utc, tokyo);
32/// ```
33#[non_exhaustive]
34#[derive(Debug, Clone)]
35pub enum TimezoneSource {
36    /// System local time as adjusted by the `TZ` env var if set.
37    Local,
38    /// UTC (no offset, no DST).
39    Utc,
40    /// A named IANA zone.
41    Named(Tz),
42}
43
44impl TimezoneSource {
45    /// Build a `TimezoneSource::Local`.
46    pub fn local() -> Self {
47        Self::Local
48    }
49
50    /// Build a `TimezoneSource::Utc`.
51    pub fn utc() -> Self {
52        Self::Utc
53    }
54
55    /// Resolve an IANA name (e.g., `"America/New_York"`) via `chrono-tz`.
56    /// Returns `Error::InvalidIanaName` if the name is not recognized.
57    pub fn named(iana: &str) -> Result<Self, Error> {
58        iana.parse::<Tz>()
59            .map(Self::Named)
60            .map_err(|_| Error::InvalidIanaName(iana.to_owned()))
61    }
62
63    /// Format a UTC instant as the zone-local wall-clock string using the
64    /// provided strftime format. The rendering cost is uniform across the
65    /// three variants — a single offset conversion per call.
66    pub fn render(&self, instant: DateTime<Utc>, fmt: &str) -> String {
67        match self {
68            Self::Local => instant
69                .with_timezone(&chrono::Local)
70                .format(fmt)
71                .to_string(),
72            Self::Utc => instant.format(fmt).to_string(),
73            Self::Named(tz) => instant.with_timezone(tz).format(fmt).to_string(),
74        }
75    }
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81    use chrono::TimeZone;
82
83    #[test]
84    fn utc_renders_hours_as_zero_offset() {
85        let instant = Utc.with_ymd_and_hms(2026, 5, 22, 14, 30, 45).unwrap();
86        let rendered = TimezoneSource::Utc.render(instant, "%H:%M:%S");
87        assert_eq!(rendered, "14:30:45");
88    }
89
90    #[test]
91    fn named_resolves_known_iana() {
92        let tz = TimezoneSource::named("America/New_York").expect("known zone");
93        let instant = Utc.with_ymd_and_hms(2026, 5, 22, 14, 30, 45).unwrap();
94        let rendered = tz.render(instant, "%H:%M");
95        // New York in May 2026 is EDT (UTC-4); 14:30 UTC -> 10:30 EDT
96        assert_eq!(rendered, "10:30");
97    }
98
99    #[test]
100    fn named_rejects_unknown_iana() {
101        let result = TimezoneSource::named("Atlantis/Atlantica");
102        match result {
103            Err(Error::InvalidIanaName(name)) => assert_eq!(name, "Atlantis/Atlantica"),
104            other => panic!("expected InvalidIanaName, got {other:?}"),
105        }
106    }
107}