Skip to main content

t2t_core/
timedelta.rs

1//! A signed span of nanoseconds, and its spelling.
2
3use core::fmt::{self, Write as _};
4use core::str::FromStr;
5use core::time::Duration;
6
7use arrayvec::ArrayString;
8use derive_more::Debug;
9use itoa::Buffer;
10#[cfg(feature = "zerocopy")]
11use zerocopy::{FromBytes, Immutable, IntoBytes, KnownLayout};
12
13use crate::consts::{
14    NANOS_PER_DAY, NANOS_PER_HOUR, NANOS_PER_MICROSECOND, NANOS_PER_MILLISECOND, NANOS_PER_MINUTE,
15    NANOS_PER_SECOND,
16};
17use crate::errors::narrow;
18use crate::spelling::pad;
19use crate::{OutOfRangeError, ParseTimedeltaError, TickRate, Tickdelta};
20
21/// The longest spelling, [`Timedelta::MIN`]'s.
22const LONGEST: usize = "-106751d23h47m16s854ms775us808ns".len();
23
24/// A span's constructor and accessor in each unit from microseconds to days, inside its `impl`.
25macro_rules! span_units {
26    ($($unit:literal: $from:ident($count:ident), $as:ident, $nanos:ident;)*) => {
27        $(
28            #[doc = concat!("A span of `", stringify!($count), "` ", $unit, ", saturating.")]
29            #[inline]
30            #[must_use]
31            pub const fn $from($count: i64) -> Self {
32                Self($count.saturating_mul($nanos))
33            }
34        )*
35        $(
36            #[doc = concat!("The span in whole ", $unit, ", truncated toward zero.")]
37            #[inline]
38            #[must_use]
39            pub const fn $as(self) -> i64 {
40                self.0 / $nanos
41            }
42        )*
43    };
44}
45
46/// A signed span of nanoseconds: how far apart two points on one timeline are.
47///
48/// Its operators saturate at [`MIN`](Self::MIN) and [`MAX`](Self::MAX), about 292 years either
49/// way, and each has a `checked_*` twin. It is written coarsest unit first, the way a config spells
50/// it, and parses back from the same spelling.
51///
52/// # Examples
53/// ```
54/// use t2t_core::Timedelta;
55///
56/// let budget = Timedelta::from_millis(250);
57/// assert_eq!(budget.as_micros(), 250_000, "the same span, counted finer");
58/// assert_eq!(budget.to_string(), "250ms", "written in its coarsest unit");
59/// assert_eq!("1m0s250ms".parse(), Ok(Timedelta::MINUTE + budget), "and read back");
60/// ```
61#[repr(transparent)]
62#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
63#[debug("{self}")]
64#[cfg_attr(feature = "zerocopy", derive(FromBytes, IntoBytes, Immutable, KnownLayout))]
65pub struct Timedelta(pub(crate) i64);
66
67impl Timedelta {
68    /// One nanosecond, the finest span the count resolves.
69    pub const NANOSECOND: Self = Self(1);
70    /// One microsecond.
71    pub const MICROSECOND: Self = Self(NANOS_PER_MICROSECOND);
72    /// One millisecond.
73    pub const MILLISECOND: Self = Self(NANOS_PER_MILLISECOND);
74    /// One second.
75    pub const SECOND: Self = Self(NANOS_PER_SECOND);
76    /// One minute.
77    pub const MINUTE: Self = Self(NANOS_PER_MINUTE);
78    /// One hour.
79    pub const HOUR: Self = Self(NANOS_PER_HOUR);
80    /// One day of 24 hours; no calendar is consulted.
81    pub const DAY: Self = Self(NANOS_PER_DAY);
82
83    /// A span of `nanos` nanoseconds.
84    #[inline]
85    #[must_use]
86    pub const fn from_nanos(nanos: i64) -> Self {
87        Self(nanos)
88    }
89
90    /// The span in nanoseconds.
91    #[inline]
92    #[must_use]
93    pub const fn as_nanos(self) -> i64 {
94        self.0
95    }
96
97    span_units! {
98        "microseconds": from_micros(micros), as_micros, NANOS_PER_MICROSECOND;
99        "milliseconds": from_millis(millis), as_millis, NANOS_PER_MILLISECOND;
100        "seconds": from_secs(secs), as_secs, NANOS_PER_SECOND;
101        "minutes": from_mins(mins), as_mins, NANOS_PER_MINUTE;
102        "hours": from_hours(hours), as_hours, NANOS_PER_HOUR;
103        "days of 24 hours": from_days(days), as_days, NANOS_PER_DAY;
104    }
105
106    /// The nanoseconds past the whole seconds, signed as the span is.
107    #[inline]
108    #[must_use]
109    pub const fn subsec_nanos(self) -> i32 {
110        subsecond_part::<1>(self.0)
111    }
112
113    /// The whole microseconds past the whole seconds, signed as the span is.
114    #[inline]
115    #[must_use]
116    pub const fn subsec_micros(self) -> i32 {
117        subsecond_part::<NANOS_PER_MICROSECOND>(self.0)
118    }
119
120    /// The whole milliseconds past the whole seconds, signed as the span is.
121    #[inline]
122    #[must_use]
123    pub const fn subsec_millis(self) -> i32 {
124        subsecond_part::<NANOS_PER_MILLISECOND>(self.0)
125    }
126
127    /// How many ticks the span lasts at `rate`: a multiply and a shift, to within a tick.
128    #[inline]
129    #[must_use]
130    pub const fn to_tickdelta(self, rate: TickRate) -> Tickdelta {
131        Tickdelta(rate.nanos_to_ticks(self.0))
132    }
133}
134
135/// The part of `nanos` past its whole seconds, in whole units of `UNIT` nanoseconds.
136#[inline]
137#[expect(
138    clippy::arithmetic_side_effects,
139    clippy::as_conversions,
140    reason = "every unit is a nonzero constant, and a part of a second fits `i32`; `TryFrom` is not const"
141)]
142const fn subsecond_part<const UNIT: i64>(nanos: i64) -> i32 {
143    (nanos % NANOS_PER_SECOND / UNIT) as i32
144}
145
146/// Coarsest unit first, once each, `0s` for zero: `1d2h`, `1m0s250ms`, `-3us`.
147impl fmt::Display for Timedelta {
148    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
149        let mut spelling =
150            Spelling { text: ArrayString::new(), remainder: self.0.unsigned_abs(), started: false };
151        if self.0 == 0 {
152            spelling.text.write_str("0s")?;
153        } else if self.0 < 0 {
154            spelling.text.write_str("-")?;
155        }
156        // A unit apiece, so each divides by a constant, which compiles to a multiply: a tenth
157        // faster than a table of units, 46 µs against 52 µs for 1,024 spans on a Graviton4,
158        // measured under `benches/results/2026-10-02T23-14Z-9776c95-span-divisors`.
159        spelling.write_unit::<{ NANOS_PER_DAY.unsigned_abs() }>("d")?;
160        spelling.write_unit::<{ NANOS_PER_HOUR.unsigned_abs() }>("h")?;
161        spelling.write_unit::<{ NANOS_PER_MINUTE.unsigned_abs() }>("m")?;
162        spelling.write_unit::<{ NANOS_PER_SECOND.unsigned_abs() }>("s")?;
163        spelling.write_unit::<{ NANOS_PER_MILLISECOND.unsigned_abs() }>("ms")?;
164        spelling.write_unit::<{ NANOS_PER_MICROSECOND.unsigned_abs() }>("us")?;
165        spelling.write_unit::<1>("ns")?;
166        pad(formatter, &spelling.text)
167    }
168}
169
170/// A span's spelling, as far as it is written.
171struct Spelling {
172    /// What is written so far.
173    text: ArrayString<LONGEST>,
174    /// The nanoseconds left to write: unsigned, since `MIN` has no positive twin.
175    remainder: u64,
176    /// Whether a unit is written, after which each finer one is, a zero among them.
177    started: bool,
178}
179
180impl Spelling {
181    /// Writes the whole `UNIT`s left, then `suffix`, once a coarser unit is written or one is
182    /// whole, and keeps what is finer; nothing, once nothing is left.
183    #[inline]
184    #[expect(clippy::arithmetic_side_effects, reason = "`UNIT` is a nonzero constant")]
185    fn write_unit<const UNIT: u64>(&mut self, suffix: &str) -> fmt::Result {
186        if self.remainder == 0 {
187            return Ok(());
188        }
189        let count = self.remainder / UNIT;
190        if self.started || count > 0 {
191            self.text.write_str(Buffer::new().format(count))?;
192            self.text.write_str(suffix)?;
193            self.started = true;
194        }
195        self.remainder %= UNIT;
196        Ok(())
197    }
198}
199
200/// Reads what [`Display`](fmt::Display) writes, and the bare `0`: each unit at most once and
201/// coarsest first, with any count in each, so `48h` reads as the span `2d` spells.
202impl FromStr for Timedelta {
203    type Err = ParseTimedeltaError;
204
205    fn from_str(text: &str) -> Result<Self, Self::Err> {
206        parse(text.as_bytes()).ok_or(ParseTimedeltaError)
207    }
208}
209
210/// Reads `text` as a span in one pass, or `None`.
211fn parse(text: &[u8]) -> Option<Timedelta> {
212    if text == b"0" {
213        return Some(Timedelta::ZERO);
214    }
215    let (is_negative, mut rest) = match text {
216        [b'-', rest @ ..] => (true, rest),
217        _ => (false, text),
218    };
219    if rest.is_empty() {
220        return None;
221    }
222    let mut magnitude: u64 = 0;
223    let mut previous_unit = u64::MAX;
224    while !rest.is_empty() {
225        let (count, tail) = read_digits(rest)?;
226        // The units `Display` writes, `ms` tried before `m`.
227        let (unit, tail) = match tail {
228            [b'd', tail @ ..] => (NANOS_PER_DAY, tail),
229            [b'h', tail @ ..] => (NANOS_PER_HOUR, tail),
230            [b'm', b's', tail @ ..] => (NANOS_PER_MILLISECOND, tail),
231            [b'm', tail @ ..] => (NANOS_PER_MINUTE, tail),
232            [b's', tail @ ..] => (NANOS_PER_SECOND, tail),
233            [b'u', b's', tail @ ..] => (NANOS_PER_MICROSECOND, tail),
234            [b'n', b's', tail @ ..] => (1, tail),
235            _ => return None,
236        };
237        let unit = unit.unsigned_abs();
238        if unit >= previous_unit {
239            return None;
240        }
241        previous_unit = unit;
242        magnitude = magnitude.checked_add(count.checked_mul(unit)?)?;
243        rest = tail;
244    }
245    let nanos = if is_negative {
246        0_i64.checked_sub_unsigned(magnitude)
247    } else {
248        i64::try_from(magnitude).ok()
249    };
250    nanos.map(Timedelta)
251}
252
253/// The count `text` opens with, one ASCII digit at least, and the text after it.
254fn read_digits(text: &[u8]) -> Option<(u64, &[u8])> {
255    let mut count: u64 = 0;
256    let mut rest = text;
257    while let [digit @ b'0'..=b'9', tail @ ..] = rest {
258        count = count.checked_mul(10)?.checked_add(u64::from(digit.wrapping_sub(b'0')))?;
259        rest = tail;
260    }
261    (rest.len() < text.len()).then_some((count, rest))
262}
263
264/// Refuses a negative span.
265impl TryFrom<Timedelta> for Duration {
266    type Error = OutOfRangeError;
267
268    #[inline]
269    fn try_from(span: Timedelta) -> Result<Self, Self::Error> {
270        narrow(span.0).map(Self::from_nanos)
271    }
272}
273
274/// Refuses a duration past [`Timedelta::MAX`].
275impl TryFrom<Duration> for Timedelta {
276    type Error = OutOfRangeError;
277
278    #[inline]
279    fn try_from(duration: Duration) -> Result<Self, Self::Error> {
280        narrow(duration.as_nanos()).map(Self)
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use alloc::format;
287    use alloc::string::ToString as _;
288    use core::time::Duration;
289
290    use proptest::prelude::{any, prop_assert_eq, proptest};
291    use rstest::rstest;
292
293    use crate::{OutOfRangeError, ParseTimedeltaError, Timedelta};
294
295    #[test]
296    fn every_unit_scales_to_the_same_count() {
297        assert_eq!(Timedelta::from_micros(1), Timedelta::MICROSECOND, "a microsecond");
298        assert_eq!(Timedelta::from_millis(1), Timedelta::MILLISECOND, "a millisecond");
299        assert_eq!(Timedelta::from_secs(1), Timedelta::SECOND, "a second");
300        assert_eq!(Timedelta::from_mins(1), Timedelta::MINUTE, "a minute");
301        assert_eq!(Timedelta::from_hours(1), Timedelta::HOUR, "an hour");
302        assert_eq!(Timedelta::from_days(1), Timedelta::DAY, "a day");
303        assert_eq!(Timedelta::from_days(2).as_hours(), 48, "a coarser unit, counted finer");
304        assert_eq!(
305            Timedelta::from_secs(i64::MAX),
306            Timedelta::MAX,
307            "a count past the range saturates"
308        );
309    }
310
311    #[test]
312    fn a_backwards_span_counts_its_whole_units_toward_zero() {
313        let span = Timedelta::from_nanos(-1_234_567_890);
314        assert_eq!(span.as_secs(), -1, "whole seconds");
315        assert_eq!(span.as_millis(), -1_234, "whole milliseconds");
316        assert_eq!(span.subsec_nanos(), -234_567_890, "the nanoseconds past the seconds");
317        assert_eq!(span.subsec_micros(), -234_567, "the microseconds past them");
318        assert_eq!(span.subsec_millis(), -234, "and the milliseconds");
319    }
320
321    #[rstest]
322    #[case::zero(0, "0s")]
323    #[case::a_day(86_400_000_000_000, "1d")]
324    #[case::a_day_and_an_hour(90_000_000_000_000, "1d1h")]
325    #[case::a_gap_inside(60_123_000_000, "1m0s123ms")]
326    #[case::every_finer_unit(67_123_456_789, "1m7s123ms456us789ns")]
327    #[case::backwards(-3_000, "-3us")]
328    #[case::the_longest_backwards(i64::MIN, "-106751d23h47m16s854ms775us808ns")]
329    fn a_span_is_spelled_coarsest_unit_first(#[case] nanos: i64, #[case] text: &str) {
330        let span = Timedelta::from_nanos(nanos);
331        assert_eq!(span.to_string(), text, "written");
332        assert_eq!(text.parse(), Ok(span), "and read back");
333    }
334
335    #[test]
336    fn a_reading_may_skip_a_coarser_unit_or_be_a_bare_zero() {
337        assert_eq!("0".parse(), Ok(Timedelta::ZERO), "a bare zero");
338        assert_eq!("48h".parse(), Ok(Timedelta::from_days(2)), "a count past the next unit up");
339    }
340
341    #[test]
342    fn a_width_pads_the_spelling() {
343        let span = Timedelta::from_millis(500);
344        assert_eq!(format!("[{span:8}]"), "[500ms   ]", "to the left by default");
345        assert_eq!(format!("[{span:>8}]"), "[   500ms]", "to the right");
346        assert_eq!(format!("[{span:-^9}]"), "[--500ms--]", "and centred, with a fill");
347    }
348
349    #[rstest]
350    #[case::empty("")]
351    #[case::a_sign_alone("-")]
352    #[case::no_unit("1")]
353    #[case::no_count("s")]
354    #[case::a_unit_twice("1m1m")]
355    #[case::finest_first("1ns2s")]
356    #[case::spaced("5 minutes")]
357    #[case::an_unknown_unit("1msec")]
358    #[case::a_backwards_zero("-0")]
359    #[case::a_sign_display_never_writes("+1s")]
360    #[case::past_the_range("9223372036854775808ns")]
361    #[case::a_count_past_any_unit("99999999999999999999999ns")]
362    fn a_malformed_span_is_refused(#[case] text: &str) {
363        assert_eq!(text.parse::<Timedelta>(), Err(ParseTimedeltaError), "{text:?} is refused");
364    }
365
366    #[test]
367    fn a_duration_crosses_both_ways_and_a_negative_span_is_refused() {
368        let (span, duration) = (Timedelta::from_millis(5), Duration::from_millis(5));
369        assert_eq!(Duration::try_from(span), Ok(duration), "a span to a duration");
370        assert_eq!(Timedelta::try_from(duration), Ok(span), "and back");
371        assert_eq!(Duration::try_from(-span), Err(OutOfRangeError), "no duration runs backwards");
372        assert_eq!(Timedelta::try_from(Duration::MAX), Err(OutOfRangeError), "nor past the range");
373    }
374
375    proptest! {
376        #[test]
377        fn every_span_reads_back_from_its_spelling(nanos in any::<i64>()) {
378            let span = Timedelta::from_nanos(nanos);
379            prop_assert_eq!(span.to_string().parse(), Ok(span), "the spelling reads back");
380        }
381
382        #[test]
383        fn whatever_any_text_reads_as_reads_back_the_same(text in ".{0,40}") {
384            if let Ok(span) = text.parse::<Timedelta>() {
385                prop_assert_eq!(span.to_string().parse(), Ok(span), "what was read writes back");
386            }
387        }
388    }
389}