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