kine_icu/
lib.rs

1//! Kine calendar for icu4x
2#![doc = include_str!("../README.md")]
3#![warn(missing_docs)]
4
5use std::fmt::{self, Debug};
6
7use icu_calendar::types::{IsoSecond, NanoSecond};
8use kine_core::{Calendar, CalendarTime, OffsetTime, TimeResult, TimeZone};
9
10/// Re-export of `icu_calendar` calendars
11pub mod cal {
12    pub use icu_calendar;
13    pub use icu_calendar::{
14        buddhist::Buddhist,
15        coptic::Coptic,
16        ethiopian::Ethiopian,
17        indian::Indian,
18        japanese::{Japanese, JapaneseExtended},
19        julian::Julian,
20        Gregorian, Iso,
21    };
22}
23
24const NANOS_IN_SECS: i128 = 1_000_000_000;
25const NANOS_IN_SECS_U64: u64 = 1_000_000_000;
26const NANOS_IN_MINS: i128 = 60 * NANOS_IN_SECS;
27
28/// A calendar based on icu4x
29pub struct Cal<Ca: icu_calendar::AsCalendar, Tz: TimeZone> {
30    cal: Ca,
31    tz: Tz,
32}
33
34/// A time represented with an icu4x calendar and any kine timezone
35///
36/// Note that the `Debug` implementation of this trait shows ISO 8601; but you
37/// should use the methods from `icu4x` to display the time in a proper format
38/// for your user.
39#[derive(Clone, Eq, PartialEq)]
40pub struct Time<Ca: icu_calendar::AsCalendar, Tz: TimeZone> {
41    tz: Tz::Sigil,
42    time: icu_calendar::DateTime<Ca>,
43}
44
45impl<Ca: icu_calendar::AsCalendar, Tz: TimeZone> Cal<Ca, Tz> {
46    /// Create a calendar with the given settings
47    ///
48    /// This allows creating a `kine` calendar that deals with time in the `tz` timezone, and
49    /// whose days are in the `cal` calendar.
50    pub fn new(cal: Ca, tz: Tz) -> Self {
51        Self { cal, tz }
52    }
53}
54
55impl<Ca: icu_calendar::AsCalendar, Tz: TimeZone> Time<Ca, Tz> {
56    /// Create a `Time` from known sigils and datetimes
57    ///
58    /// Usually you will not need this, and should just use the `.read` and `.write` methods.
59    pub fn new(tz: Tz::Sigil, time: icu_calendar::DateTime<Ca>) -> Self {
60        Self { tz, time }
61    }
62
63    /// Retrieve the timezone associated with this time
64    pub fn tz(&self) -> &Tz::Sigil {
65        &self.tz
66    }
67
68    /// Retrieve the `icu_calendar::DateTime` associated with this time
69    pub fn icu(&self) -> &icu_calendar::DateTime<Ca> {
70        &self.time
71    }
72}
73
74impl<Ca, Tz> Calendar for Cal<Ca, Tz>
75where
76    Ca: Clone + icu_calendar::AsCalendar,
77    Tz: Clone + TimeZone,
78    <Tz as TimeZone>::Sigil: Clone,
79{
80    type Time = Time<Ca, Tz>;
81
82    fn write(&self, t: &kine_core::Time) -> kine_core::Result<Self::Time> {
83        // Convert the time to local time
84        let offset_time = t.write(self.tz.clone())?;
85
86        // Compute the number of seconds, nanoseconds and leap-second-nanoseconds
87        let pseudo_nanos = offset_time.as_pseudo_nanos_since_posix_epoch();
88        let extra_nanos = i128::from(offset_time.extra_nanos());
89        let (minutes, submin_pseudo_nanos) =
90            num_integer::div_mod_floor(pseudo_nanos, NANOS_IN_MINS);
91        let (seconds, nanos) =
92            num_integer::div_mod_floor(submin_pseudo_nanos + extra_nanos, NANOS_IN_SECS);
93
94        // Build the result
95        let minutes = i32::try_from(minutes).map_err(|_| kine_core::Error::OutOfRange)?;
96        let mut res = icu_calendar::DateTime::from_minutes_since_local_unix_epoch(minutes);
97        res.time.second = IsoSecond::try_from(u8::try_from(seconds).unwrap()).unwrap();
98        res.time.nanosecond = NanoSecond::try_from(u32::try_from(nanos).unwrap()).unwrap();
99
100        Ok(Time {
101            tz: offset_time.sigil().clone(),
102            time: res.to_calendar(self.cal.clone()),
103        })
104    }
105}
106
107impl<Ca, Tz> CalendarTime for Time<Ca, Tz>
108where
109    Ca: icu_calendar::AsCalendar,
110    Tz: TimeZone,
111    <Tz as TimeZone>::Sigil: Clone,
112{
113    fn read(&self) -> kine_core::Result<TimeResult> {
114        let time = self.time.to_calendar(icu_calendar::Iso);
115        let local_mins = i128::from(time.minutes_since_local_unix_epoch());
116        // TODO: revisit after https://github.com/unicode-org/icu4x/issues/3085 answered
117        let seconds_with_leap = time.time.second.number();
118        let seconds_without_leap = std::cmp::min(seconds_with_leap, 59);
119        let nanos_outside_leap = match seconds_with_leap > 59 {
120            true => NANOS_IN_SECS - 1,
121            false => i128::from(time.time.nanosecond.number()),
122        };
123        let extra_nanos = match seconds_with_leap > 59 {
124            true => {
125                u64::from(time.time.nanosecond.number())
126                    + u64::from(seconds_with_leap - 60) * NANOS_IN_SECS_U64
127                    + 1
128            }
129            false => 0,
130        };
131        let local_nanos = local_mins * NANOS_IN_MINS
132            + i128::from(seconds_without_leap) * NANOS_IN_SECS
133            + nanos_outside_leap;
134        let offset_time = OffsetTime::from_pseudo_nanos_since_posix_epoch(
135            self.tz.clone(),
136            local_nanos,
137            extra_nanos,
138        );
139        offset_time.read()
140    }
141}
142
143impl<Tz: TimeZone> Debug for Time<icu_calendar::Iso, Tz> {
144    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145        let date = &self.time.date;
146        let time = &self.time.time;
147        write!(
148            f,
149            "{}-{:02}-{:02}T{:02}:{:02}:{:02}.{:09}{}",
150            date.year().number,
151            date.month().ordinal,
152            date.day_of_month().0,
153            time.hour.number(),
154            time.minute.number(),
155            time.second.number(),
156            time.nanosecond.number(),
157            self.tz,
158        )
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use icu_calendar::{
165        types::{IsoSecond, NanoSecond},
166        Iso,
167    };
168    use kine_core::{
169        tz::{Utc, UtcSigil},
170        Calendar, CalendarTime, Duration, TimeResult,
171    };
172
173    use crate::{Cal, Time, NANOS_IN_MINS, NANOS_IN_SECS};
174
175    // icu4x works based on i32 minutes from epoch
176    const MIN_NANOS: i128 = -(i32::MIN as i128 * NANOS_IN_MINS);
177    const MAX_NANOS: i128 = -(i32::MAX as i128 * NANOS_IN_MINS);
178
179    const NANOS_IN_SECS_U32: u32 = 1_000_000_000;
180
181    fn mktime(nanos: i128) -> kine_core::Time {
182        kine_core::Time::POSIX_EPOCH + Duration::from_nanos(nanos)
183    }
184
185    #[test]
186    fn negative_time_writes_correctly() {
187        let time = mktime(-NANOS_IN_MINS);
188        let written = time.write(Cal::new(Iso, Utc.clone()));
189        let expected =
190            icu_calendar::DateTime::try_new_iso_datetime(1969, 12, 31, 23, 59, 10).unwrap();
191        assert_eq!(written, Ok(Time::new(UtcSigil, expected)));
192    }
193
194    #[test]
195    fn leap_second_reads_correctly() {
196        // Normal behavior with one second
197        let mut time: Time<Iso, Utc> = Time::new(
198            UtcSigil,
199            icu_calendar::DateTime::try_new_iso_datetime(1969, 12, 31, 23, 59, 60).unwrap(),
200        );
201        assert_eq!(
202            time.read(),
203            Ok(TimeResult::One(mktime(-10 * NANOS_IN_SECS)))
204        );
205        time.time.time.nanosecond = NanoSecond::try_from(NANOS_IN_SECS_U32 / 2).unwrap();
206        assert_eq!(
207            time.read(),
208            Ok(TimeResult::One(mktime(-19 * NANOS_IN_SECS / 2)))
209        );
210
211        // Love the 10 seconds at posix epoch
212        time.time.time.second = IsoSecond::try_from(61_u8).unwrap();
213        assert_eq!(
214            time.read(),
215            Ok(TimeResult::One(mktime(-17 * NANOS_IN_SECS / 2)))
216        );
217        time.time.time.second = IsoSecond::try_from(65_u8).unwrap();
218        assert_eq!(
219            time.read(),
220            Ok(TimeResult::One(mktime(-9 * NANOS_IN_SECS / 2)))
221        );
222        time.time.time.second = IsoSecond::try_from(69_u8).unwrap();
223        assert_eq!(time.read(), Ok(TimeResult::One(mktime(-NANOS_IN_SECS / 2))));
224    }
225
226    #[test]
227    fn negative_time_reads_correctly() {
228        let time: Time<Iso, Utc> = Time::new(
229            UtcSigil,
230            icu_calendar::DateTime::try_new_iso_datetime(1969, 12, 31, 23, 59, 10).unwrap(),
231        );
232        let read = time.read();
233        let expected = mktime(-NANOS_IN_MINS);
234        assert_eq!(read, Ok(TimeResult::One(expected)));
235    }
236
237    #[test]
238    fn iso_conversion_round_trip() {
239        bolero::check!().with_type::<i128>().for_each(|&t| {
240            let assert_out_of_range = |t| {
241                assert!(
242                    t < MIN_NANOS || t > MAX_NANOS,
243                    "Returned out of range for time {t} that is not close to the ends of the range"
244                )
245            };
246            let time = kine_core::Time::POSIX_EPOCH + Duration::from_nanos(t);
247            let cal = Cal::new(Iso, Utc.clone());
248            let formatted = match cal.write(&time) {
249                Err(kine_core::Error::OutOfRange) => {
250                    assert_out_of_range(t);
251                    return;
252                }
253                Ok(t) => t,
254            };
255            let time_bis = match formatted.read() {
256                Err(kine_core::Error::OutOfRange) => {
257                    assert_out_of_range(t);
258                    return;
259                }
260                Ok(TimeResult::One(t)) => t,
261                Ok(t) => panic!(
262                    "Converting formatted ISO time to time did not return exactly one result: {t:?}"
263                ),
264            };
265            assert_eq!(
266                time, time_bis,
267                "Round-tripping through formatted ISO time lost information"
268            );
269        })
270    }
271}