#![allow(deprecated)]
use core::fmt;
use core::marker::PhantomData;
use std::num::NonZeroU64;
use std::ops::ControlFlow;
use easy_cast::Conv;
use serde::de::{self, Visitor};
use serde::{Deserializer, Serializer};
use serde_with::{DeserializeAs, SerializeAs};
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("duration of {secs}s + {nsecs}ns is out of range")]
pub struct DurationOverflow {
pub secs: u64,
pub nsecs: u64,
}
pub trait DurationExt<D> {
fn try_new(secs: u64, nsecs: u64) -> Result<D, DurationOverflow>;
fn saturating_from_nanos_i64(nanos: i64) -> D;
fn display(self) -> DurationDisplay;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DurationStyle {
#[default]
LargestUnit,
Stopwatch,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DurationDisplay {
duration: std::time::Duration,
style: DurationStyle,
}
impl DurationDisplay {
#[must_use]
pub const fn largest_unit(mut self) -> Self {
self.style = DurationStyle::LargestUnit;
self
}
#[must_use]
pub const fn stopwatch(mut self) -> Self {
self.style = DurationStyle::Stopwatch;
self
}
fn fmt_largest_unit(self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fn item(unit: &'static str, value: u64) -> ControlFlow<(&'static str, u64)> {
if value > 0 {
ControlFlow::Break((unit, value))
} else {
ControlFlow::Continue(())
}
}
fn segments(d: std::time::Duration) -> ControlFlow<(&'static str, u64), ()> {
let secs = d.as_secs();
let nanos = d.subsec_nanos();
let years = secs / 31_557_600; let year_days = secs % 31_557_600;
let months = year_days / 2_630_016; let month_days = year_days % 2_630_016;
let days = month_days / 86400;
let day_secs = month_days % 86400;
let hours = day_secs / 3600;
let minutes = day_secs % 3600 / 60;
let seconds = day_secs % 60;
let millis = nanos / 1_000_000;
let micros = nanos / 1_000;
item("y", years)?;
item("mo", months)?;
item("d", days)?;
item("h", hours)?;
item("m", minutes)?;
item("s", seconds)?;
item("ms", u64::from(millis))?;
item("us", u64::from(micros))?;
item("ns", u64::from(nanos))?;
ControlFlow::Continue(())
}
match segments(self.duration) {
ControlFlow::Break((unit, value)) => write!(f, "{value}{unit}"),
ControlFlow::Continue(()) => write!(f, "0s"),
}
}
fn fmt_stopwatch(self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let total_secs = self.duration.as_secs();
let millis = self.duration.subsec_millis();
if total_secs >= 3600 {
let hours = total_secs / 3600;
let mins = (total_secs % 3600) / 60;
let secs = total_secs % 60;
write!(f, "{hours}h{mins}m{secs}s")
} else if total_secs >= 60 {
let mins = total_secs / 60;
let secs = total_secs % 60;
write!(f, "{mins}m{secs}s")
} else if total_secs > 0 {
if millis > 0 {
write!(f, "{total_secs}.{millis:03}s")
} else {
write!(f, "{total_secs}s")
}
} else {
write!(f, "{millis}ms")
}
}
}
impl fmt::Display for DurationDisplay {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.style {
DurationStyle::LargestUnit => self.fmt_largest_unit(f),
DurationStyle::Stopwatch => self.fmt_stopwatch(f),
}
}
}
impl DurationExt<Self> for std::time::Duration {
#[allow(clippy::disallowed_methods)]
fn try_new(secs: u64, nsecs: u64) -> Result<Self, DurationOverflow> {
let carry = nsecs / 1_000_000_000;
let nanos = u32::conv(nsecs % 1_000_000_000);
let secs = secs.checked_add(carry).ok_or(DurationOverflow { secs, nsecs })?;
Ok(Self::new(secs, nanos))
}
fn saturating_from_nanos_i64(nanos: i64) -> Self {
Self::from_nanos(nanos.max(0).cast_unsigned())
}
fn display(self) -> DurationDisplay {
DurationDisplay {
duration: self,
style: DurationStyle::default(),
}
}
}
impl DurationExt<Self> for time::Duration {
fn try_new(secs: u64, nsecs: u64) -> Result<Self, DurationOverflow> {
let std = std::time::Duration::try_new(secs, nsecs)?;
Self::try_from(std).map_err(|_| DurationOverflow { secs, nsecs })
}
fn saturating_from_nanos_i64(nanos: i64) -> Self {
Self::nanoseconds(nanos.max(0))
}
fn display(self) -> DurationDisplay {
std::time::Duration::try_from(self).unwrap_or_default().display()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct NonZeroDuration(std::time::Duration);
impl NonZeroDuration {
#[must_use]
pub const fn new(duration: std::time::Duration) -> Option<Self> {
if duration.is_zero() {
None
} else {
Some(Self(duration))
}
}
#[must_use]
pub const fn from_secs(secs: NonZeroU64) -> Self {
Self(std::time::Duration::from_secs(secs.get()))
}
#[must_use]
pub const fn get(self) -> std::time::Duration {
self.0
}
}
pub trait DurationUnit {
const SECS_PER_UNIT: u64;
}
pub enum Seconds {}
pub enum Minutes {}
pub enum Days {}
impl DurationUnit for Seconds {
const SECS_PER_UNIT: u64 = 1;
}
impl DurationUnit for Minutes {
const SECS_PER_UNIT: u64 = 60;
}
impl DurationUnit for Days {
const SECS_PER_UNIT: u64 = 86_400;
}
fn parse_duration_in<'de, U, D>(
deserializer: D,
clamp_negative_to_zero: bool,
) -> Result<std::time::Duration, D::Error>
where
U: DurationUnit,
D: Deserializer<'de>,
{
struct DurationVisitor<U> {
clamp_negative_to_zero: bool,
_unit: PhantomData<U>,
}
impl<U: DurationUnit> Visitor<'_> for DurationVisitor<U> {
type Value = std::time::Duration;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a number, or a duration string like \"500ms\"")
}
fn visit_u64<E: de::Error>(self, n: u64) -> Result<Self::Value, E> {
n.checked_mul(U::SECS_PER_UNIT)
.map(std::time::Duration::from_secs)
.ok_or_else(|| E::custom("duration is too large"))
}
fn visit_i64<E: de::Error>(self, n: i64) -> Result<Self::Value, E> {
match u64::try_from(n) {
Ok(n) => self.visit_u64(n),
Err(_) if self.clamp_negative_to_zero => Ok(std::time::Duration::ZERO),
Err(_) => Err(E::custom(format!("duration cannot be negative: {n}"))),
}
}
fn visit_f64<E: de::Error>(self, n: f64) -> Result<Self::Value, E> {
if n < 0.0 && self.clamp_negative_to_zero {
return Ok(std::time::Duration::ZERO);
}
if !n.is_finite() || n < 0.0 {
return Err(E::custom(format!("invalid duration: {n}")));
}
std::time::Duration::try_from_secs_f64(n * U::SECS_PER_UNIT as f64)
.map_err(|_| E::custom("duration is out of range"))
}
fn visit_str<E: de::Error>(self, value: &str) -> Result<Self::Value, E> {
if let Ok(duration) = humantime::parse_duration(value) {
return Ok(duration);
}
if let Ok(n) = value.parse::<u64>() {
return self.visit_u64(n);
}
if let Ok(n) = value.parse::<f64>() {
return self.visit_f64(n);
}
Err(E::custom(format!("invalid duration: {value:?}")))
}
}
deserializer.deserialize_any(DurationVisitor::<U> {
clamp_negative_to_zero,
_unit: PhantomData,
})
}
impl serde::Serialize for NonZeroDuration {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&humantime::format_duration(self.0).to_string())
}
}
impl<'de> serde::Deserialize<'de> for NonZeroDuration {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
Self::new(parse_duration_in::<Seconds, D>(deserializer, false)?)
.ok_or_else(|| de::Error::custom("duration must be non-zero"))
}
}
#[deprecated(note = "accepts a bare, unit-less number only for backward compatibility with older \
configs; write durations as unit strings like \"30s\", \"5m\", or \"4d\". \
New config fields should require unit strings rather than adding \
bare-number parsing.")]
pub struct AsDuration<U>(PhantomData<U>);
impl<'de, U: DurationUnit> DeserializeAs<'de, std::time::Duration> for AsDuration<U> {
fn deserialize_as<D: Deserializer<'de>>(
deserializer: D,
) -> Result<std::time::Duration, D::Error> {
parse_duration_in::<U, D>(deserializer, false)
}
}
impl<'de, U: DurationUnit> DeserializeAs<'de, NonZeroDuration> for AsDuration<U> {
fn deserialize_as<D: Deserializer<'de>>(deserializer: D) -> Result<NonZeroDuration, D::Error> {
NonZeroDuration::new(parse_duration_in::<U, D>(deserializer, false)?)
.ok_or_else(|| de::Error::custom("duration must be non-zero"))
}
}
impl<U> SerializeAs<std::time::Duration> for AsDuration<U> {
fn serialize_as<S: Serializer>(
source: &std::time::Duration,
serializer: S,
) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&humantime::format_duration(*source).to_string())
}
}
impl<U> SerializeAs<NonZeroDuration> for AsDuration<U> {
fn serialize_as<S: Serializer>(
source: &NonZeroDuration,
serializer: S,
) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&humantime::format_duration(source.get()).to_string())
}
}
#[deprecated(note = "accepts a bare, unit-less number only for backward compatibility with older \
configs; write durations as unit strings like \"30s\" or \"5m\" (or \"0\" \
to disable). New config fields should require unit strings rather than \
adding bare-number parsing.")]
pub struct AsDisableableDuration<U>(PhantomData<U>);
impl<'de, U: DurationUnit> DeserializeAs<'de, Option<NonZeroDuration>>
for AsDisableableDuration<U>
{
fn deserialize_as<D: Deserializer<'de>>(
deserializer: D,
) -> Result<Option<NonZeroDuration>, D::Error> {
Ok(NonZeroDuration::new(parse_duration_in::<U, D>(deserializer, true)?))
}
}
impl<U> SerializeAs<Option<NonZeroDuration>> for AsDisableableDuration<U> {
fn serialize_as<S: Serializer>(
source: &Option<NonZeroDuration>,
serializer: S,
) -> Result<S::Ok, S::Error> {
match source {
Some(duration) => {
serializer.serialize_str(&humantime::format_duration(duration.get()).to_string())
}
None => serializer.serialize_str("0"),
}
}
}
#[cfg(test)]
mod tests {
use proptest::prelude::*;
use rstest::rstest;
use super::*;
#[rstest]
fn nonzero_duration_rejects_zero() {
assert!(NonZeroDuration::new(std::time::Duration::ZERO).is_none());
assert_eq!(
NonZeroDuration::new(std::time::Duration::from_secs(5)).map(NonZeroDuration::get),
Some(std::time::Duration::from_secs(5)),
);
}
#[rstest]
fn disableable_duration_marker_uses_unit_and_zero_is_none() {
use serde::{Deserialize, Serialize};
use serde_with::serde_as;
#[serde_as]
#[derive(Serialize, Deserialize, PartialEq, Debug)]
struct Holder {
#[serde_as(as = "AsDisableableDuration<Seconds>")]
secs: Option<NonZeroDuration>,
#[serde_as(as = "AsDisableableDuration<Minutes>")]
mins: Option<NonZeroDuration>,
}
let of = |json: &str| serde_json::from_str::<Holder>(json).unwrap();
let d = |ms| NonZeroDuration::new(std::time::Duration::from_millis(ms));
let h = of(r#"{"secs":300,"mins":0}"#);
assert_eq!(h.secs, d(300_000));
assert_eq!(h.mins, None);
let h = of(r#"{"secs":0,"mins":5}"#);
assert_eq!(h.secs, None);
assert_eq!(h.mins, d(300_000));
let h = of(r#"{"secs":"500ms","mins":"90s"}"#);
assert_eq!(h.secs, d(500));
assert_eq!(h.mins, d(90_000));
let h = Holder {
secs: d(500),
mins: d(300_000),
};
let json = serde_json::to_string(&h).unwrap();
assert_eq!(serde_json::from_str::<Holder>(&json).unwrap(), h);
}
#[rstest]
fn disableable_duration_treats_negative_as_none() {
use serde::Deserialize;
use serde_with::serde_as;
#[serde_as]
#[derive(Deserialize)]
struct Holder {
#[serde_as(as = "AsDisableableDuration<Minutes>")]
window: Option<NonZeroDuration>,
#[serde_as(as = "AsDuration<Seconds>")]
strict: std::time::Duration,
}
let h: Holder = serde_json::from_str(r#"{"window":-5,"strict":1}"#).unwrap();
assert_eq!(h.window, None);
assert_eq!(h.strict, std::time::Duration::from_secs(1));
let h: Holder = serde_json::from_str(r#"{"window":"-5","strict":1}"#).unwrap();
assert_eq!(h.window, None);
assert!(serde_json::from_str::<Holder>(r#"{"window":1,"strict":-1}"#).is_err());
}
#[rstest]
fn required_duration_marker_uses_unit_and_zero_policy() {
use serde::Deserialize;
use serde_with::serde_as;
#[serde_as]
#[derive(Deserialize)]
struct Holder {
#[serde_as(as = "AsDuration<Days>")]
retention: std::time::Duration,
#[serde_as(as = "AsDuration<Seconds>")]
timeout: NonZeroDuration,
}
let h: Holder = serde_json::from_str(r#"{"retention":2,"timeout":"1500ms"}"#).unwrap();
assert_eq!(h.retention, std::time::Duration::from_secs(2 * 86_400));
assert_eq!(h.timeout.get(), std::time::Duration::from_millis(1500));
let h: Holder = serde_json::from_str(r#"{"retention":0,"timeout":30}"#).unwrap();
assert_eq!(h.retention, std::time::Duration::ZERO);
assert!(serde_json::from_str::<Holder>(r#"{"retention":2,"timeout":0}"#).is_err());
}
#[rstest]
fn nonzero_duration_deserialize_rejects_zero() {
assert!(serde_json::from_str::<NonZeroDuration>("0").is_err());
assert_eq!(
serde_json::from_str::<NonZeroDuration>(r#""5m""#).unwrap().get(),
std::time::Duration::from_secs(300),
);
assert_eq!(
serde_json::from_str::<NonZeroDuration>("300").unwrap().get(),
std::time::Duration::from_secs(300),
);
}
#[rstest]
#[case::zero(0, 0)]
#[case::positive(1_500_000_000, 1_500_000_000)]
#[case::negative_clamps_to_zero(-1, 0)]
#[case::min_clamps_to_zero(i64::MIN, 0)]
#[case::max(i64::MAX, u128::conv(i64::MAX))]
fn saturating_from_nanos_i64_clamps(#[case] nanos: i64, #[case] expected: u128) {
assert_eq!(std::time::Duration::saturating_from_nanos_i64(nanos).as_nanos(), expected);
assert_eq!(
<time::Duration as DurationExt<_>>::saturating_from_nanos_i64(nanos)
.whole_nanoseconds(),
i128::conv(expected)
);
}
#[rstest]
#[case::whole_seconds(5, 0, 5_000_000_000)]
#[case::carries_up(1, 2_500_000_000, 3_500_000_000)]
#[case::only_nanos(0, 1, 1)]
fn try_new_sums_components(#[case] secs: u64, #[case] nsecs: u64, #[case] expected: u128) {
assert_eq!(std::time::Duration::try_new(secs, nsecs).unwrap().as_nanos(), expected);
assert_eq!(
<time::Duration as DurationExt<_>>::try_new(secs, nsecs).unwrap().whole_nanoseconds(),
i128::conv(expected)
);
}
#[rstest]
#[case::max_u64_seconds(u64::MAX, 0, true)]
#[case::largest_nanos_without_carry(u64::MAX, 999_999_999, true)]
#[case::carry_overflows_u64_seconds(u64::MAX, 1_000_000_000, false)]
fn std_try_new_range(#[case] secs: u64, #[case] nsecs: u64, #[case] representable: bool) {
assert_eq!(std::time::Duration::try_new(secs, nsecs).is_ok(), representable);
}
#[rstest]
#[case::max_i64_seconds(u64::conv(i64::MAX), 0, true)]
#[case::one_second_past_i64(u64::conv(i64::MAX) + 1, 0, false)]
#[case::carry_crosses_i64(u64::conv(i64::MAX), 1_000_000_000, false)]
#[case::past_u64_seconds(u64::MAX, 0, false)]
#[case::carry_overflows_u64_seconds(u64::MAX, 1_000_000_000, false)]
fn time_try_new_range(#[case] secs: u64, #[case] nsecs: u64, #[case] representable: bool) {
assert_eq!(<time::Duration as DurationExt<_>>::try_new(secs, nsecs).is_ok(), representable);
}
#[rstest]
#[case::zero(0, "0s")]
#[case::sub_second(814_000_000, "814ms")]
#[case::seconds(1_500_000_000, "1s")]
#[case::minutes(90_000_000_000, "1m")]
#[case::truncates_not_rounds(7_199_000_000_000, "1h")]
fn format_duration_shows_most_significant_unit(#[case] nanos: u64, #[case] expected: &str) {
assert_eq!(
std::time::Duration::from_nanos(nanos).display().largest_unit().to_string(),
expected
);
}
#[rstest]
#[case::zero(0, "0ms")]
#[case::millis(5_000_000, "5ms")]
#[case::sub_second_only(814_000_000, "814ms")]
#[case::whole_second(1_000_000_000, "1s")]
#[case::fractional_second(1_234_000_000, "1.234s")]
#[case::minutes(90_000_000_000, "1m30s")]
#[case::hours(3_723_000_000_000, "1h2m3s")]
#[case::days_stay_in_hours(259_200_000_000_000, "72h0m0s")]
fn stopwatch_keeps_subsecond_resolution(#[case] nanos: i64, #[case] expected: &str) {
assert_eq!(
std::time::Duration::saturating_from_nanos_i64(nanos).display().stopwatch().to_string(),
expected
);
}
#[rstest]
fn stopwatch_clamps_a_negative_time_duration() {
let negative = time::Duration::nanoseconds(-5_000_000_000);
assert_eq!(negative.display().stopwatch().to_string(), "0ms");
}
proptest! {
#[rstest]
fn std_try_new_is_total(secs in any::<u64>(), nsecs in any::<u64>()) {
let expected = u128::from(secs) * 1_000_000_000 + u128::from(nsecs);
match std::time::Duration::try_new(secs, nsecs) {
Ok(d) => prop_assert_eq!(d.as_nanos(), expected),
Err(e) => {
prop_assert_eq!(e, DurationOverflow { secs, nsecs });
prop_assert!(expected / 1_000_000_000 > u128::from(u64::MAX));
}
}
}
#[rstest]
fn time_try_new_tracks_std_try_new(secs in any::<u64>(), nsecs in any::<u64>()) {
let std_result = std::time::Duration::try_new(secs, nsecs);
let time_result = <time::Duration as DurationExt<_>>::try_new(secs, nsecs);
match (std_result, time_result) {
(Ok(s), Ok(t)) => {
prop_assert_eq!(t.whole_nanoseconds(), i128::conv(s.as_nanos()));
}
(Ok(s), Err(_)) => prop_assert!(s.as_secs() > u64::conv(i64::MAX)),
(Err(_), Err(_)) => {}
(Err(_), Ok(_)) => prop_assert!(false, "time succeeded where std failed"),
}
}
}
}