Skip to main content

lexe_common/
time.rs

1use std::{
2    fmt::{self, Display},
3    str::FromStr,
4    time::{Duration, SystemTime, UNIX_EPOCH},
5};
6
7use serde::{Serialize, de};
8
9/// [`Display`]s a [`Duration`] in ms with 3 decimal places, e.g. "123.456ms".
10///
11/// Useful to log elapsed times in a consistent unit, as [`Duration`]'s default
12/// implementation will go with seconds, ms, nanos etc depending on the value.
13pub struct DisplayMs(pub Duration);
14
15impl Display for DisplayMs {
16    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
17        let ms = self.0.as_secs_f64() * 1000.0;
18        write!(f, "{ms:.3}ms")
19    }
20}
21
22/// A timestamp in milliseconds since the UNIX epoch (January 1, 1970).
23/// Serialized as a non-negative integer.
24//
25// - Internally represented by a non-negative [`i64`] to ease interoperability
26//   with some platforms we use which don't support unsigned ints well (Postgres
27//   and Dart/Flutter).
28// - Can represent any time from January 1st, 1970 00:00:00.000 UTC to roughly
29//   292 million years in the future.
30#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)]
31#[derive(Serialize)]
32pub struct TimestampMs(i64);
33
34/// Errors that can occur when attempting to construct a [`TimestampMs`].
35#[derive(Debug, Eq, PartialEq, thiserror::Error)]
36pub enum Error {
37    #[error("timestamp value is negative")]
38    Negative,
39
40    #[error("timestamp is more than 292 million years past epoch")]
41    TooLarge,
42
43    #[error("timestamp is before January 1st, 1970")]
44    BeforeEpoch,
45
46    #[error("failed to parse timestamp: {0}")]
47    Parse(#[from] std::num::ParseIntError),
48}
49
50impl TimestampMs {
51    pub const MIN: Self = TimestampMs(0);
52    pub const MAX: Self = TimestampMs(i64::MAX);
53
54    /// Creates a new [`TimestampMs`] from the current [`SystemTime`].
55    ///
56    /// Panics if the current time is not within bounds.
57    pub fn now() -> Self {
58        Self::try_from(SystemTime::now()).unwrap()
59    }
60
61    /// Get this unix timestamp as an [`i64`] in milliseconds from unix epoch.
62    #[inline]
63    pub fn to_i64(self) -> i64 {
64        self.0
65    }
66
67    /// Get this unix timestamp as a [`u64`] in milliseconds from unix epoch.
68    #[inline]
69    pub fn to_u64(self) -> u64 {
70        debug_assert!(self.0 >= 0);
71        self.0 as u64
72    }
73
74    /// Construct [`TimestampMs`] from seconds since Unix epoch.
75    pub fn from_secs(secs: u64) -> Result<Self, Error> {
76        Self::try_from(Duration::from_secs(secs))
77    }
78
79    /// Infallibly construct [`TimestampMs`] from seconds since Unix epoch.
80    pub fn from_secs_u32(secs: u32) -> Self {
81        Self(i64::from(secs) * 1000)
82    }
83
84    /// Construct [`TimestampMs`] from milliseconds since Unix epoch.
85    pub fn from_millis(millis: u64) -> Result<Self, Error> {
86        Self::try_from(Duration::from_millis(millis))
87    }
88
89    /// Construct [`TimestampMs`] from [`Duration`] since Unix epoch.
90    pub fn from_duration(dur_since_epoch: Duration) -> Result<Self, Error> {
91        i64::try_from(dur_since_epoch.as_millis())
92            .map(Self)
93            .map_err(|_| Error::TooLarge)
94    }
95
96    /// Construct [`TimestampMs`] from a [`SystemTime`].
97    pub fn from_system_time(system_time: SystemTime) -> Result<Self, Error> {
98        let duration = system_time
99            .duration_since(UNIX_EPOCH)
100            .map_err(|_| Error::BeforeEpoch)?;
101        Self::try_from(duration)
102    }
103
104    /// Quickly create a dummy timestamp which can be used in tests.
105    #[cfg(any(test, feature = "test-utils"))]
106    pub fn from_u8(i: u8) -> Self {
107        Self(i64::from(i))
108    }
109
110    /// Get this unix timestamp as a [`u64`] in milliseconds from unix epoch.
111    #[inline]
112    pub fn to_millis(self) -> u64 {
113        self.to_u64()
114    }
115
116    /// Get this unix timestamp as a [`u64`] in seconds from unix epoch.
117    #[inline]
118    pub fn to_secs(self) -> u64 {
119        Duration::from_millis(self.to_millis()).as_secs()
120    }
121
122    /// Get this unix timestamp as a [`Duration`] from the unix epoch.
123    #[inline]
124    pub fn to_duration(self) -> Duration {
125        Duration::from_millis(self.to_millis())
126    }
127
128    /// Get this unix timestamp as a [`SystemTime`].
129    #[inline]
130    pub fn to_system_time(self) -> SystemTime {
131        // This add is infallible -- it doesn't panic even with Self::MAX.
132        UNIX_EPOCH + self.to_duration()
133    }
134
135    pub fn checked_add(self, duration: Duration) -> Option<Self> {
136        let dur_ms = i64::try_from(duration.as_millis()).ok()?;
137        let added = self.0.checked_add(dur_ms)?;
138        Self::try_from(added).ok()
139    }
140
141    pub fn checked_sub(self, duration: Duration) -> Option<Self> {
142        let dur_ms = i64::try_from(duration.as_millis()).ok()?;
143        let subtracted = self.0.checked_sub(dur_ms)?;
144        Self::try_from(subtracted).ok()
145    }
146
147    pub fn saturating_add(self, duration: Duration) -> Self {
148        self.checked_add(duration).unwrap_or(Self::MAX)
149    }
150
151    pub fn saturating_sub(self, duration: Duration) -> Self {
152        self.checked_sub(duration).unwrap_or(Self::MIN)
153    }
154
155    /// Returns the [`Duration`] elapsed from `earlier` to `self`,
156    /// or [`None`] if `earlier` is later than `self`.
157    pub fn checked_duration_since(self, earlier: Self) -> Option<Duration> {
158        let dur_ms = self.to_u64().checked_sub(earlier.to_u64())?;
159        Some(Duration::from_millis(dur_ms))
160    }
161
162    /// Returns the [`Duration`] elapsed from `earlier` to `self`,
163    /// saturating to [`Duration::ZERO`] if `earlier` is later than `self`.
164    pub fn saturating_duration_since(self, earlier: Self) -> Duration {
165        let dur_ms = self.to_u64().saturating_sub(earlier.to_u64());
166        Duration::from_millis(dur_ms)
167    }
168
169    /// Returns the [`Duration`] elapsed since this timestamp,
170    /// or [`None`] if it is in the future.
171    #[inline]
172    pub fn checked_elapsed(self) -> Option<Duration> {
173        Self::now().checked_duration_since(self)
174    }
175
176    /// Returns the [`Duration`] elapsed since this timestamp,
177    /// saturating to [`Duration::ZERO`] if it is in the future.
178    #[inline]
179    pub fn saturating_elapsed(self) -> Duration {
180        Self::now().saturating_duration_since(self)
181    }
182
183    /// Formats this timestamp into a human-readable string relative to `now`,
184    /// e.g. "just now", "5 minutes ago", "in 3 hours",
185    /// "in 2 years and 5 weeks".
186    ///
187    /// Picks the largest whole unit that fits; weeks and years also carry the
188    /// remainder in the next unit down. Sub-second differences in either
189    /// direction read as "just now".
190    pub fn to_relative_string(self, now: Self) -> String {
191        const MINUTE: u64 = 60;
192        const HOUR: u64 = 60 * MINUTE;
193        const DAY: u64 = 24 * HOUR;
194        const WEEK: u64 = 7 * DAY;
195        const YEAR: u64 = 365 * DAY;
196
197        /// e.g. "1 week", "3 weeks"
198        fn fmt_1_unit(count: u64, unit: &str) -> String {
199            let plural = if count == 1 { "" } else { "s" };
200            format!("{count} {unit}{plural}")
201        }
202
203        /// A count plus its remainder in the next unit down, e.g.
204        /// "3 weeks and 2 days". The remainder is omitted when zero.
205        fn fmt_2_units(
206            count: u64,
207            unit: &str,
208            rem: u64,
209            rem_unit: &str,
210        ) -> String {
211            let major = fmt_1_unit(count, unit);
212            match rem {
213                0 => major,
214                _ => format!("{major} and {}", fmt_1_unit(rem, rem_unit)),
215            }
216        }
217
218        let elapsed = now.checked_duration_since(self);
219        let secs = match elapsed {
220            Some(elapsed) => elapsed.as_secs(),
221            None => self.saturating_duration_since(now).as_secs(),
222        };
223
224        // Largest whole unit that fits. Weeks and years carry a remainder,
225        // since "51 weeks" and "3 years" alone lose too much precision.
226        let magnitude = match secs {
227            0 => return "just now".to_owned(),
228            s if s < MINUTE => fmt_1_unit(s, "second"),
229            s if s < HOUR => fmt_1_unit(s / MINUTE, "minute"),
230            s if s < DAY => fmt_1_unit(s / HOUR, "hour"),
231            s if s < WEEK => fmt_1_unit(s / DAY, "day"),
232            s if s < YEAR =>
233                fmt_2_units(s / WEEK, "week", (s % WEEK) / DAY, "day"),
234            s => fmt_2_units(s / YEAR, "year", (s % YEAR) / WEEK, "week"),
235        };
236
237        match elapsed {
238            Some(_) => format!("{magnitude} ago"),
239            None => format!("in {magnitude}"),
240        }
241    }
242
243    /// Floors the timestamp to the most recent second.
244    #[cfg(test)]
245    fn floor_secs(self) -> Self {
246        let rem = self.0 % 1000;
247        Self(self.0 - rem)
248    }
249}
250
251impl From<TimestampMs> for Duration {
252    #[inline]
253    fn from(t: TimestampMs) -> Self {
254        t.to_duration()
255    }
256}
257
258impl From<TimestampMs> for SystemTime {
259    #[inline]
260    fn from(t: TimestampMs) -> Self {
261        t.to_system_time()
262    }
263}
264
265/// Attempts to convert a [`SystemTime`] into a [`TimestampMs`].
266///
267/// Returns an error if the [`SystemTime`] is not within bounds.
268impl TryFrom<SystemTime> for TimestampMs {
269    type Error = Error;
270    fn try_from(system_time: SystemTime) -> Result<Self, Self::Error> {
271        Self::from_system_time(system_time)
272    }
273}
274
275/// Attempts to convert a [`Duration`] since the UNIX epoch into a
276/// [`TimestampMs`].
277///
278/// Returns an error if the [`Duration`] is too large.
279impl TryFrom<Duration> for TimestampMs {
280    type Error = Error;
281    fn try_from(dur_since_epoch: Duration) -> Result<Self, Self::Error> {
282        Self::from_duration(dur_since_epoch)
283    }
284}
285
286/// Attempt to convert an [`i64`] in milliseconds since unix epoch into a
287/// [`TimestampMs`].
288impl TryFrom<i64> for TimestampMs {
289    type Error = Error;
290    #[inline]
291    fn try_from(ms: i64) -> Result<Self, Self::Error> {
292        if ms >= Self::MIN.0 {
293            Ok(Self(ms))
294        } else {
295            Err(Error::Negative)
296        }
297    }
298}
299
300impl FromStr for TimestampMs {
301    type Err = Error;
302    fn from_str(s: &str) -> Result<Self, Self::Err> {
303        Self::try_from(i64::from_str(s)?)
304    }
305}
306
307impl Display for TimestampMs {
308    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
309        i64::fmt(&self.0, f)
310    }
311}
312
313impl<'de> de::Deserialize<'de> for TimestampMs {
314    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
315    where
316        D: de::Deserializer<'de>,
317    {
318        i64::deserialize(deserializer)
319            .and_then(|x| Self::try_from(x).map_err(de::Error::custom))
320    }
321}
322
323#[cfg(any(test, feature = "test-utils"))]
324mod arbitrary_impl {
325    use proptest::{
326        arbitrary::Arbitrary,
327        strategy::{BoxedStrategy, Strategy},
328    };
329
330    use super::*;
331
332    impl Arbitrary for TimestampMs {
333        type Parameters = ();
334        type Strategy = BoxedStrategy<Self>;
335        fn arbitrary_with(_args: Self::Parameters) -> Self::Strategy {
336            (Self::MIN.0..Self::MAX.0).prop_map(Self).boxed()
337        }
338    }
339}
340
341#[cfg(test)]
342mod test {
343    use proptest::{prop_assert_eq, proptest};
344
345    use super::*;
346    use crate::test_utils::roundtrip;
347
348    #[test]
349    fn timestamp_roundtrip() {
350        roundtrip::fromstr_display_roundtrip_proptest::<TimestampMs>();
351        roundtrip::json_string_roundtrip_proptest::<TimestampMs>();
352    }
353
354    #[test]
355    fn deserialize_enforces_nonnegative() {
356        // We deserialize from JSON numbers; note that it is NOT e.g. "\"42\""
357        assert_eq!(serde_json::from_str::<TimestampMs>("42").unwrap().0, 42);
358        assert_eq!(serde_json::from_str::<TimestampMs>("0").unwrap().0, 0);
359        assert!(serde_json::from_str::<TimestampMs>("-42").is_err());
360    }
361
362    // Value conversions should roundtrip.
363    fn assert_conversion_roundtrips(t: TimestampMs) {
364        // Seconds
365        let floored = t.floor_secs();
366        assert_eq!(TimestampMs::from_secs(floored.to_secs()), Ok(floored));
367        if let Ok(secs) = u32::try_from(floored.to_secs()) {
368            assert_eq!(TimestampMs::from_secs_u32(secs), floored);
369        }
370
371        // Milliseconds
372        assert_eq!(TimestampMs::from_millis(t.to_millis()), Ok(t));
373        assert_eq!(TimestampMs::try_from(t.to_i64()), Ok(t));
374
375        // Duration
376        assert_eq!(TimestampMs::from_duration(t.to_duration()), Ok(t));
377        assert_eq!(TimestampMs::try_from(t.to_duration()), Ok(t));
378
379        // SystemTime
380        assert_eq!(TimestampMs::from_system_time(t.to_system_time()), Ok(t));
381        assert_eq!(TimestampMs::try_from(t.to_system_time()), Ok(t));
382    }
383
384    #[test]
385    fn timestamp_conversions_roundtrip() {
386        assert_conversion_roundtrips(TimestampMs::MIN);
387        assert_conversion_roundtrips(TimestampMs::MAX);
388
389        proptest!(|(t: TimestampMs)| assert_conversion_roundtrips(t));
390    }
391
392    #[test]
393    fn timestamp_diff() {
394        proptest!(|(ts1: TimestampMs, ts2: TimestampMs)| {
395            // Determine which timestamp is lesser/greater
396            let (lesser, greater) = if ts1 <= ts2 {
397                (ts1, ts2)
398            } else {
399                (ts2, ts1)
400            };
401
402            let diff =
403                Duration::from_millis(greater.to_millis() - lesser.to_millis());
404
405            let added = lesser.checked_add(diff).unwrap();
406            prop_assert_eq!(added, greater);
407
408            let subtracted = greater.checked_sub(diff).unwrap();
409            prop_assert_eq!(subtracted, lesser);
410        })
411    }
412
413    #[test]
414    fn timestamp_saturating_ops() {
415        proptest!(|(ts: TimestampMs)| {
416            prop_assert_eq!(
417                ts.saturating_add(TimestampMs::MAX.to_duration()),
418                TimestampMs::MAX
419            );
420            prop_assert_eq!(
421                ts.saturating_sub(TimestampMs::MAX.to_duration()),
422                TimestampMs::MIN
423            );
424            prop_assert_eq!(
425                ts.saturating_add(TimestampMs::MIN.to_duration()),
426                ts
427            );
428            prop_assert_eq!(
429                ts.saturating_sub(TimestampMs::MIN.to_duration()),
430                ts
431            );
432        })
433    }
434
435    #[test]
436    fn timestamp_duration_since() {
437        proptest!(|(ts1: TimestampMs, ts2: TimestampMs)| {
438            let (lesser, greater) =
439                if ts1 <= ts2 { (ts1, ts2) } else { (ts2, ts1) };
440            let diff =
441                Duration::from_millis(greater.to_millis() - lesser.to_millis());
442
443            // `greater` since `lesser` is exactly `diff`.
444            prop_assert_eq!(greater.checked_duration_since(lesser), Some(diff));
445            prop_assert_eq!(greater.saturating_duration_since(lesser), diff);
446
447            // `lesser` since `greater` underflows: `None` / saturates to
448            // `ZERO`, except when equal, where the diff is just `ZERO`.
449            let expected_checked = (lesser == greater).then_some(Duration::ZERO);
450            prop_assert_eq!(
451                lesser.checked_duration_since(greater),
452                expected_checked
453            );
454            prop_assert_eq!(
455                lesser.saturating_duration_since(greater),
456                Duration::ZERO
457            );
458        })
459    }
460
461    #[test]
462    fn timestamp_elapsed() {
463        // `now` lies between MIN and MAX, so a min timestamp has elapsed while
464        // a max (far-future) timestamp underflows.
465        assert!(TimestampMs::MIN.checked_elapsed().is_some());
466        assert!(TimestampMs::MIN.saturating_elapsed() > Duration::ZERO);
467
468        assert_eq!(TimestampMs::MAX.checked_elapsed(), None);
469        assert_eq!(TimestampMs::MAX.saturating_elapsed(), Duration::ZERO);
470    }
471
472    /// Sanity check the relative formatting at every granularity.
473    #[test]
474    fn relative_string_sanity() {
475        const MINUTE: u64 = 60;
476        const HOUR: u64 = 60 * MINUTE;
477        const DAY: u64 = 24 * HOUR;
478        const WEEK: u64 = 7 * DAY;
479        const YEAR: u64 = 365 * DAY;
480
481        // (secs away from `now`, past rendering, future rendering)
482        let cases = [
483            (0, "just now", "just now"),
484            (1, "1 second ago", "in 1 second"),
485            (59, "59 seconds ago", "in 59 seconds"),
486            (MINUTE, "1 minute ago", "in 1 minute"),
487            (5 * MINUTE + 30, "5 minutes ago", "in 5 minutes"),
488            (HOUR, "1 hour ago", "in 1 hour"),
489            (3 * HOUR + 59 * MINUTE, "3 hours ago", "in 3 hours"),
490            (DAY + 12 * HOUR, "1 day ago", "in 1 day"),
491            (6 * DAY, "6 days ago", "in 6 days"),
492            (WEEK, "1 week ago", "in 1 week"),
493            (WEEK + DAY, "1 week and 1 day ago", "in 1 week and 1 day"),
494            (
495                3 * WEEK + 2 * DAY,
496                "3 weeks and 2 days ago",
497                "in 3 weeks and 2 days",
498            ),
499            (
500                51 * WEEK + 6 * DAY,
501                "51 weeks and 6 days ago",
502                "in 51 weeks and 6 days",
503            ),
504            (YEAR, "1 year ago", "in 1 year"),
505            (
506                YEAR + 5 * WEEK,
507                "1 year and 5 weeks ago",
508                "in 1 year and 5 weeks",
509            ),
510            // Remainders below the next unit down are dropped, not rounded.
511            (
512                2 * YEAR + WEEK + 3 * DAY,
513                "2 years and 1 week ago",
514                "in 2 years and 1 week",
515            ),
516            (
517                143 * WEEK,
518                "2 years and 38 weeks ago",
519                "in 2 years and 38 weeks",
520            ),
521        ];
522
523        let now = TimestampMs::from_secs(100 * YEAR).unwrap();
524        for (secs, past, future) in cases {
525            let secs = Duration::from_secs(secs);
526            let past_ts = now.saturating_sub(secs);
527            let future_ts = now.saturating_add(secs);
528
529            assert_eq!(past_ts.to_relative_string(now), past);
530            assert_eq!(future_ts.to_relative_string(now), future);
531        }
532    }
533}