use core::cmp::Ordering;
use core::fmt;
use core::str::FromStr;
use std::borrow::Borrow;
use crate::model::{MetricState, ProcessSnapshot, ProcessState, UserIdentity};
use crate::units::{Percent, Rate};
#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum ProcessSortKey {
#[default]
Cpu,
Memory,
Read,
Write,
Pid,
Name,
Age,
User,
State,
Threads,
Virtual,
}
impl ProcessSortKey {
pub const ALL: [Self; 11] = [
Self::Cpu,
Self::Memory,
Self::Name,
Self::Pid,
Self::User,
Self::State,
Self::Read,
Self::Write,
Self::Age,
Self::Threads,
Self::Virtual,
];
pub const NAMES: &'static str =
"cpu, memory, read, write, pid, name, age, user, state, threads, virtual";
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Cpu => "cpu",
Self::Memory => "memory",
Self::Read => "read",
Self::Write => "write",
Self::Pid => "pid",
Self::Name => "name",
Self::Age => "age",
Self::User => "user",
Self::State => "state",
Self::Threads => "threads",
Self::Virtual => "virtual",
}
}
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Cpu => "CPU%",
Self::Memory => "memory (RSS)",
Self::Read => "read rate",
Self::Write => "write rate",
Self::Pid => "PID",
Self::Name => "name",
Self::Age => "age",
Self::User => "user",
Self::State => "state",
Self::Threads => "threads",
Self::Virtual => "virtual memory",
}
}
}
impl fmt::Display for ProcessSortKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
#[error(
"unknown process sort field `{name}`; expected one of {}",
ProcessSortKey::NAMES
)]
pub struct UnknownSortKey {
name: Box<str>,
}
impl UnknownSortKey {
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
}
impl FromStr for ProcessSortKey {
type Err = UnknownSortKey;
fn from_str(text: &str) -> Result<Self, Self::Err> {
let normalized: String = text
.trim()
.chars()
.map(|character| match character {
'-' => '_',
other => other.to_ascii_lowercase(),
})
.collect();
match normalized.as_str() {
"cpu" | "cpu_percent" => Ok(Self::Cpu),
"memory" | "mem" | "rss" => Ok(Self::Memory),
"read" | "read_rate" => Ok(Self::Read),
"write" | "write_rate" => Ok(Self::Write),
"pid" => Ok(Self::Pid),
"name" | "command" | "comm" => Ok(Self::Name),
"age" | "started" => Ok(Self::Age),
"user" | "uid" => Ok(Self::User),
"state" => Ok(Self::State),
"threads" | "thread_count" => Ok(Self::Threads),
"virtual" | "virt" | "vsz" => Ok(Self::Virtual),
_ => Err(UnknownSortKey { name: text.into() }),
}
}
}
#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum SortDirection {
Ascending,
#[default]
Descending,
}
impl SortDirection {
#[must_use]
pub const fn from_descending(descending: bool) -> Self {
if descending {
Self::Descending
} else {
Self::Ascending
}
}
#[must_use]
pub const fn is_descending(self) -> bool {
matches!(self, Self::Descending)
}
#[must_use]
pub const fn reversed(self) -> Self {
match self {
Self::Ascending => Self::Descending,
Self::Descending => Self::Ascending,
}
}
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Ascending => "ascending",
Self::Descending => "descending",
}
}
const fn apply(self, ordering: Ordering) -> Ordering {
match self {
Self::Ascending => ordering,
Self::Descending => ordering.reverse(),
}
}
}
#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
pub struct ProcessSort {
pub key: ProcessSortKey,
pub direction: SortDirection,
}
impl ProcessSort {
#[must_use]
pub const fn new(key: ProcessSortKey, direction: SortDirection) -> Self {
Self { key, direction }
}
#[must_use]
pub const fn descending(key: ProcessSortKey) -> Self {
Self::new(key, SortDirection::Descending)
}
#[must_use]
pub const fn ascending(key: ProcessSortKey) -> Self {
Self::new(key, SortDirection::Ascending)
}
#[must_use]
pub const fn reversed(self) -> Self {
Self::new(self.key, self.direction.reversed())
}
#[must_use]
pub const fn with_key(self, key: ProcessSortKey) -> Self {
Self::new(key, self.direction)
}
#[must_use]
pub fn compare(&self, left: &ProcessSnapshot, right: &ProcessSnapshot) -> Ordering {
self.compare_column(left, right)
.then_with(|| left.identity.cmp(&right.identity))
}
pub fn sort<P: Borrow<ProcessSnapshot>>(&self, rows: &mut [P]) {
rows.sort_by(|left, right| self.compare(left.borrow(), right.borrow()));
}
#[must_use]
pub fn order<P: Borrow<ProcessSnapshot>>(&self, rows: &[P]) -> Vec<usize> {
let mut indexed: Vec<(usize, &ProcessSnapshot)> =
rows.iter().map(Borrow::borrow).enumerate().collect();
indexed.sort_by(|(_, left), (_, right)| self.compare(left, right));
indexed.into_iter().map(|(index, _)| index).collect()
}
fn compare_column(&self, left: &ProcessSnapshot, right: &ProcessSnapshot) -> Ordering {
let direction = self.direction;
match self.key {
ProcessSortKey::Cpu => {
compare_metric(&left.cpu, &right.cpu, direction, compare_percent)
}
ProcessSortKey::Memory => compare_metric(
&left.memory.rss_bytes,
&right.memory.rss_bytes,
direction,
Ord::cmp,
),
ProcessSortKey::Virtual => compare_metric(
&left.memory.virtual_bytes,
&right.memory.virtual_bytes,
direction,
Ord::cmp,
),
ProcessSortKey::Read => {
compare_metric(&left.io.read, &right.io.read, direction, compare_rate)
}
ProcessSortKey::Write => {
compare_metric(&left.io.write, &right.io.write, direction, compare_rate)
}
ProcessSortKey::Threads => {
compare_metric(&left.threads, &right.threads, direction, Ord::cmp)
}
ProcessSortKey::Age => compare_metric(&left.age, &right.age, direction, Ord::cmp),
ProcessSortKey::User => {
compare_metric(&left.user, &right.user, direction, compare_user)
}
ProcessSortKey::Pid => direction.apply(left.identity.pid.cmp(&right.identity.pid)),
ProcessSortKey::Name => direction.apply(
compare_ignoring_case(&left.name, &right.name).then_with(|| {
compare_ignoring_case(left.command_or_name(), right.command_or_name())
}),
),
ProcessSortKey::State => direction.apply(compare_state(left.state, right.state)),
}
}
}
fn compare_metric<T, F>(
left: &MetricState<T>,
right: &MetricState<T>,
direction: SortDirection,
compare_value: F,
) -> Ordering
where
F: Fn(&T, &T) -> Ordering,
{
match (left.displayable(), right.displayable()) {
(Some((left_value, left_age)), Some((right_value, right_age))) => direction
.apply(compare_value(left_value, right_value))
.then_with(|| left_age.cmp(&right_age)),
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(None, None) => Ordering::Equal,
}
}
fn compare_percent(left: &Percent, right: &Percent) -> Ordering {
left.value().total_cmp(&right.value())
}
fn compare_rate(left: &Rate, right: &Rate) -> Ordering {
left.per_second().total_cmp(&right.per_second())
}
fn compare_user(left: &UserIdentity, right: &UserIdentity) -> Ordering {
match (&left.name, &right.name) {
(Some(left_name), Some(right_name)) => {
compare_ignoring_case(left_name, right_name).then_with(|| left.uid.cmp(&right.uid))
}
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(None, None) => left.uid.cmp(&right.uid),
}
}
fn compare_state(left: ProcessState, right: ProcessState) -> Ordering {
let (left_code, right_code) = (left.code(), right.code());
left_code
.to_ascii_lowercase()
.cmp(&right_code.to_ascii_lowercase())
.then_with(|| left_code.cmp(&right_code))
}
fn compare_ignoring_case(left: &str, right: &str) -> Ordering {
let mut left_chars = left.chars().flat_map(char::to_lowercase);
let mut right_chars = right.chars().flat_map(char::to_lowercase);
loop {
match (left_chars.next(), right_chars.next()) {
(Some(left_char), Some(right_char)) => match left_char.cmp(&right_char) {
Ordering::Equal => {}
difference => return difference,
},
(None, None) => return left.cmp(right),
(None, Some(_)) => return Ordering::Less,
(Some(_), None) => return Ordering::Greater,
}
}
}
#[cfg(test)]
mod tests {
use core::time::Duration;
use proptest::prelude::*;
use super::super::fixtures::process;
use super::*;
use crate::model::{ProcessIdentity, UnavailableReason};
fn identities(rows: &[ProcessSnapshot], order: &[usize]) -> Vec<ProcessIdentity> {
order
.iter()
.filter_map(|&index| rows.get(index).map(|row| row.identity))
.collect()
}
fn pids(rows: &[ProcessSnapshot], order: &[usize]) -> Vec<u32> {
identities(rows, order)
.into_iter()
.map(|identity| identity.pid)
.collect()
}
fn measured_for(key: ProcessSortKey, pid: u32, magnitude: u16) -> ProcessSnapshot {
let fixture = process(pid, u64::from(pid));
let wide = u64::from(magnitude);
let float = f32::from(magnitude);
match key {
ProcessSortKey::Cpu => fixture.cpu(float),
ProcessSortKey::Memory => fixture.rss(wide),
ProcessSortKey::Virtual => fixture.virtual_bytes(wide),
ProcessSortKey::Read => fixture.read(f64::from(magnitude)),
ProcessSortKey::Write => fixture.write(f64::from(magnitude)),
ProcessSortKey::Threads => fixture.threads(u32::from(magnitude)),
ProcessSortKey::Age => fixture.age(wide),
ProcessSortKey::User => {
fixture.user(u32::from(magnitude), Some(&format!("u{magnitude:03}")))
}
ProcessSortKey::Pid | ProcessSortKey::Name | ProcessSortKey::State => fixture,
}
.build()
}
#[test]
fn equal_keys_are_broken_by_identity_not_by_input_order() {
let hot_low_pid = process(700, 5).cpu(50.0).build();
let hot_high_pid = process(900, 5).cpu(50.0).build();
let sort = ProcessSort::default();
let mut forwards = vec![hot_low_pid.clone(), hot_high_pid.clone()];
let mut backwards = vec![hot_high_pid, hot_low_pid];
sort.sort(&mut forwards);
sort.sort(&mut backwards);
assert_eq!(
forwards.iter().map(|p| p.identity.pid).collect::<Vec<_>>(),
vec![700, 900]
);
assert_eq!(
backwards.iter().map(|p| p.identity.pid).collect::<Vec<_>>(),
vec![700, 900],
"input order must not influence the result"
);
}
#[test]
fn a_reused_pid_is_ordered_by_its_start_key() {
let old = process(4242, 100).cpu(10.0).build();
let new = process(4242, 900).cpu(10.0).build();
let mut rows = vec![new, old];
ProcessSort::default().sort(&mut rows);
assert_eq!(
rows.iter()
.map(|p| p.identity.start_key)
.collect::<Vec<_>>(),
vec![100, 900]
);
}
#[test]
fn two_refreshes_of_the_same_table_produce_the_same_order() {
let first: Vec<ProcessSnapshot> = (1..=6)
.map(|pid| process(pid, u64::from(pid)).cpu(0.0).build())
.collect();
let second: Vec<ProcessSnapshot> = first.iter().rev().cloned().collect();
let sort = ProcessSort::default();
assert_eq!(
identities(&first, &sort.order(&first)),
identities(&second, &sort.order(&second))
);
}
#[test]
fn reversing_the_direction_does_not_reverse_the_tie_break() {
let rows = vec![
process(1, 1).cpu(5.0).build(),
process(2, 2).cpu(5.0).build(),
process(3, 3).cpu(5.0).build(),
];
let descending = ProcessSort::default();
assert_eq!(pids(&rows, &descending.order(&rows)), vec![1, 2, 3]);
assert_eq!(
pids(&rows, &descending.reversed().order(&rows)),
vec![1, 2, 3],
"tie-break is direction-independent so selection stays put"
);
}
#[test]
fn unavailable_values_sort_last_in_both_directions_for_every_key() {
for key in ProcessSortKey::ALL {
if matches!(
key,
ProcessSortKey::Pid | ProcessSortKey::Name | ProcessSortKey::State
) {
continue;
}
let rows = vec![
measured_for(key, 1, 1),
process(2, 2).build(),
measured_for(key, 3, 9),
];
for direction in [SortDirection::Descending, SortDirection::Ascending] {
let order = pids(&rows, &ProcessSort::new(key, direction).order(&rows));
assert_eq!(
order.last(),
Some(&2),
"{key:?} {direction:?}: the unmeasured row must sort last"
);
}
}
}
#[test]
fn an_unmeasured_value_is_not_treated_as_zero() {
let rows = vec![
process(1, 1).cpu(0.0).build(),
process(2, 2)
.cpu_state(MetricState::PermissionDenied)
.build(),
];
let order = ProcessSort::ascending(ProcessSortKey::Cpu).order(&rows);
assert_eq!(pids(&rows, &order), vec![1, 2]);
}
#[test]
fn every_flavour_of_unavailable_ranks_equally_and_is_ordered_by_identity() {
let rows = vec![
process(30, 30)
.cpu_state(MetricState::TemporarilyUnavailable(
UnavailableReason::ProcessExited,
))
.build(),
process(10, 10).cpu_state(MetricState::Unsupported).build(),
process(20, 20)
.cpu_state(MetricState::PermissionDenied)
.build(),
process(40, 40).cpu_state(MetricState::WarmingUp).build(),
];
let order = ProcessSort::default().order(&rows);
assert_eq!(pids(&rows, &order), vec![10, 20, 30, 40]);
}
#[test]
fn a_stale_value_keeps_its_place_instead_of_dropping_to_the_bottom() {
let stale = MetricState::Available(Percent::new(90.0).expect("valid"))
.into_stale(Duration::from_secs(2));
let rows = vec![
process(1, 1).cpu(5.0).build(),
process(2, 2).cpu_state(stale).build(),
process(3, 3).cpu_state(MetricState::WarmingUp).build(),
];
let order = ProcessSort::default().order(&rows);
assert_eq!(
pids(&rows, &order),
vec![2, 1, 3],
"stale 90% outranks fresh 5%, and only the valueless row sorts last"
);
}
#[test]
fn fresh_beats_stale_on_an_exact_tie() {
let stale = MetricState::Available(Percent::new(7.0).expect("valid"))
.into_stale(Duration::from_secs(9));
let rows = vec![
process(9, 9).cpu_state(stale).build(),
process(1, 1).cpu(7.0).build(),
];
let order = ProcessSort::default().order(&rows);
assert_eq!(pids(&rows, &order), vec![1, 9]);
}
#[test]
fn cpu_sorts_by_magnitude_and_may_exceed_one_hundred_percent() {
let rows = vec![
process(1, 1).cpu(54.0).build(),
process(2, 2).cpu(287.0).build(),
process(3, 3).cpu(0.5).build(),
];
let order = ProcessSort::default().order(&rows);
assert_eq!(pids(&rows, &order), vec![2, 1, 3]);
}
#[test]
fn memory_and_virtual_are_independent_columns() {
let rows = vec![
process(1, 1).rss(1_000).virtual_bytes(9_000_000).build(),
process(2, 2).rss(9_000).virtual_bytes(1_000).build(),
];
assert_eq!(
pids(
&rows,
&ProcessSort::descending(ProcessSortKey::Memory).order(&rows)
),
vec![2, 1]
);
assert_eq!(
pids(
&rows,
&ProcessSort::descending(ProcessSortKey::Virtual).order(&rows)
),
vec![1, 2]
);
}
#[test]
fn read_and_write_rates_are_independent_columns() {
let rows = vec![
process(1, 1).read(18_000_000.0).write(1.0).build(),
process(2, 2).read(1.0).write(42_000_000.0).build(),
];
assert_eq!(
pids(
&rows,
&ProcessSort::descending(ProcessSortKey::Read).order(&rows)
),
vec![1, 2]
);
assert_eq!(
pids(
&rows,
&ProcessSort::descending(ProcessSortKey::Write).order(&rows)
),
vec![2, 1]
);
}
#[test]
fn name_sorting_ignores_case_and_falls_back_to_the_command_line() {
let rows = vec![
process(1, 1).name("Zsh").build(),
process(2, 2).name("cargo").command("cargo test").build(),
process(3, 3).name("cargo").command("cargo build").build(),
process(4, 4).name("apache").build(),
];
let order = ProcessSort::ascending(ProcessSortKey::Name).order(&rows);
assert_eq!(
pids(&rows, &order),
vec![4, 3, 2, 1],
"apache, cargo build, cargo test, Zsh"
);
}
#[test]
fn user_sorting_puts_unresolved_names_after_resolved_ones() {
let rows = vec![
process(1, 1).user(0, None).build(),
process(2, 2).user(501, Some("gabor")).build(),
process(3, 3).user(70, Some("_postgres")).build(),
process(4, 4)
.user_state(MetricState::PermissionDenied)
.build(),
];
let order = ProcessSort::ascending(ProcessSortKey::User).order(&rows);
assert_eq!(
pids(&rows, &order),
vec![3, 2, 1, 4],
"_postgres, gabor, uid 0 (unnamed), then the unattributable row"
);
}
#[test]
fn state_sorting_puts_zombies_first_when_descending() {
let rows = vec![
process(1, 1).state(ProcessState::Sleeping).build(),
process(2, 2).state(ProcessState::Zombie).build(),
process(3, 3).state(ProcessState::Running).build(),
process(4, 4)
.state(ProcessState::UninterruptibleSleep)
.build(),
];
let order = ProcessSort::descending(ProcessSortKey::State).order(&rows);
assert_eq!(pids(&rows, &order).first(), Some(&2));
assert_eq!(
pids(&rows, &order).last(),
Some(&4),
"D-state is the other extreme, one keypress away"
);
}
#[test]
fn pid_and_age_and_thread_columns_order_by_magnitude() {
let rows = vec![
process(900, 1).age(10).threads(2).build(),
process(100, 2).age(90).threads(64).build(),
];
assert_eq!(
pids(
&rows,
&ProcessSort::ascending(ProcessSortKey::Pid).order(&rows)
),
vec![100, 900]
);
assert_eq!(
pids(
&rows,
&ProcessSort::descending(ProcessSortKey::Age).order(&rows)
),
vec![100, 900]
);
assert_eq!(
pids(
&rows,
&ProcessSort::descending(ProcessSortKey::Threads).order(&rows)
),
vec![100, 900]
);
}
#[test]
fn sorting_an_empty_or_single_row_table_is_a_no_op() {
let mut empty: Vec<ProcessSnapshot> = Vec::new();
ProcessSort::default().sort(&mut empty);
assert!(empty.is_empty());
assert!(ProcessSort::default().order(&empty).is_empty());
let mut single = vec![process(1, 1).build()];
ProcessSort::default().sort(&mut single);
assert_eq!(single.len(), 1);
}
#[test]
fn sorting_works_on_borrowed_rows_too() {
let rows = [
process(1, 1).cpu(1.0).build(),
process(2, 2).cpu(2.0).build(),
];
let mut borrowed: Vec<&ProcessSnapshot> = rows.iter().collect();
ProcessSort::default().sort(&mut borrowed);
assert_eq!(
borrowed.iter().map(|p| p.identity.pid).collect::<Vec<_>>(),
vec![2, 1]
);
}
#[test]
fn config_field_names_round_trip() {
for key in ProcessSortKey::ALL {
assert_eq!(
key.as_str().parse::<ProcessSortKey>(),
Ok(key),
"{key:?} does not round-trip"
);
assert!(ProcessSortKey::NAMES.contains(key.as_str()));
}
}
#[test]
fn field_name_parsing_accepts_documented_aliases_and_ignores_case() {
assert_eq!("MEM".parse(), Ok(ProcessSortKey::Memory));
assert_eq!("rss".parse(), Ok(ProcessSortKey::Memory));
assert_eq!(" Command ".parse(), Ok(ProcessSortKey::Name));
assert_eq!("thread-count".parse(), Ok(ProcessSortKey::Threads));
assert_eq!("VSZ".parse(), Ok(ProcessSortKey::Virtual));
}
#[test]
fn an_unknown_field_name_is_reported_with_the_offending_text() {
let error = "cpu%".parse::<ProcessSortKey>().expect_err("not a field");
assert_eq!(error.name(), "cpu%");
let message = error.to_string();
assert!(message.contains("cpu%"), "{message}");
assert!(message.contains("virtual"), "{message}");
}
#[test]
fn the_default_ordering_matches_the_documented_config_default() {
let default = ProcessSort::default();
assert_eq!(default.key, ProcessSortKey::Cpu);
assert!(default.direction.is_descending());
assert_eq!(
SortDirection::from_descending(false),
SortDirection::Ascending
);
}
#[test]
fn choosing_a_column_keeps_the_direction_and_reversing_keeps_the_column() {
let sort = ProcessSort::ascending(ProcessSortKey::Name);
assert_eq!(
sort.with_key(ProcessSortKey::Age),
ProcessSort::ascending(ProcessSortKey::Age)
);
assert_eq!(
sort.reversed(),
ProcessSort::descending(ProcessSortKey::Name)
);
assert_eq!(sort.reversed().reversed(), sort);
}
#[test]
fn the_selector_lists_every_key_exactly_once() {
let mut names: Vec<&str> = ProcessSortKey::ALL.iter().map(|key| key.as_str()).collect();
names.sort_unstable();
names.dedup();
assert_eq!(names.len(), ProcessSortKey::ALL.len());
for key in ProcessSortKey::ALL {
assert!(!key.label().is_empty());
}
}
#[test]
fn comparison_is_antisymmetric_and_only_identical_rows_are_equal() {
let left = process(1, 1).cpu(5.0).build();
let right = process(2, 2).cpu(5.0).build();
let sort = ProcessSort::default();
assert_eq!(sort.compare(&left, &right), Ordering::Less);
assert_eq!(sort.compare(&right, &left), Ordering::Greater);
assert_eq!(sort.compare(&left, &left), Ordering::Equal);
}
proptest! {
#[test]
fn ordering_is_independent_of_input_order(
rows in prop::collection::vec((1u32..40, 0u64..3, prop::option::of(0u32..4)), 1..24),
rotation in 0usize..24,
) {
let table: Vec<ProcessSnapshot> = rows
.iter()
.enumerate()
.map(|(index, &(pid, start_key, cpu))| {
let unique = u64::try_from(index).unwrap_or(0);
let fixture = process(pid, start_key.wrapping_mul(1000) + unique);
match cpu {
Some(value) => fixture.cpu(f32::from(u16::try_from(value).unwrap_or(0))),
None => fixture,
}
.build()
})
.collect();
let mut rotated = table.clone();
let length = rotated.len();
if length > 0 {
rotated.rotate_left(rotation % length);
}
for key in ProcessSortKey::ALL {
for direction in [SortDirection::Ascending, SortDirection::Descending] {
let sort = ProcessSort::new(key, direction);
prop_assert_eq!(
identities(&table, &sort.order(&table)),
identities(&rotated, &sort.order(&rotated)),
"{:?} {:?} depends on input order",
key,
direction
);
}
}
}
}
}