lox-time 0.1.0-alpha.22

Time modelling tools for the Lox ecosystem
Documentation
// SPDX-FileCopyrightText: 2024 Angus Morrison <github@angus-morrison.com>
// SPDX-FileCopyrightText: 2024 Helge Eichhorn <git@helgeeichhorn.de>
//
// SPDX-License-Identifier: MPL-2.0

/*!
    Module `utc` exposes [Utc], a leap-second aware representation for UTC datetimes.

    Due to the complexity inherent in working with leap seconds, it is intentionally segregated
    from the continuous time formats, and is used exclusively as an input format to Lox.
*/

use std::fmt::{self, Display, Formatter};
use std::str::FromStr;

use itertools::Itertools;
use lox_core::i64::consts::{SECONDS_PER_DAY, SECONDS_PER_HALF_DAY};
use lox_test_utils::approx_eq::{ApproxEq, ApproxEqResults};
use thiserror::Error;

use crate::calendar_dates::{CalendarDate, Date, DateError};
use crate::deltas::{TimeDelta, ToDelta};
use crate::julian_dates::{self, Epoch, JulianDate};
use crate::time_of_day::{CivilTime, TimeOfDay, TimeOfDayError};
use crate::utc::leap_seconds::{DefaultLeapSecondsProvider, LeapSecondsProvider};

/// Leap second tables and provider trait.
pub mod leap_seconds;
/// Transformations between UTC and continuous time scales.
pub mod transformations;

/// Error type returned when attempting to construct a [Utc] instance from invalid inputs.
#[derive(Debug, Clone, Error, PartialEq, Eq)]
pub enum UtcError {
    /// Invalid date component.
    #[error(transparent)]
    DateError(#[from] DateError),
    /// Invalid time-of-day component.
    #[error(transparent)]
    TimeError(#[from] TimeOfDayError),
    /// Second 60 was specified on a date without a leap second.
    #[error("no leap second on {0}")]
    NonLeapSecondDate(Date),
    /// The UTC datetime could not be constructed (e.g. from a non-finite delta).
    #[error("unable to construct UTC datetime")]
    UtcUndefined,
    /// The input string is not valid ISO 8601.
    #[error("invalid ISO string `{0}`")]
    InvalidIsoString(String),
}

/// Coordinated Universal Time.
#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, PartialOrd, Ord)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Utc {
    date: Date,
    time: TimeOfDay,
}

impl Utc {
    /// Creates a new [Utc] instance from the given [Date] and [TimeOfDay], with leap second
    /// validation provided by the [LeapSecondsProvider].
    ///
    /// # Errors
    ///
    /// - [UtcError::NonLeapSecondDate] if `time.seconds` is 60 seconds and the date is not a leap
    ///   second date.
    pub fn new(
        date: Date,
        time: TimeOfDay,
        provider: &impl LeapSecondsProvider,
    ) -> Result<Self, UtcError> {
        if time.second() == 60 && !provider.is_leap_second_date(date) {
            return Err(UtcError::NonLeapSecondDate(date));
        }
        Ok(Self { date, time })
    }

    /// Returns a new [UtcBuilder].
    pub fn builder() -> UtcBuilder {
        UtcBuilder::default()
    }

    /// Constructs a new [Utc] instance from the given ISO 8601 string, with leap second validation
    /// provided by the [LeapSecondsProvider].
    ///
    /// # Errors
    ///
    /// - [UtcError::InvalidIsoString] if the input string is not a valid ISO 8601 string.
    /// - [UtcError::DateError] if the date component of the string is invalid.
    /// - [UtcError::TimeError] if the time component of the string is invalid.
    /// - [UtcError::NonLeapSecondDate] if the time component is 60 seconds and the date is not a
    ///   leap second date.
    pub fn from_iso_with_provider<T: LeapSecondsProvider>(
        iso: &str,
        provider: &T,
    ) -> Result<Self, UtcError> {
        let _ = iso.strip_suffix('Z');

        let Some((date, time_and_scale)) = iso.split_once('T') else {
            return Err(UtcError::InvalidIsoString(iso.to_owned()));
        };

        let (time, scale_abbrv) = time_and_scale
            .split_whitespace()
            .collect_tuple()
            .unwrap_or((time_and_scale, ""));

        if !scale_abbrv.is_empty() && scale_abbrv != "UTC" {
            return Err(UtcError::InvalidIsoString(iso.to_owned()));
        }

        let date: Date = date.parse()?;
        let time: TimeOfDay = time.parse()?;

        Utc::new(date, time, provider)
    }

    /// Constructs a new [Utc] instance from the given ISO 8601 string, with leap second validation
    /// provided by [BuiltinLeapSeconds].
    pub fn from_iso(iso: &str) -> Result<Self, UtcError> {
        Self::from_iso_with_provider(iso, &DefaultLeapSecondsProvider)
    }

    /// Constructs a new [Utc] instance from a [TimeDelta] relative to J2000.
    ///
    /// Note that this constructor is not leap-second aware.
    pub fn from_delta(delta: TimeDelta) -> Result<Self, UtcError> {
        let (seconds, subsecond) = delta
            .as_seconds_and_subsecond()
            .ok_or(UtcError::UtcUndefined)?;
        let date = Date::from_seconds_since_j2000(seconds);
        let time = TimeOfDay::from_seconds_since_j2000(seconds).with_subsecond(subsecond);
        Ok(Self { date, time })
    }
}

impl ToDelta for Utc {
    fn to_delta(&self) -> TimeDelta {
        let seconds = self.date.j2000_day_number() * SECONDS_PER_DAY + self.time.second_of_day()
            - SECONDS_PER_HALF_DAY;
        TimeDelta::from_seconds_and_subsecond(seconds, self.time.subsecond())
    }
}

impl Display for Utc {
    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
        let precision = f.precision().unwrap_or(3);
        write!(f, "{}T{:.*} UTC", self.date(), precision, self.time())
    }
}

impl FromStr for Utc {
    type Err = UtcError;

    fn from_str(iso: &str) -> Result<Self, Self::Err> {
        Self::from_iso(iso)
    }
}

impl CalendarDate for Utc {
    fn date(&self) -> Date {
        self.date
    }
}

impl CivilTime for Utc {
    fn time(&self) -> TimeOfDay {
        self.time
    }
}

impl JulianDate for Utc {
    fn julian_date(&self, epoch: Epoch, unit: julian_dates::Unit) -> f64 {
        self.to_delta().julian_date(epoch, unit)
    }
}

impl ApproxEq for Utc {
    fn approx_eq(&self, rhs: &Self, atol: f64, rtol: f64) -> ApproxEqResults {
        self.to_delta().approx_eq(&rhs.to_delta(), atol, rtol)
    }
}

/// A builder for constructing [Utc] instances piecewise.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UtcBuilder {
    date: Result<Date, DateError>,
    time: Result<TimeOfDay, TimeOfDayError>,
}

impl Default for UtcBuilder {
    /// Returns a new [UtcBuilder] at 2000-01-01T00:00:00.000 UTC.
    fn default() -> Self {
        Self {
            date: Ok(Date::default()),
            time: Ok(TimeOfDay::default()),
        }
    }
}

impl UtcBuilder {
    /// Sets the year, month and day fields of the [Utc] instance being built.
    pub fn with_ymd(self, year: i64, month: u8, day: u8) -> Self {
        Self {
            date: Date::new(year, month, day),
            ..self
        }
    }

    /// Sets the hour, minute, second and subsecond fields of the [Utc] instance being built.
    pub fn with_hms(self, hour: u8, minute: u8, seconds: f64) -> Self {
        Self {
            time: TimeOfDay::from_hms(hour, minute, seconds),
            ..self
        }
    }

    /// Constructs the [Utc] instance with leap second validation provided by the given
    /// [LeapSecondsProvider].
    pub fn build_with_provider(self, provider: &impl LeapSecondsProvider) -> Result<Utc, UtcError> {
        let date = self.date?;
        let time = self.time?;
        Utc::new(date, time, provider)
    }

    /// Constructs the [Utc] instance with leap second validation provided by [BuiltinLeapSeconds].
    pub fn build(self) -> Result<Utc, UtcError> {
        self.build_with_provider(&DefaultLeapSecondsProvider)
    }
}

/// The `utc` macro simplifies the creation of [Utc] instances.
///
/// # Examples
///
/// ```rust
/// use lox_time::utc;
/// use lox_time::utc::Utc;
///
/// utc!(2000, 1, 2); // 2000-01-02T00:00:00.000 UTC
/// utc!(2000, 1, 2, 3); // 2000-01-01T03:00:00.000 UTC
/// utc!(2000, 1, 2, 3, 4); // 2000-01-01T03:04:00.000 UTC
/// utc!(2000, 1, 2, 3, 4, 5.6); // 2000-01-01T03:04:05.600 UTC
/// ```
#[macro_export]
macro_rules! utc {
    ($year:literal, $month:literal, $day:literal) => {
        $crate::utc::Utc::builder()
            .with_ymd($year, $month, $day)
            .build()
    };
    ($year:literal, $month:literal, $day:literal, $hour:literal) => {
        $crate::utc::Utc::builder()
            .with_ymd($year, $month, $day)
            .with_hms($hour, 0, 0.0)
            .build()
    };
    ($year:literal, $month:literal, $day:literal, $hour:literal, $minute:literal) => {
        $crate::utc::Utc::builder()
            .with_ymd($year, $month, $day)
            .with_hms($hour, $minute, 0.0)
            .build()
    };
    ($year:literal, $month:literal, $day:literal, $hour:literal, $minute:literal, $second:literal) => {
        $crate::utc::Utc::builder()
            .with_ymd($year, $month, $day)
            .with_hms($hour, $minute, $second)
            .build()
    };
}

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

    #[test]
    fn test_utc_display() {
        let utc = Utc::default();
        let expected = "2000-01-01T00:00:00.000 UTC".to_string();
        let actual = utc.to_string();
        assert_eq!(expected, actual);
        let expected = "2000-01-01T00:00:00.000000000000000 UTC".to_string();
        let actual = format!("{utc:.15}");
        assert_eq!(expected, actual);
    }

    #[rstest]
    #[case(utc!(2000, 1, 1), Utc::builder().with_ymd(2000, 1, 1).build())]
    #[case(utc!(2000, 1, 1, 12), Utc::builder().with_ymd(2000, 1, 1).with_hms(12, 0, 0.0).build())]
    #[case(utc!(2000, 1, 1, 12, 13), Utc::builder().with_ymd(2000, 1, 1).with_hms(12, 13, 0.0).build())]
    #[case(utc!(2000, 1, 1, 12, 13, 14.15), Utc::builder().with_ymd(2000, 1, 1).with_hms(12, 13, 14.15).build())]
    fn test_utc_macro(
        #[case] actual: Result<Utc, UtcError>,
        #[case] expected: Result<Utc, UtcError>,
    ) {
        assert_eq!(actual, expected)
    }

    #[test]
    fn test_utc_non_leap_second_date() {
        let actual = Utc::builder()
            .with_ymd(2000, 1, 1)
            .with_hms(23, 59, 60.0)
            .build();
        let expected = Err(UtcError::NonLeapSecondDate(Date::new(2000, 1, 1).unwrap()));
        assert_eq!(actual, expected)
    }

    #[test]
    fn test_utc_before_1960() {
        let actual = Utc::builder().with_ymd(1959, 12, 31).build();
        assert!(actual.is_ok());
    }

    #[test]
    fn test_utc_builder_with_provider() {
        let exp = utc!(2000, 1, 1).unwrap();
        let act = Utc::builder()
            .with_ymd(2000, 1, 1)
            .build_with_provider(&DefaultLeapSecondsProvider)
            .unwrap();
        assert_eq!(exp, act)
    }

    #[rstest]
    #[case("2000-01-01T00:00:00", Ok(utc!(2000, 1, 1).unwrap()))]
    #[case("2000-01-01T00:00:00 UTC", Ok(utc!(2000, 1, 1).unwrap()))]
    #[case("2000-01-01T00:00:00.000Z", Ok(utc!(2000, 1, 1).unwrap()))]
    #[case("2000-1-01T00:00:00", Err(UtcError::DateError(DateError::InvalidIsoString("2000-1-01".to_string()))))]
    #[case("2000-01-01T0:00:00", Err(UtcError::TimeError(TimeOfDayError::InvalidIsoString("0:00:00".to_string()))))]
    #[case("2000-01-01-00:00:00", Err(UtcError::InvalidIsoString("2000-01-01-00:00:00".to_string())))]
    #[case("2000-01-01T00:00:00 TAI", Err(UtcError::InvalidIsoString("2000-01-01T00:00:00 TAI".to_string())))]
    fn test_utc_from_str(#[case] iso: &str, #[case] expected: Result<Utc, UtcError>) {
        let actual: Result<Utc, UtcError> = iso.parse();
        assert_eq!(actual, expected)
    }
}