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}