use std::collections::HashMap;
use std::io::Read;
use std::sync::OnceLock;
use jiff::civil::Date;
use crate::RenderZone;
static RAW: &[u8] = include_bytes!("../data/holidays.json.gz");
type Table = HashMap<String, HashMap<String, String>>;
fn table() -> &'static Table {
static TABLE: OnceLock<Table> = OnceLock::new();
TABLE.get_or_init(|| load_or_empty("holidays", decode(RAW)))
}
fn load_or_empty<T: Default>(what: &str, decoded: Result<T, String>) -> T {
decoded.unwrap_or_else(|e| {
tracing::error!(table = what, error = %e, "embedded holiday data failed to decode; degrading to empty");
T::default()
})
}
fn decode(raw: &[u8]) -> Result<Table, String> {
let mut gz = flate2::read::GzDecoder::new(raw);
let mut json = String::new();
gz.read_to_string(&mut json)
.map_err(|e| format!("gzip inflate of holidays.json.gz failed: {e}"))?;
serde_json::from_str(&json).map_err(|e| format!("JSON parse of holiday table failed: {e}"))
}
#[must_use]
pub fn lookup(country: &str, date: Date) -> Option<String> {
let key = date.to_string();
table().get(&country.to_uppercase())?.get(&key).cloned()
}
#[must_use]
pub fn supported_country_count() -> usize {
table().len()
}
#[must_use]
pub fn country_for_zone(iana: &str) -> Option<&'static str> {
zones().get(iana).map(String::as_str)
}
#[must_use]
pub fn in_zone(zone: &RenderZone, date: Date) -> Option<String> {
let RenderZone::Named(tz) = zone else {
return None;
};
lookup(country_for_zone(tz.iana_name()?)?, date)
}
#[must_use]
pub fn in_zone_rendered(zone: &RenderZone, rendered: &str) -> Option<String> {
let date = rendered.get(..10)?.parse().ok()?;
in_zone(zone, date)
}
fn zones() -> &'static HashMap<String, String> {
static ZONES: OnceLock<HashMap<String, String>> = OnceLock::new();
static RAW: &str = include_str!("../data/zone_country.json");
ZONES.get_or_init(|| load_or_empty("zone→country", decode_zones(RAW)))
}
fn decode_zones(raw: &str) -> Result<HashMap<String, String>, String> {
serde_json::from_str(raw).map_err(|e| format!("JSON parse of zone→country table failed: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decode_rejects_non_gzip_input() {
let err = decode(b"this is plainly not a gzip stream").unwrap_err();
assert!(err.contains("gzip inflate"), "unexpected error: {err}");
}
#[test]
fn decode_rejects_valid_gzip_with_bad_json() {
use flate2::write::GzEncoder;
use flate2::Compression;
use std::io::Write;
let mut enc = GzEncoder::new(Vec::new(), Compression::default());
enc.write_all(b"{ not valid json").unwrap();
let gz = enc.finish().unwrap();
let err = decode(&gz).unwrap_err();
assert!(err.contains("JSON parse"), "unexpected error: {err}");
}
#[test]
fn decode_zones_rejects_bad_json() {
let err = decode_zones("{ not valid json").unwrap_err();
assert!(err.contains("JSON parse"), "unexpected error: {err}");
}
#[test]
fn load_or_empty_degrades_loudly_on_err() {
let sub = tracing_subscriber::fmt()
.with_writer(std::io::sink)
.finish();
let degraded: Table = tracing::subscriber::with_default(sub, || {
load_or_empty("test", Err("simulated corruption".to_string()))
});
assert!(degraded.is_empty());
}
#[test]
fn load_or_empty_passes_ok_through() {
let mut m = Table::new();
m.insert("XX".to_string(), HashMap::new());
let kept = load_or_empty("test", Ok(m));
assert_eq!(kept.len(), 1);
}
}