use core::fmt::{self, Write as _};
use core::str::FromStr;
use core::time::Duration;
use arrayvec::ArrayString;
use derive_more::Debug;
use itoa::Buffer;
#[cfg(feature = "zerocopy")]
use zerocopy::{FromBytes, Immutable, IntoBytes, KnownLayout};
use crate::consts::{
NANOS_PER_DAY, NANOS_PER_HOUR, NANOS_PER_MICROSECOND, NANOS_PER_MILLISECOND, NANOS_PER_MINUTE,
NANOS_PER_SECOND,
};
use crate::errors::narrow;
use crate::spelling::pad;
use crate::{OutOfRangeError, ParseTimedeltaError, TickRate, Tickdelta};
const LONGEST: usize = "-106751d23h47m16s854ms775us808ns".len();
macro_rules! span_units {
($($unit:literal: $from:ident($count:ident), $as:ident, $nanos:ident;)*) => {
$(
#[doc = concat!("A span of `", stringify!($count), "` ", $unit, ", saturating.")]
#[inline]
#[must_use]
pub const fn $from($count: i64) -> Self {
Self($count.saturating_mul($nanos))
}
)*
$(
#[doc = concat!("The span in whole ", $unit, ", truncated toward zero.")]
#[inline]
#[must_use]
pub const fn $as(self) -> i64 {
self.0 / $nanos
}
)*
};
}
#[repr(transparent)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
#[debug("{self}")]
#[cfg_attr(feature = "zerocopy", derive(FromBytes, IntoBytes, Immutable, KnownLayout))]
pub struct Timedelta(pub(crate) i64);
impl Timedelta {
pub const NANOSECOND: Self = Self(1);
pub const MICROSECOND: Self = Self(NANOS_PER_MICROSECOND);
pub const MILLISECOND: Self = Self(NANOS_PER_MILLISECOND);
pub const SECOND: Self = Self(NANOS_PER_SECOND);
pub const MINUTE: Self = Self(NANOS_PER_MINUTE);
pub const HOUR: Self = Self(NANOS_PER_HOUR);
pub const DAY: Self = Self(NANOS_PER_DAY);
#[inline]
#[must_use]
pub const fn from_nanos(nanos: i64) -> Self {
Self(nanos)
}
#[inline]
#[must_use]
pub const fn as_nanos(self) -> i64 {
self.0
}
span_units! {
"microseconds": from_micros(micros), as_micros, NANOS_PER_MICROSECOND;
"milliseconds": from_millis(millis), as_millis, NANOS_PER_MILLISECOND;
"seconds": from_secs(secs), as_secs, NANOS_PER_SECOND;
"minutes": from_mins(mins), as_mins, NANOS_PER_MINUTE;
"hours": from_hours(hours), as_hours, NANOS_PER_HOUR;
"days of 24 hours": from_days(days), as_days, NANOS_PER_DAY;
}
#[inline]
#[must_use]
pub const fn subsec_nanos(self) -> i32 {
subsecond_part::<1>(self.0)
}
#[inline]
#[must_use]
pub const fn subsec_micros(self) -> i32 {
subsecond_part::<NANOS_PER_MICROSECOND>(self.0)
}
#[inline]
#[must_use]
pub const fn subsec_millis(self) -> i32 {
subsecond_part::<NANOS_PER_MILLISECOND>(self.0)
}
#[inline]
#[must_use]
pub const fn to_tickdelta(self, rate: TickRate) -> Tickdelta {
Tickdelta(rate.nanos_to_ticks(self.0))
}
}
#[inline]
#[expect(
clippy::arithmetic_side_effects,
clippy::as_conversions,
reason = "every unit is a nonzero constant, and a part of a second fits `i32`; `TryFrom` is not const"
)]
const fn subsecond_part<const UNIT: i64>(nanos: i64) -> i32 {
(nanos % NANOS_PER_SECOND / UNIT) as i32
}
impl fmt::Display for Timedelta {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut spelling =
Spelling { text: ArrayString::new(), remainder: self.0.unsigned_abs(), started: false };
if self.0 == 0 {
spelling.text.write_str("0s")?;
} else if self.0 < 0 {
spelling.text.write_str("-")?;
}
spelling.write_unit::<{ NANOS_PER_DAY.unsigned_abs() }>("d")?;
spelling.write_unit::<{ NANOS_PER_HOUR.unsigned_abs() }>("h")?;
spelling.write_unit::<{ NANOS_PER_MINUTE.unsigned_abs() }>("m")?;
spelling.write_unit::<{ NANOS_PER_SECOND.unsigned_abs() }>("s")?;
spelling.write_unit::<{ NANOS_PER_MILLISECOND.unsigned_abs() }>("ms")?;
spelling.write_unit::<{ NANOS_PER_MICROSECOND.unsigned_abs() }>("us")?;
spelling.write_unit::<1>("ns")?;
pad(formatter, &spelling.text)
}
}
struct Spelling {
text: ArrayString<LONGEST>,
remainder: u64,
started: bool,
}
impl Spelling {
#[inline]
#[expect(clippy::arithmetic_side_effects, reason = "`UNIT` is a nonzero constant")]
fn write_unit<const UNIT: u64>(&mut self, suffix: &str) -> fmt::Result {
if self.remainder == 0 {
return Ok(());
}
let count = self.remainder / UNIT;
if self.started || count > 0 {
self.text.write_str(Buffer::new().format(count))?;
self.text.write_str(suffix)?;
self.started = true;
}
self.remainder %= UNIT;
Ok(())
}
}
impl FromStr for Timedelta {
type Err = ParseTimedeltaError;
fn from_str(text: &str) -> Result<Self, Self::Err> {
parse(text.as_bytes()).ok_or(ParseTimedeltaError)
}
}
fn parse(text: &[u8]) -> Option<Timedelta> {
if text == b"0" {
return Some(Timedelta::ZERO);
}
let (is_negative, mut rest) = match text {
[b'-', rest @ ..] => (true, rest),
_ => (false, text),
};
if rest.is_empty() {
return None;
}
let mut magnitude: u64 = 0;
let mut previous_unit = u64::MAX;
while !rest.is_empty() {
let (count, tail) = read_digits(rest)?;
let (unit, tail) = match tail {
[b'd', tail @ ..] => (NANOS_PER_DAY, tail),
[b'h', tail @ ..] => (NANOS_PER_HOUR, tail),
[b'm', b's', tail @ ..] => (NANOS_PER_MILLISECOND, tail),
[b'm', tail @ ..] => (NANOS_PER_MINUTE, tail),
[b's', tail @ ..] => (NANOS_PER_SECOND, tail),
[b'u', b's', tail @ ..] => (NANOS_PER_MICROSECOND, tail),
[b'n', b's', tail @ ..] => (1, tail),
_ => return None,
};
let unit = unit.unsigned_abs();
if unit >= previous_unit {
return None;
}
previous_unit = unit;
magnitude = magnitude.checked_add(count.checked_mul(unit)?)?;
rest = tail;
}
let nanos = if is_negative {
0_i64.checked_sub_unsigned(magnitude)
} else {
i64::try_from(magnitude).ok()
};
nanos.map(Timedelta)
}
fn read_digits(text: &[u8]) -> Option<(u64, &[u8])> {
let mut count: u64 = 0;
let mut rest = text;
while let [digit @ b'0'..=b'9', tail @ ..] = rest {
count = count.checked_mul(10)?.checked_add(u64::from(digit.wrapping_sub(b'0')))?;
rest = tail;
}
(rest.len() < text.len()).then_some((count, rest))
}
impl TryFrom<Timedelta> for Duration {
type Error = OutOfRangeError;
#[inline]
fn try_from(span: Timedelta) -> Result<Self, Self::Error> {
narrow(span.0).map(Self::from_nanos)
}
}
impl TryFrom<Duration> for Timedelta {
type Error = OutOfRangeError;
#[inline]
fn try_from(duration: Duration) -> Result<Self, Self::Error> {
narrow(duration.as_nanos()).map(Self)
}
}
#[cfg(test)]
mod tests {
use alloc::format;
use alloc::string::ToString as _;
use core::time::Duration;
use proptest::prelude::{any, prop_assert_eq, proptest};
use rstest::rstest;
use crate::{OutOfRangeError, ParseTimedeltaError, Timedelta};
#[test]
fn every_unit_scales_to_the_same_count() {
assert_eq!(Timedelta::from_micros(1), Timedelta::MICROSECOND, "a microsecond");
assert_eq!(Timedelta::from_millis(1), Timedelta::MILLISECOND, "a millisecond");
assert_eq!(Timedelta::from_secs(1), Timedelta::SECOND, "a second");
assert_eq!(Timedelta::from_mins(1), Timedelta::MINUTE, "a minute");
assert_eq!(Timedelta::from_hours(1), Timedelta::HOUR, "an hour");
assert_eq!(Timedelta::from_days(1), Timedelta::DAY, "a day");
assert_eq!(Timedelta::from_days(2).as_hours(), 48, "a coarser unit, counted finer");
assert_eq!(
Timedelta::from_secs(i64::MAX),
Timedelta::MAX,
"a count past the range saturates"
);
}
#[test]
fn a_backwards_span_counts_its_whole_units_toward_zero() {
let span = Timedelta::from_nanos(-1_234_567_890);
assert_eq!(span.as_secs(), -1, "whole seconds");
assert_eq!(span.as_millis(), -1_234, "whole milliseconds");
assert_eq!(span.subsec_nanos(), -234_567_890, "the nanoseconds past the seconds");
assert_eq!(span.subsec_micros(), -234_567, "the microseconds past them");
assert_eq!(span.subsec_millis(), -234, "and the milliseconds");
}
#[rstest]
#[case::zero(0, "0s")]
#[case::a_day(86_400_000_000_000, "1d")]
#[case::a_day_and_an_hour(90_000_000_000_000, "1d1h")]
#[case::a_gap_inside(60_123_000_000, "1m0s123ms")]
#[case::every_finer_unit(67_123_456_789, "1m7s123ms456us789ns")]
#[case::backwards(-3_000, "-3us")]
#[case::the_longest_backwards(i64::MIN, "-106751d23h47m16s854ms775us808ns")]
fn a_span_is_spelled_coarsest_unit_first(#[case] nanos: i64, #[case] text: &str) {
let span = Timedelta::from_nanos(nanos);
assert_eq!(span.to_string(), text, "written");
assert_eq!(text.parse(), Ok(span), "and read back");
}
#[test]
fn a_reading_may_skip_a_coarser_unit_or_be_a_bare_zero() {
assert_eq!("0".parse(), Ok(Timedelta::ZERO), "a bare zero");
assert_eq!("48h".parse(), Ok(Timedelta::from_days(2)), "a count past the next unit up");
}
#[test]
fn a_width_pads_the_spelling() {
let span = Timedelta::from_millis(500);
assert_eq!(format!("[{span:8}]"), "[500ms ]", "to the left by default");
assert_eq!(format!("[{span:>8}]"), "[ 500ms]", "to the right");
assert_eq!(format!("[{span:-^9}]"), "[--500ms--]", "and centred, with a fill");
}
#[rstest]
#[case::empty("")]
#[case::a_sign_alone("-")]
#[case::no_unit("1")]
#[case::no_count("s")]
#[case::a_unit_twice("1m1m")]
#[case::finest_first("1ns2s")]
#[case::spaced("5 minutes")]
#[case::an_unknown_unit("1msec")]
#[case::a_backwards_zero("-0")]
#[case::a_sign_display_never_writes("+1s")]
#[case::past_the_range("9223372036854775808ns")]
#[case::a_count_past_any_unit("99999999999999999999999ns")]
fn a_malformed_span_is_refused(#[case] text: &str) {
assert_eq!(text.parse::<Timedelta>(), Err(ParseTimedeltaError), "{text:?} is refused");
}
#[test]
fn a_duration_crosses_both_ways_and_a_negative_span_is_refused() {
let (span, duration) = (Timedelta::from_millis(5), Duration::from_millis(5));
assert_eq!(Duration::try_from(span), Ok(duration), "a span to a duration");
assert_eq!(Timedelta::try_from(duration), Ok(span), "and back");
assert_eq!(Duration::try_from(-span), Err(OutOfRangeError), "no duration runs backwards");
assert_eq!(Timedelta::try_from(Duration::MAX), Err(OutOfRangeError), "nor past the range");
}
proptest! {
#[test]
fn every_span_reads_back_from_its_spelling(nanos in any::<i64>()) {
let span = Timedelta::from_nanos(nanos);
prop_assert_eq!(span.to_string().parse(), Ok(span), "the spelling reads back");
}
#[test]
fn whatever_any_text_reads_as_reads_back_the_same(text in ".{0,40}") {
if let Ok(span) = text.parse::<Timedelta>() {
prop_assert_eq!(span.to_string().parse(), Ok(span), "what was read writes back");
}
}
}
}