Skip to main content

monitrs_core/model/
metric.rs

1//! Per-metric availability.
2//!
3//! §4 forbids representing platform support as one global boolean. Every metric
4//! that an OS may withhold is wrapped in [`MetricState`], and the single most
5//! important invariant in this crate is that **unavailable is never zero**.
6
7use core::fmt;
8use core::time::Duration;
9
10/// Why a normally-available metric is missing from this particular sample.
11///
12/// The specification sketches `TemporarilyUnavailable { reason: String }`, but
13/// §4 explicitly permits a typed enum when a per-sample `String` is too costly.
14/// It is: a `String` in every field of every sample would allocate thousands of
15/// times per second. The human-readable message is produced at the UI layer by
16/// [`UnavailableReason::message`].
17#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
18#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
19#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
20pub enum UnavailableReason {
21    /// A cumulative counter moved backwards, so this sample's delta is invalid.
22    ///
23    /// §8.2 requires returning this rather than a huge or negative rate.
24    CounterReset,
25    /// The device, mount, or interface vanished between two reads.
26    DeviceDisappeared,
27    /// The interface was renamed, invalidating the previous counter baseline.
28    InterfaceRenamed,
29    /// The process exited between enumeration and the detail read (§8.2).
30    ///
31    /// Expected during normal sampling and never worth a warning log (§14.1).
32    ProcessExited,
33    /// The underlying read failed for a reason other than permissions.
34    ReadFailed,
35    /// The data was present but did not match the expected format.
36    ParseFailed,
37    /// Collection exceeded its time budget and was abandoned for this sample.
38    Timeout,
39    /// Enrichment was skipped to stay inside a budget under high load (§16.2).
40    SkippedUnderLoad,
41    /// A utilization percentage was requested but the link speed is unknown.
42    ///
43    /// §7.4 forbids rendering a network utilization percentage without a known
44    /// link capacity.
45    LinkSpeedUnknown,
46    /// The metric requires at least two samples and only one exists so far.
47    NeedsSecondSample,
48}
49
50impl UnavailableReason {
51    /// A short, lower-case explanation suitable for a status line or tooltip.
52    #[must_use]
53    pub const fn message(self) -> &'static str {
54        match self {
55            Self::CounterReset => "counter reset",
56            Self::DeviceDisappeared => "device disappeared",
57            Self::InterfaceRenamed => "interface renamed",
58            Self::ProcessExited => "process exited",
59            Self::ReadFailed => "read failed",
60            Self::ParseFailed => "unparsable data",
61            Self::Timeout => "collection timed out",
62            Self::SkippedUnderLoad => "skipped under load",
63            Self::LinkSpeedUnknown => "link speed unknown",
64            Self::NeedsSecondSample => "needs a second sample",
65        }
66    }
67}
68
69impl fmt::Display for UnavailableReason {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        f.write_str(self.message())
72    }
73}
74
75/// The availability of a single metric in a single sample.
76///
77/// The `Stale` variant is an addition to the five states listed in §4. It exists
78/// because §4 also states that a temporarily unavailable metric may retain its
79/// last good value *only* if that value is visibly marked stale and carries its
80/// age. Encoding the value and its age in the type makes it impossible to render
81/// a retained value without knowing it is stale.
82#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
83#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
84#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
85pub enum MetricState<T> {
86    /// Measured in this sample.
87    Available(T),
88    /// The last known-good value, retained across a transient failure.
89    ///
90    /// Must be rendered with a visible stale marker and `age` (§4).
91    Stale {
92        /// The retained value.
93        value: T,
94        /// How long ago `value` was actually measured.
95        age: Duration,
96    },
97    /// The metric needs more samples before it means anything (§8.2, §26).
98    ///
99    /// The first sample of delta-based data is *not* zero.
100    WarmingUp,
101    /// The OS refused the read. Not a fatal error (§9.2).
102    PermissionDenied,
103    /// This platform does not expose the metric at all.
104    Unsupported,
105    /// Normally available, absent from this sample.
106    TemporarilyUnavailable(UnavailableReason),
107}
108
109impl<T> MetricState<T> {
110    /// The freshly measured value, if this sample actually measured it.
111    ///
112    /// Returns `None` for stale values. Use this for anything that feeds a
113    /// calculation, a diagnostic rule, or a rate baseline.
114    #[must_use]
115    pub const fn fresh(&self) -> Option<&T> {
116        match self {
117            Self::Available(value) => Some(value),
118            _ => None,
119        }
120    }
121
122    /// The best available value, fresh or stale, paired with its age.
123    ///
124    /// Use this only for *display*, and only alongside the returned age so the
125    /// staleness can be shown.
126    #[must_use]
127    pub const fn displayable(&self) -> Option<(&T, Duration)> {
128        match self {
129            Self::Available(value) => Some((value, Duration::ZERO)),
130            Self::Stale { value, age } => Some((value, *age)),
131            _ => None,
132        }
133    }
134
135    /// Whether this sample measured the metric.
136    #[must_use]
137    pub const fn is_available(&self) -> bool {
138        matches!(self, Self::Available(_))
139    }
140
141    /// Whether a retained value is being shown instead of a fresh one.
142    #[must_use]
143    pub const fn is_stale(&self) -> bool {
144        matches!(self, Self::Stale { .. })
145    }
146
147    /// Whether the metric will never be available on this platform.
148    ///
149    /// Layout code uses this to drop optional panels when space is scarce (§4).
150    #[must_use]
151    pub const fn is_unsupported(&self) -> bool {
152        matches!(self, Self::Unsupported)
153    }
154
155    /// Whether the metric is expected to become available shortly.
156    #[must_use]
157    pub const fn is_warming_up(&self) -> bool {
158        matches!(self, Self::WarmingUp)
159    }
160
161    /// Transforms the contained value, preserving the availability state.
162    #[must_use]
163    pub fn map<U, F: FnOnce(T) -> U>(self, f: F) -> MetricState<U> {
164        match self {
165            Self::Available(value) => MetricState::Available(f(value)),
166            Self::Stale { value, age } => MetricState::Stale {
167                value: f(value),
168                age,
169            },
170            Self::WarmingUp => MetricState::WarmingUp,
171            Self::PermissionDenied => MetricState::PermissionDenied,
172            Self::Unsupported => MetricState::Unsupported,
173            Self::TemporarilyUnavailable(reason) => MetricState::TemporarilyUnavailable(reason),
174        }
175    }
176
177    /// Borrows the contained value.
178    #[must_use]
179    pub const fn as_ref(&self) -> MetricState<&T> {
180        match self {
181            Self::Available(value) => MetricState::Available(value),
182            Self::Stale { value, age } => MetricState::Stale { value, age: *age },
183            Self::WarmingUp => MetricState::WarmingUp,
184            Self::PermissionDenied => MetricState::PermissionDenied,
185            Self::Unsupported => MetricState::Unsupported,
186            Self::TemporarilyUnavailable(reason) => MetricState::TemporarilyUnavailable(*reason),
187        }
188    }
189
190    /// The placeholder to render when there is no value.
191    ///
192    /// Returns `None` when a value *is* present. The strings are the ones §4
193    /// mandates, and all of them are strict 7-bit ASCII so they are legal in
194    /// both glyph modes (§5.1).
195    #[must_use]
196    pub const fn placeholder(&self) -> Option<&'static str> {
197        match self {
198            Self::Available(_) | Self::Stale { .. } => None,
199            Self::WarmingUp => Some("warming up"),
200            Self::PermissionDenied => Some("permission denied"),
201            Self::Unsupported => Some("n/a"),
202            Self::TemporarilyUnavailable(reason) => Some(reason.message()),
203        }
204    }
205
206    /// A single-character redundant cue, so meaning survives without color (§5.2).
207    #[must_use]
208    pub const fn symbol(&self) -> char {
209        match self {
210            Self::Available(_) => ' ',
211            Self::Stale { .. } => '~',
212            Self::WarmingUp => '.',
213            Self::PermissionDenied => '!',
214            Self::Unsupported => '-',
215            Self::TemporarilyUnavailable(_) => '?',
216        }
217    }
218
219    /// Converts a fresh value into a stale one aged by `age`.
220    ///
221    /// Collectors call this when a read fails but a previous value is worth
222    /// keeping on screen. Anything that is not currently `Available` is returned
223    /// unchanged, so staleness cannot compound into a fake value.
224    #[must_use]
225    pub fn into_stale(self, age: Duration) -> Self {
226        match self {
227            Self::Available(value) => Self::Stale { value, age },
228            other => other,
229        }
230    }
231}
232
233impl<T> From<Option<T>> for MetricState<T> {
234    /// Treats a missing optional value as [`MetricState::Unsupported`].
235    ///
236    /// Collectors that know a more specific reason must construct the variant
237    /// directly rather than going through `Option`.
238    fn from(value: Option<T>) -> Self {
239        match value {
240            Some(value) => Self::Available(value),
241            None => Self::Unsupported,
242        }
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn unavailable_is_never_zero() {
252        // The single most important invariant in the crate: there is no API that
253        // turns an unavailable metric into a number.
254        let state: MetricState<u64> = MetricState::PermissionDenied;
255        assert!(state.fresh().is_none());
256        assert!(state.displayable().is_none());
257        assert_eq!(state.placeholder(), Some("permission denied"));
258    }
259
260    #[test]
261    fn warming_up_is_distinct_from_zero() {
262        let warming: MetricState<u64> = MetricState::WarmingUp;
263        let zero = MetricState::Available(0u64);
264        assert_ne!(warming, zero);
265        assert_eq!(warming.fresh(), None);
266        assert_eq!(zero.fresh(), Some(&0));
267    }
268
269    #[test]
270    fn stale_values_are_only_readable_together_with_their_age() {
271        let state = MetricState::Available(42u64).into_stale(Duration::from_secs(3));
272        assert!(state.is_stale());
273        // A stale value is deliberately invisible to calculations...
274        assert_eq!(state.fresh(), None);
275        // ...and readable for display only alongside its age.
276        let (value, age) = state.displayable().expect("stale values are displayable");
277        assert_eq!(*value, 42);
278        assert_eq!(age, Duration::from_secs(3));
279    }
280
281    #[test]
282    fn staleness_cannot_be_applied_to_a_missing_value() {
283        let state: MetricState<u64> = MetricState::Unsupported.into_stale(Duration::from_secs(9));
284        assert_eq!(state, MetricState::Unsupported);
285        let state: MetricState<u64> = MetricState::WarmingUp.into_stale(Duration::from_secs(9));
286        assert_eq!(state, MetricState::WarmingUp);
287    }
288
289    #[test]
290    fn staleness_does_not_compound() {
291        let once = MetricState::Available(7u64).into_stale(Duration::from_secs(1));
292        let twice = once.into_stale(Duration::from_secs(30));
293        assert_eq!(
294            once, twice,
295            "re-staling must not overwrite the original age"
296        );
297    }
298
299    #[test]
300    fn map_preserves_availability_state() {
301        assert_eq!(
302            MetricState::Available(2u64).map(|v| v * 2),
303            MetricState::Available(4u64)
304        );
305        let stale = MetricState::Stale {
306            value: 2u64,
307            age: Duration::from_secs(5),
308        };
309        assert_eq!(
310            stale.map(|v| v * 2),
311            MetricState::Stale {
312                value: 4,
313                age: Duration::from_secs(5)
314            }
315        );
316        let denied: MetricState<u64> = MetricState::PermissionDenied;
317        assert_eq!(denied.map(|v| v * 2), MetricState::PermissionDenied);
318    }
319
320    #[test]
321    fn every_state_has_a_redundant_non_color_cue() {
322        let states: [MetricState<u64>; 6] = [
323            MetricState::Available(1),
324            MetricState::Stale {
325                value: 1,
326                age: Duration::ZERO,
327            },
328            MetricState::WarmingUp,
329            MetricState::PermissionDenied,
330            MetricState::Unsupported,
331            MetricState::TemporarilyUnavailable(UnavailableReason::ReadFailed),
332        ];
333        let mut symbols: Vec<char> = states.iter().map(MetricState::symbol).collect();
334        symbols.sort_unstable();
335        symbols.dedup();
336        assert_eq!(
337            symbols.len(),
338            states.len(),
339            "symbols must be distinguishable"
340        );
341    }
342
343    #[test]
344    fn placeholders_are_strict_ascii_so_they_are_legal_in_both_glyph_modes() {
345        let reasons = [
346            UnavailableReason::CounterReset,
347            UnavailableReason::DeviceDisappeared,
348            UnavailableReason::InterfaceRenamed,
349            UnavailableReason::ProcessExited,
350            UnavailableReason::ReadFailed,
351            UnavailableReason::ParseFailed,
352            UnavailableReason::Timeout,
353            UnavailableReason::SkippedUnderLoad,
354            UnavailableReason::LinkSpeedUnknown,
355            UnavailableReason::NeedsSecondSample,
356        ];
357        for reason in reasons {
358            assert!(
359                reason.message().is_ascii(),
360                "{reason:?} message is not strict ASCII"
361            );
362        }
363        for text in ["warming up", "permission denied", "n/a"] {
364            assert!(text.is_ascii());
365        }
366    }
367}