Skip to main content

monitrs_core/process/
sort.rs

1//! Deterministic ordering of the process table (§7.2).
2//!
3//! §7.2 requires "stable sorting with PID/start-time tie-breaker" and forbids row
4//! selection from jumping unpredictably on each refresh. Three rules make that
5//! true, and all three are pinned by tests in this file:
6//!
7//! 1. **Every comparison ends in an identity comparison.** Two rows with equal
8//!    keys are ordered by `(pid, start_key)`, which is a property of the processes
9//!    themselves rather than of the order the OS happened to enumerate them in. A
10//!    refresh that returns the same processes therefore returns the same order,
11//!    even when a hundred idle rows all report exactly `0%`.
12//! 2. **A metric with no value never compares as zero** (§26). Rows whose value
13//!    was never measured are parked at the end of the list, in *both* directions,
14//!    so reversing the sort cannot fill the top of the table with blanks.
15//! 3. **Only the value comparison is reversed by the direction.** The tie-break,
16//!    the unavailable-last rule, and the fresh-before-stale rule are
17//!    direction-independent, so `S` (reverse sort, §6.2) is a predictable
18//!    operation rather than a mirror of unrelated internal rules.
19//!
20//! A [`MetricState::Stale`] value is ranked by the value it still displays rather
21//! than treated as missing. That is deliberate: a single failed read must not
22//! teleport a busy row to the bottom of the table, and the renderer already marks
23//! stale cells with their age (§4), so nobody is misled. Fresh beats stale on an
24//! exact tie, which keeps the ordering total.
25
26use core::cmp::Ordering;
27use core::fmt;
28use core::str::FromStr;
29use std::borrow::Borrow;
30
31use crate::model::{MetricState, ProcessSnapshot, ProcessState, UserIdentity};
32use crate::units::{Percent, Rate};
33
34/// A sortable column of the process table (§7.2).
35///
36/// One variant per sortable column, using the same names the `[processes] sort`
37/// config key and the `--sort` flag accept (§12), so a config value round-trips
38/// through [`ProcessSortKey::as_str`] and [`FromStr`] without a translation table
39/// somewhere else in the codebase.
40#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
41#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
42#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
43pub enum ProcessSortKey {
44    /// CPU percentage, core-normalized. The default, per §12.
45    #[default]
46    Cpu,
47    /// Resident set size.
48    ///
49    /// Also the ordering of the `MEM%` column: the share of total memory is RSS
50    /// divided by a constant, so it produces the same order and does not need a
51    /// second key (§7.2 lists them as one priority level).
52    Memory,
53    /// Read throughput.
54    Read,
55    /// Write throughput.
56    Write,
57    /// Process id.
58    Pid,
59    /// Process name, then command line.
60    Name,
61    /// Time since the process started.
62    Age,
63    /// Owning user.
64    User,
65    /// Scheduling state.
66    State,
67    /// Thread count.
68    Threads,
69    /// Virtual size.
70    Virtual,
71}
72
73impl ProcessSortKey {
74    /// Every key, in the order the sort selector (`s`, §6.2) should list them.
75    ///
76    /// Ordered by the §7.2 column priority so the most useful sorts are the ones
77    /// nearest the top of the selector.
78    pub const ALL: [Self; 11] = [
79        Self::Cpu,
80        Self::Memory,
81        Self::Name,
82        Self::Pid,
83        Self::User,
84        Self::State,
85        Self::Read,
86        Self::Write,
87        Self::Age,
88        Self::Threads,
89        Self::Virtual,
90    ];
91
92    /// The canonical names, for the "expected one of ..." half of a config error.
93    pub const NAMES: &'static str =
94        "cpu, memory, read, write, pid, name, age, user, state, threads, virtual";
95
96    /// The canonical configuration name (§12).
97    #[must_use]
98    pub const fn as_str(self) -> &'static str {
99        match self {
100            Self::Cpu => "cpu",
101            Self::Memory => "memory",
102            Self::Read => "read",
103            Self::Write => "write",
104            Self::Pid => "pid",
105            Self::Name => "name",
106            Self::Age => "age",
107            Self::User => "user",
108            Self::State => "state",
109            Self::Threads => "threads",
110            Self::Virtual => "virtual",
111        }
112    }
113
114    /// A human label for the sort selector and the status line.
115    #[must_use]
116    pub const fn label(self) -> &'static str {
117        match self {
118            Self::Cpu => "CPU%",
119            Self::Memory => "memory (RSS)",
120            Self::Read => "read rate",
121            Self::Write => "write rate",
122            Self::Pid => "PID",
123            Self::Name => "name",
124            Self::Age => "age",
125            Self::User => "user",
126            Self::State => "state",
127            Self::Threads => "threads",
128            Self::Virtual => "virtual memory",
129        }
130    }
131}
132
133impl fmt::Display for ProcessSortKey {
134    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135        f.write_str(self.as_str())
136    }
137}
138
139/// The error returned when a sort field name is not one this build knows.
140///
141/// Carries the offending text so the config layer can point at the exact key
142/// (§12) instead of reporting "invalid configuration".
143#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
144#[error(
145    "unknown process sort field `{name}`; expected one of {}",
146    ProcessSortKey::NAMES
147)]
148pub struct UnknownSortKey {
149    name: Box<str>,
150}
151
152impl UnknownSortKey {
153    /// The text that failed to parse.
154    #[must_use]
155    pub fn name(&self) -> &str {
156        &self.name
157    }
158}
159
160impl FromStr for ProcessSortKey {
161    type Err = UnknownSortKey;
162
163    /// Parses a sort field name.
164    ///
165    /// ASCII case is ignored and `-` is accepted for `_`, because a value typed on
166    /// the command line and a value written in TOML should not disagree. The alias
167    /// list is fixed and local: §12 requires configuration parsing to be
168    /// deterministic, so there is no fuzzy matching and no locale involvement.
169    fn from_str(text: &str) -> Result<Self, Self::Err> {
170        let normalized: String = text
171            .trim()
172            .chars()
173            .map(|character| match character {
174                '-' => '_',
175                other => other.to_ascii_lowercase(),
176            })
177            .collect();
178        match normalized.as_str() {
179            "cpu" | "cpu_percent" => Ok(Self::Cpu),
180            "memory" | "mem" | "rss" => Ok(Self::Memory),
181            "read" | "read_rate" => Ok(Self::Read),
182            "write" | "write_rate" => Ok(Self::Write),
183            "pid" => Ok(Self::Pid),
184            "name" | "command" | "comm" => Ok(Self::Name),
185            "age" | "started" => Ok(Self::Age),
186            "user" | "uid" => Ok(Self::User),
187            "state" => Ok(Self::State),
188            "threads" | "thread_count" => Ok(Self::Threads),
189            "virtual" | "virt" | "vsz" => Ok(Self::Virtual),
190            _ => Err(UnknownSortKey { name: text.into() }),
191        }
192    }
193}
194
195/// Which end of the ordering comes first.
196#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
197#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
198#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
199pub enum SortDirection {
200    /// Smallest first.
201    Ascending,
202    /// Largest first. The default, per §12 (`descending = true`).
203    #[default]
204    Descending,
205}
206
207impl SortDirection {
208    /// Builds a direction from the `[processes] descending` config flag (§12).
209    #[must_use]
210    pub const fn from_descending(descending: bool) -> Self {
211        if descending {
212            Self::Descending
213        } else {
214            Self::Ascending
215        }
216    }
217
218    /// Whether the largest value comes first.
219    #[must_use]
220    pub const fn is_descending(self) -> bool {
221        matches!(self, Self::Descending)
222    }
223
224    /// The opposite direction, for the `S` key (§6.2).
225    #[must_use]
226    pub const fn reversed(self) -> Self {
227        match self {
228            Self::Ascending => Self::Descending,
229            Self::Descending => Self::Ascending,
230        }
231    }
232
233    /// A one-word label for the header indicator.
234    #[must_use]
235    pub const fn label(self) -> &'static str {
236        match self {
237            Self::Ascending => "ascending",
238            Self::Descending => "descending",
239        }
240    }
241
242    /// Applies this direction to a value comparison.
243    const fn apply(self, ordering: Ordering) -> Ordering {
244        match self {
245            Self::Ascending => ordering,
246            Self::Descending => ordering.reverse(),
247        }
248    }
249}
250
251/// A complete process table ordering: which column, and which way round.
252#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
253pub struct ProcessSort {
254    /// The column being sorted.
255    pub key: ProcessSortKey,
256    /// The direction of the value comparison.
257    pub direction: SortDirection,
258}
259
260impl ProcessSort {
261    /// Builds an ordering.
262    #[must_use]
263    pub const fn new(key: ProcessSortKey, direction: SortDirection) -> Self {
264        Self { key, direction }
265    }
266
267    /// Builds a descending ordering, which is what almost every metric column
268    /// wants: the interesting rows are the big ones.
269    #[must_use]
270    pub const fn descending(key: ProcessSortKey) -> Self {
271        Self::new(key, SortDirection::Descending)
272    }
273
274    /// Builds an ascending ordering.
275    #[must_use]
276    pub const fn ascending(key: ProcessSortKey) -> Self {
277        Self::new(key, SortDirection::Ascending)
278    }
279
280    /// The same column, sorted the other way round (`S`, §6.2).
281    #[must_use]
282    pub const fn reversed(self) -> Self {
283        Self::new(self.key, self.direction.reversed())
284    }
285
286    /// A different column, keeping the current direction.
287    ///
288    /// Picking a column from the selector (`s`, §6.2) must not silently flip the
289    /// direction as well; §7.2 forbids the table rearranging in ways the user did
290    /// not ask for.
291    #[must_use]
292    pub const fn with_key(self, key: ProcessSortKey) -> Self {
293        Self::new(key, self.direction)
294    }
295
296    /// Compares two processes under this ordering.
297    ///
298    /// A total order: reflexive, antisymmetric, and transitive, with the identity
299    /// tie-break guaranteeing that only genuinely identical processes compare
300    /// [`Ordering::Equal`]. That is what makes it safe to hand to
301    /// [`slice::sort_by`] and what keeps two refreshes of the same table in the
302    /// same order (§7.2).
303    #[must_use]
304    pub fn compare(&self, left: &ProcessSnapshot, right: &ProcessSnapshot) -> Ordering {
305        self.compare_column(left, right)
306            .then_with(|| left.identity.cmp(&right.identity))
307    }
308
309    /// Sorts rows in place under this ordering.
310    ///
311    /// Generic over `Borrow` so it works on an owned `Vec<ProcessSnapshot>` and on
312    /// a borrowed `Vec<&ProcessSnapshot>` alike.
313    pub fn sort<P: Borrow<ProcessSnapshot>>(&self, rows: &mut [P]) {
314        rows.sort_by(|left, right| self.compare(left.borrow(), right.borrow()));
315    }
316
317    /// The display order of `rows`, as indices into `rows`.
318    ///
319    /// Returning indices keeps the published snapshot immutable (§10.4) and avoids
320    /// cloning a process table that can hold ten thousand rows (§16.1).
321    #[must_use]
322    pub fn order<P: Borrow<ProcessSnapshot>>(&self, rows: &[P]) -> Vec<usize> {
323        let mut indexed: Vec<(usize, &ProcessSnapshot)> =
324            rows.iter().map(Borrow::borrow).enumerate().collect();
325        indexed.sort_by(|(_, left), (_, right)| self.compare(left, right));
326        indexed.into_iter().map(|(index, _)| index).collect()
327    }
328
329    /// The column comparison, before the identity tie-break.
330    fn compare_column(&self, left: &ProcessSnapshot, right: &ProcessSnapshot) -> Ordering {
331        let direction = self.direction;
332        match self.key {
333            ProcessSortKey::Cpu => {
334                compare_metric(&left.cpu, &right.cpu, direction, compare_percent)
335            }
336            ProcessSortKey::Memory => compare_metric(
337                &left.memory.rss_bytes,
338                &right.memory.rss_bytes,
339                direction,
340                Ord::cmp,
341            ),
342            ProcessSortKey::Virtual => compare_metric(
343                &left.memory.virtual_bytes,
344                &right.memory.virtual_bytes,
345                direction,
346                Ord::cmp,
347            ),
348            ProcessSortKey::Read => {
349                compare_metric(&left.io.read, &right.io.read, direction, compare_rate)
350            }
351            ProcessSortKey::Write => {
352                compare_metric(&left.io.write, &right.io.write, direction, compare_rate)
353            }
354            ProcessSortKey::Threads => {
355                compare_metric(&left.threads, &right.threads, direction, Ord::cmp)
356            }
357            ProcessSortKey::Age => compare_metric(&left.age, &right.age, direction, Ord::cmp),
358            ProcessSortKey::User => {
359                compare_metric(&left.user, &right.user, direction, compare_user)
360            }
361            ProcessSortKey::Pid => direction.apply(left.identity.pid.cmp(&right.identity.pid)),
362            ProcessSortKey::Name => direction.apply(
363                compare_ignoring_case(&left.name, &right.name).then_with(|| {
364                    compare_ignoring_case(left.command_or_name(), right.command_or_name())
365                }),
366            ),
367            ProcessSortKey::State => direction.apply(compare_state(left.state, right.state)),
368        }
369    }
370}
371
372/// Compares two metrics so that a value which was never measured never behaves
373/// like a zero (§26).
374///
375/// The class ordering (has a value, then has none) and the age comparison are
376/// deliberately outside `direction`: see the module documentation.
377fn compare_metric<T, F>(
378    left: &MetricState<T>,
379    right: &MetricState<T>,
380    direction: SortDirection,
381    compare_value: F,
382) -> Ordering
383where
384    F: Fn(&T, &T) -> Ordering,
385{
386    match (left.displayable(), right.displayable()) {
387        (Some((left_value, left_age)), Some((right_value, right_age))) => direction
388            .apply(compare_value(left_value, right_value))
389            // Age is zero for a fresh value, so this puts fresh before stale on an
390            // exact tie and orders two stale rows by how stale they are.
391            .then_with(|| left_age.cmp(&right_age)),
392        (Some(_), None) => Ordering::Less,
393        (None, Some(_)) => Ordering::Greater,
394        // Which *kind* of unavailable a value is says nothing about magnitude, so
395        // the identity tie-break orders these rows instead.
396        (None, None) => Ordering::Equal,
397    }
398}
399
400/// Orders two percentages.
401///
402/// `total_cmp` rather than `partial_cmp`: [`Percent`] validates finiteness at
403/// construction so no `NaN` can reach here, and a total order is required for the
404/// comparator to be sound.
405fn compare_percent(left: &Percent, right: &Percent) -> Ordering {
406    left.value().total_cmp(&right.value())
407}
408
409/// Orders two rates, on the same reasoning as [`compare_percent`].
410fn compare_rate(left: &Rate, right: &Rate) -> Ordering {
411    left.per_second().total_cmp(&right.per_second())
412}
413
414/// Orders two owners the way the `USER` column renders them.
415///
416/// A resolved name sorts before an unresolved one: the numeric fallback is not a
417/// name, and grouping the unresolvable rows together keeps them out of the middle
418/// of an alphabetical list. This is inside the value comparison rather than the
419/// unavailable-last rule because the uid *is* known — only its label is missing.
420fn compare_user(left: &UserIdentity, right: &UserIdentity) -> Ordering {
421    match (&left.name, &right.name) {
422        (Some(left_name), Some(right_name)) => {
423            compare_ignoring_case(left_name, right_name).then_with(|| left.uid.cmp(&right.uid))
424        }
425        (Some(_), None) => Ordering::Less,
426        (None, Some(_)) => Ordering::Greater,
427        (None, None) => left.uid.cmp(&right.uid),
428    }
429}
430
431/// Orders two scheduling states by the `ps` letter shown in the `STATE` column.
432///
433/// Sorting a column by what it displays is the least surprising rule available,
434/// and it has a useful side effect: because case is folded with uppercase first,
435/// a descending state sort puts `Z` (zombie) at the top, which is the reason
436/// anyone sorts by state (§7.2 requires zombies to stand out).
437fn compare_state(left: ProcessState, right: ProcessState) -> Ordering {
438    let (left_code, right_code) = (left.code(), right.code());
439    left_code
440        .to_ascii_lowercase()
441        .cmp(&right_code.to_ascii_lowercase())
442        .then_with(|| left_code.cmp(&right_code))
443}
444
445/// Orders two strings case-insensitively, falling back to an exact comparison.
446///
447/// Allocation-free: a process table can hold ten thousand rows and a sort makes
448/// `O(n log n)` comparisons, so lowercasing into a `String` per comparison is not
449/// affordable (§16.1). Folding is Rust's `char::to_lowercase`, which is simple
450/// (not full) Unicode case folding — deterministic and dependency-free (§13).
451fn compare_ignoring_case(left: &str, right: &str) -> Ordering {
452    let mut left_chars = left.chars().flat_map(char::to_lowercase);
453    let mut right_chars = right.chars().flat_map(char::to_lowercase);
454    loop {
455        match (left_chars.next(), right_chars.next()) {
456            (Some(left_char), Some(right_char)) => match left_char.cmp(&right_char) {
457                Ordering::Equal => {}
458                difference => return difference,
459            },
460            (None, None) => return left.cmp(right),
461            (None, Some(_)) => return Ordering::Less,
462            (Some(_), None) => return Ordering::Greater,
463        }
464    }
465}
466
467#[cfg(test)]
468mod tests {
469    use core::time::Duration;
470
471    use proptest::prelude::*;
472
473    use super::super::fixtures::process;
474    use super::*;
475    use crate::model::{ProcessIdentity, UnavailableReason};
476
477    fn identities(rows: &[ProcessSnapshot], order: &[usize]) -> Vec<ProcessIdentity> {
478        order
479            .iter()
480            .filter_map(|&index| rows.get(index).map(|row| row.identity))
481            .collect()
482    }
483
484    fn pids(rows: &[ProcessSnapshot], order: &[usize]) -> Vec<u32> {
485        identities(rows, order)
486            .into_iter()
487            .map(|identity| identity.pid)
488            .collect()
489    }
490
491    /// Every key, paired with a fixture that measures *only* that key.
492    fn measured_for(key: ProcessSortKey, pid: u32, magnitude: u16) -> ProcessSnapshot {
493        let fixture = process(pid, u64::from(pid));
494        let wide = u64::from(magnitude);
495        let float = f32::from(magnitude);
496        match key {
497            ProcessSortKey::Cpu => fixture.cpu(float),
498            ProcessSortKey::Memory => fixture.rss(wide),
499            ProcessSortKey::Virtual => fixture.virtual_bytes(wide),
500            ProcessSortKey::Read => fixture.read(f64::from(magnitude)),
501            ProcessSortKey::Write => fixture.write(f64::from(magnitude)),
502            ProcessSortKey::Threads => fixture.threads(u32::from(magnitude)),
503            ProcessSortKey::Age => fixture.age(wide),
504            ProcessSortKey::User => {
505                fixture.user(u32::from(magnitude), Some(&format!("u{magnitude:03}")))
506            }
507            // None of these can be unavailable, so the fixture is unchanged and
508            // the unavailable-last test below skips them.
509            ProcessSortKey::Pid | ProcessSortKey::Name | ProcessSortKey::State => fixture,
510        }
511        .build()
512    }
513
514    #[test]
515    fn equal_keys_are_broken_by_identity_not_by_input_order() {
516        let hot_low_pid = process(700, 5).cpu(50.0).build();
517        let hot_high_pid = process(900, 5).cpu(50.0).build();
518        let sort = ProcessSort::default();
519
520        let mut forwards = vec![hot_low_pid.clone(), hot_high_pid.clone()];
521        let mut backwards = vec![hot_high_pid, hot_low_pid];
522        sort.sort(&mut forwards);
523        sort.sort(&mut backwards);
524
525        assert_eq!(
526            forwards.iter().map(|p| p.identity.pid).collect::<Vec<_>>(),
527            vec![700, 900]
528        );
529        assert_eq!(
530            backwards.iter().map(|p| p.identity.pid).collect::<Vec<_>>(),
531            vec![700, 900],
532            "input order must not influence the result"
533        );
534    }
535
536    #[test]
537    fn a_reused_pid_is_ordered_by_its_start_key() {
538        let old = process(4242, 100).cpu(10.0).build();
539        let new = process(4242, 900).cpu(10.0).build();
540        let mut rows = vec![new, old];
541        ProcessSort::default().sort(&mut rows);
542        assert_eq!(
543            rows.iter()
544                .map(|p| p.identity.start_key)
545                .collect::<Vec<_>>(),
546            vec![100, 900]
547        );
548    }
549
550    #[test]
551    fn two_refreshes_of_the_same_table_produce_the_same_order() {
552        // The §7.2 anti-jumping requirement: identical values, different
553        // enumeration order, identical result.
554        let first: Vec<ProcessSnapshot> = (1..=6)
555            .map(|pid| process(pid, u64::from(pid)).cpu(0.0).build())
556            .collect();
557        let second: Vec<ProcessSnapshot> = first.iter().rev().cloned().collect();
558        let sort = ProcessSort::default();
559
560        assert_eq!(
561            identities(&first, &sort.order(&first)),
562            identities(&second, &sort.order(&second))
563        );
564    }
565
566    #[test]
567    fn reversing_the_direction_does_not_reverse_the_tie_break() {
568        let rows = vec![
569            process(1, 1).cpu(5.0).build(),
570            process(2, 2).cpu(5.0).build(),
571            process(3, 3).cpu(5.0).build(),
572        ];
573        let descending = ProcessSort::default();
574        assert_eq!(pids(&rows, &descending.order(&rows)), vec![1, 2, 3]);
575        assert_eq!(
576            pids(&rows, &descending.reversed().order(&rows)),
577            vec![1, 2, 3],
578            "tie-break is direction-independent so selection stays put"
579        );
580    }
581
582    #[test]
583    fn unavailable_values_sort_last_in_both_directions_for_every_key() {
584        for key in ProcessSortKey::ALL {
585            if matches!(
586                key,
587                ProcessSortKey::Pid | ProcessSortKey::Name | ProcessSortKey::State
588            ) {
589                // These columns are always measured; there is no unavailable case.
590                continue;
591            }
592            let rows = vec![
593                measured_for(key, 1, 1),
594                process(2, 2).build(),
595                measured_for(key, 3, 9),
596            ];
597            for direction in [SortDirection::Descending, SortDirection::Ascending] {
598                let order = pids(&rows, &ProcessSort::new(key, direction).order(&rows));
599                assert_eq!(
600                    order.last(),
601                    Some(&2),
602                    "{key:?} {direction:?}: the unmeasured row must sort last"
603                );
604            }
605        }
606    }
607
608    #[test]
609    fn an_unmeasured_value_is_not_treated_as_zero() {
610        let rows = vec![
611            process(1, 1).cpu(0.0).build(),
612            process(2, 2)
613                .cpu_state(MetricState::PermissionDenied)
614                .build(),
615        ];
616        // Ascending: 0% is the smallest measured value, yet the denied row still
617        // comes after it. If "unavailable" were zero, the tie-break would decide
618        // and PID 2 would be first.
619        let order = ProcessSort::ascending(ProcessSortKey::Cpu).order(&rows);
620        assert_eq!(pids(&rows, &order), vec![1, 2]);
621    }
622
623    #[test]
624    fn every_flavour_of_unavailable_ranks_equally_and_is_ordered_by_identity() {
625        let rows = vec![
626            process(30, 30)
627                .cpu_state(MetricState::TemporarilyUnavailable(
628                    UnavailableReason::ProcessExited,
629                ))
630                .build(),
631            process(10, 10).cpu_state(MetricState::Unsupported).build(),
632            process(20, 20)
633                .cpu_state(MetricState::PermissionDenied)
634                .build(),
635            process(40, 40).cpu_state(MetricState::WarmingUp).build(),
636        ];
637        let order = ProcessSort::default().order(&rows);
638        assert_eq!(pids(&rows, &order), vec![10, 20, 30, 40]);
639    }
640
641    #[test]
642    fn a_stale_value_keeps_its_place_instead_of_dropping_to_the_bottom() {
643        let stale = MetricState::Available(Percent::new(90.0).expect("valid"))
644            .into_stale(Duration::from_secs(2));
645        let rows = vec![
646            process(1, 1).cpu(5.0).build(),
647            process(2, 2).cpu_state(stale).build(),
648            process(3, 3).cpu_state(MetricState::WarmingUp).build(),
649        ];
650        let order = ProcessSort::default().order(&rows);
651        assert_eq!(
652            pids(&rows, &order),
653            vec![2, 1, 3],
654            "stale 90% outranks fresh 5%, and only the valueless row sorts last"
655        );
656    }
657
658    #[test]
659    fn fresh_beats_stale_on_an_exact_tie() {
660        let stale = MetricState::Available(Percent::new(7.0).expect("valid"))
661            .into_stale(Duration::from_secs(9));
662        let rows = vec![
663            process(9, 9).cpu_state(stale).build(),
664            process(1, 1).cpu(7.0).build(),
665        ];
666        // Descending order, and the fresh row wins despite its higher PID losing
667        // the tie-break: freshness is compared before identity.
668        let order = ProcessSort::default().order(&rows);
669        assert_eq!(pids(&rows, &order), vec![1, 9]);
670    }
671
672    #[test]
673    fn cpu_sorts_by_magnitude_and_may_exceed_one_hundred_percent() {
674        let rows = vec![
675            process(1, 1).cpu(54.0).build(),
676            process(2, 2).cpu(287.0).build(),
677            process(3, 3).cpu(0.5).build(),
678        ];
679        let order = ProcessSort::default().order(&rows);
680        assert_eq!(pids(&rows, &order), vec![2, 1, 3]);
681    }
682
683    #[test]
684    fn memory_and_virtual_are_independent_columns() {
685        let rows = vec![
686            process(1, 1).rss(1_000).virtual_bytes(9_000_000).build(),
687            process(2, 2).rss(9_000).virtual_bytes(1_000).build(),
688        ];
689        assert_eq!(
690            pids(
691                &rows,
692                &ProcessSort::descending(ProcessSortKey::Memory).order(&rows)
693            ),
694            vec![2, 1]
695        );
696        assert_eq!(
697            pids(
698                &rows,
699                &ProcessSort::descending(ProcessSortKey::Virtual).order(&rows)
700            ),
701            vec![1, 2]
702        );
703    }
704
705    #[test]
706    fn read_and_write_rates_are_independent_columns() {
707        let rows = vec![
708            process(1, 1).read(18_000_000.0).write(1.0).build(),
709            process(2, 2).read(1.0).write(42_000_000.0).build(),
710        ];
711        assert_eq!(
712            pids(
713                &rows,
714                &ProcessSort::descending(ProcessSortKey::Read).order(&rows)
715            ),
716            vec![1, 2]
717        );
718        assert_eq!(
719            pids(
720                &rows,
721                &ProcessSort::descending(ProcessSortKey::Write).order(&rows)
722            ),
723            vec![2, 1]
724        );
725    }
726
727    #[test]
728    fn name_sorting_ignores_case_and_falls_back_to_the_command_line() {
729        let rows = vec![
730            process(1, 1).name("Zsh").build(),
731            process(2, 2).name("cargo").command("cargo test").build(),
732            process(3, 3).name("cargo").command("cargo build").build(),
733            process(4, 4).name("apache").build(),
734        ];
735        let order = ProcessSort::ascending(ProcessSortKey::Name).order(&rows);
736        assert_eq!(
737            pids(&rows, &order),
738            vec![4, 3, 2, 1],
739            "apache, cargo build, cargo test, Zsh"
740        );
741    }
742
743    #[test]
744    fn user_sorting_puts_unresolved_names_after_resolved_ones() {
745        let rows = vec![
746            process(1, 1).user(0, None).build(),
747            process(2, 2).user(501, Some("gabor")).build(),
748            process(3, 3).user(70, Some("_postgres")).build(),
749            process(4, 4)
750                .user_state(MetricState::PermissionDenied)
751                .build(),
752        ];
753        let order = ProcessSort::ascending(ProcessSortKey::User).order(&rows);
754        assert_eq!(
755            pids(&rows, &order),
756            vec![3, 2, 1, 4],
757            "_postgres, gabor, uid 0 (unnamed), then the unattributable row"
758        );
759    }
760
761    #[test]
762    fn state_sorting_puts_zombies_first_when_descending() {
763        let rows = vec![
764            process(1, 1).state(ProcessState::Sleeping).build(),
765            process(2, 2).state(ProcessState::Zombie).build(),
766            process(3, 3).state(ProcessState::Running).build(),
767            process(4, 4)
768                .state(ProcessState::UninterruptibleSleep)
769                .build(),
770        ];
771        let order = ProcessSort::descending(ProcessSortKey::State).order(&rows);
772        assert_eq!(pids(&rows, &order).first(), Some(&2));
773        assert_eq!(
774            pids(&rows, &order).last(),
775            Some(&4),
776            "D-state is the other extreme, one keypress away"
777        );
778    }
779
780    #[test]
781    fn pid_and_age_and_thread_columns_order_by_magnitude() {
782        let rows = vec![
783            process(900, 1).age(10).threads(2).build(),
784            process(100, 2).age(90).threads(64).build(),
785        ];
786        assert_eq!(
787            pids(
788                &rows,
789                &ProcessSort::ascending(ProcessSortKey::Pid).order(&rows)
790            ),
791            vec![100, 900]
792        );
793        assert_eq!(
794            pids(
795                &rows,
796                &ProcessSort::descending(ProcessSortKey::Age).order(&rows)
797            ),
798            vec![100, 900]
799        );
800        assert_eq!(
801            pids(
802                &rows,
803                &ProcessSort::descending(ProcessSortKey::Threads).order(&rows)
804            ),
805            vec![100, 900]
806        );
807    }
808
809    #[test]
810    fn sorting_an_empty_or_single_row_table_is_a_no_op() {
811        let mut empty: Vec<ProcessSnapshot> = Vec::new();
812        ProcessSort::default().sort(&mut empty);
813        assert!(empty.is_empty());
814        assert!(ProcessSort::default().order(&empty).is_empty());
815
816        let mut single = vec![process(1, 1).build()];
817        ProcessSort::default().sort(&mut single);
818        assert_eq!(single.len(), 1);
819    }
820
821    #[test]
822    fn sorting_works_on_borrowed_rows_too() {
823        let rows = [
824            process(1, 1).cpu(1.0).build(),
825            process(2, 2).cpu(2.0).build(),
826        ];
827        let mut borrowed: Vec<&ProcessSnapshot> = rows.iter().collect();
828        ProcessSort::default().sort(&mut borrowed);
829        assert_eq!(
830            borrowed.iter().map(|p| p.identity.pid).collect::<Vec<_>>(),
831            vec![2, 1]
832        );
833    }
834
835    #[test]
836    fn config_field_names_round_trip() {
837        for key in ProcessSortKey::ALL {
838            assert_eq!(
839                key.as_str().parse::<ProcessSortKey>(),
840                Ok(key),
841                "{key:?} does not round-trip"
842            );
843            assert!(ProcessSortKey::NAMES.contains(key.as_str()));
844        }
845    }
846
847    #[test]
848    fn field_name_parsing_accepts_documented_aliases_and_ignores_case() {
849        assert_eq!("MEM".parse(), Ok(ProcessSortKey::Memory));
850        assert_eq!("rss".parse(), Ok(ProcessSortKey::Memory));
851        assert_eq!(" Command ".parse(), Ok(ProcessSortKey::Name));
852        assert_eq!("thread-count".parse(), Ok(ProcessSortKey::Threads));
853        assert_eq!("VSZ".parse(), Ok(ProcessSortKey::Virtual));
854    }
855
856    #[test]
857    fn an_unknown_field_name_is_reported_with_the_offending_text() {
858        let error = "cpu%".parse::<ProcessSortKey>().expect_err("not a field");
859        assert_eq!(error.name(), "cpu%");
860        let message = error.to_string();
861        assert!(message.contains("cpu%"), "{message}");
862        assert!(message.contains("virtual"), "{message}");
863    }
864
865    #[test]
866    fn the_default_ordering_matches_the_documented_config_default() {
867        // §12: `sort = "cpu"`, `descending = true`.
868        let default = ProcessSort::default();
869        assert_eq!(default.key, ProcessSortKey::Cpu);
870        assert!(default.direction.is_descending());
871        assert_eq!(
872            SortDirection::from_descending(false),
873            SortDirection::Ascending
874        );
875    }
876
877    #[test]
878    fn choosing_a_column_keeps_the_direction_and_reversing_keeps_the_column() {
879        let sort = ProcessSort::ascending(ProcessSortKey::Name);
880        assert_eq!(
881            sort.with_key(ProcessSortKey::Age),
882            ProcessSort::ascending(ProcessSortKey::Age)
883        );
884        assert_eq!(
885            sort.reversed(),
886            ProcessSort::descending(ProcessSortKey::Name)
887        );
888        assert_eq!(sort.reversed().reversed(), sort);
889    }
890
891    #[test]
892    fn the_selector_lists_every_key_exactly_once() {
893        let mut names: Vec<&str> = ProcessSortKey::ALL.iter().map(|key| key.as_str()).collect();
894        names.sort_unstable();
895        names.dedup();
896        assert_eq!(names.len(), ProcessSortKey::ALL.len());
897        for key in ProcessSortKey::ALL {
898            assert!(!key.label().is_empty());
899        }
900    }
901
902    #[test]
903    fn comparison_is_antisymmetric_and_only_identical_rows_are_equal() {
904        let left = process(1, 1).cpu(5.0).build();
905        let right = process(2, 2).cpu(5.0).build();
906        let sort = ProcessSort::default();
907        assert_eq!(sort.compare(&left, &right), Ordering::Less);
908        assert_eq!(sort.compare(&right, &left), Ordering::Greater);
909        assert_eq!(sort.compare(&left, &left), Ordering::Equal);
910    }
911
912    proptest! {
913        /// The §7.2 anti-jumping property, stated as a property test: the order of
914        /// a table cannot depend on the order the collector enumerated it in.
915        #[test]
916        fn ordering_is_independent_of_input_order(
917            rows in prop::collection::vec((1u32..40, 0u64..3, prop::option::of(0u32..4)), 1..24),
918            rotation in 0usize..24,
919        ) {
920            let table: Vec<ProcessSnapshot> = rows
921                .iter()
922                .enumerate()
923                .map(|(index, &(pid, start_key, cpu))| {
924                    // The index keeps identities unique while values collide hard,
925                    // which is exactly the situation that makes rows jump.
926                    let unique = u64::try_from(index).unwrap_or(0);
927                    let fixture = process(pid, start_key.wrapping_mul(1000) + unique);
928                    match cpu {
929                        Some(value) => fixture.cpu(f32::from(u16::try_from(value).unwrap_or(0))),
930                        None => fixture,
931                    }
932                    .build()
933                })
934                .collect();
935
936            let mut rotated = table.clone();
937            let length = rotated.len();
938            if length > 0 {
939                rotated.rotate_left(rotation % length);
940            }
941
942            for key in ProcessSortKey::ALL {
943                for direction in [SortDirection::Ascending, SortDirection::Descending] {
944                    let sort = ProcessSort::new(key, direction);
945                    prop_assert_eq!(
946                        identities(&table, &sort.order(&table)),
947                        identities(&rotated, &sort.order(&rotated)),
948                        "{:?} {:?} depends on input order",
949                        key,
950                        direction
951                    );
952                }
953            }
954        }
955    }
956}