use chrono::{DateTime, NaiveDateTime, TimeZone, Utc};
use chrono_tz::Tz;
use std::collections::HashSet;
use std::sync::{Mutex, OnceLock};
fn should_warn_unknown_tz(name: &str) -> bool {
static WARNED: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
let mut seen = WARNED
.get_or_init(|| Mutex::new(HashSet::new()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
seen.insert(name.to_string())
}
pub fn active_tz() -> Tz {
let Some(settings) = crate::settings::get_opt() else {
return Tz::UTC;
};
let Some(name) = settings.time_zone.as_deref() else {
return Tz::UTC;
};
tz_or_utc(name)
}
pub fn tz_or_utc(name: &str) -> Tz {
match name.parse::<Tz>() {
Ok(tz) => tz,
Err(_) => {
if should_warn_unknown_tz(name) {
tracing::warn!(
tz = name,
"umbral::timezone: unknown IANA tz `{name}` — falling back to UTC"
);
}
Tz::UTC
}
}
}
pub fn naive_local_to_utc(naive: NaiveDateTime) -> Option<DateTime<Utc>> {
naive_local_to_utc_checked(naive).ok()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LocalTimeError {
Ambiguous {
earlier: DateTime<Utc>,
later: DateTime<Utc>,
},
Nonexistent,
}
pub fn naive_local_to_utc_checked(naive: NaiveDateTime) -> Result<DateTime<Utc>, LocalTimeError> {
let tz = active_tz();
if tz == Tz::UTC {
return Ok(naive.and_utc());
}
match tz.from_local_datetime(&naive) {
chrono::LocalResult::Single(dt) => Ok(dt.with_timezone(&Utc)),
chrono::LocalResult::Ambiguous(a, b) => Err(LocalTimeError::Ambiguous {
earlier: a.with_timezone(&Utc),
later: b.with_timezone(&Utc),
}),
chrono::LocalResult::None => Err(LocalTimeError::Nonexistent),
}
}
pub fn utc_to_naive_local(utc: DateTime<Utc>) -> NaiveDateTime {
let tz = active_tz();
if tz == Tz::UTC {
return utc.naive_utc();
}
utc.with_timezone(&tz).naive_local()
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveDate;
#[test]
fn unknown_tz_falls_back_to_utc() {
let tz = tz_or_utc("Not/A/Real/Zone");
assert_eq!(tz, Tz::UTC);
}
#[test]
fn unknown_tz_warns_once_per_distinct_name() {
let name = "Bogus/Zone/For/Dedup/Test";
assert!(should_warn_unknown_tz(name), "first sighting should warn");
assert!(
!should_warn_unknown_tz(name),
"repeat sighting of the same name must not warn again"
);
assert!(should_warn_unknown_tz("Another/Bogus/Zone"));
}
#[test]
fn naive_round_trip_through_utc_is_identity() {
let naive = NaiveDate::from_ymd_opt(2026, 6, 7)
.unwrap()
.and_hms_opt(13, 30, 0)
.unwrap();
let utc = naive_local_to_utc(naive).expect("Tz::UTC is unambiguous");
let back = utc_to_naive_local(utc);
assert_eq!(naive, back);
}
}