Skip to main content

lys_core/
clock.rs

1//! Instance-owned UTC time for creation and expiry decisions.
2
3use std::sync::Arc;
4use std::time::{Duration, SystemTime, UNIX_EPOCH};
5
6use chrono::{DateTime, Utc};
7
8/// The UTC instant a provider supplies without requiring consumers to name its dependency.
9pub type UtcInstant = DateTime<Utc>;
10
11/// A failure to obtain or represent a clock reading.
12#[derive(Debug, thiserror::Error)]
13#[non_exhaustive]
14pub enum ClockError {
15    /// The provider could not supply a reading.
16    #[error("clock unavailable: {reason}")]
17    Unavailable {
18        /// The provider's reason for refusing the read.
19        reason: String,
20    },
21    /// The instant cannot be represented by the receiving time type.
22    #[error("clock unavailable: instant is outside the supported time range")]
23    InstantOutOfRange {
24        /// The original integer conversion failure, when one produced the refusal.
25        #[source]
26        source: Option<std::num::TryFromIntError>,
27    },
28}
29
30/// A UTC clock supplied when an owner is constructed.
31pub trait Clock: std::fmt::Debug + Send + Sync {
32    /// Returns this instance's current UTC instant.
33    ///
34    /// # Errors
35    ///
36    /// Returns a named clock failure when a reading cannot be supplied.
37    fn now(&self) -> Result<DateTime<Utc>, ClockError>;
38
39    /// Returns this instance's current whole unsigned Unix second.
40    ///
41    /// # Errors
42    ///
43    /// Propagates the provider's failure and refuses pre-epoch instants.
44    fn unix_seconds(&self) -> Result<u64, ClockError> {
45        unix_seconds(self.now()?)
46    }
47}
48
49/// The explicit clock choice owned by a constructed service or command.
50///
51/// The production variant requires no provider allocation. Supplied providers
52/// are moved into their owner and are never replaced after a read failure.
53#[derive(Debug, Default)]
54pub enum ClockSource {
55    /// Read the operating system clock.
56    #[default]
57    System,
58    /// Read the provider supplied by the owner at construction.
59    Supplied(Arc<dyn Clock>),
60}
61
62impl Clock for ClockSource {
63    fn now(&self) -> Result<DateTime<Utc>, ClockError> {
64        match self {
65            Self::System => SystemClock.now(),
66            Self::Supplied(clock) => clock.now(),
67        }
68    }
69}
70
71/// The production clock, read directly from the operating system.
72#[derive(Debug, Clone, Copy, Default)]
73pub struct SystemClock;
74
75impl Clock for SystemClock {
76    fn now(&self) -> Result<DateTime<Utc>, ClockError> {
77        match SystemTime::now().duration_since(UNIX_EPOCH) {
78            Ok(duration) => utc_from_duration(duration, false),
79            Err(error) => utc_from_duration(error.duration(), true),
80        }
81    }
82}
83
84/// Converts a UTC instant to the existing whole unsigned Unix seconds.
85///
86/// # Errors
87///
88/// Returns [`ClockError::InstantOutOfRange`] for a pre-epoch instant.
89pub fn unix_seconds(instant: DateTime<Utc>) -> Result<u64, ClockError> {
90    u64::try_from(instant.timestamp()).map_err(|source| ClockError::InstantOutOfRange {
91        source: Some(source),
92    })
93}
94
95fn utc_from_duration(duration: Duration, before_epoch: bool) -> Result<DateTime<Utc>, ClockError> {
96    let seconds =
97        i64::try_from(duration.as_secs()).map_err(|source| ClockError::InstantOutOfRange {
98            source: Some(source),
99        })?;
100    let nanos = duration.subsec_nanos();
101    let (seconds, nanos) = if before_epoch {
102        let seconds = seconds
103            .checked_neg()
104            .ok_or(ClockError::InstantOutOfRange { source: None })?;
105        if nanos == 0 {
106            (seconds, 0)
107        } else {
108            (
109                seconds
110                    .checked_sub(1)
111                    .ok_or(ClockError::InstantOutOfRange { source: None })?,
112                1_000_000_000 - nanos,
113            )
114        }
115    } else {
116        (seconds, nanos)
117    };
118    DateTime::from_timestamp(seconds, nanos).ok_or(ClockError::InstantOutOfRange { source: None })
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn system_conversion_preserves_epoch_and_fractional_seconds() {
127        assert_eq!(
128            utc_from_duration(Duration::ZERO, false)
129                .unwrap()
130                .timestamp(),
131            0
132        );
133        let instant = utc_from_duration(Duration::new(7, 123_456_789), false).unwrap();
134        assert_eq!(instant.timestamp(), 7);
135        assert_eq!(instant.timestamp_subsec_nanos(), 123_456_789);
136    }
137
138    #[test]
139    fn system_conversion_preserves_negative_fractional_seconds() {
140        let instant = utc_from_duration(Duration::new(7, 123_456_789), true).unwrap();
141        assert_eq!(instant.timestamp(), -8);
142        assert_eq!(instant.timestamp_subsec_nanos(), 876_543_211);
143        assert_eq!(
144            utc_from_duration(Duration::from_secs(7), true)
145                .unwrap()
146                .timestamp(),
147            -7
148        );
149        assert!(matches!(
150            unix_seconds(instant),
151            Err(ClockError::InstantOutOfRange { .. })
152        ));
153        let error = unix_seconds(instant).unwrap_err();
154        assert!(
155            std::error::Error::source(&error)
156                .is_some_and(<dyn std::error::Error + 'static>::is::<std::num::TryFromIntError>)
157        );
158    }
159
160    #[test]
161    fn system_conversion_refuses_unrepresentable_seconds_without_zero() {
162        for before_epoch in [false, true] {
163            assert!(matches!(
164                utc_from_duration(Duration::MAX, before_epoch),
165                Err(ClockError::InstantOutOfRange { .. })
166            ));
167            let error = utc_from_duration(Duration::MAX, before_epoch).unwrap_err();
168            assert!(
169                std::error::Error::source(&error).is_some_and(
170                    <dyn std::error::Error + 'static>::is::<std::num::TryFromIntError>
171                )
172            );
173            assert!(matches!(
174                utc_from_duration(Duration::from_secs(i64::MAX.unsigned_abs()), before_epoch),
175                Err(ClockError::InstantOutOfRange { .. })
176            ));
177        }
178    }
179}