viirya commented on code in PR #11482:
URL: https://github.com/apache/arrow-rs/pull/11482#discussion_r4238824894


##########
arrow-array/src/timezone.rs:
##########
@@ -48,7 +48,360 @@ fn parse_fixed_offset(tz: &str) -> Option<FixedOffset> {
     }
 }
 
-#[cfg(feature = "chrono-tz")]
+#[cfg(feature = "jiff")]
+mod private {
+    use super::*;
+    use chrono::offset::TimeZone;
+    use chrono::{Datelike, LocalResult, NaiveDate, NaiveDateTime, NaiveTime, 
Offset, Timelike};
+    use jiff::Timestamp;
+    use jiff::civil::DateTime as JiffDateTime;
+    use jiff::tz::{AmbiguousOffset, TimeZone as JiffTimeZone};
+    use std::collections::HashMap;
+    use std::fmt::Display;
+    use std::str::FromStr;
+    use std::sync::{OnceLock, RwLock};
+
+    /// An [`Offset`] for [`Tz`]
+    #[derive(Debug, Copy, Clone)]
+    pub struct TzOffset {
+        tz: Tz,
+        offset: FixedOffset,
+    }
+
+    impl std::fmt::Display for TzOffset {
+        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+            self.offset.fmt(f)
+        }
+    }
+
+    impl Offset for TzOffset {
+        fn fix(&self) -> FixedOffset {
+            self.offset
+        }
+    }
+
+    /// A named timezone resolved by jiff, together with the name it was 
parsed from.
+    ///
+    /// [`Tz`] is `Copy` whereas [`JiffTimeZone`] is reference counted, so 
each distinct name is
+    /// resolved once and leaked to give `Tz` a `'static` reference. The set 
of names jiff accepts
+    /// is bounded by the bundled database, so the leaked memory is bounded 
too, and the hot path
+    /// (offset lookup) takes no lock.
+    #[derive(Debug)]
+    struct NamedTz {
+        name: Box<str>,
+        tz: JiffTimeZone,
+    }
+
+    fn named_tz(name: &str) -> Result<&'static NamedTz, ArrowError> {
+        static CACHE: OnceLock<RwLock<HashMap<Box<str>, &'static NamedTz>>> = 
OnceLock::new();
+        let cache = CACHE.get_or_init(Default::default);
+        if let Some(tz) = cache.read().unwrap().get(name) {
+            return Ok(tz);
+        }
+        // Same message as the chrono-tz path, so callers see one error 
regardless of feature
+        let tz = JiffTimeZone::get(name).map_err(|_| {

Review Comment:
   This goes through jiff's global `db()`. If anything else in the dependency 
graph enables jiff's default features (for example, `opendal-core` already 
depends on jiff in this workspace's lockfile), jiff reads `/usr/share/zoneinfo` 
/ `$TZDIR` before the bundle. With `TZDIR` pointing at a directory whose 
`America/Los_Angeles` is a copy of `Asia/Tokyo`, `Tz` returned +09:00, and a 
name only present there (`Fake_Zone`) parsed successfully. Could this use 
`jiff::tz::TimeZoneDatabase::bundled()` explicitly, held in a static?



##########
arrow-array/src/timezone.rs:
##########
@@ -48,7 +48,360 @@ fn parse_fixed_offset(tz: &str) -> Option<FixedOffset> {
     }
 }
 
-#[cfg(feature = "chrono-tz")]
+#[cfg(feature = "jiff")]
+mod private {
+    use super::*;
+    use chrono::offset::TimeZone;
+    use chrono::{Datelike, LocalResult, NaiveDate, NaiveDateTime, NaiveTime, 
Offset, Timelike};
+    use jiff::Timestamp;
+    use jiff::civil::DateTime as JiffDateTime;
+    use jiff::tz::{AmbiguousOffset, TimeZone as JiffTimeZone};
+    use std::collections::HashMap;
+    use std::fmt::Display;
+    use std::str::FromStr;
+    use std::sync::{OnceLock, RwLock};
+
+    /// An [`Offset`] for [`Tz`]
+    #[derive(Debug, Copy, Clone)]
+    pub struct TzOffset {
+        tz: Tz,
+        offset: FixedOffset,
+    }
+
+    impl std::fmt::Display for TzOffset {
+        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+            self.offset.fmt(f)
+        }
+    }
+
+    impl Offset for TzOffset {
+        fn fix(&self) -> FixedOffset {
+            self.offset
+        }
+    }
+
+    /// A named timezone resolved by jiff, together with the name it was 
parsed from.
+    ///
+    /// [`Tz`] is `Copy` whereas [`JiffTimeZone`] is reference counted, so 
each distinct name is
+    /// resolved once and leaked to give `Tz` a `'static` reference. The set 
of names jiff accepts
+    /// is bounded by the bundled database, so the leaked memory is bounded 
too, and the hot path
+    /// (offset lookup) takes no lock.
+    #[derive(Debug)]
+    struct NamedTz {
+        name: Box<str>,
+        tz: JiffTimeZone,
+    }
+
+    fn named_tz(name: &str) -> Result<&'static NamedTz, ArrowError> {
+        static CACHE: OnceLock<RwLock<HashMap<Box<str>, &'static NamedTz>>> = 
OnceLock::new();
+        let cache = CACHE.get_or_init(Default::default);
+        if let Some(tz) = cache.read().unwrap().get(name) {
+            return Ok(tz);
+        }
+        // Same message as the chrono-tz path, so callers see one error 
regardless of feature
+        let tz = JiffTimeZone::get(name).map_err(|_| {
+            ArrowError::ParseError(format!(
+                "Invalid timezone \"{name}\": failed to parse timezone"
+            ))
+        })?;
+        let mut cache = cache.write().unwrap();
+        Ok(cache.entry(name.into()).or_insert_with(|| {

Review Comment:
   jiff's lookup is ASCII case-insensitive, but the cache is keyed by the input 
string, so every casing is resolved and leaked separately. All 4096 case 
variants of the first 12 chars of `america/los_angeles` were accepted and 
leaked. `string_to_datetime` (arrow-cast/src/parse.rs:221) parses the timezone 
suffix of each row, so CSV/JSON/cast input can reach this. It's also a 
behaviour change: chrono-tz's `FromStr` is case-sensitive, while this accepts 
`utc`, `america/new_york`, `Etc/Unknown` and `Factory`, and `Display` echoes 
the non-canonical casing. Requiring `tz.iana_name() == Some(name)` (or 
keying/displaying by the canonical name) would fix both.



##########
arrow-array/src/timezone.rs:
##########
@@ -48,7 +48,360 @@ fn parse_fixed_offset(tz: &str) -> Option<FixedOffset> {
     }
 }
 
-#[cfg(feature = "chrono-tz")]
+#[cfg(feature = "jiff")]
+mod private {
+    use super::*;
+    use chrono::offset::TimeZone;
+    use chrono::{Datelike, LocalResult, NaiveDate, NaiveDateTime, NaiveTime, 
Offset, Timelike};
+    use jiff::Timestamp;
+    use jiff::civil::DateTime as JiffDateTime;
+    use jiff::tz::{AmbiguousOffset, TimeZone as JiffTimeZone};
+    use std::collections::HashMap;
+    use std::fmt::Display;
+    use std::str::FromStr;
+    use std::sync::{OnceLock, RwLock};
+
+    /// An [`Offset`] for [`Tz`]
+    #[derive(Debug, Copy, Clone)]
+    pub struct TzOffset {
+        tz: Tz,
+        offset: FixedOffset,
+    }
+
+    impl std::fmt::Display for TzOffset {
+        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+            self.offset.fmt(f)
+        }
+    }
+
+    impl Offset for TzOffset {
+        fn fix(&self) -> FixedOffset {
+            self.offset
+        }
+    }
+
+    /// A named timezone resolved by jiff, together with the name it was 
parsed from.
+    ///
+    /// [`Tz`] is `Copy` whereas [`JiffTimeZone`] is reference counted, so 
each distinct name is
+    /// resolved once and leaked to give `Tz` a `'static` reference. The set 
of names jiff accepts
+    /// is bounded by the bundled database, so the leaked memory is bounded 
too, and the hot path
+    /// (offset lookup) takes no lock.
+    #[derive(Debug)]
+    struct NamedTz {
+        name: Box<str>,
+        tz: JiffTimeZone,
+    }
+
+    fn named_tz(name: &str) -> Result<&'static NamedTz, ArrowError> {
+        static CACHE: OnceLock<RwLock<HashMap<Box<str>, &'static NamedTz>>> = 
OnceLock::new();
+        let cache = CACHE.get_or_init(Default::default);
+        if let Some(tz) = cache.read().unwrap().get(name) {
+            return Ok(tz);
+        }
+        // Same message as the chrono-tz path, so callers see one error 
regardless of feature
+        let tz = JiffTimeZone::get(name).map_err(|_| {
+            ArrowError::ParseError(format!(
+                "Invalid timezone \"{name}\": failed to parse timezone"
+            ))
+        })?;
+        let mut cache = cache.write().unwrap();
+        Ok(cache.entry(name.into()).or_insert_with(|| {
+            Box::leak(Box::new(NamedTz {
+                name: name.into(),
+                tz,
+            }))
+        }))
+    }
+
+    /// An Arrow [`TimeZone`]
+    #[derive(Debug, Copy, Clone)]
+    pub struct Tz(TzInner);
+
+    #[derive(Debug, Copy, Clone)]
+    enum TzInner {
+        Timezone(&'static NamedTz),
+        Offset(FixedOffset),
+    }
+
+    impl FromStr for Tz {
+        type Err = ArrowError;
+
+        fn from_str(tz: &str) -> Result<Self, Self::Err> {
+            match parse_fixed_offset(tz) {
+                Some(offset) => Ok(Self(TzInner::Offset(offset))),
+                None => Ok(Self(TzInner::Timezone(named_tz(tz)?))),
+            }
+        }
+    }
+
+    impl Display for Tz {
+        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+            match self.0 {
+                TzInner::Timezone(tz) => f.write_str(&tz.name),
+                TzInner::Offset(offset) => offset.fmt(f),
+            }
+        }
+    }
+
+    fn fixed_offset(offset: jiff::tz::Offset) -> FixedOffset {
+        // Real timezone data never holds an offset of a day or more, so this 
cannot fail
+        FixedOffset::east_opt(offset.seconds()).expect("timezone offset within 
a day")

Review Comment:
   jiff allows offsets up to ±25:59:59, so this holds for the bundled data but 
not for an arbitrary TZif file, which can be loaded through the host zoneinfo 
path above. Using the bundled db explicitly would make the comment true. 
Otherwise, clamping or returning UTC would be safer than panicking.



##########
.github/workflows/arrow.yml:
##########
@@ -77,6 +77,8 @@ jobs:
         run: cargo test -p arrow
       - name: Test arrow except pyarrow
         run: cargo test -p arrow 
--features=force_validate,prettyprint,ipc_compression,ffi,chrono-tz
+      - name: Test arrow with named timezones resolved by jiff

Review Comment:
   With this PR, `cargo test -p arrow-array --all-features` (line 49) selects 
jiff, and the other arrow-array steps enable neither feature. The chrono-tz 
`private` module and its tests are no longer compiled, run, or linted (clippy 
also uses `--all-features`). Could you add a `cargo test -p arrow-array 
--features chrono-tz` step and a matching clippy step?



##########
arrow-array/Cargo.toml:
##########
@@ -55,6 +55,9 @@ arrow-schema = { workspace = true }
 arrow-data = { workspace = true }
 chrono = { workspace = true }
 chrono-tz = { version = "0.10", optional = true }
+# `tzdb-bundle-always` compiles the IANA database into the crate, as chrono-tz 
does, so results

Review Comment:
   This only holds if no other crate in the build enables jiff's 
`tzdb-zoneinfo` (see the comment on `named_tz`). The PR description also lists 
only `std + tzdb-bundle-always`, while `tz-fat` and `perf-inline` are enabled 
too.



##########
arrow/benches/named_timezone.rs:
##########
@@ -0,0 +1,125 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Benchmarks for named (IANA) timezones, which the `chrono-tz` and `jiff` 
features resolve.
+//!
+//! Run with `--features chrono-tz` and again with `--features chrono-tz,jiff` 
to compare the two
+//! implementations behind [`Tz`].
+
+use arrow::array::timezone::Tz;
+use arrow::array::{TimestampMicrosecondArray, TimestampMillisecondArray};
+use arrow::compute::{DatePart, cast, date_part};
+use arrow::datatypes::DataType;
+use chrono::{DateTime, NaiveDateTime, TimeZone};
+use criterion::{Criterion, criterion_group, criterion_main};
+use std::hint::black_box;
+
+const ZONES: [&str; 4] = ["UTC", "America/Los_Angeles", "Europe/London", 
"Asia/Tokyo"];
+
+/// Instant ranges in microseconds. Fat TZif data, and so jiff's transition 
tables, stop at 2038;

Review Comment:
   2038–2100 is still inside chrono-tz's precomputed table: LA offsets match 
jiff exactly until 2100. So the ~2x there is the cost of jiff evaluating the 
POSIX rule versus a table lookup, not chrono-tz skipping the rule. That range 
is a pure slowdown with no correctness gain, which seems worth saying in the 
description. Also, `tz_parse` is single-threaded, so it doesn't capture 
contention on the global `RwLock` when a timezone is parsed per row.



##########
arrow-array/src/timezone.rs:
##########
@@ -48,7 +48,360 @@ fn parse_fixed_offset(tz: &str) -> Option<FixedOffset> {
     }
 }
 
-#[cfg(feature = "chrono-tz")]
+#[cfg(feature = "jiff")]
+mod private {
+    use super::*;
+    use chrono::offset::TimeZone;
+    use chrono::{Datelike, LocalResult, NaiveDate, NaiveDateTime, NaiveTime, 
Offset, Timelike};
+    use jiff::Timestamp;
+    use jiff::civil::DateTime as JiffDateTime;
+    use jiff::tz::{AmbiguousOffset, TimeZone as JiffTimeZone};
+    use std::collections::HashMap;
+    use std::fmt::Display;
+    use std::str::FromStr;
+    use std::sync::{OnceLock, RwLock};
+
+    /// An [`Offset`] for [`Tz`]
+    #[derive(Debug, Copy, Clone)]
+    pub struct TzOffset {
+        tz: Tz,
+        offset: FixedOffset,
+    }
+
+    impl std::fmt::Display for TzOffset {
+        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+            self.offset.fmt(f)
+        }
+    }
+
+    impl Offset for TzOffset {
+        fn fix(&self) -> FixedOffset {
+            self.offset
+        }
+    }
+
+    /// A named timezone resolved by jiff, together with the name it was 
parsed from.
+    ///
+    /// [`Tz`] is `Copy` whereas [`JiffTimeZone`] is reference counted, so 
each distinct name is
+    /// resolved once and leaked to give `Tz` a `'static` reference. The set 
of names jiff accepts
+    /// is bounded by the bundled database, so the leaked memory is bounded 
too, and the hot path
+    /// (offset lookup) takes no lock.
+    #[derive(Debug)]
+    struct NamedTz {
+        name: Box<str>,
+        tz: JiffTimeZone,
+    }
+
+    fn named_tz(name: &str) -> Result<&'static NamedTz, ArrowError> {
+        static CACHE: OnceLock<RwLock<HashMap<Box<str>, &'static NamedTz>>> = 
OnceLock::new();
+        let cache = CACHE.get_or_init(Default::default);
+        if let Some(tz) = cache.read().unwrap().get(name) {
+            return Ok(tz);
+        }
+        // Same message as the chrono-tz path, so callers see one error 
regardless of feature
+        let tz = JiffTimeZone::get(name).map_err(|_| {
+            ArrowError::ParseError(format!(
+                "Invalid timezone \"{name}\": failed to parse timezone"
+            ))
+        })?;
+        let mut cache = cache.write().unwrap();
+        Ok(cache.entry(name.into()).or_insert_with(|| {
+            Box::leak(Box::new(NamedTz {
+                name: name.into(),
+                tz,
+            }))
+        }))
+    }
+
+    /// An Arrow [`TimeZone`]
+    #[derive(Debug, Copy, Clone)]
+    pub struct Tz(TzInner);
+
+    #[derive(Debug, Copy, Clone)]
+    enum TzInner {
+        Timezone(&'static NamedTz),
+        Offset(FixedOffset),
+    }
+
+    impl FromStr for Tz {
+        type Err = ArrowError;
+
+        fn from_str(tz: &str) -> Result<Self, Self::Err> {
+            match parse_fixed_offset(tz) {
+                Some(offset) => Ok(Self(TzInner::Offset(offset))),
+                None => Ok(Self(TzInner::Timezone(named_tz(tz)?))),
+            }
+        }
+    }
+
+    impl Display for Tz {
+        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+            match self.0 {
+                TzInner::Timezone(tz) => f.write_str(&tz.name),
+                TzInner::Offset(offset) => offset.fmt(f),
+            }
+        }
+    }
+
+    fn fixed_offset(offset: jiff::tz::Offset) -> FixedOffset {
+        // Real timezone data never holds an offset of a day or more, so this 
cannot fail
+        FixedOffset::east_opt(offset.seconds()).expect("timezone offset within 
a day")
+    }
+
+    /// The instant jiff should consult for a UTC datetime. jiff covers years 
-9999 to 9999;
+    /// anything outside that range takes the offset in force at the nearest 
covered instant.
+    fn jiff_timestamp(utc: &NaiveDateTime) -> Timestamp {
+        let seconds = utc.and_utc().timestamp();
+        Timestamp::from_second(seconds).unwrap_or(if seconds < 0 {
+            Timestamp::MIN
+        } else {
+            Timestamp::MAX
+        })
+    }
+
+    /// The civil datetime jiff should resolve for a local datetime, or `None` 
when the year is
+    /// outside jiff's range. Sub-second precision is irrelevant to the offset 
in force.
+    fn jiff_civil(local: &NaiveDateTime) -> Option<JiffDateTime> {
+        JiffDateTime::new(
+            i16::try_from(local.year()).ok()?,
+            local.month() as i8,
+            local.day() as i8,
+            local.hour() as i8,
+            local.minute() as i8,
+            local.second() as i8,
+            0,
+        )
+        .ok()
+    }
+
+    impl Tz {
+        fn offset(&self, offset: FixedOffset) -> TzOffset {
+            TzOffset { tz: *self, offset }
+        }
+    }
+
+    impl TimeZone for Tz {
+        type Offset = TzOffset;
+
+        fn from_offset(offset: &Self::Offset) -> Self {
+            offset.tz
+        }
+
+        fn offset_from_local_date(&self, local: &NaiveDate) -> 
LocalResult<Self::Offset> {
+            match self.0 {
+                TzInner::Offset(tz) => tz
+                    .offset_from_local_date(local)
+                    .map(|x| self.offset(x.fix())),
+                TzInner::Timezone(_) => {
+                    // A date has no single offset on a day with a transition. 
As chrono-tz
+                    // does, prefer any offset that was in force at some point 
during the day
+                    // over reporting the ambiguity, so that unambiguous times 
on such a day
+                    // still resolve.
+                    let earliest = 
self.offset_from_local_datetime(&local.and_time(NaiveTime::MIN));
+                    let latest =
+                        self.offset_from_local_datetime(&local.and_hms_opt(23, 
59, 59).unwrap());
+                    match (earliest, latest) {
+                        (result @ LocalResult::Single(_), _) => result,
+                        (_, result @ LocalResult::Single(_)) => result,
+                        (LocalResult::Ambiguous(offset, _), _) => 
LocalResult::Single(offset),
+                        (_, LocalResult::Ambiguous(offset, _)) => 
LocalResult::Single(offset),
+                        (LocalResult::None, LocalResult::None) => 
LocalResult::None,
+                    }
+                }
+            }
+        }
+
+        fn offset_from_local_datetime(&self, local: &NaiveDateTime) -> 
LocalResult<Self::Offset> {
+            match self.0 {
+                TzInner::Offset(tz) => tz
+                    .offset_from_local_datetime(local)
+                    .map(|x| self.offset(x.fix())),
+                TzInner::Timezone(tz) => match jiff_civil(local) {
+                    None => 
LocalResult::Single(self.offset_from_utc_datetime(local)),
+                    Some(dt) => match 
tz.tz.to_ambiguous_timestamp(dt).offset() {
+                        AmbiguousOffset::Unambiguous { offset } => {
+                            
LocalResult::Single(self.offset(fixed_offset(offset)))
+                        }
+                        AmbiguousOffset::Gap { .. } => LocalResult::None,
+                        AmbiguousOffset::Fold { before, after } => 
LocalResult::Ambiguous(
+                            self.offset(fixed_offset(before)),
+                            self.offset(fixed_offset(after)),
+                        ),
+                    },
+                },
+            }
+        }
+
+        fn offset_from_utc_date(&self, utc: &NaiveDate) -> Self::Offset {
+            self.offset_from_utc_datetime(&utc.and_time(NaiveTime::MIN))
+        }
+
+        fn offset_from_utc_datetime(&self, utc: &NaiveDateTime) -> 
Self::Offset {
+            match self.0 {
+                TzInner::Offset(tz) => 
self.offset(tz.offset_from_utc_datetime(utc).fix()),
+                TzInner::Timezone(tz) => {
+                    
self.offset(fixed_offset(tz.tz.to_offset(jiff_timestamp(utc))))
+                }
+            }
+        }
+    }
+
+    #[cfg(test)]
+    mod tests {
+        use super::*;
+        use chrono::{Timelike, Utc};
+
+        #[test]
+        fn test_with_timezone() {
+            let vals = [
+                Utc.timestamp_millis_opt(37800000).unwrap(),
+                Utc.timestamp_millis_opt(86339000).unwrap(),
+            ];
+
+            assert_eq!(10, vals[0].hour());
+            assert_eq!(23, vals[1].hour());
+
+            let tz: Tz = "America/Los_Angeles".parse().unwrap();
+
+            assert_eq!(2, vals[0].with_timezone(&tz).hour());
+            assert_eq!(15, vals[1].with_timezone(&tz).hour());
+        }
+
+        #[test]
+        fn test_dst_transitions_from_utc() {
+            let tz: Tz = "Australia/Sydney".parse().unwrap();
+            let standard = FixedOffset::east_opt(10 * 60 * 60).unwrap();
+            let daylight = FixedOffset::east_opt(11 * 60 * 60).unwrap();
+            let at = |y, m, d, h, min| {
+                NaiveDate::from_ymd_opt(y, m, d)
+                    .unwrap()
+                    .and_hms_opt(h, min, 0)
+                    .unwrap()
+            };
+            // Daylight time ended 2021-04-04T03:00+11:00, which is 
2021-04-03T16:00Z
+            assert_eq!(
+                tz.offset_from_utc_datetime(&at(2021, 4, 3, 15, 30)).fix(),
+                daylight
+            );
+            assert_eq!(
+                tz.offset_from_utc_datetime(&at(2021, 4, 3, 16, 30)).fix(),
+                standard
+            );
+            // Daylight time started 2021-10-03T02:00+10:00, which is 
2021-10-02T16:00Z
+            assert_eq!(
+                tz.offset_from_utc_datetime(&at(2021, 10, 2, 15, 30)).fix(),
+                standard
+            );
+            assert_eq!(
+                tz.offset_from_utc_datetime(&at(2021, 10, 2, 16, 30)).fix(),
+                daylight
+            );
+        }
+
+        #[test]
+        fn test_dst_transitions_from_local() {
+            let tz: Tz = "America/New_York".parse().unwrap();
+            let eastern_standard = FixedOffset::west_opt(5 * 60 * 60).unwrap();
+            let eastern_daylight = FixedOffset::west_opt(4 * 60 * 60).unwrap();
+            let at = |m, d, h, min| {
+                NaiveDate::from_ymd_opt(2024, m, d)
+                    .unwrap()
+                    .and_hms_opt(h, min, 0)
+                    .unwrap()
+            };
+            match tz.offset_from_local_datetime(&at(1, 15, 12, 0)) {
+                LocalResult::Single(offset) => assert_eq!(offset.fix(), 
eastern_standard),
+                other => panic!("expected a single offset, got {other:?}"),
+            }
+            // 02:30 on 2024-03-10 was skipped when clocks went forward
+            assert!(matches!(
+                tz.offset_from_local_datetime(&at(3, 10, 2, 30)),
+                LocalResult::None
+            ));
+            // 01:30 on 2024-11-03 happened twice; the daylight reading comes 
first
+            match tz.offset_from_local_datetime(&at(11, 3, 1, 30)) {
+                LocalResult::Ambiguous(first, second) => {
+                    assert_eq!(first.fix(), eastern_daylight);
+                    assert_eq!(second.fix(), eastern_standard);
+                }
+                other => panic!("expected an ambiguous offset, got {other:?}"),
+            }
+            // A date on a transition day still resolves to an offset in force 
that day
+            assert!(matches!(
+                tz.offset_from_local_date(&NaiveDate::from_ymd_opt(2024, 3, 
10).unwrap()),
+                LocalResult::Single(_)
+            ));
+        }
+
+        /// chrono-tz stops applying daylight saving after its last 
precomputed transition,
+        /// around 2099. jiff applies the zone's rules indefinitely, as 
`java.time` and libc do.
+        #[test]
+        fn test_dst_applies_after_2099() {
+            let tz: Tz = "America/Los_Angeles".parse().unwrap();
+            let pacific_standard = FixedOffset::west_opt(8 * 60 * 60).unwrap();
+            let pacific_daylight = FixedOffset::west_opt(7 * 60 * 60).unwrap();
+            for year in [2024, 2100, 2300, 9999] {
+                let summer = NaiveDate::from_ymd_opt(year, 7, 1)
+                    .unwrap()
+                    .and_hms_opt(12, 0, 0)
+                    .unwrap();
+                let winter = NaiveDate::from_ymd_opt(year, 1, 1)
+                    .unwrap()
+                    .and_hms_opt(12, 0, 0)
+                    .unwrap();
+                assert_eq!(
+                    tz.offset_from_utc_datetime(&summer).fix(),
+                    pacific_daylight,
+                    "summer {year}"
+                );
+                assert_eq!(
+                    tz.offset_from_utc_datetime(&winter).fix(),
+                    pacific_standard,
+                    "winter {year}"
+                );
+                match tz.offset_from_local_datetime(&summer) {
+                    LocalResult::Single(offset) => {
+                        assert_eq!(offset.fix(), pacific_daylight, "local 
summer {year}")
+                    }
+                    other => panic!("expected a single offset for {year}, got 
{other:?}"),
+                }
+            }
+        }
+
+        #[test]
+        fn test_beyond_jiff_range() {
+            let tz: Tz = "America/Los_Angeles".parse().unwrap();
+            let far = NaiveDate::from_ymd_opt(20_000, 7, 1)
+                .unwrap()
+                .and_hms_opt(12, 0, 0)
+                .unwrap();
+            // Falls back to the offset in force at jiff's last covered 
instant rather than failing
+            let utc = tz.offset_from_utc_datetime(&far).fix();
+            assert!(utc.local_minus_utc() == -8 * 60 * 60 || 
utc.local_minus_utc() == -7 * 60 * 60);

Review Comment:
   The clamp deterministically gives −08:00 here, since jiff's max timestamp is 
in December, so the assertion could be exact. On open question 3: 
`offset_from_utc_datetime` is infallible, so an "error" would have to be a 
panic. Clamping seems like the only workable option within the trait.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to