Skip to main content

dnspls_core/
time.rs

1use std::{error::Error, fmt};
2
3use serde::{Deserialize, Serialize};
4
5/// Milliseconds from the Unix epoch.
6#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
7#[serde(transparent)]
8pub struct UnixMillis(i64);
9
10impl UnixMillis {
11    pub const fn new(value: i64) -> Self {
12        Self(value)
13    }
14
15    pub const fn get(self) -> i64 {
16        self.0
17    }
18
19    /// Adds a non-negative duration without saturating.
20    ///
21    /// # Errors
22    ///
23    /// Returns [`TimeError::Overflow`] if the result is not representable.
24    pub fn checked_add(self, duration: DurationMillis) -> Result<Self, TimeError> {
25        let duration = i64::try_from(duration.0).map_err(|_| TimeError::Overflow)?;
26        self.0
27            .checked_add(duration)
28            .map(Self)
29            .ok_or(TimeError::Overflow)
30    }
31}
32
33/// A non-negative millisecond duration.
34#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
35#[serde(transparent)]
36pub struct DurationMillis(u64);
37
38impl DurationMillis {
39    pub const fn new(value: u64) -> Self {
40        Self(value)
41    }
42
43    pub const fn get(self) -> u64 {
44        self.0
45    }
46}
47
48/// The validity interval attached to one observation.
49#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
50pub struct FreshWindow {
51    observed_at: UnixMillis,
52    expires_at: UnixMillis,
53}
54
55impl FreshWindow {
56    /// Builds a validity window after checking temporal ordering.
57    ///
58    /// # Errors
59    ///
60    /// Returns [`TimeError::InvertedWindow`] when expiry precedes observation.
61    pub fn new(observed_at: UnixMillis, expires_at: UnixMillis) -> Result<Self, TimeError> {
62        if expires_at < observed_at {
63            return Err(TimeError::InvertedWindow);
64        }
65        Ok(Self {
66            observed_at,
67            expires_at,
68        })
69    }
70
71    /// Builds a validity window from an observation time and duration.
72    ///
73    /// # Errors
74    ///
75    /// Returns [`TimeError::Overflow`] if expiry is not representable.
76    pub fn for_duration(
77        observed_at: UnixMillis,
78        duration: DurationMillis,
79    ) -> Result<Self, TimeError> {
80        Self::new(observed_at, observed_at.checked_add(duration)?)
81    }
82
83    pub const fn observed_at(self) -> UnixMillis {
84        self.observed_at
85    }
86
87    pub const fn expires_at(self) -> UnixMillis {
88        self.expires_at
89    }
90
91    /// The interval is inclusive at both boundaries.
92    pub const fn state_at(self, now: UnixMillis) -> TemporalState {
93        if now.0 < self.observed_at.0 {
94            TemporalState::NotYetValid
95        } else if now.0 <= self.expires_at.0 {
96            TemporalState::Fresh
97        } else {
98            TemporalState::Stale
99        }
100    }
101}
102
103#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
104#[serde(rename_all = "snake_case")]
105pub enum TemporalState {
106    NotYetValid,
107    Fresh,
108    Stale,
109}
110
111#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
112#[serde(rename_all = "snake_case")]
113pub enum TimeError {
114    InvertedWindow,
115    Overflow,
116}
117
118impl fmt::Display for TimeError {
119    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
120        let message = match self {
121            Self::InvertedWindow => "freshness expiry precedes observation time",
122            Self::Overflow => "timestamp arithmetic overflowed",
123        };
124        formatter.write_str(message)
125    }
126}
127
128impl Error for TimeError {}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn freshness_boundaries_are_explicit() {
136        let window = FreshWindow::new(UnixMillis::new(10), UnixMillis::new(20)).unwrap();
137        assert_eq!(
138            window.state_at(UnixMillis::new(9)),
139            TemporalState::NotYetValid
140        );
141        assert_eq!(window.state_at(UnixMillis::new(10)), TemporalState::Fresh);
142        assert_eq!(window.state_at(UnixMillis::new(20)), TemporalState::Fresh);
143        assert_eq!(window.state_at(UnixMillis::new(21)), TemporalState::Stale);
144    }
145}