icu_time 2.2.0

Processing of dates, times, and time zones with a focus on i18n and interop
Documentation
// This file is part of ICU4X. For terms of use, please see the file
// called LICENSE at the top level of the ICU4X source tree
// (online at: https://github.com/unicode-org/icu4x/blob/main/LICENSE ).

use core::fmt;

use icu_calendar::types::RataDie;
use icu_calendar::{AsCalendar, Iso};
use zerovec::ule::AsULE;

use crate::Time;
use crate::{zone::UtcOffset, DateTime, ZonedDateTime};

/// The moment in time for resolving a time zone name.
///
/// **What is this for?** Most software deals with _time zone transitions_,
/// computing the UTC offset on a given point in time. In ICU4X, we deal with
/// _time zone display names_. Whereas time zone transitions occur multiple
/// times per year in some time zones, the set of display names changes more
/// rarely. For example, ICU4X needs to know when a region switches from
/// Eastern Time to Central Time.
///
/// This type can only represent display name changes after 1970, and only to
/// a coarse (15-minute) granularity, which is sufficient for CLDR and TZDB
/// data within that time frame.
///
/// # Examples
///
/// The region of Metlakatla (Alaska) used to be on Pacific Time but is now
/// on Alaska Time.
///
/// ```
/// use icu::calendar::Iso;
/// use icu::datetime::fieldsets::zone::GenericLong;
/// use icu::datetime::NoCalendarFormatter;
/// use icu::locale::locale;
/// use icu::time::zone::TimeZone;
/// use icu::time::zone::ZoneNameTimestamp;
/// use icu::time::ZonedDateTime;
/// use writeable::assert_writeable_eq;
///
/// let metlakatla = TimeZone::from_iana_id("America/Metlakatla");
///
/// let zone_formatter =
///     NoCalendarFormatter::try_new(locale!("en-US").into(), GenericLong)
///         .unwrap();
///
/// let time_zone_info_past = metlakatla
///     .without_offset()
///     .with_zone_name_timestamp(ZoneNameTimestamp::far_in_past());
/// let time_zone_info_future = metlakatla
///     .without_offset()
///     .with_zone_name_timestamp(ZoneNameTimestamp::far_in_future());
///
/// // Check the display names:
/// let name_past = zone_formatter.format(&time_zone_info_past);
/// let name_future = zone_formatter.format(&time_zone_info_future);
///
/// assert_writeable_eq!(name_past, "Pacific Time");
/// assert_writeable_eq!(name_future, "Alaska Time");
/// ```
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct ZoneNameTimestamp(u32);

const RD_EPOCH: RataDie = calendrical_calculations::gregorian::fixed_from_gregorian(1970, 1, 1);

impl ZoneNameTimestamp {
    /// Recovers the UTC datetime for this [`ZoneNameTimestamp`].
    ///
    /// This will always return a [`ZonedDateTime`] with [`UtcOffset::zero()`]
    ///
    /// # Examples
    ///
    /// [`ZonedDateTime`] does _not_ necessarily roundtrip:
    ///
    /// ```
    /// use icu::calendar::Date;
    /// use icu::time::zone::ZoneNameTimestamp;
    /// use icu::time::{ZonedDateTime, Time, zone::UtcOffset};
    ///
    /// let zoned_date_time = ZonedDateTime {
    ///     date: Date::try_new_iso(2025, 4, 30).unwrap(),
    ///     time: Time::try_new(13, 58, 16, 500000000).unwrap(),
    ///     zone: UtcOffset::zero(),
    /// };
    ///
    /// let zone_name_timestamp = ZoneNameTimestamp::from_zoned_date_time(zoned_date_time);
    ///
    /// let recovered_zoned_date_time = zone_name_timestamp.to_zoned_date_time_iso();
    ///
    /// // The datetime doesn't roundtrip:
    /// assert_ne!(zoned_date_time, recovered_zoned_date_time);
    ///
    /// // The exact behavior is subject to change. For illustration only:
    /// assert_eq!(recovered_zoned_date_time.date, zoned_date_time.date);
    /// assert_eq!(recovered_zoned_date_time.time.hour, zoned_date_time.time.hour);
    /// assert_eq!(recovered_zoned_date_time.time.minute.number(), 45); // rounded down
    /// assert_eq!(recovered_zoned_date_time.time.second.number(), 0); // always zero
    /// assert_eq!(recovered_zoned_date_time.time.subsecond.number(), 0); // always zero
    /// ```
    pub fn to_zoned_date_time_iso(self) -> ZonedDateTime<Iso, UtcOffset> {
        ZonedDateTime::from_epoch_milliseconds_and_utc_offset(
            self.epoch_seconds() * 1000,
            UtcOffset::zero(),
        )
    }

    /// Creates an instance of [`ZoneNameTimestamp`] from a [`ZonedDateTime`] with an explicit [`UtcOffset`].
    pub fn from_zoned_date_time<C: AsCalendar>(
        zoned_date_time: ZonedDateTime<C, UtcOffset>,
    ) -> Self {
        Self::from_rd_time_zone(
            zoned_date_time.date.to_rata_die(),
            zoned_date_time.time,
            zoned_date_time.zone,
        )
    }

    /// Use [`Self::from_zoned_date_time`].
    #[deprecated(since = "2.2.0", note = "use `Self::from_zoned_date_time`")]
    pub fn from_zoned_date_time_iso(zoned_date_time: ZonedDateTime<Iso, UtcOffset>) -> Self {
        Self::from_zoned_date_time(zoned_date_time)
    }

    pub(crate) fn from_rd_time_zone(rd: RataDie, time: Time, zone: UtcOffset) -> Self {
        Self::from_epoch_seconds(
            (rd - RD_EPOCH) * 24 * 60 * 60 + time.seconds_since_midnight() as i64
                - zone.to_seconds() as i64,
        )
    }

    /// Creates an instance of [`ZoneNameTimestamp`] from a number of seconds since the UNIX epoch.
    pub fn from_epoch_seconds(seconds: i64) -> Self {
        let seconds = match seconds {
            // Values that are not multiples of 15, that we map to the next multiple
            // of 15 (which is always 00:15 or 00:45, values that are otherwise unused).
            63593070..63593100 => 63593100,
            307622400..307622700 => 307622700,
            576041460..576042300 => 576042300,
            576043260..576044100 => 576044100,
            594180060..594180900 => 594180900,
            607491060..607491900 => 607491900,
            1601740860..1601741700 => 1601741700,
            1633190460..1633191300 => 1633191300,
            1664640060..1664640900 => 1664640900,
            s => s,
        };
        let qh = seconds / 60 / 15;
        let qh_clamped = qh.clamp(Self::far_in_past().0 as i64, Self::far_in_future().0 as i64);
        // Valid cast as the value is clamped to u32 values.
        Self(qh_clamped as u32)
    }

    /// Returns the *approximate* number of seconds since the UNIX epoch represented by this
    /// timestamp.
    ///
    /// As this type is only used for time zone name resolution, it does not store a
    /// full-precision timestamp internally.
    fn epoch_seconds(self) -> i64 {
        match self.0 as i64 * 15 * 60 {
            // See `from_epoch_seconds`
            63593100 => 63593070,
            307622700 => 307622400,
            576042300 => 576041460,
            576044100 => 576043260,
            594180900 => 594180060,
            607491900 => 607491060,
            1601741700 => 1601740860,
            1633191300 => 1633190460,
            1664640900 => 1664640060,
            ms => ms,
        }
    }

    /// Recovers the UTC datetime for this [`ZoneNameTimestamp`].
    #[deprecated(
        since = "2.1.0",
        note = "returns a UTC DateTime, which is the wrong type. Use `to_zoned_date_time_iso` instead"
    )]
    pub fn to_date_time_iso(self) -> DateTime<Iso> {
        let ZonedDateTime {
            date,
            time,
            zone: _utc_offset_zero,
        } = self.to_zoned_date_time_iso();
        DateTime { date, time }
    }

    /// Creates an instance of [`ZoneNameTimestamp`] from a UTC datetime.
    ///
    /// The datetime might be clamped and might lose precision.
    #[deprecated(
        since = "2.1.0",
        note = "implicitly interprets the DateTime as UTC. Use `from_zoned_date_time_iso` instead."
    )]
    pub fn from_date_time_iso(DateTime { date, time }: DateTime<Iso>) -> Self {
        Self::from_zoned_date_time(ZonedDateTime {
            date,
            time,
            zone: UtcOffset::zero(),
        })
    }

    /// Returns a [`ZoneNameTimestamp`] for a time far in the past.
    pub fn far_in_past() -> Self {
        Self(0)
    }

    /// Returns a [`ZoneNameTimestamp`] for a time far in the future.
    pub fn far_in_future() -> Self {
        Self(0xFFFFFF)
    }
}

impl fmt::Debug for ZoneNameTimestamp {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "~{:?}", self.to_zoned_date_time_iso())
    }
}

impl AsULE for ZoneNameTimestamp {
    type ULE = <u32 as AsULE>::ULE;
    #[inline]
    fn to_unaligned(self) -> Self::ULE {
        self.0.to_unaligned()
    }
    #[inline]
    fn from_unaligned(unaligned: Self::ULE) -> Self {
        Self(u32::from_unaligned(unaligned))
    }
}

#[cfg(feature = "alloc")]
impl<'a> zerovec::maps::ZeroMapKV<'a> for ZoneNameTimestamp {
    type Container = zerovec::ZeroVec<'a, Self>;
    type Slice = zerovec::ZeroSlice<Self>;
    type GetType = <Self as AsULE>::ULE;
    type OwnedType = Self;
}

#[cfg(feature = "serde")]
impl serde::Serialize for ZoneNameTimestamp {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: serde::Serializer,
    {
        #[cfg(feature = "alloc")]
        if serializer.is_human_readable() {
            let date_time = self.to_zoned_date_time_iso();
            let year = date_time.date.era_year().year;
            let month = date_time.date.month().number();
            let day = date_time.date.day_of_month().0;
            let hour = date_time.time.hour.number();
            let minute = date_time.time.minute.number();
            let second = date_time.time.second.number();
            let mut s = alloc::format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}");
            if second != 0 {
                use core::fmt::Write;
                let _infallible = write!(&mut s, ":{second:02}");
            }
            // don't serialize the metadata for now
            return serializer.serialize_str(&s);
        }
        serializer.serialize_u32(self.0)
    }
}

#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for ZoneNameTimestamp {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        #[cfg(feature = "alloc")]
        if deserializer.is_human_readable() {
            use serde::de::Error;
            let e0 = D::Error::custom("invalid");
            let e1 = |_| D::Error::custom("invalid");
            let e2 = |_| D::Error::custom("invalid");
            let e3 = |_| D::Error::custom("invalid");

            let parts = alloc::borrow::Cow::<'de, str>::deserialize(deserializer)?;
            if parts.len() != 16 {
                return Err(e0);
            }
            let year = parts[0..4].parse::<i32>().map_err(e1)?;
            let month = parts[5..7].parse::<u8>().map_err(e1)?;
            let day = parts[8..10].parse::<u8>().map_err(e1)?;
            let hour = parts[11..13].parse::<u8>().map_err(e1)?;
            let minute = parts[14..16].parse::<u8>().map_err(e1)?;
            return Ok(Self::from_zoned_date_time(ZonedDateTime {
                date: icu_calendar::Date::try_new_iso(year, month, day).map_err(e2)?,
                time: Time::try_new(hour, minute, 0, 0).map_err(e3)?,
                zone: UtcOffset::zero(),
            }));
        }
        u32::deserialize(deserializer).map(Self)
    }
}

#[cfg(test)]
mod test {
    use super::*;

    #[test]
    fn test_packing() {
        #[derive(Debug)]
        struct TestCase {
            input: &'static str,
            output: &'static str,
        }
        for test_case in [
            // Behavior at the epoch
            TestCase {
                input: "1970-01-01T00:00Z",
                output: "1970-01-01T00:00Z",
            },
            TestCase {
                input: "1970-01-01T00:01Z",
                output: "1970-01-01T00:00Z",
            },
            TestCase {
                input: "1970-01-01T00:15Z",
                output: "1970-01-01T00:15Z",
            },
            TestCase {
                input: "1970-01-01T00:29Z",
                output: "1970-01-01T00:15Z",
            },
            // Min Value Clamping
            TestCase {
                input: "1969-12-31T23:59Z",
                output: "1970-01-01T00:00Z",
            },
            TestCase {
                input: "1969-12-31T12:00Z",
                output: "1970-01-01T00:00Z",
            },
            TestCase {
                input: "1900-07-15T12:34Z",
                output: "1970-01-01T00:00Z",
            },
            // Max Value Clamping
            TestCase {
                input: "2448-06-25T15:45Z",
                output: "2448-06-25T15:45Z",
            },
            TestCase {
                input: "2448-06-25T16:00Z",
                output: "2448-06-25T15:45Z",
            },
            TestCase {
                input: "2448-06-26T00:00Z",
                output: "2448-06-25T15:45Z",
            },
            TestCase {
                input: "2500-01-01T00:00Z",
                output: "2448-06-25T15:45Z",
            },
            // Offset adjusments
            TestCase {
                input: "2025-10-10T10:15+02",
                output: "2025-10-10T08:15Z",
            },
            // Other cases
            TestCase {
                input: "2025-04-30T15:18:25Z",
                output: "2025-04-30T15:15Z",
            },
        ] {
            let znt = ZoneNameTimestamp::from_zoned_date_time(
                ZonedDateTime::try_offset_only_from_str(test_case.input, Iso).unwrap(),
            );
            let actual = znt.to_zoned_date_time_iso();
            assert_eq!(
                ZonedDateTime::try_offset_only_from_str(test_case.output, Iso).unwrap(),
                actual,
                "{test_case:?}"
            );
        }
    }
}