Skip to main content

grafeo_common/types/
timestamp.rs

1//! Timestamps for temporal properties.
2//!
3//! Stored as microseconds since Unix epoch - plenty of precision for most uses.
4
5use super::date::civil_from_days;
6use serde::{Deserialize, Serialize};
7use std::fmt;
8use std::time::{Duration as StdDuration, SystemTime, UNIX_EPOCH};
9
10/// A point in time, stored as microseconds since Unix epoch.
11///
12/// Microsecond precision, covering roughly 290,000 years in each direction
13/// from 1970. Create with [`from_secs()`](Self::from_secs),
14/// [`from_millis()`](Self::from_millis), or [`now()`](Self::now).
15#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)]
16#[repr(transparent)]
17pub struct Timestamp(i64);
18
19impl Timestamp {
20    /// The Unix epoch (1970-01-01 00:00:00 UTC).
21    pub const EPOCH: Self = Self(0);
22
23    /// The minimum representable timestamp.
24    pub const MIN: Self = Self(i64::MIN);
25
26    /// The maximum representable timestamp.
27    pub const MAX: Self = Self(i64::MAX);
28
29    /// Creates a timestamp from microseconds since the Unix epoch.
30    #[inline]
31    #[must_use]
32    pub const fn from_micros(micros: i64) -> Self {
33        Self(micros)
34    }
35
36    /// Creates a timestamp from milliseconds since the Unix epoch.
37    #[inline]
38    #[must_use]
39    pub const fn from_millis(millis: i64) -> Self {
40        Self(millis * 1000)
41    }
42
43    /// Creates a timestamp from seconds since the Unix epoch.
44    #[inline]
45    #[must_use]
46    pub const fn from_secs(secs: i64) -> Self {
47        Self(secs * 1_000_000)
48    }
49
50    /// Returns the current time as a timestamp.
51    #[must_use]
52    pub fn now() -> Self {
53        let duration = SystemTime::now()
54            .duration_since(UNIX_EPOCH)
55            .unwrap_or(StdDuration::ZERO);
56        // reason: current wall-clock micros since epoch fit i64 for ~292,000 years
57        #[allow(clippy::cast_possible_truncation)]
58        Self::from_micros(duration.as_micros() as i64)
59    }
60
61    /// Returns the timestamp as microseconds since the Unix epoch.
62    #[inline]
63    #[must_use]
64    pub const fn as_micros(&self) -> i64 {
65        self.0
66    }
67
68    /// Returns the timestamp as milliseconds since the Unix epoch.
69    #[inline]
70    #[must_use]
71    pub const fn as_millis(&self) -> i64 {
72        self.0 / 1000
73    }
74
75    /// Returns the timestamp as seconds since the Unix epoch.
76    #[inline]
77    #[must_use]
78    pub const fn as_secs(&self) -> i64 {
79        self.0 / 1_000_000
80    }
81
82    /// Returns the timestamp as a `SystemTime`, if it's within the representable range.
83    #[must_use]
84    pub fn as_system_time(&self) -> Option<SystemTime> {
85        if self.0 >= 0 {
86            // reason: self.0 is checked >= 0 on the line above
87            #[allow(clippy::cast_sign_loss)]
88            Some(UNIX_EPOCH + StdDuration::from_micros(self.0 as u64))
89        } else {
90            UNIX_EPOCH.checked_sub(StdDuration::from_micros(self.0.unsigned_abs()))
91        }
92    }
93
94    /// Adds a duration to this timestamp.
95    #[must_use]
96    pub const fn add_micros(self, micros: i64) -> Self {
97        Self(self.0.saturating_add(micros))
98    }
99
100    /// Subtracts a duration from this timestamp.
101    #[must_use]
102    pub const fn sub_micros(self, micros: i64) -> Self {
103        Self(self.0.saturating_sub(micros))
104    }
105
106    /// Returns the duration between this timestamp and another.
107    ///
108    /// Returns a positive value if `other` is before `self`, negative otherwise.
109    #[must_use]
110    pub const fn duration_since(self, other: Self) -> i64 {
111        self.0 - other.0
112    }
113
114    /// Creates a timestamp from a date and time.
115    #[must_use]
116    pub fn from_date_time(date: super::Date, time: super::Time) -> Self {
117        let day_micros = date.as_days() as i64 * 86_400_000_000;
118        // reason: time nanos < 86.4e12, divided by 1000 is well within i64 range
119        #[allow(clippy::cast_possible_wrap)]
120        let time_micros = (time.as_nanos() / 1000) as i64;
121        // If the time has an offset, subtract it to get UTC
122        let offset_micros = time.offset_seconds().unwrap_or(0) as i64 * 1_000_000;
123        Self(day_micros + time_micros - offset_micros)
124    }
125
126    /// Extracts the date component (UTC).
127    #[must_use]
128    pub fn to_date(self) -> super::Date {
129        // reason: i64 micros / 86.4e9 yields a day count within i32 range for any valid timestamp
130        #[allow(clippy::cast_possible_truncation)]
131        let days = self.0.div_euclid(86_400_000_000) as i32;
132        super::Date::from_days(days)
133    }
134
135    /// Extracts the time-of-day component (UTC).
136    #[must_use]
137    pub fn to_time(self) -> super::Time {
138        let day_nanos = self.0.rem_euclid(86_400_000_000) as u64 * 1000;
139        super::Time::from_nanos(day_nanos).unwrap_or_default()
140    }
141
142    /// Truncates this timestamp to the given unit.
143    ///
144    /// - `"year"`: truncates to midnight on January 1st
145    /// - `"month"`: truncates to midnight on the 1st of the month
146    /// - `"day"`: truncates to midnight (zeroes time component)
147    /// - `"hour"`: zeroes minutes, seconds, microseconds
148    /// - `"minute"`: zeroes seconds, microseconds
149    /// - `"second"`: zeroes microseconds
150    #[must_use]
151    pub fn truncate(self, unit: &str) -> Option<Self> {
152        match unit {
153            "year" => {
154                let date = self.to_date();
155                let jan1 = super::Date::from_ymd(date.year(), 1, 1)?;
156                Some(jan1.to_timestamp())
157            }
158            "month" => {
159                let date = self.to_date();
160                let first = super::Date::from_ymd(date.year(), date.month(), 1)?;
161                Some(first.to_timestamp())
162            }
163            "day" => {
164                let days = self.0.div_euclid(86_400_000_000);
165                Some(Self(days * 86_400_000_000))
166            }
167            "hour" => {
168                let days = self.0.div_euclid(86_400_000_000);
169                let day_micros = self.0.rem_euclid(86_400_000_000);
170                let hours = day_micros / 3_600_000_000;
171                Some(Self(days * 86_400_000_000 + hours * 3_600_000_000))
172            }
173            "minute" => {
174                let days = self.0.div_euclid(86_400_000_000);
175                let day_micros = self.0.rem_euclid(86_400_000_000);
176                let minutes = day_micros / 60_000_000;
177                Some(Self(days * 86_400_000_000 + minutes * 60_000_000))
178            }
179            "second" => {
180                let days = self.0.div_euclid(86_400_000_000);
181                let day_micros = self.0.rem_euclid(86_400_000_000);
182                let seconds = day_micros / 1_000_000;
183                Some(Self(days * 86_400_000_000 + seconds * 1_000_000))
184            }
185            _ => None,
186        }
187    }
188
189    /// Adds a temporal duration to this timestamp.
190    #[must_use]
191    pub fn add_duration(self, dur: &super::Duration) -> Self {
192        // Add months via date arithmetic
193        let date = self
194            .to_date()
195            .add_duration(&super::Duration::from_months(dur.months()));
196        let time = self.to_time();
197        let base = Self::from_date_time(date, time);
198        // Add days and nanos directly
199        let day_micros = dur.days() * 86_400_000_000;
200        let nano_micros = dur.nanos() / 1000;
201        Self(base.0 + day_micros + nano_micros)
202    }
203}
204
205impl fmt::Debug for Timestamp {
206    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
207        write!(f, "Timestamp({}μs)", self.0)
208    }
209}
210
211impl fmt::Display for Timestamp {
212    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
213        let micros = self.0;
214        let micro_frac = micros.rem_euclid(1_000_000) as u64;
215
216        // reason: i64 micros / 86.4e9 yields a day count within i32 range for any valid timestamp
217        #[allow(clippy::cast_possible_truncation)]
218        let total_days = micros.div_euclid(86_400_000_000) as i32;
219        let day_micros = micros.rem_euclid(86_400_000_000);
220        let day_secs = day_micros / 1_000_000;
221
222        let hours = day_secs / 3600;
223        let minutes = (day_secs % 3600) / 60;
224        let seconds = day_secs % 60;
225
226        let (year, month, day) = civil_from_days(total_days);
227
228        write!(
229            f,
230            "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:06}Z",
231            year, month, day, hours, minutes, seconds, micro_frac
232        )
233    }
234}
235
236impl From<i64> for Timestamp {
237    fn from(micros: i64) -> Self {
238        Self::from_micros(micros)
239    }
240}
241
242impl From<Timestamp> for i64 {
243    fn from(ts: Timestamp) -> Self {
244        ts.0
245    }
246}
247
248impl TryFrom<SystemTime> for Timestamp {
249    type Error = ();
250
251    fn try_from(time: SystemTime) -> Result<Self, Self::Error> {
252        match time.duration_since(UNIX_EPOCH) {
253            Ok(duration) => {
254                let micros = i64::try_from(duration.as_micros()).map_err(|_| ())?;
255                Ok(Self::from_micros(micros))
256            }
257            Err(e) => {
258                let micros = i64::try_from(e.duration().as_micros()).map_err(|_| ())?;
259                Ok(Self::from_micros(micros.checked_neg().ok_or(())?))
260            }
261        }
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn test_timestamp_creation() {
271        let ts = Timestamp::from_secs(1000);
272        assert_eq!(ts.as_secs(), 1000);
273        assert_eq!(ts.as_millis(), 1_000_000);
274        assert_eq!(ts.as_micros(), 1_000_000_000);
275
276        let ts = Timestamp::from_millis(1234);
277        assert_eq!(ts.as_millis(), 1234);
278
279        let ts = Timestamp::from_micros(1_234_567);
280        assert_eq!(ts.as_micros(), 1_234_567);
281    }
282
283    #[test]
284    #[cfg(not(miri))] // SystemTime::now() requires clock_gettime, blocked by Miri isolation
285    fn test_timestamp_now() {
286        let ts = Timestamp::now();
287        // Should be after year 2020
288        assert!(ts.as_secs() > 1_577_836_800);
289    }
290
291    #[test]
292    fn test_timestamp_arithmetic() {
293        let ts = Timestamp::from_secs(1000);
294
295        let ts2 = ts.add_micros(1_000_000);
296        assert_eq!(ts2.as_secs(), 1001);
297
298        let ts3 = ts.sub_micros(1_000_000);
299        assert_eq!(ts3.as_secs(), 999);
300
301        assert_eq!(ts2.duration_since(ts), 1_000_000);
302        assert_eq!(ts.duration_since(ts2), -1_000_000);
303    }
304
305    #[test]
306    fn test_timestamp_ordering() {
307        let ts1 = Timestamp::from_secs(100);
308        let ts2 = Timestamp::from_secs(200);
309
310        assert!(ts1 < ts2);
311        assert!(ts2 > ts1);
312        assert_eq!(ts1, Timestamp::from_secs(100));
313    }
314
315    #[test]
316    #[cfg(not(miri))] // SystemTime::now() requires clock_gettime, blocked by Miri isolation
317    fn test_timestamp_system_time_conversion() {
318        let now = SystemTime::now();
319        let ts: Timestamp = now.try_into().unwrap();
320        let back = ts.as_system_time().unwrap();
321
322        // Should be within 1 microsecond
323        let diff = back
324            .duration_since(now)
325            .or_else(|e| Ok::<_, ()>(e.duration()))
326            .unwrap();
327        assert!(diff.as_micros() < 2);
328    }
329
330    #[test]
331    fn test_truncate() {
332        // 2024-06-15T14:30:45.123456Z
333        let date = crate::types::Date::from_ymd(2024, 6, 15).unwrap();
334        let time = crate::types::Time::from_hms_nano(14, 30, 45, 123_456_000).unwrap();
335        let ts = Timestamp::from_date_time(date, time);
336
337        let year = ts.truncate("year").unwrap();
338        assert_eq!(year.to_date().to_string(), "2024-01-01");
339        assert_eq!(year.to_time().hour(), 0);
340
341        let month = ts.truncate("month").unwrap();
342        assert_eq!(month.to_date().to_string(), "2024-06-01");
343        assert_eq!(month.to_time().hour(), 0);
344
345        let day = ts.truncate("day").unwrap();
346        assert_eq!(day.to_date().to_string(), "2024-06-15");
347        assert_eq!(day.to_time().hour(), 0);
348
349        let hour = ts.truncate("hour").unwrap();
350        assert_eq!(hour.to_time().hour(), 14);
351        assert_eq!(hour.to_time().minute(), 0);
352
353        let minute = ts.truncate("minute").unwrap();
354        assert_eq!(minute.to_time().hour(), 14);
355        assert_eq!(minute.to_time().minute(), 30);
356        assert_eq!(minute.to_time().second(), 0);
357
358        let second = ts.truncate("second").unwrap();
359        assert_eq!(second.to_time().second(), 45);
360        assert_eq!(second.to_time().nanosecond(), 0);
361
362        assert!(ts.truncate("invalid").is_none());
363    }
364
365    #[test]
366    fn test_timestamp_epoch() {
367        assert_eq!(Timestamp::EPOCH.as_micros(), 0);
368        assert_eq!(Timestamp::EPOCH.as_secs(), 0);
369    }
370
371    #[test]
372    fn test_add_duration_days_and_nanos() {
373        use crate::types::Duration;
374        let ts = Timestamp::from_secs(1_700_000_000); // 2023-11-14T22:13:20Z
375        let dur = Duration::from_days(1);
376        let result = ts.add_duration(&dur);
377        // Adding 1 day = 86400 seconds = 86_400_000_000 microseconds
378        assert_eq!(result.as_micros() - ts.as_micros(), 86_400_000_000);
379    }
380
381    #[test]
382    fn test_add_duration_months() {
383        use crate::types::Duration;
384        let ts = Timestamp::from_secs(1_700_000_000); // 2023-11-14
385        let dur = Duration::from_months(2);
386        let result = ts.add_duration(&dur);
387        let result_date = result.to_date();
388        // Nov + 2 months = January (next year)
389        assert_eq!(result_date.month(), 1);
390        assert_eq!(result_date.year(), 2024);
391    }
392}