use core::num::NonZeroU64;
use core::str::FromStr;
use derive_more::{Debug, Display};
#[cfg(feature = "zerocopy")]
use zerocopy::{Immutable, KnownLayout};
use crate::ParseTickRateError;
use crate::consts::NANOS_PER_SECOND;
use crate::spelling::{Count, read_count};
#[derive(Debug, Display, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[display("{}", Count(hertz.get(), " Hz"))]
#[debug("{self}")]
#[cfg_attr(feature = "zerocopy", derive(Immutable, KnownLayout))]
pub struct TickRate {
hertz: NonZeroU64,
ticks_to_nanos: Scale,
nanos_to_ticks: Scale,
}
impl TickRate {
pub const GIGAHERTZ: Self = Self::from_hertz(1_000_000_000).expect("a billion is no zero");
#[inline]
#[must_use]
pub const fn from_hertz(hertz: u64) -> Option<Self> {
let Some(nonzero) = NonZeroU64::new(hertz) else {
return None;
};
let nanos_per_second = NANOS_PER_SECOND.unsigned_abs();
Some(Self {
hertz: nonzero,
ticks_to_nanos: Scale::new(nanos_per_second, hertz),
nanos_to_ticks: Scale::new(hertz, nanos_per_second),
})
}
#[inline]
#[must_use]
pub const fn as_hertz(self) -> u64 {
self.hertz.get()
}
#[inline]
pub(crate) const fn ticks_to_nanos(self, ticks: i64) -> i64 {
self.ticks_to_nanos.apply(ticks)
}
#[inline]
pub(crate) const fn nanos_to_ticks(self, nanos: i64) -> i64 {
self.nanos_to_ticks.apply(nanos)
}
}
impl FromStr for TickRate {
type Err = ParseTickRateError;
fn from_str(text: &str) -> Result<Self, Self::Err> {
let hertz = text.strip_suffix(" Hz").and_then(read_count);
hertz.and_then(Self::from_hertz).ok_or(ParseTickRateError)
}
}
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "zerocopy", derive(Immutable, KnownLayout))]
struct Scale {
factor: u64,
shift: u32,
}
impl Scale {
const fn new(numerator: u64, denominator: u64) -> Self {
let numerator = widen(numerator);
let denominator = widen(denominator);
let mut shift = 64_u32
.saturating_add(numerator.leading_zeros())
.saturating_sub(denominator.leading_zeros());
while shift > 0 && !fits_u64(quotient(numerator, denominator, shift)) {
shift = shift.saturating_sub(1);
}
Self { factor: narrow(quotient(numerator, denominator, shift)), shift }
}
#[inline]
const fn apply(self, count: i64) -> i64 {
let magnitude =
widen(count.unsigned_abs()).wrapping_mul(widen(self.factor)).wrapping_shr(self.shift);
let magnitude = if fits_u64(magnitude) { narrow(magnitude) } else { u64::MAX };
if count < 0 {
0_i64.saturating_sub_unsigned(magnitude)
} else {
0_i64.saturating_add_unsigned(magnitude)
}
}
}
const fn quotient(numerator: u128, denominator: u128, shift: u32) -> u128 {
numerator.wrapping_shl(shift).div_ceil(denominator)
}
const fn widen(value: u64) -> u128 {
#[expect(clippy::as_conversions, reason = "widening is lossless; `From` is not const")]
let widened = value as u128;
widened
}
const fn fits_u64(value: u128) -> bool {
value <= widen(u64::MAX)
}
const fn narrow(value: u128) -> u64 {
#[expect(
clippy::as_conversions,
clippy::cast_possible_truncation,
reason = "every caller checks the value fits; `TryFrom` is not const"
)]
let narrowed = value as u64;
narrowed
}
#[cfg(test)]
mod tests {
use alloc::format;
use alloc::string::ToString as _;
use proptest::prelude::{any, prop_assert, proptest};
use rstest::rstest;
use crate::{ParseTickRateError, TickRate, Tickdelta, Timedelta};
fn rate(hertz: u64) -> TickRate {
TickRate::from_hertz(hertz).expect("a nonzero rate")
}
fn exact(count: i64, numerator: u64, denominator: u64) -> i128 {
i128::from(count)
.checked_mul(i128::from(numerator))
.and_then(|product| product.checked_div(i128::from(denominator)))
.expect("a 64-bit product fits 128 bits, over a nonzero denominator")
}
#[rstest]
#[case::apple_silicon(24_000_000)]
#[case::armv8_6(1_000_000_000)]
#[case::a_tsc(3_000_000_000)]
#[case::a_tsc_from_its_crystal(3_379_200_000)]
fn a_second_of_ticks_is_a_second(#[case] hertz: u64) {
let second = Tickdelta::from_ticks(i64::try_from(hertz).expect("a rate below `i64::MAX`"));
assert_eq!(second.to_timedelta(rate(hertz)), Timedelta::SECOND, "a second of ticks");
assert_eq!(Timedelta::SECOND.to_tickdelta(rate(hertz)), second, "and back, exactly");
}
#[test]
fn a_backwards_count_converts_backwards() {
let span = Tickdelta::from_ticks(-24).to_timedelta(rate(24_000_000));
assert_eq!(span, -Timedelta::MICROSECOND, "ticks to nanoseconds");
let ticks = (-Timedelta::MICROSECOND).to_tickdelta(rate(3_000_000_000));
assert_eq!(ticks, Tickdelta::from_ticks(-3_000), "and nanoseconds to ticks");
}
#[test]
fn a_conversion_past_the_range_saturates() {
assert_eq!(Tickdelta::MAX.to_timedelta(rate(1)), Timedelta::MAX, "forwards");
assert_eq!(Tickdelta::MIN.to_timedelta(rate(1)), Timedelta::MIN, "backwards");
assert_eq!(
Timedelta::MAX.to_tickdelta(rate(u64::MAX)),
Tickdelta::MAX,
"the other way about"
);
}
#[test]
fn a_rate_reads_back_from_its_hertz() {
assert_eq!(rate(24_000_000).to_string(), "24000000 Hz", "written as its hertz");
assert_eq!("24000000 Hz".parse(), Ok(rate(24_000_000)), "and read back");
assert_eq!(TickRate::GIGAHERTZ.as_hertz(), 1_000_000_000, "a gigahertz in hertz");
assert_eq!(TickRate::from_hertz(0), None, "a counter that never ticks");
}
#[test]
fn a_width_pads_the_rate_with_its_unit() {
assert_eq!(format!("[{:>8}]", rate(24)), "[ 24 Hz]", "to the right");
assert_eq!(format!("{:?}", rate(24)), "24 Hz", "and debugged as displayed");
}
#[rstest]
#[case::zero("0 Hz")]
#[case::no_unit("24000000")]
#[case::a_sign_display_never_writes("+24 Hz")]
#[case::kilohertz("24 kHz")]
#[case::past_the_range("18446744073709551616 Hz")]
fn a_malformed_rate_is_refused(#[case] text: &str) {
assert_eq!(text.parse::<TickRate>(), Err(ParseTickRateError), "{text:?} is refused");
}
proptest! {
#[test]
fn a_conversion_is_within_one_unit_of_the_exact_quotient(
count in any::<i64>(),
hertz in 1_u64..,
) {
let rate = rate(hertz);
let nanos = Tickdelta::from_ticks(count).to_timedelta(rate).as_nanos();
let ticks = Timedelta::from_nanos(count).to_tickdelta(rate).as_ticks();
for (actual, exact) in [
(nanos, exact(count, 1_000_000_000, hertz)),
(ticks, exact(count, hertz, 1_000_000_000)),
] {
let exact = exact.clamp(i128::from(i64::MIN), i128::from(i64::MAX));
prop_assert!(i128::from(actual).abs_diff(exact) <= 1, "{actual} against {exact}");
}
}
}
}