Skip to main content

smos_domain/value_objects/
timestamp.rs

1//! `Timestamp` — UTC instant wrapper around `time::OffsetDateTime`.
2
3use crate::error::DomainError;
4use serde::{Deserialize, Serialize};
5use time::OffsetDateTime;
6
7/// UTC timestamp used everywhere in the domain.
8///
9/// Wrapping `OffsetDateTime` keeps the public surface small (we only ever need
10/// "now", unix conversions, and ordering) and lets us swap the underlying crate
11/// later without touching call sites.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
13#[serde(transparent)]
14pub struct Timestamp(OffsetDateTime);
15
16impl Timestamp {
17    /// Construct from a raw `OffsetDateTime`. Adapters that need
18    /// "current time" (e.g. `SystemClock` in `smos`) call this
19    /// with `OffsetDateTime::now_utc()`; the domain crate itself never
20    /// reaches for wall-clock time so it stays IO-free.
21    pub fn from_offset_date_time(odt: OffsetDateTime) -> Self {
22        Self(odt)
23    }
24
25    /// Current UTC instant.
26    ///
27    /// `pub(crate)` on purpose: reading the system clock is IO, and the
28    /// domain crate is IO-free in production. The helper survives for
29    /// domain-internal tests that do not want to thread a `Clock` port
30    /// through every fixture; production code reaches the wall clock
31    /// through the `Clock` port in `smos-application`.
32    #[allow(dead_code)] // only called from in-crate tests
33    pub(crate) fn now_utc() -> Self {
34        Self(OffsetDateTime::now_utc())
35    }
36
37    pub fn from_unix_secs(secs: i64) -> Result<Self, DomainError> {
38        match OffsetDateTime::from_unix_timestamp(secs) {
39            Ok(odt) => Ok(Self(odt)),
40            Err(_) => Err(DomainError::InvalidTimestamp(format!(
41                "unix_secs out of range: {secs}"
42            ))),
43        }
44    }
45
46    pub fn from_unix_millis(ms: i64) -> Result<Self, DomainError> {
47        let secs = ms.div_euclid(1000);
48        let nanos = (ms.rem_euclid(1000)) as u32 * 1_000_000;
49        match OffsetDateTime::from_unix_timestamp_nanos(
50            (secs as i128) * 1_000_000_000 + nanos as i128,
51        ) {
52            Ok(odt) => Ok(Self(odt)),
53            Err(_) => Err(DomainError::InvalidTimestamp(format!(
54                "unix_millis out of range: {ms}"
55            ))),
56        }
57    }
58
59    pub fn as_unix_secs(&self) -> i64 {
60        self.0.unix_timestamp()
61    }
62
63    pub fn as_unix_millis(&self) -> i64 {
64        // `unix_timestamp_nanos` returns an `i128`; for the supported
65        // OffsetDateTime range the value is always positive and well below
66        // `i64::MAX`, but the previous `as i64` cast would silently wrap on a
67        // far-future timestamp (and produce a negative `i64`). Use
68        // `i64::try_from` and saturate at BOTH ends so a far-past timestamp
69        // saturates to `i64::MIN` (not `i64::MAX` — the previous one-sided
70        // saturation was asymmetric and would have shifted a far-past
71        // timestamp into the far-future bucket).
72        let nanos = self.0.unix_timestamp_nanos();
73        let millis = nanos / 1_000_000;
74        match i64::try_from(millis) {
75            Ok(v) => v,
76            Err(_) => {
77                if millis < 0 {
78                    i64::MIN
79                } else {
80                    i64::MAX
81                }
82            }
83        }
84    }
85
86    pub fn as_offset_date_time(&self) -> OffsetDateTime {
87        self.0
88    }
89}
90
91impl std::fmt::Display for Timestamp {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        write!(f, "{}", self.0)
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    #[test]
102    fn now_utc_returns_reasonable_year() {
103        let ts = Timestamp::now_utc();
104        assert!(ts.as_offset_date_time().year() >= 2026);
105    }
106
107    #[test]
108    fn from_unix_secs_roundtrips() {
109        let ts = Timestamp::from_unix_secs(1_700_000_000).unwrap();
110        assert_eq!(ts.as_unix_secs(), 1_700_000_000);
111    }
112
113    #[test]
114    fn from_unix_millis_roundtrips() {
115        let ts = Timestamp::from_unix_millis(1_700_000_012).unwrap();
116        assert_eq!(ts.as_unix_millis(), 1_700_000_012);
117    }
118
119    #[test]
120    fn from_unix_secs_and_millis_agree() {
121        let secs = 1_234_567_890i64;
122        let from_s = Timestamp::from_unix_secs(secs).unwrap();
123        let from_ms = Timestamp::from_unix_millis(secs * 1000).unwrap();
124        assert_eq!(from_s.as_unix_secs(), from_ms.as_unix_secs());
125    }
126
127    #[test]
128    fn ordering_works() {
129        let earlier = Timestamp::from_unix_secs(1000).unwrap();
130        let later = Timestamp::from_unix_secs(2000).unwrap();
131        assert!(earlier < later);
132    }
133
134    #[test]
135    fn serde_roundtrip_preserves_value() {
136        let ts = Timestamp::from_unix_secs(1_700_000_000).unwrap();
137        let json = serde_json::to_string(&ts).unwrap();
138        let back: Timestamp = serde_json::from_str(&json).unwrap();
139        assert_eq!(ts, back);
140    }
141}