use chrono::{DateTime, NaiveDateTime, Offset, TimeZone, Utc};
use chrono_tz::Tz;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimezoneConfig {
pub default_timezone: String,
pub entity_timezones: HashMap<String, String>,
pub consolidation_timezone: String,
}
impl Default for TimezoneConfig {
fn default() -> Self {
Self {
default_timezone: "America/New_York".to_string(),
entity_timezones: HashMap::new(),
consolidation_timezone: "UTC".to_string(),
}
}
}
impl TimezoneConfig {
pub fn new(default_tz: &str) -> Self {
Self {
default_timezone: default_tz.to_string(),
entity_timezones: HashMap::new(),
consolidation_timezone: "UTC".to_string(),
}
}
pub fn with_consolidation(mut self, tz: &str) -> Self {
self.consolidation_timezone = tz.to_string();
self
}
pub fn add_mapping(mut self, entity_pattern: &str, timezone: &str) -> Self {
self.entity_timezones
.insert(entity_pattern.to_string(), timezone.to_string());
self
}
}
#[derive(Debug, Clone)]
pub struct TimezoneHandler {
config: TimezoneConfig,
default_tz: Tz,
consolidation_tz: Tz,
entity_tz_cache: HashMap<String, Tz>,
}
impl TimezoneHandler {
pub fn new(config: TimezoneConfig) -> Result<Self, TimezoneError> {
let default_tz: Tz = config
.default_timezone
.parse()
.map_err(|_| TimezoneError::InvalidTimezone(config.default_timezone.clone()))?;
let consolidation_tz: Tz = config
.consolidation_timezone
.parse()
.map_err(|_| TimezoneError::InvalidTimezone(config.consolidation_timezone.clone()))?;
let mut entity_tz_cache = HashMap::new();
for (pattern, tz_name) in &config.entity_timezones {
let tz: Tz = tz_name
.parse()
.map_err(|_| TimezoneError::InvalidTimezone(tz_name.clone()))?;
entity_tz_cache.insert(pattern.clone(), tz);
}
Ok(Self {
config,
default_tz,
consolidation_tz,
entity_tz_cache,
})
}
pub fn us_eastern() -> Self {
Self::new(TimezoneConfig::default()).expect("Default timezone config should be valid")
}
pub fn get_entity_timezone(&self, entity_code: &str) -> Tz {
if let Some(tz) = self.entity_tz_cache.get(entity_code) {
return *tz;
}
for (pattern, tz) in &self.entity_tz_cache {
if let Some(prefix) = pattern.strip_suffix('*') {
if entity_code.starts_with(prefix) {
return *tz;
}
} else if let Some(suffix) = pattern.strip_prefix('*') {
if entity_code.ends_with(suffix) {
return *tz;
}
}
}
self.default_tz
}
pub fn to_utc(&self, local: NaiveDateTime, entity_code: &str) -> DateTime<Utc> {
let tz = self.get_entity_timezone(entity_code);
tz.from_local_datetime(&local)
.single()
.unwrap_or_else(|| {
tz.from_local_datetime(&local)
.earliest()
.expect("valid time components")
})
.with_timezone(&Utc)
}
pub fn to_local(&self, utc: DateTime<Utc>, entity_code: &str) -> NaiveDateTime {
let tz = self.get_entity_timezone(entity_code);
utc.with_timezone(&tz).naive_local()
}
pub fn to_consolidation(&self, local: NaiveDateTime, entity_code: &str) -> DateTime<Tz> {
let utc = self.to_utc(local, entity_code);
utc.with_timezone(&self.consolidation_tz)
}
pub fn consolidation_timezone(&self) -> Tz {
self.consolidation_tz
}
pub fn default_timezone(&self) -> Tz {
self.default_tz
}
pub fn get_timezone_name(&self, entity_code: &str) -> String {
self.get_entity_timezone(entity_code).name().to_string()
}
pub fn get_utc_offset_hours(&self, entity_code: &str, at: NaiveDateTime) -> f64 {
let tz = self.get_entity_timezone(entity_code);
let offset = tz.offset_from_local_datetime(&at).single();
match offset {
Some(o) => o.fix().local_minus_utc() as f64 / 3600.0,
None => 0.0,
}
}
pub fn config(&self) -> &TimezoneConfig {
&self.config
}
}
#[derive(Debug, Clone)]
pub enum TimezoneError {
InvalidTimezone(String),
AmbiguousTime(NaiveDateTime),
}
impl std::fmt::Display for TimezoneError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TimezoneError::InvalidTimezone(tz) => {
write!(f, "Invalid timezone: '{tz}'. Use IANA timezone names.")
}
TimezoneError::AmbiguousTime(dt) => {
write!(f, "Ambiguous local time: {dt}")
}
}
}
}
impl std::error::Error for TimezoneError {}
pub struct TimezonePresets;
impl TimezonePresets {
pub fn us_centric() -> TimezoneConfig {
TimezoneConfig::new("America/New_York")
.with_consolidation("America/New_York")
.add_mapping("*_WEST", "America/Los_Angeles")
.add_mapping("*_CENTRAL", "America/Chicago")
.add_mapping("*_MOUNTAIN", "America/Denver")
}
pub fn eu_centric() -> TimezoneConfig {
TimezoneConfig::new("Europe/London")
.with_consolidation("Europe/London")
.add_mapping("DE_*", "Europe/Berlin")
.add_mapping("FR_*", "Europe/Paris")
.add_mapping("CH_*", "Europe/Zurich")
}
pub fn apac_centric() -> TimezoneConfig {
TimezoneConfig::new("Asia/Singapore")
.with_consolidation("Asia/Singapore")
.add_mapping("JP_*", "Asia/Tokyo")
.add_mapping("CN_*", "Asia/Shanghai")
.add_mapping("IN_*", "Asia/Kolkata")
.add_mapping("AU_*", "Australia/Sydney")
}
pub fn global_utc() -> TimezoneConfig {
TimezoneConfig::new("America/New_York")
.with_consolidation("UTC")
.add_mapping("US_*", "America/New_York")
.add_mapping("EU_*", "Europe/London")
.add_mapping("APAC_*", "Asia/Singapore")
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use chrono::Timelike;
#[test]
fn test_default_timezone() {
let handler = TimezoneHandler::us_eastern();
let tz = handler.get_entity_timezone("UNKNOWN_COMPANY");
assert_eq!(tz.name(), "America/New_York");
}
#[test]
fn test_exact_match() {
let config = TimezoneConfig::new("America/New_York").add_mapping("1000", "Europe/London");
let handler = TimezoneHandler::new(config).unwrap();
assert_eq!(handler.get_entity_timezone("1000").name(), "Europe/London");
assert_eq!(
handler.get_entity_timezone("2000").name(),
"America/New_York"
);
}
#[test]
fn test_prefix_pattern() {
let config = TimezoneConfig::new("America/New_York").add_mapping("EU_*", "Europe/Berlin");
let handler = TimezoneHandler::new(config).unwrap();
assert_eq!(
handler.get_entity_timezone("EU_1000").name(),
"Europe/Berlin"
);
assert_eq!(
handler.get_entity_timezone("EU_SUBSIDIARY").name(),
"Europe/Berlin"
);
assert_eq!(
handler.get_entity_timezone("US_1000").name(),
"America/New_York"
);
}
#[test]
fn test_suffix_pattern() {
let config = TimezoneConfig::new("America/New_York").add_mapping("*_APAC", "Asia/Tokyo");
let handler = TimezoneHandler::new(config).unwrap();
assert_eq!(
handler.get_entity_timezone("1000_APAC").name(),
"Asia/Tokyo"
);
assert_eq!(
handler.get_entity_timezone("CORP_APAC").name(),
"Asia/Tokyo"
);
assert_eq!(
handler.get_entity_timezone("1000_US").name(),
"America/New_York"
);
}
#[test]
fn test_to_utc() {
let handler = TimezoneHandler::new(TimezoneConfig::new("America/New_York")).unwrap();
let local =
NaiveDateTime::parse_from_str("2024-06-15 10:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
let utc = handler.to_utc(local, "US_1000");
assert_eq!(utc.hour(), 14);
}
#[test]
fn test_to_local() {
let config = TimezoneConfig::new("America/New_York").add_mapping("EU_*", "Europe/London");
let handler = TimezoneHandler::new(config).unwrap();
let utc = DateTime::parse_from_rfc3339("2024-06-15T12:00:00Z")
.unwrap()
.with_timezone(&Utc);
let london_local = handler.to_local(utc, "EU_1000");
assert_eq!(london_local.hour(), 13);
let ny_local = handler.to_local(utc, "US_1000");
assert_eq!(ny_local.hour(), 8);
}
#[test]
fn test_presets() {
let _ = TimezoneHandler::new(TimezonePresets::us_centric()).unwrap();
let _ = TimezoneHandler::new(TimezonePresets::eu_centric()).unwrap();
let _ = TimezoneHandler::new(TimezonePresets::apac_centric()).unwrap();
let _ = TimezoneHandler::new(TimezonePresets::global_utc()).unwrap();
}
#[test]
fn test_invalid_timezone() {
let config = TimezoneConfig::new("Invalid/Timezone");
let result = TimezoneHandler::new(config);
assert!(result.is_err());
}
}