Skip to main content

monitrs_core/units/
percent.rs

1//! Validated percentage values.
2//!
3//! Percentages are *calculated* values, so §10.4 permits floating point here.
4//! Raw counters and timestamps must never use these types.
5
6use core::fmt;
7
8/// A non-negative, finite percentage.
9///
10/// Deliberately **not** clamped to `0..=100`: process CPU under the default
11/// `"core"` normalization legitimately exceeds 100% for a multi-threaded
12/// process (§8.3). Meters that need a bounded value call
13/// [`Percent::clamped_to_100`].
14#[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd)]
15#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
16#[cfg_attr(feature = "serde", serde(transparent))]
17pub struct Percent(f32);
18
19impl Percent {
20    /// Zero percent.
21    pub const ZERO: Self = Self(0.0);
22    /// One hundred percent.
23    pub const FULL: Self = Self(100.0);
24
25    /// Builds a percentage, rejecting NaN, infinities, and negative values.
26    ///
27    /// Returning `None` rather than clamping is intentional: a NaN percentage
28    /// means the *calculation* was wrong, and §4 forbids silently converting an
29    /// unavailable value to a number.
30    #[must_use]
31    pub fn new(value: f32) -> Option<Self> {
32        if value.is_finite() && value >= 0.0 {
33            Some(Self(value))
34        } else {
35            None
36        }
37    }
38
39    /// Builds a percentage from a `part / whole` ratio.
40    ///
41    /// Returns `None` when `whole` is zero, because "0 of 0" has no defined
42    /// utilization and must be reported as unavailable rather than as 0%.
43    #[must_use]
44    pub fn ratio(part: u64, whole: u64) -> Option<Self> {
45        if whole == 0 {
46            return None;
47        }
48        // The division happens in f64 so that exbibyte-scale counters keep their
49        // precision; narrowing the *result* to f32 is intentional, and any value
50        // the narrowing could not represent is rejected by `new`.
51        #[allow(clippy::cast_possible_truncation)]
52        let percent = (part as f64 / whole as f64 * 100.0) as f32;
53        Self::new(percent)
54    }
55
56    /// Clamps into `0.0..=100.0` for bar and meter rendering.
57    #[must_use]
58    pub fn clamped_to_100(self) -> Self {
59        Self(self.0.min(100.0))
60    }
61
62    /// The underlying value.
63    #[must_use]
64    pub const fn value(self) -> f32 {
65        self.0
66    }
67
68    /// The value as a `0.0..=1.0`-ish fraction (may exceed 1.0).
69    #[must_use]
70    pub fn fraction(self) -> f32 {
71        self.0 / 100.0
72    }
73
74    /// Difference in percentage *points*, which may be negative.
75    #[must_use]
76    pub fn points_from(self, other: Self) -> f32 {
77        self.0 - other.0
78    }
79}
80
81impl fmt::Display for Percent {
82    /// Renders with one decimal only when it adds information (§5.4).
83    ///
84    /// Below 10% a single decimal distinguishes 0.4% from 0.9%; above that the
85    /// decimal is noise and causes column jitter.
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        if self.0 < 10.0 && self.0 != 0.0 {
88            write!(f, "{:.1}%", self.0)
89        } else {
90            write!(f, "{:.0}%", self.0)
91        }
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn rejects_non_finite_and_negative() {
101        assert!(Percent::new(f32::NAN).is_none());
102        assert!(Percent::new(f32::INFINITY).is_none());
103        assert!(Percent::new(-0.5).is_none());
104        assert!(Percent::new(0.0).is_some());
105        assert!(Percent::new(287.0).is_some(), "process CPU may exceed 100%");
106    }
107
108    #[test]
109    fn zero_whole_is_undefined_not_zero() {
110        assert!(Percent::ratio(0, 0).is_none());
111        assert!(Percent::ratio(5, 0).is_none());
112    }
113
114    #[test]
115    fn ratio_computes_expected_values() {
116        let p = Percent::ratio(1, 4).expect("4 is non-zero");
117        assert!((p.value() - 25.0).abs() < f32::EPSILON);
118    }
119
120    #[test]
121    fn ratio_survives_counters_that_overflow_f32_precision() {
122        // 16 EiB-scale counters must not produce NaN or a negative percentage.
123        let p = Percent::ratio(u64::MAX / 2, u64::MAX).expect("non-zero whole");
124        assert!((p.value() - 50.0).abs() < 0.01, "got {}", p.value());
125    }
126
127    #[test]
128    fn clamping_only_affects_the_upper_bound() {
129        let p = Percent::new(287.0).expect("valid");
130        assert!((p.clamped_to_100().value() - 100.0).abs() < f32::EPSILON);
131        let q = Percent::new(37.0).expect("valid");
132        assert!((q.clamped_to_100().value() - 37.0).abs() < f32::EPSILON);
133    }
134
135    #[test]
136    fn display_adds_a_decimal_only_below_ten_percent() {
137        assert_eq!(Percent::new(0.0).expect("valid").to_string(), "0%");
138        assert_eq!(Percent::new(4.2).expect("valid").to_string(), "4.2%");
139        assert_eq!(Percent::new(37.4).expect("valid").to_string(), "37%");
140        assert_eq!(Percent::new(287.0).expect("valid").to_string(), "287%");
141    }
142}