Skip to main content

umbral_core/
timezone.rs

1//! Gap 106 — IANA timezone resolution for the marshalling layer.
2//!
3//! The framework's database storage is UTC-everywhere
4//! (`TIMESTAMPTZ` on Postgres, ISO-8601 text on SQLite). The
5//! configured `Settings::time_zone` only affects the marshalling
6//! boundary:
7//!
8//! - **Write path** — naive datetimes arriving from HTML
9//!   `<input type="datetime-local">` (`2026-06-03T22:24`) are
10//!   interpreted in the configured tz then converted to UTC for
11//!   storage. With `time_zone = None` the input is treated as UTC,
12//!   matching the historical behaviour.
13//!
14//! - **Read path** — stored UTC values are converted back to the
15//!   configured tz before rendering for admin forms (so the user
16//!   sees wall-clock time, not UTC). REST endpoints are unaffected;
17//!   their JSON output stays RFC-3339 UTC by design.
18//!
19//! Misconfiguration (an unknown tz name) is logged at `WARN` and
20//! falls back to UTC. We never panic on a tz string — the cost of a
21//! typo in production is "users see UTC times for a release," not
22//! "boot crashes." The `tz_or_utc` helper is the single source of
23//! that fallback.
24
25use chrono::{DateTime, NaiveDateTime, TimeZone, Utc};
26use chrono_tz::Tz;
27use std::collections::HashSet;
28use std::sync::{Mutex, OnceLock};
29
30/// Record `name` as an unknown-tz we've warned about; returns `true` the
31/// first time a given name is seen and `false` on every subsequent call.
32///
33/// `tz_or_utc` runs on the per-value datetime marshalling path, so a typo'd
34/// `UMBRAL_TIME_ZONE` would otherwise emit one `warn!` per row. Deduping by
35/// name keeps the log to a single line per distinct bad value (matching the
36/// "one-shot warning" the module doc promises) while still surfacing a second
37/// *different* typo. Pure + testable: first call `true`, repeats `false`.
38fn should_warn_unknown_tz(name: &str) -> bool {
39    static WARNED: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
40    let mut seen = WARNED
41        .get_or_init(|| Mutex::new(HashSet::new()))
42        .lock()
43        .unwrap_or_else(std::sync::PoisonError::into_inner);
44    seen.insert(name.to_string())
45}
46
47/// Resolve the configured `Settings::time_zone` to a `chrono_tz::Tz`.
48/// Returns `Tz::UTC` when settings are unset, when the value is
49/// `None`, or when the IANA name doesn't resolve.
50pub fn active_tz() -> Tz {
51    let Some(settings) = crate::settings::get_opt() else {
52        return Tz::UTC;
53    };
54    let Some(name) = settings.time_zone.as_deref() else {
55        return Tz::UTC;
56    };
57    tz_or_utc(name)
58}
59
60/// Parse an IANA name, falling back to UTC with a one-shot warning
61/// on failure. Public for callers that need the raw lookup (e.g.
62/// per-user tz overrides surfaced from the session).
63pub fn tz_or_utc(name: &str) -> Tz {
64    match name.parse::<Tz>() {
65        Ok(tz) => tz,
66        Err(_) => {
67            if should_warn_unknown_tz(name) {
68                tracing::warn!(
69                    tz = name,
70                    "umbral::timezone: unknown IANA tz `{name}` — falling back to UTC"
71                );
72            }
73            Tz::UTC
74        }
75    }
76}
77
78/// Interpret a naive datetime in the active tz and convert to UTC.
79///
80/// Returns `None` for ambiguous local times (the autumn DST overlap
81/// hour, e.g. 2024-11-03 01:30 in America/New_York) — the caller
82/// should surface a validation error rather than silently pick one
83/// of the two possible UTC instants.
84///
85/// In `Tz::UTC` mode (`time_zone = None`), this is a straight
86/// `naive.and_utc()` — no DST ambiguity is possible so it always
87/// returns `Some`.
88///
89/// Prefer [`naive_local_to_utc_checked`] on any path that can report an error:
90/// this signature throws away *why* the conversion failed, and the two reasons
91/// need different messages.
92pub fn naive_local_to_utc(naive: NaiveDateTime) -> Option<DateTime<Utc>> {
93    naive_local_to_utc_checked(naive).ok()
94}
95
96/// Why a naive local datetime has no single UTC instant.
97///
98/// A wall-clock reading is not a moment in time until you say where the clock
99/// is, and twice a year even that isn't enough.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub enum LocalTimeError {
102    /// The clocks went back, so this reading happens twice. Carries both
103    /// candidate instants, earlier first, so an error message can offer them.
104    Ambiguous {
105        earlier: DateTime<Utc>,
106        later: DateTime<Utc>,
107    },
108    /// The clocks went forward, so this reading never happens at all.
109    Nonexistent,
110}
111
112/// Interpret `naive` as wall-clock time in the active timezone and convert it to
113/// UTC, reporting *why* when there is no single answer.
114///
115/// The two failures are the DST transitions, and neither has a defensible
116/// fallback:
117///
118/// - **Ambiguous** — `2026-11-01T01:30` in `America/New_York` is both `05:30Z`
119///   (EDT) and `06:30Z` (EST). Picking one silently corrupts half the rows.
120/// - **Nonexistent** — `2026-03-08T02:30` in the same zone never occurs; the
121///   local clock jumps `02:00 → 03:00`.
122///
123/// In `Tz::UTC` mode (`time_zone = None`) neither can happen, so this always
124/// succeeds.
125pub fn naive_local_to_utc_checked(naive: NaiveDateTime) -> Result<DateTime<Utc>, LocalTimeError> {
126    let tz = active_tz();
127    if tz == Tz::UTC {
128        return Ok(naive.and_utc());
129    }
130    match tz.from_local_datetime(&naive) {
131        chrono::LocalResult::Single(dt) => Ok(dt.with_timezone(&Utc)),
132        chrono::LocalResult::Ambiguous(a, b) => Err(LocalTimeError::Ambiguous {
133            earlier: a.with_timezone(&Utc),
134            later: b.with_timezone(&Utc),
135        }),
136        chrono::LocalResult::None => Err(LocalTimeError::Nonexistent),
137    }
138}
139
140/// Convert a stored UTC datetime to wall-clock time in the active
141/// tz. With `time_zone = None` this is the identity function and
142/// the returned naive value equals the UTC input's naive part.
143pub fn utc_to_naive_local(utc: DateTime<Utc>) -> NaiveDateTime {
144    let tz = active_tz();
145    if tz == Tz::UTC {
146        return utc.naive_utc();
147    }
148    utc.with_timezone(&tz).naive_local()
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use chrono::NaiveDate;
155
156    #[test]
157    fn unknown_tz_falls_back_to_utc() {
158        let tz = tz_or_utc("Not/A/Real/Zone");
159        assert_eq!(tz, Tz::UTC);
160    }
161
162    #[test]
163    fn unknown_tz_warns_once_per_distinct_name() {
164        // First sighting of a distinct bad name warns; repeats are
165        // suppressed so the per-value marshalling path can't flood logs.
166        let name = "Bogus/Zone/For/Dedup/Test";
167        assert!(should_warn_unknown_tz(name), "first sighting should warn");
168        assert!(
169            !should_warn_unknown_tz(name),
170            "repeat sighting of the same name must not warn again"
171        );
172        // A different bad name is still surfaced.
173        assert!(should_warn_unknown_tz("Another/Bogus/Zone"));
174    }
175
176    #[test]
177    fn naive_round_trip_through_utc_is_identity() {
178        // With Tz::UTC (default — settings absent in test process)
179        // the round-trip is identity by construction.
180        let naive = NaiveDate::from_ymd_opt(2026, 6, 7)
181            .unwrap()
182            .and_hms_opt(13, 30, 0)
183            .unwrap();
184        let utc = naive_local_to_utc(naive).expect("Tz::UTC is unambiguous");
185        let back = utc_to_naive_local(utc);
186        assert_eq!(naive, back);
187    }
188}