icu_time/zone/
zone_name_timestamp.rs

1// This file is part of ICU4X. For terms of use, please see the file
2// called LICENSE at the top level of the ICU4X source tree
3// (online at: https://github.com/unicode-org/icu4x/blob/main/LICENSE ).
4
5use core::fmt;
6
7use icu_calendar::{types::RataDie, Date, Iso};
8use zerovec::{maps::ZeroMapKV, ule::AsULE, ZeroSlice, ZeroVec};
9
10/// The epoch for time zone names. This is set to 1970-01-01 since the TZDB often drops data before then.
11const ZONE_NAME_EPOCH: RataDie = calendrical_calculations::iso::const_fixed_from_iso(1970, 1, 1);
12const QUARTER_HOURS_IN_DAY_I64: i64 = 24 * 4;
13const QUARTER_HOURS_IN_DAY_U32: u32 = 24 * 4;
14const MIN_QUARTER_HOURS_I64: i64 = 0;
15const MIN_QUARTER_HOURS_U32: u32 = 0;
16const MAX_QUARTER_HOURS_I64: i64 = 0xFFFFFF;
17const MAX_QUARTER_HOURS_U32: u32 = 0xFFFFFF;
18
19use crate::{DateTime, Hour, Minute, Nanosecond, Second, Time};
20
21/// Internal intermediate type for interfacing with [`ZoneNameTimestamp`].
22#[derive(Debug, Copy, Clone)]
23struct ZoneNameTimestampParts {
24    /// Invariant: between MIN_QUARTER_HOURS_U32 and MAX_QUARTER_HOURS_U32 (inclusive).
25    /// This range covers almost 500 years.
26    quarter_hours_since_local_unix_epoch: u32,
27    /// Currently the metadata is unused. It is reserved for future use, such as:
28    /// - A time zone UTC offset
29    /// - Extra bits for the epoch quarter-hours
30    /// - Bitmask to use the epoch quarter hour bits more efficiently
31    metadata: u8,
32}
33
34impl ZoneNameTimestampParts {
35    /// Recovers the DateTime from these parts.
36    fn date_time(self) -> DateTime<Iso> {
37        let qh = self.quarter_hours_since_local_unix_epoch;
38        // Note: the `as` casts below are trivially safe because the remainder is in range
39        let (days, remainder) = (
40            (qh / QUARTER_HOURS_IN_DAY_U32) as i64,
41            (qh % QUARTER_HOURS_IN_DAY_U32) as u8,
42        );
43        let (hours, minutes) = (remainder / 4, (remainder % 4) * 15);
44        DateTime {
45            date: Date::from_rata_die(ZONE_NAME_EPOCH + days, Iso),
46            time: Time {
47                hour: Hour::try_from(hours).unwrap_or_else(|_| {
48                    debug_assert!(false, "ZoneNameTimestampParts: out of range: {self:?}");
49                    Hour::zero()
50                }),
51                minute: Minute::try_from(minutes).unwrap_or_else(|_| {
52                    debug_assert!(false, "ZoneNameTimestampParts: out of range: {self:?}");
53                    Minute::zero()
54                }),
55                second: Second::zero(),
56                subsecond: Nanosecond::zero(),
57            },
58        }
59    }
60
61    /// Creates an instance of this type with all invariants upheld.
62    fn from_saturating_date_time_with_metadata(date_time: DateTime<Iso>, metadata: u8) -> Self {
63        // Note: RataDie should be in range for this multiplication.
64        let qh_days = (date_time.date.to_rata_die() - ZONE_NAME_EPOCH) * QUARTER_HOURS_IN_DAY_I64;
65        // Note: Hour is 0 to 23 in a u8 so it should be in range for this multiplication.
66        let qh_hours = date_time.time.hour.number() * 4;
67        let qh_minutes = date_time.time.minute.number() / 15;
68        let qh_total = qh_days + (qh_hours as i64) + (qh_minutes as i64);
69        let qh_clamped = qh_total.clamp(MIN_QUARTER_HOURS_I64, MAX_QUARTER_HOURS_I64);
70        let qh_u32 = match u32::try_from(qh_clamped) {
71            Ok(x) => x,
72            Err(_) => {
73                debug_assert!(
74                    false,
75                    "ZoneNameTimestampParts: saturation invariants not upheld: {date_time:?}"
76                );
77                0
78            }
79        };
80        ZoneNameTimestampParts {
81            quarter_hours_since_local_unix_epoch: qh_u32,
82            metadata,
83        }
84    }
85}
86
87/// The moment in time for resolving a time zone name.
88///
89/// **What is this for?** Most software deals with _time zone transitions_,
90/// computing the UTC offset on a given point in time. In ICU4X, we deal with
91/// _time zone display names_. Whereas time zone transitions occur multiple
92/// times per year in some time zones, the set of display names changes more
93/// rarely. For example, ICU4X needs to know when a region switches from
94/// Eastern Time to Central Time.
95///
96/// This type can only represent display name changes after 1970, and only to
97/// a coarse (15-minute) granularity, which is sufficient for CLDR and TZDB
98/// data within that time frame.
99///
100/// # Examples
101///
102/// The region of Metlakatla (Alaska) switched between Pacific Time and
103/// Alaska Time multiple times between 2010 and 2025.
104///
105/// ```
106/// use icu::time::zone::IanaParser;
107/// use icu::time::zone::ZoneNameTimestamp;
108/// use icu::datetime::NoCalendarFormatter;
109/// use icu::datetime::fieldsets::zone::GenericLong;
110/// use icu::locale::locale;
111/// use writeable::assert_writeable_eq;
112///
113/// let metlakatla = IanaParser::new().parse("America/Metlakatla");
114///
115/// let zone_formatter = NoCalendarFormatter::try_new(
116///     locale!("en-US").into(),
117///     GenericLong,
118/// )
119/// .unwrap();
120///
121/// let time_zone_info_2010 = metlakatla.without_offset().at_date_time_iso("2010-01-01T00:00".parse().unwrap());
122/// let time_zone_info_2025 = metlakatla.without_offset().at_date_time_iso("2025-01-01T00:00".parse().unwrap());
123///
124/// // TimeZoneInfo::at_date_time_iso and ZoneNameTimestamp::from_date_time_iso are equivalent:
125/// assert_eq!(
126///     time_zone_info_2010.zone_name_timestamp(),
127///     ZoneNameTimestamp::from_date_time_iso("2010-01-01T00:00".parse().unwrap())
128/// );
129/// assert_eq!(
130///     time_zone_info_2025.zone_name_timestamp(),
131///     ZoneNameTimestamp::from_date_time_iso("2025-01-01T00:00".parse().unwrap())
132/// );
133///
134/// // Check the display names:
135/// let name_2010 = zone_formatter.format(&time_zone_info_2010);
136/// let name_2025 = zone_formatter.format(&time_zone_info_2025);
137///
138/// assert_writeable_eq!(name_2010, "Pacific Time");
139/// assert_writeable_eq!(name_2025, "Alaska Time");
140/// ```
141#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
142pub struct ZoneNameTimestamp(u32);
143
144impl ZoneNameTimestamp {
145    /// Recovers the local datetime for this [`ZoneNameTimestamp`].
146    ///
147    /// For more information, see [`Self::from_date_time_iso()`].
148    pub fn to_date_time_iso(self) -> DateTime<Iso> {
149        let parts = self.to_parts();
150        parts.date_time()
151    }
152
153    /// Creates an instance of [`ZoneNameTimestamp`] from a local datetime.
154    ///
155    /// The datetime might be clamped and might lose precision.
156    ///
157    /// Note: Currently, this type cannot represent ambiguous times in the
158    /// period after a time zone transition. For example, if a "fall back"
159    /// time zone transition occurs at 02:00, then the times 01:00-02:00
160    /// occur twice. To ensure that you get the correct time zone display
161    /// name _after_ a transition, you can pick any time later in the day.
162    ///
163    /// # Examples
164    ///
165    /// DateTime does _not_ necessarily roundtrip:
166    ///
167    /// ```
168    /// use icu::calendar::Date;
169    /// use icu::time::zone::ZoneNameTimestamp;
170    /// use icu::time::{DateTime, Time};
171    ///
172    /// let date_time = DateTime {
173    ///     date: Date::try_new_iso(2025, 4, 30).unwrap(),
174    ///     time: Time::try_new(13, 58, 16, 500000000).unwrap(),
175    /// };
176    ///
177    /// let zone_name_timestamp = ZoneNameTimestamp::from_date_time_iso(date_time);
178    ///
179    /// let recovered_date_time = zone_name_timestamp.to_date_time_iso();
180    ///
181    /// // The datetime doesn't roundtrip:
182    /// assert_ne!(date_time, recovered_date_time);
183    ///
184    /// // The exact behavior is subject to change. For illustration only:
185    /// assert_eq!(recovered_date_time.date, date_time.date);
186    /// assert_eq!(recovered_date_time.time.hour, date_time.time.hour);
187    /// assert_eq!(recovered_date_time.time.minute.number(), 45); // rounded down
188    /// assert_eq!(recovered_date_time.time.second.number(), 0); // always zero
189    /// assert_eq!(recovered_date_time.time.subsecond.number(), 0); // always zero
190    /// ```
191    pub fn from_date_time_iso(date_time: DateTime<Iso>) -> Self {
192        let metadata = 0; // currently unused (reserved)
193        let parts =
194            ZoneNameTimestampParts::from_saturating_date_time_with_metadata(date_time, metadata);
195        Self::from_parts(parts)
196    }
197
198    /// Returns a [`ZoneNameTimestamp`] for a time far in the past.
199    pub fn far_in_past() -> Self {
200        Self::from_parts(ZoneNameTimestampParts {
201            quarter_hours_since_local_unix_epoch: MIN_QUARTER_HOURS_U32,
202            metadata: 0, // currently unused (reserved)
203        })
204    }
205
206    /// Returns a [`ZoneNameTimestamp`] for a time far in the future.
207    pub fn far_in_future() -> Self {
208        Self::from_parts(ZoneNameTimestampParts {
209            quarter_hours_since_local_unix_epoch: MAX_QUARTER_HOURS_U32,
210            metadata: 0, // currently unused (reserved)
211        })
212    }
213
214    fn to_parts(self) -> ZoneNameTimestampParts {
215        let metadata = ((self.0 & 0xFF000000) >> 24) as u8;
216        let qh_recovered = self.0 & 0x00FFFFFF;
217        ZoneNameTimestampParts {
218            quarter_hours_since_local_unix_epoch: qh_recovered,
219            metadata,
220        }
221    }
222
223    fn from_parts(parts: ZoneNameTimestampParts) -> Self {
224        let metadata_shifted = (parts.metadata as u32) << 24;
225        debug_assert!(parts.quarter_hours_since_local_unix_epoch <= 0x00FFFFFF);
226        let qh_masked = parts.quarter_hours_since_local_unix_epoch & 0x00FFFFFF;
227        Self(metadata_shifted | qh_masked)
228    }
229}
230
231impl fmt::Debug for ZoneNameTimestamp {
232    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
233        let parts = self.to_parts();
234        f.debug_struct("ZoneNameTimestamp")
235            .field("date_time", &parts.date_time())
236            .field("metadata", &parts.metadata)
237            .finish()
238    }
239}
240
241impl AsULE for ZoneNameTimestamp {
242    type ULE = <u32 as AsULE>::ULE;
243    #[inline]
244    fn to_unaligned(self) -> Self::ULE {
245        self.0.to_unaligned()
246    }
247    #[inline]
248    fn from_unaligned(unaligned: Self::ULE) -> Self {
249        Self(u32::from_unaligned(unaligned))
250    }
251}
252
253impl<'a> ZeroMapKV<'a> for ZoneNameTimestamp {
254    type Container = ZeroVec<'a, Self>;
255    type Slice = ZeroSlice<Self>;
256    type GetType = <Self as AsULE>::ULE;
257    type OwnedType = Self;
258}
259
260#[cfg(feature = "serde")]
261impl serde::Serialize for ZoneNameTimestamp {
262    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
263    where
264        S: serde::Serializer,
265    {
266        #[cfg(feature = "alloc")]
267        if serializer.is_human_readable() {
268            let date_time = self.to_date_time_iso();
269            let year = date_time.date.extended_year();
270            let month = date_time.date.month().month_number();
271            let day = date_time.date.day_of_month().0;
272            let hour = date_time.time.hour.number();
273            let minute = date_time.time.minute.number();
274            // don't serialize the metadata for now
275            return serializer.serialize_str(&alloc::format!(
276                "{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}"
277            ));
278        }
279        serializer.serialize_u32(self.0)
280    }
281}
282
283#[cfg(feature = "serde")]
284impl<'de> serde::Deserialize<'de> for ZoneNameTimestamp {
285    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
286    where
287        D: serde::Deserializer<'de>,
288    {
289        #[cfg(feature = "alloc")]
290        if deserializer.is_human_readable() {
291            use serde::de::Error;
292            let e0 = D::Error::custom("invalid");
293            let e1 = |_| D::Error::custom("invalid");
294            let e2 = |_| D::Error::custom("invalid");
295            let e3 = |_| D::Error::custom("invalid");
296
297            let parts = alloc::borrow::Cow::<'de, str>::deserialize(deserializer)?;
298            if parts.len() != 16 {
299                return Err(e0);
300            }
301            let year = parts[0..4].parse::<i32>().map_err(e1)?;
302            let month = parts[5..7].parse::<u8>().map_err(e1)?;
303            let day = parts[8..10].parse::<u8>().map_err(e1)?;
304            let hour = parts[11..13].parse::<u8>().map_err(e1)?;
305            let minute = parts[14..16].parse::<u8>().map_err(e1)?;
306            return Ok(Self::from_date_time_iso(DateTime {
307                date: Date::try_new_iso(year, month, day).map_err(e2)?,
308                time: Time::try_new(hour, minute, 0, 0).map_err(e3)?,
309            }));
310        }
311        u32::deserialize(deserializer).map(Self)
312    }
313}
314
315#[cfg(test)]
316mod test {
317    use super::*;
318
319    #[test]
320    fn test_packing() {
321        #[derive(Debug)]
322        struct TestCase {
323            input: DateTime<Iso>,
324            output: DateTime<Iso>,
325        }
326        for test_case in [
327            // Behavior at the epoch
328            TestCase {
329                input: "1970-01-01T00:00".parse().unwrap(),
330                output: "1970-01-01T00:00".parse().unwrap(),
331            },
332            TestCase {
333                input: "1970-01-01T00:01".parse().unwrap(),
334                output: "1970-01-01T00:00".parse().unwrap(),
335            },
336            TestCase {
337                input: "1970-01-01T00:15".parse().unwrap(),
338                output: "1970-01-01T00:15".parse().unwrap(),
339            },
340            TestCase {
341                input: "1970-01-01T00:29".parse().unwrap(),
342                output: "1970-01-01T00:15".parse().unwrap(),
343            },
344            // Min Value Clamping
345            TestCase {
346                input: "1969-12-31T23:59".parse().unwrap(),
347                output: "1970-01-01T00:00".parse().unwrap(),
348            },
349            TestCase {
350                input: "1969-12-31T12:00".parse().unwrap(),
351                output: "1970-01-01T00:00".parse().unwrap(),
352            },
353            TestCase {
354                input: "1900-07-15T12:34".parse().unwrap(),
355                output: "1970-01-01T00:00".parse().unwrap(),
356            },
357            // Max Value Clamping
358            TestCase {
359                input: "2448-06-25T15:45".parse().unwrap(),
360                output: "2448-06-25T15:45".parse().unwrap(),
361            },
362            TestCase {
363                input: "2448-06-25T16:00".parse().unwrap(),
364                output: "2448-06-25T15:45".parse().unwrap(),
365            },
366            TestCase {
367                input: "2448-06-26T00:00".parse().unwrap(),
368                output: "2448-06-25T15:45".parse().unwrap(),
369            },
370            TestCase {
371                input: "2500-01-01T00:00".parse().unwrap(),
372                output: "2448-06-25T15:45".parse().unwrap(),
373            },
374            // Other cases
375            TestCase {
376                input: "2025-04-30T15:18:25".parse().unwrap(),
377                output: "2025-04-30T15:15".parse().unwrap(),
378            },
379        ] {
380            let znt = ZoneNameTimestamp::from_date_time_iso(test_case.input);
381            let actual = znt.to_date_time_iso();
382            assert_eq!(test_case.output, actual, "{test_case:?}");
383        }
384    }
385
386    #[test]
387    fn test_metadata_noop() {
388        let raw = (0x12345678u32).to_unaligned();
389        let znt = ZoneNameTimestamp::from_unaligned(raw);
390        let roundtrip_znt = ZoneNameTimestamp::from_date_time_iso(znt.to_date_time_iso());
391        let roundtrip_raw = roundtrip_znt.to_unaligned();
392
393        // [0..3] is the datetime. [3] is the metadata.
394        assert_eq!(raw.0[0..3], roundtrip_raw.0[0..3]);
395        assert_eq!(raw.0[3], 0x12);
396        assert_eq!(roundtrip_raw.0[3], 0);
397    }
398}