use crate::error::{EventixError, Result};
use chrono::{DateTime, NaiveDate, NaiveDateTime, Offset, TimeZone};
use chrono_tz::OffsetComponents;
use chrono_tz::Tz;
pub fn parse_timezone(tz_str: &str) -> Result<Tz> {
tz_str
.parse::<Tz>()
.map_err(|_| EventixError::InvalidTimezone(tz_str.to_string()))
}
pub fn parse_datetime_with_tz(datetime_str: &str, tz: Tz) -> Result<DateTime<Tz>> {
let naive = if let Ok(dt) = NaiveDateTime::parse_from_str(datetime_str, "%Y-%m-%d %H:%M:%S") {
dt
} else if let Ok(dt) = NaiveDateTime::parse_from_str(datetime_str, "%Y-%m-%dT%H:%M:%S") {
dt
} else {
return Err(EventixError::DateTimeParse(format!(
"Could not parse '{}'. Expected format: 'YYYY-MM-DD HH:MM:SS' or 'YYYY-MM-DDTHH:MM:SS'",
datetime_str
)));
};
tz.from_local_datetime(&naive).earliest().ok_or_else(|| {
EventixError::DateTimeParse(format!(
"Invalid datetime '{}' for timezone '{}'",
datetime_str, tz
))
})
}
pub(crate) fn resolve_local(tz: Tz, naive: NaiveDateTime) -> Option<DateTime<Tz>> {
if let Some(dt) = tz.from_local_datetime(&naive).earliest() {
return Some(dt);
}
let day_before = naive.checked_sub_signed(chrono::Duration::days(1))?;
let pre_gap_dt = tz.from_local_datetime(&day_before).earliest()?;
let pre_offset = pre_gap_dt.offset().fix();
let utc_naive = naive.checked_sub_offset(pre_offset)?;
Some(chrono::Utc.from_utc_datetime(&utc_naive).with_timezone(&tz))
}
pub(crate) fn local_day_window(date: NaiveDate, tz: Tz) -> Result<(DateTime<Tz>, DateTime<Tz>)> {
let start_naive = date
.and_hms_opt(0, 0, 0)
.ok_or_else(|| EventixError::ValidationError("Invalid start time".to_string()))?;
let next_date = date
.succ_opt()
.ok_or_else(|| EventixError::ValidationError("Invalid end time".to_string()))?;
let end_naive = next_date
.and_hms_opt(0, 0, 0)
.ok_or_else(|| EventixError::ValidationError("Invalid end time".to_string()))?;
let start_dt = resolve_local(tz, start_naive)
.ok_or_else(|| EventixError::ValidationError("Failed to resolve start time".to_string()))?;
let end_dt = resolve_local(tz, end_naive)
.ok_or_else(|| EventixError::ValidationError("Failed to resolve end time".to_string()))?;
Ok((start_dt, end_dt))
}
pub fn convert_timezone(dt: &DateTime<Tz>, target_tz: Tz) -> DateTime<Tz> {
dt.with_timezone(&target_tz)
}
pub fn is_dst(dt: &DateTime<Tz>) -> bool {
dt.offset().dst_offset() != chrono::Duration::zero()
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
use chrono::{Duration, Timelike};
#[test]
fn test_parse_timezone() {
assert!(parse_timezone("America/New_York").is_ok());
assert!(parse_timezone("UTC").is_ok());
assert!(parse_timezone("Asia/Tokyo").is_ok());
assert!(parse_timezone("Invalid/Timezone").is_err());
}
#[test]
fn test_parse_datetime() {
let tz = parse_timezone("UTC").unwrap();
assert!(parse_datetime_with_tz("2025-11-01 10:00:00", tz).is_ok());
assert!(parse_datetime_with_tz("2025-11-01T10:00:00", tz).is_ok());
assert!(parse_datetime_with_tz("invalid", tz).is_err());
}
#[test]
fn test_convert_timezone() {
let tz_utc = parse_timezone("UTC").unwrap();
let tz_ny = parse_timezone("America/New_York").unwrap();
let dt_utc = parse_datetime_with_tz("2025-11-01 15:00:00", tz_utc).unwrap();
let dt_ny = convert_timezone(&dt_utc, tz_ny);
assert!(dt_ny.hour() == 10 || dt_ny.hour() == 11);
}
#[test]
fn test_convert_timezone_across_pacific() {
let tz_utc = parse_timezone("UTC").unwrap();
let tz_la = parse_timezone("America/Los_Angeles").unwrap();
let dt_utc = parse_datetime_with_tz("2025-07-15 20:00:00", tz_utc).unwrap();
let dt_la = convert_timezone(&dt_utc, tz_la);
assert_eq!(dt_la.timezone(), tz_la);
assert_eq!(dt_la.hour(), 13);
}
#[test]
fn test_local_day_window_dst_fall_back() {
let tz = parse_timezone("America/New_York").unwrap();
let date = chrono::NaiveDate::from_ymd_opt(2025, 11, 2).unwrap();
let (start, end) = local_day_window(date, tz).unwrap();
assert_eq!(start.date_naive(), date);
assert_eq!(end.date_naive(), date.succ_opt().unwrap());
assert_eq!(end - start, Duration::hours(25));
}
#[test]
fn test_local_day_window_dst_spring_forward() {
let tz = parse_timezone("America/New_York").unwrap();
let date = chrono::NaiveDate::from_ymd_opt(2025, 3, 9).unwrap();
let (start, end) = local_day_window(date, tz).unwrap();
assert_eq!(start.date_naive(), date);
assert_eq!(end.date_naive(), date.succ_opt().unwrap());
assert_eq!(end - start, Duration::hours(23));
}
#[test]
fn test_resolve_local_dst_gap_uses_pre_gap_offset() {
let tz = parse_timezone("America/New_York").unwrap();
let naive = chrono::NaiveDate::from_ymd_opt(2025, 3, 9)
.unwrap()
.and_hms_opt(2, 30, 0)
.unwrap();
let resolved = resolve_local(tz, naive).unwrap();
assert_eq!(resolved.hour(), 3);
assert_eq!(resolved.minute(), 30);
}
}