diff --git a/chrono-tz/src/lib.rs b/chrono-tz/src/lib.rs index 578c9a1..1c1f75f 100644 --- a/chrono-tz/src/lib.rs +++ b/chrono-tz/src/lib.rs @@ -140,7 +140,7 @@ mod timezone_impl; mod timezones; pub use crate::directory::*; -pub use crate::timezone_impl::{OffsetComponents, OffsetName, TzOffset}; +pub use crate::timezone_impl::{GapInfo, OffsetComponents, OffsetName, TzOffset}; pub use crate::timezones::ParseError; pub use crate::timezones::Tz; pub use crate::timezones::TZ_VARIANTS; @@ -159,6 +159,7 @@ mod tests { use super::Europe::Moscow; use super::Europe::Vilnius; use super::Europe::Warsaw; + use super::GapInfo; use super::Pacific::Apia; use super::Pacific::Noumea; use super::Pacific::Tahiti; @@ -166,6 +167,7 @@ mod tests { use super::IANA_TZDB_VERSION; use super::US::Eastern; use super::UTC; + use chrono::NaiveDateTime; use chrono::{Duration, NaiveDate, TimeZone}; #[test] @@ -514,4 +516,95 @@ mod tests { assert_eq!(format!("{}", dt.offset()), "+0245"); assert_eq!(format!("{:?}", dt.offset()), "+0245"); } + + fn gap_info_test(tz: Tz, gap_begin: NaiveDateTime, gap_end: NaiveDateTime) { + let before = gap_begin - Duration::seconds(1); + let before_offset = tz.offset_from_local_datetime(&before).single().unwrap(); + + let gap_end = tz.from_local_datetime(&gap_end).single().unwrap(); + + let in_gap = gap_begin + Duration::seconds(1); + let GapInfo { begin, end } = tz.gap_info_from_local_datetime(&in_gap).unwrap(); + let (begin_time, begin_offset) = begin.unwrap(); + let end = end.unwrap(); + + assert_eq!(gap_begin, begin_time); + assert_eq!(before_offset, begin_offset); + assert_eq!(gap_end, end); + } + + #[test] + fn gap_info_europe_london() { + gap_info_test( + Tz::Europe__London, + NaiveDate::from_ymd_opt(2024, 3, 31) + .unwrap() + .and_hms_opt(1, 0, 0) + .unwrap(), + NaiveDate::from_ymd_opt(2024, 3, 31) + .unwrap() + .and_hms_opt(2, 0, 0) + .unwrap(), + ); + } + + #[test] + fn gap_info_europe_dublin() { + gap_info_test( + Tz::Europe__Dublin, + NaiveDate::from_ymd_opt(2024, 3, 31) + .unwrap() + .and_hms_opt(1, 0, 0) + .unwrap(), + NaiveDate::from_ymd_opt(2024, 3, 31) + .unwrap() + .and_hms_opt(2, 0, 0) + .unwrap(), + ); + } + + #[test] + fn gap_info_australia_adelaide() { + gap_info_test( + Tz::Australia__Adelaide, + NaiveDate::from_ymd_opt(2024, 10, 6) + .unwrap() + .and_hms_opt(2, 0, 0) + .unwrap(), + NaiveDate::from_ymd_opt(2024, 10, 6) + .unwrap() + .and_hms_opt(3, 0, 0) + .unwrap(), + ); + } + + #[test] + fn gap_info_samoa_skips_a_day() { + gap_info_test( + Tz::Pacific__Apia, + NaiveDate::from_ymd_opt(2011, 12, 30) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap(), + NaiveDate::from_ymd_opt(2011, 12, 31) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap(), + ); + } + + #[test] + fn gap_info_libya_2013() { + gap_info_test( + Tz::Libya, + NaiveDate::from_ymd_opt(2013, 3, 29) + .unwrap() + .and_hms_opt(1, 0, 0) + .unwrap(), + NaiveDate::from_ymd_opt(2013, 3, 29) + .unwrap() + .and_hms_opt(2, 0, 0) + .unwrap(), + ); + } } diff --git a/chrono-tz/src/timezone_impl.rs b/chrono-tz/src/timezone_impl.rs index 490c78d..50e2dc2 100644 --- a/chrono-tz/src/timezone_impl.rs +++ b/chrono-tz/src/timezone_impl.rs @@ -2,7 +2,8 @@ use core::cmp::Ordering; use core::fmt::{Debug, Display, Error, Formatter, Write}; use chrono::{ - Duration, FixedOffset, LocalResult, NaiveDate, NaiveDateTime, NaiveTime, Offset, TimeZone, + DateTime, Duration, FixedOffset, LocalResult, NaiveDate, NaiveDateTime, NaiveTime, Offset, + TimeZone, }; use crate::binary_search::binary_search; @@ -403,3 +404,69 @@ impl TimeZone for Tz { TzOffset::new(*self, timespans.get(index)) } } + +/// Represents the information of a gap. +pub struct GapInfo { + /// When available it contains information about the beginning of the gap. + /// + /// The time represents the first instant in which the gap starts. + /// This means that it is the first instant that when used with [`TimeZone::from_local_datetime`] + /// it will return [`LocalResult::None`]. + /// + /// The offset represents the offset of the first instant before the gap. + pub begin: Option<(NaiveDateTime, TzOffset)>, + /// When available it contains the first instant after the gap. + pub end: Option>, +} + +impl Tz { + /// Returns information about a gap. + /// + /// It returns `None` if `local` is not in a gap for the current timezone. + /// + /// If `local` is at the limits of the known timestamps the fields `begin` or `end` in + /// [`GapInfo`] will be `None`. + pub fn gap_info_from_local_datetime(&self, local: &NaiveDateTime) -> Option { + let timestamp = local.and_utc().timestamp(); + let timespans = self.timespans(); + let index = binary_search(0, timespans.len(), |i| { + timespans.local_span(i).cmp(timestamp) + }); + + match index { + Ok(_) => None, + Err(end_idx) => { + let begin = if end_idx == 0 { + None + } else { + let start_idx = end_idx - 1; + + timespans + .local_span(start_idx) + .end + .and_then(|start_time| DateTime::from_timestamp(start_time, 0)) + .map(|start_time| { + ( + start_time.naive_local(), + TzOffset::new(*self, timespans.get(start_idx)), + ) + }) + }; + let end = if end_idx == timespans.len() { + None + } else { + timespans + .local_span(end_idx) + .begin + .and_then(|end_time| DateTime::from_timestamp(end_time, 0)) + .and_then(|date_time| { + // we create the DateTime from a timestamp that exists in the timezone + self.from_local_datetime(&date_time.naive_local()).single() + }) + }; + + Some(GapInfo { begin, end }) + } + } + } +}