Skip to content

Commit

Permalink
Add gap_info_from_local_datetime to get information about a gap (#188)
Browse files Browse the repository at this point in the history
  • Loading branch information
acrrd authored Jan 20, 2025
1 parent bc86bc5 commit cc9c6ed
Show file tree
Hide file tree
Showing 2 changed files with 168 additions and 2 deletions.
95 changes: 94 additions & 1 deletion chrono-tz/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -159,13 +159,15 @@ 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;
use super::Tz;
use super::IANA_TZDB_VERSION;
use super::US::Eastern;
use super::UTC;
use chrono::NaiveDateTime;
use chrono::{Duration, NaiveDate, TimeZone};

#[test]
Expand Down Expand Up @@ -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 } = GapInfo::new(&in_gap, &tz).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(),
);
}
}
75 changes: 74 additions & 1 deletion chrono-tz/src/timezone_impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -403,3 +404,75 @@ impl TimeZone for Tz {
TzOffset::new(*self, timespans.get(index))
}
}

/// Represents the information of a gap.
///
/// This returns useful information that can be used when converting a local [`NaiveDateTime`]
/// to a timezone-aware [`DateTime`] with [`TimeZone::from_local_datetime`] and a gap
/// ([`LocalResult::None`]) is found.
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<DateTime<Tz>>,
}

impl GapInfo {
/// Return 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 new(local: &NaiveDateTime, tz: &Tz) -> Option<Self> {
let timestamp = local.and_utc().timestamp();
let timespans = tz.timespans();
let index = binary_search(0, timespans.len(), |i| {
timespans.local_span(i).cmp(timestamp)
});

let Err(end_idx) = index else {
return None;
};

let begin = match end_idx {
0 => None,
_ => {
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(*tz, timespans.get(start_idx)),
)
})
}
};

let end = match end_idx {
_ if end_idx >= timespans.len() => None,
_ => {
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
tz.from_local_datetime(&date_time.naive_local()).single()
})
}
};

Some(Self { begin, end })
}
}

0 comments on commit cc9c6ed

Please sign in to comment.