1use 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-08")]
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
21const LONGEST: usize = "-106751d23h47m16s854ms775us808ns".len();
23
24macro_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#[repr(transparent)]
62#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
63#[debug("{self}")]
64#[cfg_attr(feature = "zerocopy-08", derive(FromBytes, IntoBytes, Immutable, KnownLayout))]
65pub struct Timedelta(pub(crate) i64);
66
67impl Timedelta {
68 pub const NANOSECOND: Self = Self(1);
70 pub const MICROSECOND: Self = Self(NANOS_PER_MICROSECOND);
72 pub const MILLISECOND: Self = Self(NANOS_PER_MILLISECOND);
74 pub const SECOND: Self = Self(NANOS_PER_SECOND);
76 pub const MINUTE: Self = Self(NANOS_PER_MINUTE);
78 pub const HOUR: Self = Self(NANOS_PER_HOUR);
80 pub const DAY: Self = Self(NANOS_PER_DAY);
82
83 #[inline]
85 #[must_use]
86 pub const fn from_nanos(nanos: i64) -> Self {
87 Self(nanos)
88 }
89
90 #[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 #[inline]
108 #[must_use]
109 pub const fn subsec_nanos(self) -> i32 {
110 subsecond_part::<1>(self.0)
111 }
112
113 #[inline]
115 #[must_use]
116 pub const fn subsec_micros(self) -> i32 {
117 subsecond_part::<NANOS_PER_MICROSECOND>(self.0)
118 }
119
120 #[inline]
122 #[must_use]
123 pub const fn subsec_millis(self) -> i32 {
124 subsecond_part::<NANOS_PER_MILLISECOND>(self.0)
125 }
126
127 #[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#[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
146impl 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 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
170struct Spelling {
172 text: ArrayString<LONGEST>,
174 remainder: u64,
176 started: bool,
178}
179
180impl Spelling {
181 #[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
200impl 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
210fn 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 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
253fn 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
264impl 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
274impl 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}