use core::{fmt, str::FromStr};
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default)]
pub struct Percent(f64);
impl Percent {
pub const ZERO: Self = Self(0.0);
pub const HUNDRED: Self = Self(100.0);
pub fn new(value: f64) -> Self {
Self(value.clamp(0.0, 100.0))
}
pub fn from_fraction(f: f64) -> Self {
Self::new(f * 100.0)
}
#[inline]
pub const fn as_percent(self) -> f64 {
self.0
}
#[inline]
pub fn as_fraction(self) -> f64 {
self.0 / 100.0
}
pub fn as_kernel_probability(self) -> u32 {
((self.0 / 100.0) * (u32::MAX as f64)) as u32
}
#[inline]
pub fn is_zero(self) -> bool {
self.0 == 0.0
}
}
impl fmt::Display for Percent {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.0.fract() == 0.0 {
write!(f, "{}%", self.0 as u64)
} else {
write!(f, "{}%", self.0)
}
}
}
impl FromStr for Percent {
type Err = PercentParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let s = s.trim();
let s = s.strip_suffix('%').unwrap_or(s);
let value: f64 = s
.parse()
.map_err(|_| PercentParseError::Invalid(s.to_string()))?;
if !value.is_finite() {
return Err(PercentParseError::Invalid(s.to_string()));
}
Ok(Self::new(value))
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum PercentParseError {
#[error("invalid percent string: {0}")]
Invalid(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn percent_construction_clamps() {
assert_eq!(Percent::new(50.0).as_percent(), 50.0);
assert_eq!(Percent::new(150.0).as_percent(), 100.0);
assert_eq!(Percent::new(-1.0).as_percent(), 0.0);
}
#[test]
fn percent_from_fraction() {
assert_eq!(Percent::from_fraction(0.5).as_percent(), 50.0);
assert_eq!(Percent::from_fraction(1.0).as_percent(), 100.0);
assert_eq!(Percent::from_fraction(2.0).as_percent(), 100.0); }
#[test]
fn percent_accessors() {
let p = Percent::new(25.0);
assert_eq!(p.as_percent(), 25.0);
assert_eq!(p.as_fraction(), 0.25);
}
#[test]
fn percent_kernel_probability() {
assert_eq!(Percent::ZERO.as_kernel_probability(), 0);
assert_eq!(Percent::HUNDRED.as_kernel_probability(), u32::MAX);
let mid = Percent::new(50.0).as_kernel_probability();
assert!(mid.abs_diff(u32::MAX / 2) <= 1);
}
#[test]
fn percent_display_integer() {
assert_eq!(Percent::ZERO.to_string(), "0%");
assert_eq!(Percent::new(50.0).to_string(), "50%");
assert_eq!(Percent::HUNDRED.to_string(), "100%");
}
#[test]
fn percent_display_fractional() {
assert_eq!(Percent::new(1.5).to_string(), "1.5%");
assert_eq!(Percent::new(0.25).to_string(), "0.25%");
}
#[test]
fn percent_fromstr_with_suffix() {
let p: Percent = "50%".parse().unwrap();
assert_eq!(p.as_percent(), 50.0);
}
#[test]
fn percent_fromstr_no_suffix() {
let p: Percent = "50".parse().unwrap();
assert_eq!(p.as_percent(), 50.0);
}
#[test]
fn percent_fromstr_fractional() {
let p: Percent = "1.5".parse().unwrap();
assert_eq!(p.as_percent(), 1.5);
}
#[test]
fn percent_fromstr_clamps() {
let p: Percent = "150%".parse().unwrap();
assert_eq!(p.as_percent(), 100.0);
}
#[test]
fn percent_fromstr_rejects_garbage() {
assert!("abc".parse::<Percent>().is_err());
assert!("".parse::<Percent>().is_err());
assert!("nan".parse::<Percent>().is_err());
assert!("inf".parse::<Percent>().is_err());
}
#[test]
fn percent_is_zero() {
assert!(Percent::ZERO.is_zero());
assert!(!Percent::new(0.001).is_zero());
}
}