Skip to main content

lox_core/time/
time_of_day.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 `time_of_day` exposes the concrete representation of a time of day with leap second
8    support, [TimeOfDay].
9
10    The [CivilTime] trait supports arbitrary time representations to express themselves as a
11    human-readable time of day.
12*/
13
14use std::fmt::Display;
15use std::str::FromStr;
16use std::{cmp::Ordering, sync::OnceLock};
17
18use crate::units::Angle;
19use num::ToPrimitive;
20use regex::Regex;
21use thiserror::Error;
22
23use super::subsecond::Subsecond;
24use crate::i64::consts::{
25    SECONDS_PER_DAY, SECONDS_PER_HALF_DAY, SECONDS_PER_HOUR, SECONDS_PER_MINUTE,
26};
27
28fn iso_regex() -> &'static Regex {
29    static ISO: OnceLock<Regex> = OnceLock::new();
30    ISO.get_or_init(|| {
31        Regex::new(r"(?<hour>\d{2}):(?<minute>\d{2}):(?<second>\d{2})(?<subsecond>\.\d+)?").unwrap()
32    })
33}
34
35/// Error type returned when attempting to construct a [TimeOfDay] with a greater number of
36/// floating-point seconds than are in a day.
37#[derive(Debug, Copy, Clone, Error)]
38#[error("seconds must be in the range [0.0..86401.0) but was {0}")]
39pub struct InvalidSeconds(f64);
40
41impl PartialEq for InvalidSeconds {
42    fn eq(&self, other: &Self) -> bool {
43        self.0.total_cmp(&other.0) == Ordering::Equal
44    }
45}
46
47impl Eq for InvalidSeconds {}
48
49/// Error type returned when attempting to construct a [TimeOfDay] from invalid components.
50#[derive(Debug, Clone, Error, PartialEq, Eq)]
51pub enum TimeOfDayError {
52    /// Hour is outside the valid range `[0, 24)`.
53    #[error("hour must be in the range [0..24) but was {0}")]
54    InvalidHour(u8),
55    /// Minute is outside the valid range `[0, 60)`.
56    #[error("minute must be in the range [0..60) but was {0}")]
57    InvalidMinute(u8),
58    /// Second is outside the valid range `[0, 61)`.
59    #[error("second must be in the range [0..61) but was {0}")]
60    InvalidSecond(u8),
61    /// Second of day is outside the valid range `[0, 86401)`.
62    #[error("second must be in the range [0..86401) but was {0}")]
63    InvalidSecondOfDay(u64),
64    /// Floating-point seconds value is out of range.
65    #[error(transparent)]
66    InvalidSeconds(#[from] InvalidSeconds),
67    /// A leap second was specified at a time other than the end of the day.
68    #[error("leap seconds are only valid at the end of the day")]
69    InvalidLeapSecond,
70    /// The input string is not a valid ISO 8601 time.
71    #[error("invalid ISO string `{0}`")]
72    InvalidIsoString(String),
73}
74
75/// `CivilTime` is the trait by which high-precision time representations expose human-readable time
76/// components.
77pub trait CivilTime {
78    /// Returns the time-of-day component.
79    fn time(&self) -> TimeOfDay;
80
81    /// Returns the hour (0–23).
82    fn hour(&self) -> u8 {
83        self.time().hour()
84    }
85
86    /// Returns the minute (0–59).
87    fn minute(&self) -> u8 {
88        self.time().minute()
89    }
90
91    /// Returns the second (0–60, where 60 represents a leap second).
92    fn second(&self) -> u8 {
93        self.time().second()
94    }
95
96    /// Returns the second including the subsecond fraction as an `f64`.
97    fn as_seconds_f64(&self) -> f64 {
98        self.time().subsecond().as_seconds_f64() + self.time().second() as f64
99    }
100
101    /// Returns the millisecond component (0–999).
102    fn millisecond(&self) -> u32 {
103        self.time().subsecond().milliseconds()
104    }
105
106    /// Returns the microsecond component (0–999).
107    fn microsecond(&self) -> u32 {
108        self.time().subsecond().microseconds()
109    }
110
111    /// Returns the nanosecond component (0–999).
112    fn nanosecond(&self) -> u32 {
113        self.time().subsecond().nanoseconds()
114    }
115
116    /// Returns the picosecond component (0–999).
117    fn picosecond(&self) -> u32 {
118        self.time().subsecond().picoseconds()
119    }
120
121    /// Returns the femtosecond component (0–999).
122    fn femtosecond(&self) -> u32 {
123        self.time().subsecond().femtoseconds()
124    }
125}
126
127/// A human-readable time representation with support for representing leap seconds.
128#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, PartialOrd, Ord)]
129#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
130pub struct TimeOfDay {
131    hour: u8,
132    minute: u8,
133    second: u8,
134    subsecond: Subsecond,
135}
136
137impl TimeOfDay {
138    /// Midnight (00:00:00.000).
139    pub const MIDNIGHT: Self = TimeOfDay {
140        hour: 0,
141        minute: 0,
142        second: 0,
143        subsecond: Subsecond::ZERO,
144    };
145
146    /// Noon (12:00:00.000).
147    pub const NOON: Self = TimeOfDay {
148        hour: 12,
149        minute: 0,
150        second: 0,
151        subsecond: Subsecond::ZERO,
152    };
153    /// Constructs a new `TimeOfDay` instance from the given hour, minute, and second components.
154    ///
155    /// # Errors
156    ///
157    /// - [TimeOfDayError::InvalidHour] if `hour` is not in the range `0..24`.
158    /// - [TimeOfDayError::InvalidMinute] if `minute` is not in the range `0..60`.
159    /// - [TimeOfDayError::InvalidSecond] if `second` is not in the range `0..61`.
160    pub fn new(hour: u8, minute: u8, second: u8) -> Result<Self, TimeOfDayError> {
161        if !(0..24).contains(&hour) {
162            return Err(TimeOfDayError::InvalidHour(hour));
163        }
164        if !(0..60).contains(&minute) {
165            return Err(TimeOfDayError::InvalidMinute(minute));
166        }
167        if !(0..61).contains(&second) {
168            return Err(TimeOfDayError::InvalidSecond(second));
169        }
170        Ok(Self {
171            hour,
172            minute,
173            second,
174            subsecond: Subsecond::default(),
175        })
176    }
177
178    /// Constructs a new `TimeOfDay` instance from an ISO 8601 time string.
179    ///
180    /// # Errors
181    ///
182    /// - [TimeOfDayError::InvalidIsoString] if the input string is not a valid ISO 8601 time
183    ///   string.
184    /// - [TimeOfDayError::InvalidHour] if the hour component is not in the range `0..24`.
185    /// - [TimeOfDayError::InvalidMinute] if the minute component is not in the range `0..60`.
186    /// - [TimeOfDayError::InvalidSecond] if the second component is not in the range `0..61`.
187    pub fn from_iso(iso: &str) -> Result<Self, TimeOfDayError> {
188        let caps = iso_regex()
189            .captures(iso)
190            .ok_or(TimeOfDayError::InvalidIsoString(iso.to_owned()))?;
191        let hour: u8 = caps["hour"]
192            .parse()
193            .map_err(|_| TimeOfDayError::InvalidIsoString(iso.to_owned()))?;
194        let minute: u8 = caps["minute"]
195            .parse()
196            .map_err(|_| TimeOfDayError::InvalidIsoString(iso.to_owned()))?;
197        let second: u8 = caps["second"]
198            .parse()
199            .map_err(|_| TimeOfDayError::InvalidIsoString(iso.to_owned()))?;
200        let mut time = TimeOfDay::new(hour, minute, second)?;
201        if let Some(subsecond) = caps.name("subsecond") {
202            let subsecond_str = subsecond.as_str().trim_start_matches('.');
203            let subsecond: Subsecond = subsecond_str
204                .parse()
205                .map_err(|_| TimeOfDayError::InvalidIsoString(iso.to_owned()))?;
206            time.with_subsecond(subsecond);
207        }
208        Ok(time)
209    }
210
211    /// Constructs a `TimeOfDay` from an hour only (minute and second default to zero).
212    pub fn from_hour(hour: u8) -> Result<Self, TimeOfDayError> {
213        Self::new(hour, 0, 0)
214    }
215
216    /// Constructs a `TimeOfDay` from hour and minute (second defaults to zero).
217    pub fn from_hour_and_minute(hour: u8, minute: u8) -> Result<Self, TimeOfDayError> {
218        Self::new(hour, minute, 0)
219    }
220
221    /// Constructs a new `TimeOfDay` instance from the given hour, minute, and floating-point second
222    /// components.
223    ///
224    /// # Errors
225    ///
226    /// - [TimeOfDayError::InvalidHour] if `hour` is not in the range `0..24`.
227    /// - [TimeOfDayError::InvalidMinute] if `minute` is not in the range `0..60`.
228    /// - [TimeOfDayError::InvalidSeconds] if `seconds` is not in the range `0.0..86401.0`.
229    pub fn from_hms(hour: u8, minute: u8, seconds: f64) -> Result<Self, TimeOfDayError> {
230        if !(0.0..86401.0).contains(&seconds) {
231            return Err(TimeOfDayError::InvalidSeconds(InvalidSeconds(seconds)));
232        }
233        let second = seconds.trunc() as u8;
234        let fraction = seconds.fract();
235        let subsecond = Subsecond::from_f64(fraction)
236            .ok_or(TimeOfDayError::InvalidSeconds(InvalidSeconds(seconds)))?;
237        Ok(Self::new(hour, minute, second)?.with_subsecond(subsecond))
238    }
239
240    /// Constructs a new `TimeOfDay` instance from the given second of a day.
241    ///
242    /// # Errors
243    ///
244    /// - [TimeOfDayError::InvalidSecondOfDay] if `second_of_day` is not in the range `0..86401`.
245    pub fn from_second_of_day(second_of_day: u64) -> Result<Self, TimeOfDayError> {
246        if !(0..86401).contains(&second_of_day) {
247            return Err(TimeOfDayError::InvalidSecondOfDay(second_of_day));
248        }
249        if second_of_day == SECONDS_PER_DAY as u64 {
250            return Self::new(23, 59, 60);
251        }
252        let hour = (second_of_day / 3600) as u8;
253        let minute = ((second_of_day % 3600) / 60) as u8;
254        let second = (second_of_day % 60) as u8;
255        Self::new(hour, minute, second)
256    }
257
258    /// Constructs a new `TimeOfDay` instance from an integral number of seconds since J2000.
259    ///
260    /// Note that this constructor is not leap-second aware.
261    pub fn from_seconds_since_j2000(seconds: i64) -> Self {
262        let mut second_of_day = (seconds + SECONDS_PER_HALF_DAY) % SECONDS_PER_DAY;
263        if second_of_day.is_negative() {
264            second_of_day += SECONDS_PER_DAY;
265        }
266        Self::from_second_of_day(
267            second_of_day
268                .to_u64()
269                .unwrap_or_else(|| unreachable!("second of day should be positive")),
270        )
271        .unwrap_or_else(|_| unreachable!("second of day should be in range"))
272    }
273
274    /// Sets the [TimeOfDay]'s subsecond component.
275    pub fn with_subsecond(&mut self, subsecond: Subsecond) -> Self {
276        self.subsecond = subsecond;
277        *self
278    }
279
280    /// Returns the hour (0–23).
281    pub fn hour(&self) -> u8 {
282        self.hour
283    }
284
285    /// Returns the minute (0–59).
286    pub fn minute(&self) -> u8 {
287        self.minute
288    }
289
290    /// Returns the second (0–60, where 60 represents a leap second).
291    pub fn second(&self) -> u8 {
292        self.second
293    }
294
295    /// Returns the subsecond component.
296    pub fn subsecond(&self) -> Subsecond {
297        self.subsecond
298    }
299
300    /// Returns the second including the subsecond fraction as an `f64`.
301    pub fn seconds_f64(&self) -> f64 {
302        self.subsecond.as_seconds_f64() + self.second as f64
303    }
304
305    /// Returns the number of integral seconds since the start of the day.
306    pub fn second_of_day(&self) -> i64 {
307        self.hour as i64 * SECONDS_PER_HOUR
308            + self.minute as i64 * SECONDS_PER_MINUTE
309            + self.second as i64
310    }
311
312    /// Converts the time of day to an [`Angle`] (hour angle representation).
313    pub fn to_angle(&self) -> Angle {
314        Angle::from_hms(self.hour as i64, self.minute, self.seconds_f64())
315    }
316}
317
318impl Display for TimeOfDay {
319    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
320        let precision = f.precision().unwrap_or(3);
321        write!(
322            f,
323            "{:02}:{:02}:{:02}{}",
324            self.hour,
325            self.minute,
326            self.second,
327            format!("{:.*}", precision, self.subsecond).trim_start_matches('0')
328        )
329    }
330}
331
332impl FromStr for TimeOfDay {
333    type Err = TimeOfDayError;
334
335    fn from_str(iso: &str) -> Result<Self, Self::Err> {
336        Self::from_iso(iso)
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use rstest::rstest;
343
344    use super::*;
345
346    #[rstest]
347    #[case(43201, TimeOfDay::new(12, 0, 1))]
348    #[case(86399, TimeOfDay::new(23, 59, 59))]
349    #[case(86400, TimeOfDay::new(23, 59, 60))]
350    fn test_time_of_day_from_second_of_day(
351        #[case] second_of_day: u64,
352        #[case] expected: Result<TimeOfDay, TimeOfDayError>,
353    ) {
354        let actual = TimeOfDay::from_second_of_day(second_of_day);
355        assert_eq!(actual, expected);
356    }
357
358    #[test]
359    fn test_time_of_day_display() {
360        let subsecond: Subsecond = "123456789123456".parse().unwrap();
361        let time = TimeOfDay::new(12, 0, 0).unwrap().with_subsecond(subsecond);
362        assert_eq!(format!("{time}"), "12:00:00.123");
363        assert_eq!(format!("{time:.15}"), "12:00:00.123456789123456");
364    }
365
366    #[rstest]
367    #[case(TimeOfDay::new(24, 0, 0), Err(TimeOfDayError::InvalidHour(24)))]
368    #[case(TimeOfDay::new(0, 60, 0), Err(TimeOfDayError::InvalidMinute(60)))]
369    #[case(TimeOfDay::new(0, 0, 61), Err(TimeOfDayError::InvalidSecond(61)))]
370    #[case(
371        TimeOfDay::from_second_of_day(86401),
372        Err(TimeOfDayError::InvalidSecondOfDay(86401))
373    )]
374    #[case(TimeOfDay::from_hms(12, 0, -0.123), Err(TimeOfDayError::InvalidSeconds(InvalidSeconds(-0.123))))]
375    fn test_time_of_day_error(
376        #[case] actual: Result<TimeOfDay, TimeOfDayError>,
377        #[case] expected: Result<TimeOfDay, TimeOfDayError>,
378    ) {
379        assert_eq!(actual, expected);
380    }
381
382    #[rstest]
383    #[case("12:13:14", Ok(TimeOfDay::new(12, 13, 14).unwrap()))]
384    #[case("12:13:14.123", Ok(TimeOfDay::new(12, 13, 14).unwrap().with_subsecond("123".parse().unwrap())))]
385    #[case("2:13:14.123", Err(TimeOfDayError::InvalidIsoString("2:13:14.123".to_string())))]
386    #[case("12:3:14.123", Err(TimeOfDayError::InvalidIsoString("12:3:14.123".to_string())))]
387    #[case("12:13:4.123", Err(TimeOfDayError::InvalidIsoString("12:13:4.123".to_string())))]
388    fn test_time_of_day_from_string(
389        #[case] iso: &str,
390        #[case] expected: Result<TimeOfDay, TimeOfDayError>,
391    ) {
392        let actual: Result<TimeOfDay, TimeOfDayError> = iso.parse();
393        assert_eq!(actual, expected)
394    }
395
396    #[test]
397    fn test_invalid_seconds_eq() {
398        let a = InvalidSeconds(-f64::NAN);
399        let b = InvalidSeconds(f64::NAN);
400        // NaN values with different signs should not be equal
401        assert_ne!(a, b);
402        // Same NaN values should be equal
403        let c = InvalidSeconds(f64::NAN);
404        let d = InvalidSeconds(f64::NAN);
405        assert_eq!(c, d);
406    }
407}