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}