Skip to main content

shep_core/
values.rs

1//! Config value newtypes: memory sizes and durations
2
3use core::fmt;
4use core::str::FromStr;
5
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7
8// Binary units per the Flockfile grammar `^\d+(G|M|K)?$`: K/M/G are
9// KiB/MiB/GiB, not decimal. Unit definitions, not tuning thresholds.
10const KIB: u64 = 1024;
11const MIB: u64 = 1024 * KIB;
12const GIB: u64 = 1024 * MIB;
13
14// Milliseconds per unit of the Flockfile grammar `^\d+(ms|h|m|s)?$`, the
15// same way the binary units above are written: a definition each, so the
16// parser and the formatter cannot drift apart on one of them.
17const MS_PER_S: u64 = 1_000;
18const MS_PER_MIN: u64 = 60 * MS_PER_S;
19const MS_PER_H: u64 = 60 * MS_PER_MIN;
20
21/// A memory quantity in bytes, used for memory-limit thresholds
22///
23/// Parses the Flockfile grammar `^\d+(G|M|K)?$` (binary units; plain digits
24/// are bytes). Ordering compares byte counts, so a configured limit compares
25/// directly against a sampled RSS wrapped with [`MemSize::from_bytes`].
26///
27/// # Example
28/// ```
29/// use shep_core::values::MemSize;
30///
31/// let limit: MemSize = "512M".parse()?;
32/// assert_eq!(limit.bytes(), 512 << 20);
33/// assert!("512MB".parse::<MemSize>().is_err()); // strict grammar
34/// # Ok::<(), shep_core::values::ParseMemSizeError>(())
35/// ```
36// wire format: changing this is a breaking change (serialized as its string
37// form inside AppConfig, which travels over the client<->daemon socket)
38#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
39pub struct MemSize(u64);
40
41impl MemSize {
42    /// Wraps a raw byte count, e.g. an RSS sample
43    #[inline]
44    #[must_use]
45    pub const fn from_bytes(bytes: u64) -> Self {
46        Self(bytes)
47    }
48
49    /// Returns the quantity in bytes
50    #[inline]
51    #[must_use]
52    pub const fn bytes(self) -> u64 {
53        self.0
54    }
55}
56
57impl FromStr for MemSize {
58    type Err = ParseMemSizeError;
59
60    /// Parses `^\d+(G|M|K)?$`: binary units, plain digits = bytes
61    ///
62    /// # Errors
63    ///
64    /// - [`ParseMemSizeError::Empty`]: empty input.
65    /// - [`ParseMemSizeError::MissingDigits`]: unit suffix with no digits.
66    /// - [`ParseMemSizeError::InvalidCharacter`]: anything outside ASCII
67    ///   digits plus one trailing `G`/`M`/`K` (lowercase, whitespace,
68    ///   fractions, multi-letter suffixes all land here).
69    /// - [`ParseMemSizeError::Overflow`]: byte count exceeds `u64::MAX`.
70    fn from_str(s: &str) -> Result<Self, Self::Err> {
71        if s.is_empty() {
72            return Err(ParseMemSizeError::Empty);
73        }
74        let (digits, multiplier) = match s.as_bytes()[s.len() - 1] {
75            b'G' => (&s[..s.len() - 1], GIB),
76            b'M' => (&s[..s.len() - 1], MIB),
77            b'K' => (&s[..s.len() - 1], KIB),
78            _ => (s, 1),
79        };
80        if digits.is_empty() {
81            return Err(ParseMemSizeError::MissingDigits);
82        }
83        if !digits.bytes().all(|b| b.is_ascii_digit()) {
84            return Err(ParseMemSizeError::InvalidCharacter);
85        }
86        let value: u64 = digits.parse().map_err(|_| ParseMemSizeError::Overflow)?;
87        value
88            .checked_mul(multiplier)
89            .map(Self)
90            .ok_or(ParseMemSizeError::Overflow)
91    }
92}
93
94/// Formats with the largest binary unit dividing the value exactly;
95/// output always re-parses to the same value
96impl fmt::Display for MemSize {
97    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98        match self.0 {
99            0 => f.write_str("0"),
100            b if b % GIB == 0 => write!(f, "{}G", b / GIB),
101            b if b % MIB == 0 => write!(f, "{}M", b / MIB),
102            b if b % KIB == 0 => write!(f, "{}K", b / KIB),
103            b => write!(f, "{b}"),
104        }
105    }
106}
107
108impl Serialize for MemSize {
109    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
110        serializer.collect_str(self)
111    }
112}
113
114impl<'de> Deserialize<'de> for MemSize {
115    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
116        // String, not &str: the toml deserializer cannot always borrow
117        let s = String::deserialize(deserializer)?;
118        s.parse().map_err(serde::de::Error::custom)
119    }
120}
121
122/// Failure to parse a [`MemSize`] from the grammar `^\d+(G|M|K)?$`
123///
124/// `#[non_exhaustive]`: a future grammar revision, such as fractional sizes
125/// (`1.5G`) or a binary-vs-decimal distinction, would want its own variant
126/// rather than folding into [`Self::InvalidCharacter`]'s catch-all, and
127/// shep-core is a published library an out-of-tree matcher should not
128/// break for.
129#[non_exhaustive]
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub enum ParseMemSizeError {
132    /// The input string was empty
133    Empty,
134    /// A unit suffix with no digits before it (`"M"`)
135    MissingDigits,
136    /// A character outside ASCII digits plus one optional trailing
137    /// `G`/`M`/`K`: covers lowercase units, whitespace, signs, fractions,
138    /// and multi-letter suffixes such as `"MB"`
139    InvalidCharacter,
140    /// The quantity in bytes does not fit in `u64`
141    Overflow,
142}
143
144impl fmt::Display for ParseMemSizeError {
145    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
146        f.write_str(match self {
147            Self::Empty => "memory size is empty",
148            Self::MissingDigits => "memory size has a unit suffix but no digits",
149            Self::InvalidCharacter => {
150                "memory size must be ASCII digits with an optional trailing G, M, or K"
151            }
152            Self::Overflow => "memory size in bytes overflows u64",
153        })
154    }
155}
156
157impl core::error::Error for ParseMemSizeError {}
158
159/// String-shaped, matching this type's `Serialize`/`Deserialize`, which go
160/// through `Display`/`FromStr` rather than the wrapped `u64`. A derive here
161/// would emit `{"type":"integer"}` and describe a wire form that does not
162/// exist.
163///
164/// The pattern is `FromStr`'s own grammar, lifted from its doc comment
165/// above. If you change one, change the other: the paired test below is
166/// what catches it.
167#[cfg(feature = "schema")]
168impl schemars::JsonSchema for MemSize {
169    fn schema_name() -> std::borrow::Cow<'static, str> {
170        "MemSize".into()
171    }
172
173    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
174        schemars::json_schema!({
175            "type": "string",
176            "pattern": r"^\d+(G|M|K)?$",
177            "description": "A byte quantity: digits, optionally suffixed G, M or K (binary units).",
178        })
179    }
180}
181
182/// A duration from the Flockfile grammar `^\d+(ms|h|m|s)?$`
183///
184/// Plain digits are milliseconds; `ms`/`s`/`m`/`h` are
185/// milliseconds/seconds/minutes/hours. Used for `min_uptime`,
186/// `kill_timeout`, and the other lifecycle timers.
187///
188/// `ms` is checked before the single-letter suffixes, so `m` still means
189/// minutes and only a trailing `ms` means milliseconds: `5m` and `5ms`
190/// differ by a factor of sixty thousand.
191///
192/// # Example
193/// ```
194/// use shep_core::values::UpDuration;
195///
196/// assert_eq!("30s".parse::<UpDuration>()?.as_millis(), 30_000);
197/// assert_eq!("500ms".parse::<UpDuration>()?.as_millis(), 500);
198/// assert_eq!("5m".parse::<UpDuration>()?.as_millis(), 300_000);
199/// assert!("30S".parse::<UpDuration>().is_err()); // lowercase units only
200/// # Ok::<(), shep_core::values::ParseUpDurationError>(())
201/// ```
202// wire format: changing this is a breaking change (string form in AppConfig)
203#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
204pub struct UpDuration(core::time::Duration);
205
206impl UpDuration {
207    /// Wraps a raw millisecond count
208    #[inline]
209    #[must_use]
210    pub const fn from_millis(ms: u64) -> Self {
211        Self(core::time::Duration::from_millis(ms))
212    }
213
214    /// Returns the wrapped [`core::time::Duration`]
215    #[inline]
216    #[must_use]
217    pub const fn as_duration(self) -> core::time::Duration {
218        self.0
219    }
220
221    /// Returns the duration in whole milliseconds
222    #[inline]
223    #[must_use]
224    pub const fn as_millis(self) -> u64 {
225        // Sound: every constructor bounds millis to u64 (`from_millis`
226        // stores its argument directly; `FromStr` reaches this type only
227        // via a `checked_mul` that already fits in u64). Revisit if a raw
228        // `Duration` constructor is ever added.
229        self.0.as_millis() as u64
230    }
231}
232
233impl FromStr for UpDuration {
234    type Err = ParseUpDurationError;
235
236    /// Parses `^\d+(ms|h|m|s)?$`: plain digits are milliseconds
237    ///
238    /// `ms` is matched before the single-letter suffixes below, so a
239    /// trailing `m` alone still means minutes.
240    ///
241    /// # Errors
242    ///
243    /// - [`ParseUpDurationError::Empty`]: empty input.
244    /// - [`ParseUpDurationError::MissingDigits`]: unit with no digits.
245    /// - [`ParseUpDurationError::InvalidCharacter`]: anything outside ASCII
246    ///   digits plus one trailing lowercase `h`/`m`/`s`/`ms`.
247    /// - [`ParseUpDurationError::Overflow`]: milliseconds overflow `u64`.
248    fn from_str(s: &str) -> Result<Self, Self::Err> {
249        if s.is_empty() {
250            return Err(ParseUpDurationError::Empty);
251        }
252        // `ms` first: it shares its trailing `m` with the minutes suffix, so
253        // checking the single-letter match first would parse "5ms" as "5m"
254        // followed by a stray "s" and reject it, or worse, alias the two
255        // suffixes if that stray byte were ever tolerated.
256        let (digits, ms_per_unit) = if let Some(rest) = s.strip_suffix("ms") {
257            (rest, 1)
258        } else {
259            match s.as_bytes()[s.len() - 1] {
260                b'h' => (&s[..s.len() - 1], MS_PER_H),
261                b'm' => (&s[..s.len() - 1], MS_PER_MIN),
262                b's' => (&s[..s.len() - 1], MS_PER_S),
263                _ => (s, 1),
264            }
265        };
266        if digits.is_empty() {
267            return Err(ParseUpDurationError::MissingDigits);
268        }
269        if !digits.bytes().all(|b| b.is_ascii_digit()) {
270            return Err(ParseUpDurationError::InvalidCharacter);
271        }
272        let value: u64 = digits.parse().map_err(|_| ParseUpDurationError::Overflow)?;
273        value
274            .checked_mul(ms_per_unit)
275            .map(Self::from_millis)
276            .ok_or(ParseUpDurationError::Overflow)
277    }
278}
279
280/// Formats with the largest unit dividing the value exactly (ms as digits)
281impl fmt::Display for UpDuration {
282    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
283        let ms = self.as_millis();
284        match ms {
285            0 => f.write_str("0"),
286            v if v % MS_PER_H == 0 => write!(f, "{}h", v / MS_PER_H),
287            v if v % MS_PER_MIN == 0 => write!(f, "{}m", v / MS_PER_MIN),
288            v if v % MS_PER_S == 0 => write!(f, "{}s", v / MS_PER_S),
289            v => write!(f, "{v}"),
290        }
291    }
292}
293
294impl Serialize for UpDuration {
295    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
296        serializer.collect_str(self)
297    }
298}
299
300impl<'de> Deserialize<'de> for UpDuration {
301    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
302        // String, not &str: the toml deserializer cannot always borrow
303        let s = String::deserialize(deserializer)?;
304        s.parse().map_err(serde::de::Error::custom)
305    }
306}
307
308/// Failure to parse an [`UpDuration`] from the grammar `^\d+(ms|h|m|s)?$`
309///
310/// `#[non_exhaustive]`, for the same reason as [`ParseMemSizeError`]: a
311/// future grammar revision, such as fractional durations or a `d`/`w`
312/// unit, would want its own variant rather than folding into
313/// [`Self::InvalidCharacter`]'s catch-all.
314#[non_exhaustive]
315#[derive(Debug, Clone, Copy, PartialEq, Eq)]
316pub enum ParseUpDurationError {
317    /// The input string was empty
318    Empty,
319    /// A unit suffix with no digits before it (`"s"`)
320    MissingDigits,
321    /// A character outside ASCII digits plus one optional trailing
322    /// lowercase `h`/`m`/`s`/`ms`
323    InvalidCharacter,
324    /// The duration in milliseconds does not fit in `u64`
325    Overflow,
326}
327
328impl fmt::Display for ParseUpDurationError {
329    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
330        f.write_str(match self {
331            Self::Empty => "duration is empty",
332            Self::MissingDigits => "duration has a unit suffix but no digits",
333            Self::InvalidCharacter => {
334                "duration must be ASCII digits with an optional trailing h, m, s, or ms"
335            }
336            Self::Overflow => "duration in milliseconds overflows u64",
337        })
338    }
339}
340
341impl core::error::Error for ParseUpDurationError {}
342
343/// String-shaped, matching this type's `Serialize`/`Deserialize`: see
344/// [`MemSize`]'s own `JsonSchema` impl for the full reasoning, which applies
345/// here unchanged.
346#[cfg(feature = "schema")]
347impl schemars::JsonSchema for UpDuration {
348    fn schema_name() -> std::borrow::Cow<'static, str> {
349        "UpDuration".into()
350    }
351
352    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
353        schemars::json_schema!({
354            "type": "string",
355            "pattern": r"^\d+(ms|h|m|s)?$",
356            "description": "A duration: digits, optionally suffixed ms, h, m or s. Plain digits are milliseconds.",
357        })
358    }
359}
360
361/// Tree CPU over a window, as a percentage of one core.
362///
363/// `cpu_ms` is the CPU-milliseconds the tree spent during `window`. A value
364/// over 100 is a tree using more than one core, not a bug.
365///
366/// `None` when `window` is zero: dividing by it produces a nonsense figure
367/// rather than a large one.
368///
369/// Shared by the shepherd, which measures against its own periodic
370/// baseline, and by `shep lookout`, which differences two readings of
371/// [`ProcessInfo::cpu_ms`](crate::protocol::ProcessInfo::cpu_ms) over its
372/// own poll. Both put a percentage on the same screen, so the conversion
373/// lives in one place.
374#[must_use]
375pub fn cpu_percent(cpu_ms: u64, window: core::time::Duration) -> Option<f32> {
376    if window.is_zero() {
377        return None;
378    }
379    // CPU-milliseconds over wall-seconds is per-mille of one core. Computed
380    // in f64 and narrowed once at the end, since f32 would lose milliseconds
381    // off a counter that has run for a month.
382    Some((cpu_ms as f64 / window.as_secs_f64() / 10.0) as f32)
383}
384
385#[cfg(test)]
386mod mem_size_tests {
387    use super::*;
388
389    #[test]
390    fn plain_digits_parse_as_bytes() {
391        assert_eq!("123".parse::<MemSize>().unwrap().bytes(), 123);
392    }
393
394    #[test]
395    fn units_are_binary() {
396        assert_eq!("7K".parse::<MemSize>().unwrap().bytes(), 7 * 1024);
397        assert_eq!("512M".parse::<MemSize>().unwrap().bytes(), 512 << 20);
398        assert_eq!("3G".parse::<MemSize>().unwrap().bytes(), 3 << 30);
399    }
400
401    #[test]
402    fn rejects_spec_violations() {
403        use ParseMemSizeError::*;
404        assert_eq!("".parse::<MemSize>(), Err(Empty));
405        assert_eq!("G".parse::<MemSize>(), Err(MissingDigits));
406        assert_eq!("512m".parse::<MemSize>(), Err(InvalidCharacter)); // lowercase
407        assert_eq!(" 512M".parse::<MemSize>(), Err(InvalidCharacter)); // whitespace
408        assert_eq!("1.5G".parse::<MemSize>(), Err(InvalidCharacter)); // fraction
409        assert_eq!("512MB".parse::<MemSize>(), Err(InvalidCharacter)); // multi-letter
410        assert_eq!("18446744073709551616".parse::<MemSize>(), Err(Overflow));
411        assert_eq!("17179869184G".parse::<MemSize>(), Err(Overflow));
412    }
413
414    #[test]
415    fn display_uses_largest_exact_unit_and_round_trips() {
416        for bytes in [
417            0u64,
418            1,
419            1023,
420            1024,
421            1536,
422            1 << 20,
423            (1 << 30) + 1024,
424            u64::MAX,
425        ] {
426            let size = MemSize::from_bytes(bytes);
427            let reparsed: MemSize = size.to_string().parse().unwrap();
428            assert_eq!(reparsed, size, "display of {bytes} bytes must reparse");
429        }
430        assert_eq!(MemSize::from_bytes(3 << 30).to_string(), "3G");
431        assert_eq!(MemSize::from_bytes(1536).to_string(), "1536");
432    }
433
434    #[test]
435    fn serde_uses_string_form() {
436        let size: MemSize = serde_json::from_str("\"512M\"").unwrap();
437        assert_eq!(size.bytes(), 512 << 20);
438        assert_eq!(serde_json::to_string(&size).unwrap(), "\"512M\"");
439        assert!(serde_json::from_str::<MemSize>("\"512MB\"").is_err());
440    }
441
442    /// The pattern must agree with `FromStr`, not just be self-consistent.
443    /// `512T` and `1P` are in the reject list because a widened suffix set
444    /// is the way this pattern most plausibly drifts.
445    #[cfg(feature = "schema")]
446    #[test]
447    fn the_schema_pattern_agrees_with_from_str() {
448        let schema = serde_json::to_value(schemars::schema_for!(MemSize)).unwrap();
449        let pattern = schema["pattern"].as_str().unwrap();
450        let re = regex::Regex::new(pattern).unwrap();
451        for accepted in ["512M", "1G", "4096", "7K"] {
452            assert!(re.is_match(accepted), "pattern rejects {accepted}");
453            assert!(
454                accepted.parse::<MemSize>().is_ok(),
455                "FromStr rejects {accepted}"
456            );
457        }
458        for rejected in ["512MB", "512m", "1.5G", "", "M", "512T", "1P", "512g"] {
459            assert!(!re.is_match(rejected), "pattern accepts {rejected}");
460            assert!(
461                rejected.parse::<MemSize>().is_err(),
462                "FromStr accepts {rejected}"
463            );
464        }
465    }
466}
467
468#[cfg(test)]
469mod up_duration_tests {
470    use super::*;
471
472    #[test]
473    fn plain_digits_are_milliseconds() {
474        assert_eq!("1600".parse::<UpDuration>().unwrap().as_millis(), 1600);
475    }
476
477    #[test]
478    fn units_seconds_minutes_hours() {
479        assert_eq!("30s".parse::<UpDuration>().unwrap().as_millis(), 30_000);
480        assert_eq!("5m".parse::<UpDuration>().unwrap().as_millis(), 300_000);
481        assert_eq!("2h".parse::<UpDuration>().unwrap().as_millis(), 7_200_000);
482    }
483
484    /// `ms` and `m` share a trailing byte, so a naive last-byte match would
485    /// parse "5ms" as "5m" (a 60,000x error) or reject it. Pinned adjacently
486    /// so a regression shows as a wrong multiplier, not a rejected string.
487    #[test]
488    fn milliseconds_do_not_alias_minutes() {
489        assert_eq!("500ms".parse::<UpDuration>().unwrap().as_millis(), 500);
490        assert_eq!("5ms".parse::<UpDuration>().unwrap().as_millis(), 5);
491        assert_eq!("5m".parse::<UpDuration>().unwrap().as_millis(), 300_000);
492        // A bare trailing `m` at end of input is still minutes.
493        assert_eq!("1m".parse::<UpDuration>().unwrap().as_millis(), 60_000);
494    }
495
496    #[test]
497    fn rejects_spec_violations() {
498        use ParseUpDurationError::*;
499        assert_eq!("".parse::<UpDuration>(), Err(Empty));
500        assert_eq!("s".parse::<UpDuration>(), Err(MissingDigits));
501        assert_eq!("ms".parse::<UpDuration>(), Err(MissingDigits));
502        assert_eq!("30S".parse::<UpDuration>(), Err(InvalidCharacter)); // uppercase
503        assert_eq!("1.5s".parse::<UpDuration>(), Err(InvalidCharacter));
504        assert_eq!("30 s".parse::<UpDuration>(), Err(InvalidCharacter));
505        assert_eq!("30MS".parse::<UpDuration>(), Err(InvalidCharacter)); // uppercase ms
506        // Digit string itself overflows u64 before any unit multiplication.
507        assert_eq!("99999999999999999999h".parse::<UpDuration>(), Err(Overflow));
508        // Digit string fits u64 on its own, but overflows on the ×3_600_000
509        // (hours-to-ms) multiplication.
510        assert_eq!("9999999999999999h".parse::<UpDuration>(), Err(Overflow));
511    }
512
513    #[test]
514    fn display_round_trips() {
515        for ms in [
516            0u64, 1, 999, 1000, 1600, 30_000, 300_000, 7_200_000, 3_601_000,
517        ] {
518            let d = UpDuration::from_millis(ms);
519            assert_eq!(d.to_string().parse::<UpDuration>().unwrap(), d, "{ms}ms");
520        }
521        assert_eq!(UpDuration::from_millis(30_000).to_string(), "30s");
522        assert_eq!(UpDuration::from_millis(1600).to_string(), "1600");
523        assert_eq!(UpDuration::from_millis(7_200_000).to_string(), "2h");
524    }
525
526    #[test]
527    fn serde_uses_string_form() {
528        let d: UpDuration = serde_json::from_str("\"30s\"").unwrap();
529        assert_eq!(d.as_millis(), 30_000);
530        assert_eq!(serde_json::to_string(&d).unwrap(), "\"30s\"");
531    }
532
533    /// Same requirement as [`MemSize`]'s schema test: the pattern must
534    /// agree with `FromStr`, not just be self-consistent.
535    #[cfg(feature = "schema")]
536    #[test]
537    fn the_schema_pattern_agrees_with_from_str() {
538        let schema = serde_json::to_value(schemars::schema_for!(UpDuration)).unwrap();
539        let pattern = schema["pattern"].as_str().unwrap();
540        let re = regex::Regex::new(pattern).unwrap();
541        for accepted in ["1600", "30s", "5m", "2h", "500ms"] {
542            assert!(re.is_match(accepted), "pattern rejects {accepted}");
543            assert!(
544                accepted.parse::<UpDuration>().is_ok(),
545                "FromStr rejects {accepted}"
546            );
547        }
548        for rejected in ["30S", "1.5s", "30 s", "", "s", "30d", "30w", "30MS"] {
549            assert!(!re.is_match(rejected), "pattern accepts {rejected}");
550            assert!(
551                rejected.parse::<UpDuration>().is_err(),
552                "FromStr accepts {rejected}"
553            );
554        }
555    }
556}
557
558#[cfg(test)]
559mod cpu_percent_tests {
560    use core::time::Duration;
561
562    use super::*;
563
564    /// Per-mille of one core: 1000 CPU-milliseconds over one wall second is
565    /// 100% of one core, and the daemon and lookout must agree on that or the
566    /// two numbers on screen disagree.
567    #[test]
568    fn a_full_core_for_the_whole_window_is_a_hundred_percent() {
569        assert_eq!(cpu_percent(1000, Duration::from_secs(1)), Some(100.0));
570        assert_eq!(cpu_percent(1000, Duration::from_secs(2)), Some(50.0));
571    }
572
573    /// Over one core is a tree spanning several, not a bug.
574    #[test]
575    fn a_tree_over_one_core_reports_over_a_hundred() {
576        assert_eq!(cpu_percent(4000, Duration::from_secs(1)), Some(400.0));
577    }
578
579    /// A zero window would divide a near-zero delta by a near-zero number and
580    /// report anything from 0% to thousands.
581    #[test]
582    fn a_zero_window_has_no_honest_answer() {
583        assert_eq!(cpu_percent(1000, Duration::ZERO), None);
584    }
585}