This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new f2edfcc7 Align local-zone temporal schema evolution with Java (#1117)
f2edfcc7 is described below

commit f2edfcc75c62f330fbde7bcef7cd955814f74b79
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Oct 13 10:10:21 2026 +0800

    Align local-zone temporal schema evolution with Java (#1117)
---
 crates/paimon/src/arrow/evolution_string.rs   |  20 +-
 crates/paimon/src/arrow/evolution_temporal.rs | 519 ++++++++++++++++++++++++++
 crates/paimon/src/arrow/mod.rs                |   1 +
 crates/paimon/src/arrow/nested_evolution.rs   |  11 +-
 crates/paimon/src/common/date_time.rs         | 118 +++++-
 5 files changed, 643 insertions(+), 26 deletions(-)

diff --git a/crates/paimon/src/arrow/evolution_string.rs 
b/crates/paimon/src/arrow/evolution_string.rs
index ee5207d9..78083cc7 100644
--- a/crates/paimon/src/arrow/evolution_string.rs
+++ b/crates/paimon/src/arrow/evolution_string.rs
@@ -23,7 +23,7 @@ use arrow_array::{cast::AsArray, types::Int64Type, ArrayRef, 
StringArray, Time32
 use arrow_schema::{DataType as ArrowType, TimeUnit};
 use jiff::tz::TimeZone;
 
-use crate::common::date_time::{format_epoch_timestamp, format_time};
+use crate::common::date_time::{format_epoch_timestamp, format_time, 
local_offset};
 use crate::spec::DataType;
 
 /// Return None when this is not a temporal-to-character conversion.
@@ -115,24 +115,6 @@ fn render_timestamp(
     format_epoch_timestamp(millis.div_euclid(1000), nanos, precision)
 }
 
-fn local_offset(seconds: i64, zone: &TimeZone) -> i32 {
-    let timestamp = jiff::Timestamp::from_second(seconds).unwrap_or_else(|_| {
-        if seconds < 0 {
-            // Java ZoneRules uses its first wall offset before recorded 
history.
-            jiff::Timestamp::MIN
-        } else {
-            // Java's final transition rules repeat with the Gregorian 
calendar:
-            // 400 years contain 146097 days, a whole number of weeks. Look up
-            // the same month/day/weekday/time in 2370..2770, after historical
-            // transitions, while retaining the original millis for formatting.
-            const CYCLE_SECONDS: i64 = 146_097 * 86_400;
-            jiff::Timestamp::from_second(seconds.rem_euclid(CYCLE_SECONDS) + 
CYCLE_SECONDS)
-                .expect("a projected Gregorian cycle is within jiff's 
timestamp range")
-        }
-    });
-    zone.to_offset(timestamp).seconds()
-}
-
 fn invalid(message: impl Into<String>) -> crate::Error {
     crate::Error::DataInvalid {
         message: message.into(),
diff --git a/crates/paimon/src/arrow/evolution_temporal.rs 
b/crates/paimon/src/arrow/evolution_temporal.rs
new file mode 100644
index 00000000..f0cf997d
--- /dev/null
+++ b/crates/paimon/src/arrow/evolution_temporal.rs
@@ -0,0 +1,519 @@
+// 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.
+
+//! Java local-zone conversions for existing schema-evolution casts.
+
+use crate::common::date_time::{
+    epoch_day_to_java_date, java_date_to_epoch_day, local_offset, 
local_to_utc_seconds,
+};
+use crate::spec::DataType;
+use arrow_array::{
+    cast::AsArray,
+    types::{Int32Type, Int64Type},
+    ArrayRef, Date32Array, Int64Array, Time32MillisecondArray,
+};
+use arrow_schema::{DataType as ArrowType, TimeUnit};
+use jiff::tz::TimeZone;
+use std::sync::Arc;
+
+pub(super) fn cast_temporal(
+    source: &ArrayRef,
+    from: &DataType,
+    to: &DataType,
+) -> crate::Result<Option<ArrayRef>> {
+    if !matches!(
+        (from, to),
+        (
+            DataType::Timestamp(_),
+            DataType::LocalZonedTimestamp(_) | DataType::Date(_) | 
DataType::Time(_)
+        ) | (
+            DataType::LocalZonedTimestamp(_),
+            DataType::Timestamp(_) | DataType::Date(_) | DataType::Time(_)
+        ) | (
+            DataType::Date(_) | DataType::BigInt(_),
+            DataType::LocalZonedTimestamp(_)
+        )
+    ) {
+        return Ok(None);
+    }
+    let zone = TimeZone::system();
+    cast_in_zone(source, from, to, &zone)
+}
+
+fn cast_in_zone(
+    source: &ArrayRef,
+    from: &DataType,
+    to: &DataType,
+    zone: &TimeZone,
+) -> crate::Result<Option<ArrayRef>> {
+    let ltz = matches!(from, DataType::LocalZonedTimestamp(_));
+    let timestamp = matches!(
+        from,
+        DataType::Timestamp(_) | DataType::LocalZonedTimestamp(_)
+    );
+    let cross = timestamp
+        && matches!(
+            to,
+            DataType::Timestamp(_) | DataType::LocalZonedTimestamp(_)
+        )
+        && ltz != matches!(to, DataType::LocalZonedTimestamp(_));
+    let date = matches!(
+        (from, to),
+        (DataType::Date(_), DataType::LocalZonedTimestamp(_))
+    );
+    let numeric = matches!(
+        (from, to),
+        (DataType::BigInt(_), DataType::LocalZonedTimestamp(_))
+    );
+    if !cross
+        && !date
+        && !numeric
+        && !(timestamp && matches!(to, DataType::Date(_) | DataType::Time(_)))
+    {
+        return Ok(None);
+    }
+    let raw = if date {
+        arrow_cast::cast(source, &ArrowType::Int32).map_err(invalid_cast)?
+    } else {
+        arrow_cast::cast(source, &ArrowType::Int64).map_err(invalid_cast)?
+    };
+    let values = if date {
+        raw.as_primitive::<Int32Type>()
+            .iter()
+            .map(|v| v.map(i64::from))
+            .collect::<Vec<_>>()
+    } else {
+        raw.as_primitive::<Int64Type>().iter().collect()
+    };
+    let unit = match source.data_type() {
+        ArrowType::Timestamp(unit, _) => *unit,
+        _ => TimeUnit::Second,
+    };
+    let target_arrow = super::paimon_type_to_arrow(to)?;
+    let mut converted = Vec::with_capacity(values.len());
+    for value in values {
+        let Some(value) = value else {
+            converted.push(None);
+            continue;
+        };
+        let (mut millis, mut sub_milli) = if date {
+            (java_date_to_epoch_day(value as i32)? * 86_400_000, 0)
+        } else {
+            timestamp_parts(value, &unit)
+        };
+        // LocalDateTime.ofInstant retains a wide epoch day for DATE/TIME;
+        // Timestamp.fromLocalDateTime instead stores signed long milliseconds.
+        let local_millis = i128::from(millis)
+            + if ltz || numeric {
+                i128::from(local_offset(millis.div_euclid(1000), zone)) * 1000
+            } else {
+                0
+            };
+        if cross && !ltz {
+            // Timestamp.toLocalDateTime narrows the epoch day before 
resolving the zone.
+            millis = i64::from(millis.div_euclid(86_400_000) as i32) * 
86_400_000
+                + millis.rem_euclid(86_400_000);
+        }
+        if date || cross && !ltz {
+            let seconds = millis.div_euclid(1000);
+            let utc = local_to_utc_seconds(seconds, zone)?;
+            millis = utc * 1000 + millis.rem_euclid(1000);
+        } else if ltz || numeric {
+            millis = local_millis as i64;
+        }
+        let value = match to {
+            DataType::Date(_) if ltz => Some(i64::from(epoch_day_to_java_date(
+                local_millis.div_euclid(86_400_000) as i64,
+            ))),
+            DataType::Date(_) => Some(i64::from(millis.div_euclid(86_400_000) 
as i32)),
+            DataType::Time(_) => Some(local_millis.rem_euclid(86_400_000) as 
i64),
+            DataType::Timestamp(_) | DataType::LocalZonedTimestamp(_) => {
+                let precision = match to {
+                    DataType::Timestamp(t) => t.precision(),
+                    DataType::LocalZonedTimestamp(t) => t.precision(),
+                    _ => unreachable!(),
+                };
+                let from_precision = match from {
+                    DataType::Timestamp(t) => t.precision(),
+                    DataType::LocalZonedTimestamp(t) => t.precision(),
+                    _ => 0,
+                };
+                if from_precision > precision {
+                    if precision <= 3 {
+                        let quantum = 10_i64.pow(3 - precision);
+                        millis = 
millis.div_euclid(quantum).wrapping_mul(quantum);
+                        sub_milli = 0;
+                    } else {
+                        let quantum = 10_i64.pow(9 - precision);
+                        sub_milli = sub_milli / quantum * quantum;
+                    }
+                }
+                let ArrowType::Timestamp(unit, _) = &target_arrow else {
+                    unreachable!()
+                };
+                let ticks = match unit {
+                    TimeUnit::Second => i128::from(millis.div_euclid(1000)),
+                    TimeUnit::Millisecond => i128::from(millis),
+                    TimeUnit::Microsecond => {
+                        i128::from(millis) * 1000 + i128::from(sub_milli / 
1000)
+                    }
+                    TimeUnit::Nanosecond => i128::from(millis) * 1_000_000 + 
i128::from(sub_milli),
+                };
+                i64::try_from(ticks).ok()
+            }
+            _ => unreachable!(),
+        };
+        converted.push(value);
+    }
+    Ok(Some(match to {
+        DataType::Date(_) => Arc::new(Date32Array::from_iter(
+            converted.into_iter().map(|v| v.map(|v| v as i32)),
+        )) as ArrayRef,
+        DataType::Time(_) => Arc::new(Time32MillisecondArray::from_iter(
+            converted.into_iter().map(|v| v.map(|v| v as i32)),
+        )),
+        _ => arrow_cast::cast(
+            &(Arc::new(Int64Array::from(converted)) as ArrayRef),
+            &target_arrow,
+        )
+        .map_err(invalid_cast)?,
+    }))
+}
+
+fn timestamp_parts(value: i64, unit: &TimeUnit) -> (i64, i64) {
+    match unit {
+        TimeUnit::Second => (value.wrapping_mul(1000), 0),
+        TimeUnit::Millisecond => (value, 0),
+        TimeUnit::Microsecond => (value.div_euclid(1000), 
value.rem_euclid(1000) * 1000),
+        TimeUnit::Nanosecond => (value.div_euclid(1_000_000), 
value.rem_euclid(1_000_000)),
+    }
+}
+
+fn invalid_cast(error: arrow_schema::ArrowError) -> crate::Error {
+    crate::Error::DataInvalid {
+        message: format!("Cannot convert temporal schema-evolution value: 
{error}"),
+        source: None,
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::spec::{DateType, LocalZonedTimestampType, TimeType, 
TimestampType};
+    use arrow_array::{Array, TimestampMillisecondArray, 
TimestampNanosecondArray};
+
+    fn ts(ltz: bool, precision: u32) -> DataType {
+        if ltz {
+            
DataType::LocalZonedTimestamp(LocalZonedTimestampType::new(precision).unwrap())
+        } else {
+            DataType::Timestamp(TimestampType::new(precision).unwrap())
+        }
+    }
+
+    fn raw(array: &ArrayRef) -> Vec<Option<i64>> {
+        let integer = if matches!(array.data_type(), ArrowType::Date32 | 
ArrowType::Time32(_)) {
+            let array = arrow_cast::cast(array, &ArrowType::Int32).unwrap();
+            return array
+                .as_primitive::<Int32Type>()
+                .iter()
+                .map(|v| v.map(i64::from))
+                .collect();
+        } else {
+            arrow_cast::cast(array, &ArrowType::Int64).unwrap()
+        };
+        integer.as_primitive::<Int64Type>().iter().collect()
+    }
+
+    #[test]
+    fn temporal_zone_casts_match_java_including_dst_and_long_boundaries() {
+        let input = [
+            0,
+            -1,
+            1710037800123,
+            1730597400123,
+            1710055800123,
+            1730615400123,
+            i64::MAX,
+            i64::MIN,
+        ];
+        // Golden values from Java CastExecutors with source precision 9 and 
target 1.
+        for (zone, expected) in [
+            (
+                "America/New_York",
+                [
+                    [
+                        18000000,
+                        17999900,
+                        1710055800100,
+                        1730611800100,
+                        1710070200100,
+                        1730633400100,
+                        -53757322487462200,
+                        53757322519624100,
+                    ],
+                    [
+                        -18000000,
+                        -18000100,
+                        1710019800100,
+                        1730583000100,
+                        1710041400100,
+                        1730597400100,
+                        9223372036840375800,
+                        9223372036837013800,
+                    ],
+                    [-1, -1, 19791, 20029, 19792, 20030, -622191233, 
622191233],
+                    [
+                        68400000, 68399999, 77400123, 77400123, 12600123, 
5400123, 11575807,
+                        42662192,
+                    ],
+                ],
+            ),
+            (
+                "Asia/Shanghai",
+                [
+                    [
+                        -28800000,
+                        -28800100,
+                        1710009000100,
+                        1730568600100,
+                        1710027000100,
+                        1730586600100,
+                        -53757322534367200,
+                        53757322476424100,
+                    ],
+                    [
+                        28800000,
+                        28799900,
+                        1710066600100,
+                        1730626200100,
+                        1710084600100,
+                        1730644200100,
+                        -9223372036825975900,
+                        -9223372036825632900,
+                    ],
+                    [0, 0, 19792, 20030, 19792, 20030, -622191233, 622191234],
+                    [
+                        28800000, 28799999, 37800123, 34200123, 55800123, 
52200123, 54775807,
+                        3167192,
+                    ],
+                ],
+            ),
+        ] {
+            let zone = TimeZone::get(zone).unwrap();
+            for (i, (from, to)) in [
+                (ts(false, 9), ts(true, 1)),
+                (ts(true, 9), ts(false, 1)),
+                (ts(true, 9), DataType::Date(DateType::new())),
+                (ts(true, 9), DataType::Time(TimeType::new(1).unwrap())),
+            ]
+            .into_iter()
+            .enumerate()
+            {
+                let source: ArrayRef = 
Arc::new(TimestampMillisecondArray::from_iter(
+                    [None]
+                        .into_iter()
+                        .chain(input.into_iter().map(Some))
+                        .chain([None, None]),
+                ));
+                let actual = cast_in_zone(&source.slice(1, input.len() + 1), 
&from, &to, &zone)
+                    .unwrap()
+                    .unwrap();
+                assert_eq!(
+                    raw(&actual),
+                    expected[i]
+                        .into_iter()
+                        .map(Some)
+                        .chain([None])
+                        .collect::<Vec<_>>(),
+                    "{zone:?} operation {i}"
+                );
+            }
+        }
+    }
+
+    #[test]
+    fn 
timezone_conversion_preserves_submillisecond_values_and_truncates_declared_precision()
 {
+        let zone = TimeZone::get("America/New_York").unwrap();
+        let source: ArrayRef = Arc::new(TimestampNanosecondArray::from(vec![
+            Some(1710037800123456789),
+            Some(-1),
+            None,
+        ]));
+        let output = cast_in_zone(&source, &ts(false, 9), &ts(true, 6), &zone)
+            .unwrap()
+            .unwrap();
+        assert_eq!(
+            raw(&output),
+            vec![Some(1710055800123456), Some(17999999999), None]
+        );
+        let output = cast_in_zone(&source, &ts(false, 9), &ts(true, 1), &zone)
+            .unwrap()
+            .unwrap();
+        assert_eq!(
+            raw(&output),
+            vec![Some(1710055800100), Some(17999900), None]
+        );
+    }
+
+    #[test]
+    fn date_to_ltz_resolves_midnight_and_skipped_days_like_java() {
+        for (name, input, expected) in [
+            (
+                "Asia/Shanghai",
+                [0, -1, 19875, 15338],
+                [-28800000, -115200000, 1717171200000, 1325174400000],
+            ),
+            (
+                "America/New_York",
+                [0, -1, 19875, 15338],
+                [18000000, -68400000, 1717214400000, 1325221200000],
+            ),
+            // Pacific/Apia skipped all of 2011-12-30; atZone advances to Dec 
31.
+            (
+                "Pacific/Apia",
+                [15338, 15339, 0, -1],
+                [1325239200000, 1325239200000, 39600000, -46800000],
+            ),
+        ] {
+            let source: ArrayRef = Arc::new(Date32Array::from_iter(
+                input.into_iter().map(Some).chain([None]),
+            ));
+            let result = cast_in_zone(
+                &source,
+                &DataType::Date(DateType::new()),
+                &ts(true, 3),
+                &TimeZone::get(name).unwrap(),
+            )
+            .unwrap()
+            .unwrap();
+            assert_eq!(
+                raw(&result),
+                expected
+                    .into_iter()
+                    .map(Some)
+                    .chain([None])
+                    .collect::<Vec<_>>(),
+                "{name}"
+            );
+        }
+        for invalid in [i32::MIN, i32::MAX] {
+            let source: ArrayRef = Arc::new(Date32Array::from(vec![invalid]));
+            assert!(cast_in_zone(
+                &source,
+                &DataType::Date(DateType::new()),
+                &ts(true, 3),
+                &TimeZone::UTC
+            )
+            .unwrap_err()
+            .to_string()
+            .contains("Invalid Java DATE"));
+        }
+    }
+
+    #[test]
+    fn local_wall_time_can_cross_the_timezone_library_timestamp_bounds() {
+        for (zone, input, expected) in [
+            (
+                "Asia/Shanghai",
+                [-377705023201000, -377705023200000],
+                [-377705052344000, -377705052343000],
+            ),
+            (
+                "America/New_York",
+                [253402207199000, 253402207200000],
+                [253402225199000, 253402225200000],
+            ),
+        ] {
+            let source: ArrayRef = 
Arc::new(TimestampMillisecondArray::from_iter(
+                input.into_iter().map(Some).chain([None]),
+            ));
+            let output = cast_in_zone(
+                &source,
+                &ts(false, 3),
+                &ts(true, 3),
+                &TimeZone::get(zone).unwrap(),
+            )
+            .unwrap()
+            .unwrap();
+            assert_eq!(
+                raw(&output),
+                expected
+                    .into_iter()
+                    .map(Some)
+                    .chain([None])
+                    .collect::<Vec<_>>()
+            );
+        }
+    }
+
+    #[test]
+    fn numeric_ltz_keeps_java_epoch_seconds_and_long_wrapping() {
+        let source: ArrayRef = Arc::new(Int64Array::from(vec![
+            Some(0),
+            Some(-1),
+            Some(i64::MAX),
+            Some(i64::MIN),
+            None,
+        ]));
+        let result = cast_in_zone(
+            &source,
+            &DataType::BigInt(crate::spec::BigIntType::new()),
+            &ts(true, 3),
+            &TimeZone::get("Asia/Shanghai").unwrap(),
+        )
+        .unwrap()
+        .unwrap();
+        assert_eq!(
+            raw(&result),
+            vec![
+                Some(28800000),
+                Some(28799000),
+                Some(28799000),
+                Some(28800000),
+                None
+            ]
+        );
+    }
+
+    #[test]
+    fn ordinary_timestamp_date_and_time_keep_java_flooring() {
+        let source: ArrayRef = Arc::new(TimestampNanosecondArray::from(vec![
+            Some(-1),
+            Some(86400000000001),
+            None,
+        ]));
+        let from = ts(false, 9);
+        let date = cast_in_zone(
+            &source,
+            &from,
+            &DataType::Date(DateType::new()),
+            &TimeZone::UTC,
+        )
+        .unwrap()
+        .unwrap();
+        assert_eq!(raw(&date), vec![Some(-1), Some(1), None]);
+        let time = cast_in_zone(
+            &source,
+            &from,
+            &DataType::Time(TimeType::new(0).unwrap()),
+            &TimeZone::UTC,
+        )
+        .unwrap()
+        .unwrap();
+        assert_eq!(raw(&time), vec![Some(86399999), Some(0), None]);
+    }
+}
diff --git a/crates/paimon/src/arrow/mod.rs b/crates/paimon/src/arrow/mod.rs
index 06f9d4f7..fbd9ac27 100644
--- a/crates/paimon/src/arrow/mod.rs
+++ b/crates/paimon/src/arrow/mod.rs
@@ -17,6 +17,7 @@
 
 mod evolution_cast;
 mod evolution_string;
+mod evolution_temporal;
 pub(crate) mod filtering;
 pub(crate) mod format;
 pub(crate) mod nested_evolution;
diff --git a/crates/paimon/src/arrow/nested_evolution.rs 
b/crates/paimon/src/arrow/nested_evolution.rs
index 2a79f6e3..8e2d3e75 100644
--- a/crates/paimon/src/arrow/nested_evolution.rs
+++ b/crates/paimon/src/arrow/nested_evolution.rs
@@ -210,9 +210,14 @@ pub(crate) fn evolve_column(
         return Ok(result);
     }
 
-    // Apply the epoch-seconds rule at every leaf, including nested containers.
-    // Numeric -> LTZ depends on Java's system time zone and retains its 
existing
-    // path; ordinary TIMESTAMP has no such runtime dependency.
+    if let Some(result) =
+        super::evolution_temporal::cast_temporal(source, source_type, 
target_type)?
+    {
+        return Ok(result);
+    }
+
+    // Apply the ordinary TIMESTAMP epoch-seconds rule at every leaf,
+    // including nested containers.
     if matches!(source_type, DataType::Int(_) | DataType::BigInt(_))
         && matches!(target_type, DataType::Timestamp(_))
     {
diff --git a/crates/paimon/src/common/date_time.rs 
b/crates/paimon/src/common/date_time.rs
index a30f5cc5..f717a1d7 100644
--- a/crates/paimon/src/common/date_time.rs
+++ b/crates/paimon/src/common/date_time.rs
@@ -15,7 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
-//! Java temporal string formatting shared by casts and partition paths.
+//! Java temporal calendars, local-zone conversions and string formatting.
 
 use chrono::{Datelike, NaiveDateTime, Timelike};
 
@@ -72,7 +72,7 @@ pub(crate) fn format_timestamp(dt: NaiveDateTime, precision: 
u32) -> String {
 /// Java Timestamp.toLocalDateTime stores its epoch day in a signed int.
 /// Use Gregorian arithmetic so that this also covers dates beyond chrono's 
range.
 pub(crate) fn format_epoch_timestamp(seconds: i64, nanos: u32, precision: u32) 
-> String {
-    let (year, month, day) = epoch_day_to_date(seconds.div_euclid(86_400) as 
i32);
+    let (year, month, day) = 
epoch_day_to_date(i64::from(seconds.div_euclid(86_400) as i32));
     let time = seconds.rem_euclid(86_400) as u32;
     format_timestamp_parts(
         (year, month, day),
@@ -82,9 +82,9 @@ pub(crate) fn format_epoch_timestamp(seconds: i64, nanos: 
u32, precision: u32) -
     )
 }
 
-fn epoch_day_to_date(day: i32) -> (i32, u32, u32) {
+pub(crate) fn epoch_day_to_date(day: i64) -> (i32, u32, u32) {
     // Gregorian eras start on March 1, so the leap day is at the end of a 
year.
-    let days = i64::from(day) + 719_468;
+    let days = day + 719_468;
     let era = days.div_euclid(146_097);
     let day_of_era = days - era * 146_097;
     let year_of_era =
@@ -132,3 +132,113 @@ fn format_timestamp_parts(
         format!("{ymdhms}.{fraction}")
     }
 }
+
+/// Java ZoneRules uses its earliest wall offset before recorded history and
+/// repeats its final transition rules on the Gregorian 400-year cycle.
+pub(crate) fn local_offset(seconds: i64, zone: &jiff::tz::TimeZone) -> i32 {
+    let timestamp = jiff::Timestamp::from_second(seconds).unwrap_or_else(|_| {
+        if seconds < 0 {
+            jiff::Timestamp::MIN
+        } else {
+            
jiff::Timestamp::from_second(project_future_seconds(seconds)).unwrap()
+        }
+    });
+    zone.to_offset(timestamp).seconds()
+}
+
+fn project_future_seconds(seconds: i64) -> i64 {
+    const CYCLE_SECONDS: i64 = 146_097 * 86_400;
+    seconds.rem_euclid(CYCLE_SECONDS) + CYCLE_SECONDS
+}
+
+/// LocalDateTime.atZone: earlier offset in an overlap; shift forward in a gap.
+pub(crate) fn local_to_utc_seconds(seconds: i64, zone: &jiff::tz::TimeZone) -> 
crate::Result<i64> {
+    let projected = match jiff::Timestamp::from_second(seconds) {
+        Ok(timestamp) => timestamp,
+        Err(_) if seconds < 0 => return Ok(seconds - 
i64::from(local_offset(seconds, zone))),
+        Err(_) => 
jiff::Timestamp::from_second(project_future_seconds(seconds)).unwrap(),
+    };
+    let datetime = jiff::tz::TimeZone::UTC.to_datetime(projected);
+    let utc = match zone.to_timestamp(datetime) {
+        Ok(utc) => utc,
+        Err(_) if seconds < 0 => {
+            // A valid boundary wall time can have a UTC instant below jiff's
+            // minimum. Java can still resolve it with the earliest offset.
+            return Ok(seconds - i64::from(local_offset(seconds, zone)));
+        }
+        Err(_) => {
+            // The same edge exists at the upper bound. Resolve an equivalent
+            // future calendar date before applying its offset to the wide 
value.
+            let projected = 
jiff::Timestamp::from_second(project_future_seconds(seconds)).unwrap();
+            let datetime = jiff::tz::TimeZone::UTC.to_datetime(projected);
+            let utc = zone
+                .to_timestamp(datetime)
+                .map_err(|error| crate::Error::DataInvalid {
+                    message: format!("Cannot resolve local timestamp in 
{zone:?}: {error}"),
+                    source: None,
+                })?;
+            return Ok(seconds + utc.as_second() - projected.as_second());
+        }
+    };
+    Ok(seconds + utc.as_second() - projected.as_second())
+}
+
+/// DateTimeUtils.toInternal(LocalDate), including Java int overflow and 
signed division.
+pub(crate) fn epoch_day_to_java_date(day: i64) -> i32 {
+    let (year, month, day) = epoch_day_to_date(day);
+    let a = (14 - month as i32) / 12;
+    let y = year + 4800 - a;
+    let m = month as i32 + 12 * a - 3;
+    (day as i32 + (153 * m + 2) / 5)
+        .wrapping_add(365_i32.wrapping_mul(y))
+        .wrapping_add(y / 4 - y / 100 + y / 400 - 32_045)
+        .wrapping_sub(2_440_588)
+}
+
+/// DateTimeUtils.toLocalDate uses signed int Julian arithmetic. Validate the
+/// resulting date as LocalDate.of does, then recover its proleptic epoch day.
+pub(crate) fn java_date_to_epoch_day(date: i32) -> crate::Result<i64> {
+    let j = date.wrapping_add(2_440_588).wrapping_add(32_044);
+    let g = j / 146_097;
+    let dg = j % 146_097;
+    let c = (dg / 36_524 + 1) * 3 / 4;
+    let dc = dg - c * 36_524;
+    let b = dc / 1461;
+    let db = dc % 1461;
+    let a = (db / 365 + 1) * 3 / 4;
+    let da = db - a * 365;
+    let y = g * 400 + c * 100 + b * 4 + a;
+    let m = (da * 5 + 308) / 153 - 2;
+    let d = da - (m + 4) * 153 / 5 + 122;
+    let year = y - 4800 + (m + 2) / 12;
+    let month = (m + 2) % 12 + 1;
+    let day = d + 1;
+    let leap = year % 400 == 0 || year % 4 == 0 && year % 100 != 0;
+    let days = match month {
+        2 => {
+            if leap {
+                29
+            } else {
+                28
+            }
+        }
+        4 | 6 | 9 | 11 => 30,
+        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
+        _ => 0,
+    };
+    if day < 1 || day > days {
+        return Err(crate::Error::DataInvalid {
+            message: format!("Invalid Java DATE conversion: 
{year}-{month}-{day}"),
+            source: None,
+        });
+    }
+    let year = i64::from(year) - i64::from(month <= 2);
+    let era = year.div_euclid(400);
+    let yoe = year - era * 400;
+    let march = i64::from(month) + if month > 2 { -3 } else { 9 };
+    Ok(
+        era * 146_097 + yoe * 365 + yoe / 4 - yoe / 100 + (153 * march + 2) / 
5 + i64::from(day)
+            - 1
+            - 719_468,
+    )
+}

Reply via email to