Skip to main content

lox_time/
utc.rs

1// SPDX-FileCopyrightText: 2024 Angus Morrison <github@angus-morrison.com>
2// SPDX-FileCopyrightText: 2024 Helge Eichhorn <git@helgeeichhorn.de>
3//
4// SPDX-License-Identifier: MPL-2.0
5
6/*!
7    Module `utc` exposes [Utc], a leap-second aware representation for UTC datetimes.
8
9    Due to the complexity inherent in working with leap seconds, it is intentionally segregated
10    from the continuous time formats, and is used exclusively as an input format to Lox.
11*/
12
13use std::fmt::{self, Display, Formatter};
14use std::str::FromStr;
15
16use itertools::Itertools;
17use lox_core::i64::consts::{SECONDS_PER_DAY, SECONDS_PER_HALF_DAY};
18use lox_test_utils::approx_eq::{ApproxEq, ApproxEqResults};
19use thiserror::Error;
20
21use crate::calendar_dates::{CalendarDate, Date, DateError};
22use crate::deltas::{TimeDelta, ToDelta};
23use crate::julian_dates::{self, Epoch, JulianDate};
24use crate::time_of_day::{CivilTime, TimeOfDay, TimeOfDayError};
25use crate::utc::leap_seconds::{DefaultLeapSecondsProvider, LeapSecondsProvider};
26
27pub mod leap_seconds;
28pub mod transformations;
29
30/// Error type returned when attempting to construct a [Utc] instance from invalid inputs.
31#[derive(Debug, Clone, Error, PartialEq, Eq)]
32pub enum UtcError {
33    #[error(transparent)]
34    DateError(#[from] DateError),
35    #[error(transparent)]
36    TimeError(#[from] TimeOfDayError),
37    #[error("no leap second on {0}")]
38    NonLeapSecondDate(Date),
39    #[error("unable to construct UTC datetime")]
40    UtcUndefined,
41    #[error("invalid ISO string `{0}`")]
42    InvalidIsoString(String),
43}
44
45/// Coordinated Universal Time.
46#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, PartialOrd, Ord)]
47#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
48pub struct Utc {
49    date: Date,
50    time: TimeOfDay,
51}
52
53impl Utc {
54    /// Creates a new [Utc] instance from the given [Date] and [TimeOfDay], with leap second
55    /// validation provided by the [LeapSecondsProvider].
56    ///
57    /// # Errors
58    ///
59    /// - [UtcError::NonLeapSecondDate] if `time.seconds` is 60 seconds and the date is not a leap
60    ///   second date.
61    pub fn new(
62        date: Date,
63        time: TimeOfDay,
64        provider: &impl LeapSecondsProvider,
65    ) -> Result<Self, UtcError> {
66        if time.second() == 60 && !provider.is_leap_second_date(date) {
67            return Err(UtcError::NonLeapSecondDate(date));
68        }
69        Ok(Self { date, time })
70    }
71
72    /// Returns a new [UtcBuilder].
73    pub fn builder() -> UtcBuilder {
74        UtcBuilder::default()
75    }
76
77    /// Constructs a new [Utc] instance from the given ISO 8601 string, with leap second validation
78    /// provided by the [LeapSecondsProvider].
79    ///
80    /// # Errors
81    ///
82    /// - [UtcError::InvalidIsoString] if the input string is not a valid ISO 8601 string.
83    /// - [UtcError::DateError] if the date component of the string is invalid.
84    /// - [UtcError::TimeError] if the time component of the string is invalid.
85    /// - [UtcError::NonLeapSecondDate] if the time component is 60 seconds and the date is not a
86    ///   leap second date.
87    pub fn from_iso_with_provider<T: LeapSecondsProvider>(
88        iso: &str,
89        provider: &T,
90    ) -> Result<Self, UtcError> {
91        let _ = iso.strip_suffix('Z');
92
93        let Some((date, time_and_scale)) = iso.split_once('T') else {
94            return Err(UtcError::InvalidIsoString(iso.to_owned()));
95        };
96
97        let (time, scale_abbrv) = time_and_scale
98            .split_whitespace()
99            .collect_tuple()
100            .unwrap_or((time_and_scale, ""));
101
102        if !scale_abbrv.is_empty() && scale_abbrv != "UTC" {
103            return Err(UtcError::InvalidIsoString(iso.to_owned()));
104        }
105
106        let date: Date = date.parse()?;
107        let time: TimeOfDay = time.parse()?;
108
109        Utc::new(date, time, provider)
110    }
111
112    /// Constructs a new [Utc] instance from the given ISO 8601 string, with leap second validation
113    /// provided by [BuiltinLeapSeconds].
114    pub fn from_iso(iso: &str) -> Result<Self, UtcError> {
115        Self::from_iso_with_provider(iso, &DefaultLeapSecondsProvider)
116    }
117
118    /// Constructs a new [Utc] instance from a [TimeDelta] relative to J2000.
119    ///
120    /// Note that this constructor is not leap-second aware.
121    pub fn from_delta(delta: TimeDelta) -> Result<Self, UtcError> {
122        let (seconds, subsecond) = delta
123            .as_seconds_and_subsecond()
124            .ok_or(UtcError::UtcUndefined)?;
125        let date = Date::from_seconds_since_j2000(seconds);
126        let time = TimeOfDay::from_seconds_since_j2000(seconds).with_subsecond(subsecond);
127        Ok(Self { date, time })
128    }
129}
130
131impl ToDelta for Utc {
132    fn to_delta(&self) -> TimeDelta {
133        let seconds = self.date.j2000_day_number() * SECONDS_PER_DAY + self.time.second_of_day()
134            - SECONDS_PER_HALF_DAY;
135        TimeDelta::from_seconds_and_subsecond(seconds, self.time.subsecond())
136    }
137}
138
139impl Display for Utc {
140    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
141        let precision = f.precision().unwrap_or(3);
142        write!(f, "{}T{:.*} UTC", self.date(), precision, self.time())
143    }
144}
145
146impl FromStr for Utc {
147    type Err = UtcError;
148
149    fn from_str(iso: &str) -> Result<Self, Self::Err> {
150        Self::from_iso(iso)
151    }
152}
153
154impl CalendarDate for Utc {
155    fn date(&self) -> Date {
156        self.date
157    }
158}
159
160impl CivilTime for Utc {
161    fn time(&self) -> TimeOfDay {
162        self.time
163    }
164}
165
166impl JulianDate for Utc {
167    fn julian_date(&self, epoch: Epoch, unit: julian_dates::Unit) -> f64 {
168        self.to_delta().julian_date(epoch, unit)
169    }
170}
171
172impl ApproxEq for Utc {
173    fn approx_eq(&self, rhs: &Self, atol: f64, rtol: f64) -> ApproxEqResults {
174        self.to_delta().approx_eq(&rhs.to_delta(), atol, rtol)
175    }
176}
177
178/// A builder for constructing [Utc] instances piecewise.
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct UtcBuilder {
181    date: Result<Date, DateError>,
182    time: Result<TimeOfDay, TimeOfDayError>,
183}
184
185impl Default for UtcBuilder {
186    /// Returns a new [UtcBuilder] at 2000-01-01T00:00:00.000 UTC.
187    fn default() -> Self {
188        Self {
189            date: Ok(Date::default()),
190            time: Ok(TimeOfDay::default()),
191        }
192    }
193}
194
195impl UtcBuilder {
196    /// Sets the year, month and day fields of the [Utc] instance being built.
197    pub fn with_ymd(self, year: i64, month: u8, day: u8) -> Self {
198        Self {
199            date: Date::new(year, month, day),
200            ..self
201        }
202    }
203
204    /// Sets the hour, minute, second and subsecond fields of the [Utc] instance being built.
205    pub fn with_hms(self, hour: u8, minute: u8, seconds: f64) -> Self {
206        Self {
207            time: TimeOfDay::from_hms(hour, minute, seconds),
208            ..self
209        }
210    }
211
212    /// Constructs the [Utc] instance with leap second validation provided by the given
213    /// [LeapSecondsProvider].
214    pub fn build_with_provider(self, provider: &impl LeapSecondsProvider) -> Result<Utc, UtcError> {
215        let date = self.date?;
216        let time = self.time?;
217        Utc::new(date, time, provider)
218    }
219
220    /// Constructs the [Utc] instance with leap second validation provided by [BuiltinLeapSeconds].
221    pub fn build(self) -> Result<Utc, UtcError> {
222        self.build_with_provider(&DefaultLeapSecondsProvider)
223    }
224}
225
226/// The `utc` macro simplifies the creation of [Utc] instances.
227///
228/// # Examples
229///
230/// ```rust
231/// use lox_time::utc;
232/// use lox_time::utc::Utc;
233///
234/// utc!(2000, 1, 2); // 2000-01-02T00:00:00.000 UTC
235/// utc!(2000, 1, 2, 3); // 2000-01-01T03:00:00.000 UTC
236/// utc!(2000, 1, 2, 3, 4); // 2000-01-01T03:04:00.000 UTC
237/// utc!(2000, 1, 2, 3, 4, 5.6); // 2000-01-01T03:04:05.600 UTC
238/// ```
239#[macro_export]
240macro_rules! utc {
241    ($year:literal, $month:literal, $day:literal) => {
242        Utc::builder().with_ymd($year, $month, $day).build()
243    };
244    ($year:literal, $month:literal, $day:literal, $hour:literal) => {
245        Utc::builder()
246            .with_ymd($year, $month, $day)
247            .with_hms($hour, 0, 0.0)
248            .build()
249    };
250    ($year:literal, $month:literal, $day:literal, $hour:literal, $minute:literal) => {
251        Utc::builder()
252            .with_ymd($year, $month, $day)
253            .with_hms($hour, $minute, 0.0)
254            .build()
255    };
256    ($year:literal, $month:literal, $day:literal, $hour:literal, $minute:literal, $second:literal) => {
257        Utc::builder()
258            .with_ymd($year, $month, $day)
259            .with_hms($hour, $minute, $second)
260            .build()
261    };
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267    use rstest::rstest;
268
269    #[test]
270    fn test_utc_display() {
271        let utc = Utc::default();
272        let expected = "2000-01-01T00:00:00.000 UTC".to_string();
273        let actual = utc.to_string();
274        assert_eq!(expected, actual);
275        let expected = "2000-01-01T00:00:00.000000000000000 UTC".to_string();
276        let actual = format!("{utc:.15}");
277        assert_eq!(expected, actual);
278    }
279
280    #[rstest]
281    #[case(utc!(2000, 1, 1), Utc::builder().with_ymd(2000, 1, 1).build())]
282    #[case(utc!(2000, 1, 1, 12), Utc::builder().with_ymd(2000, 1, 1).with_hms(12, 0, 0.0).build())]
283    #[case(utc!(2000, 1, 1, 12, 13), Utc::builder().with_ymd(2000, 1, 1).with_hms(12, 13, 0.0).build())]
284    #[case(utc!(2000, 1, 1, 12, 13, 14.15), Utc::builder().with_ymd(2000, 1, 1).with_hms(12, 13, 14.15).build())]
285    fn test_utc_macro(
286        #[case] actual: Result<Utc, UtcError>,
287        #[case] expected: Result<Utc, UtcError>,
288    ) {
289        assert_eq!(actual, expected)
290    }
291
292    #[test]
293    fn test_utc_non_leap_second_date() {
294        let actual = Utc::builder()
295            .with_ymd(2000, 1, 1)
296            .with_hms(23, 59, 60.0)
297            .build();
298        let expected = Err(UtcError::NonLeapSecondDate(Date::new(2000, 1, 1).unwrap()));
299        assert_eq!(actual, expected)
300    }
301
302    #[test]
303    fn test_utc_before_1960() {
304        let actual = Utc::builder().with_ymd(1959, 12, 31).build();
305        assert!(actual.is_ok());
306    }
307
308    #[test]
309    fn test_utc_builder_with_provider() {
310        let exp = utc!(2000, 1, 1).unwrap();
311        let act = Utc::builder()
312            .with_ymd(2000, 1, 1)
313            .build_with_provider(&DefaultLeapSecondsProvider)
314            .unwrap();
315        assert_eq!(exp, act)
316    }
317
318    #[rstest]
319    #[case("2000-01-01T00:00:00", Ok(utc!(2000, 1, 1).unwrap()))]
320    #[case("2000-01-01T00:00:00 UTC", Ok(utc!(2000, 1, 1).unwrap()))]
321    #[case("2000-01-01T00:00:00.000Z", Ok(utc!(2000, 1, 1).unwrap()))]
322    #[case("2000-1-01T00:00:00", Err(UtcError::DateError(DateError::InvalidIsoString("2000-1-01".to_string()))))]
323    #[case("2000-01-01T0:00:00", Err(UtcError::TimeError(TimeOfDayError::InvalidIsoString("0:00:00".to_string()))))]
324    #[case("2000-01-01-00:00:00", Err(UtcError::InvalidIsoString("2000-01-01-00:00:00".to_string())))]
325    #[case("2000-01-01T00:00:00 TAI", Err(UtcError::InvalidIsoString("2000-01-01T00:00:00 TAI".to_string())))]
326    fn test_utc_from_str(#[case] iso: &str, #[case] expected: Result<Utc, UtcError>) {
327        let actual: Result<Utc, UtcError> = iso.parse();
328        assert_eq!(actual, expected)
329    }
330}