use core::fmt;
#[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(transparent))]
pub struct Percent(f32);
impl Percent {
pub const ZERO: Self = Self(0.0);
pub const FULL: Self = Self(100.0);
#[must_use]
pub fn new(value: f32) -> Option<Self> {
if value.is_finite() && value >= 0.0 {
Some(Self(value))
} else {
None
}
}
#[must_use]
pub fn ratio(part: u64, whole: u64) -> Option<Self> {
if whole == 0 {
return None;
}
#[allow(clippy::cast_possible_truncation)]
let percent = (part as f64 / whole as f64 * 100.0) as f32;
Self::new(percent)
}
#[must_use]
pub fn clamped_to_100(self) -> Self {
Self(self.0.min(100.0))
}
#[must_use]
pub const fn value(self) -> f32 {
self.0
}
#[must_use]
pub fn fraction(self) -> f32 {
self.0 / 100.0
}
#[must_use]
pub fn points_from(self, other: Self) -> f32 {
self.0 - other.0
}
}
impl fmt::Display for Percent {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.0 < 10.0 && self.0 != 0.0 {
write!(f, "{:.1}%", self.0)
} else {
write!(f, "{:.0}%", self.0)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_non_finite_and_negative() {
assert!(Percent::new(f32::NAN).is_none());
assert!(Percent::new(f32::INFINITY).is_none());
assert!(Percent::new(-0.5).is_none());
assert!(Percent::new(0.0).is_some());
assert!(Percent::new(287.0).is_some(), "process CPU may exceed 100%");
}
#[test]
fn zero_whole_is_undefined_not_zero() {
assert!(Percent::ratio(0, 0).is_none());
assert!(Percent::ratio(5, 0).is_none());
}
#[test]
fn ratio_computes_expected_values() {
let p = Percent::ratio(1, 4).expect("4 is non-zero");
assert!((p.value() - 25.0).abs() < f32::EPSILON);
}
#[test]
fn ratio_survives_counters_that_overflow_f32_precision() {
let p = Percent::ratio(u64::MAX / 2, u64::MAX).expect("non-zero whole");
assert!((p.value() - 50.0).abs() < 0.01, "got {}", p.value());
}
#[test]
fn clamping_only_affects_the_upper_bound() {
let p = Percent::new(287.0).expect("valid");
assert!((p.clamped_to_100().value() - 100.0).abs() < f32::EPSILON);
let q = Percent::new(37.0).expect("valid");
assert!((q.clamped_to_100().value() - 37.0).abs() < f32::EPSILON);
}
#[test]
fn display_adds_a_decimal_only_below_ten_percent() {
assert_eq!(Percent::new(0.0).expect("valid").to_string(), "0%");
assert_eq!(Percent::new(4.2).expect("valid").to_string(), "4.2%");
assert_eq!(Percent::new(37.4).expect("valid").to_string(), "37%");
assert_eq!(Percent::new(287.0).expect("valid").to_string(), "287%");
}
}