Files
RedBear-OS/local/recipes/system/redbear-power/source/src/process.rs
T
vasilito 9cd0a25906 redbear-power: v1.38 audit fixes + htop/btop parity
v1.37 audit found 2 new bugs + recommended 5 v1.38 htop/btop-parity features. This release fixes both bugs and ships all 5 features.

v1.37-0 (HIGH): set_tab() clears last_clicked_cpu

The v1.37 re-click-to-expand feature set last_clicked_cpu on click but never reset it on tab switch. A user who clicked Per-CPU row 5, switched tabs, and came back would unexpectedly toggle expand. Fix: add App::set_tab(TabId) helper that resets both last_clicked_cpu and expanded_cpu, and route all 9 tab keys (1-9) + T through it.

v1.37-1 (MEDIUM): mouse click respects filter

The Process tab mouse click set process_cursor from the raw screen row, ignoring the active filter. With a filter active, the cursor highlight wouldn't align with the click, and right-click opened the wrong PID detail. Fix: new App::process_cursor_at_y(y, first_data_y) that walks the post-filter visible list and clamps to the last visible row. Wired into both left-click and right-click in handle_mouse.

v1.38-2: SortDir + i key for direction toggle

htop parity for the 'i' key. New App.sort_ascending: bool. The SortMode enum gets a new sort_ascending(procs, true) method (the existing sort() now delegates to sort_ascending(procs, false) for backward compat). On each refresh, if sort_ascending is true, the processes are re-sorted after the default descending pass. Press 'i' to flip; the status flash includes the current direction.

v1.38-3: cmdline + io_priority in PID detail

htop parity. New PidDetail.cmdline reads /proc/[pid]/cmdline, replaces NUL with space, strips trailing NULs. Rendered in the PID detail popup (truncated to 120 chars). New PidDetail.io_priority reads /proc/[pid]/stat field 47. Both are tolerant of missing files.

v1.38-4: per-disk I/O throughput sparkline

btop parity. New App.disk_history: BTreeMap<String, VecDeque<u8>> keyed by disk name. Mirrors the io_history pattern: each storage refresh collects raw kbps samples, normalizes per-disk against its own max, writes u8 to the public history. Rendered in the Storage tab as a 12-char sparkline next to each disk name. Reaps disks that have disappeared.

Test count 140 -> 149 (+9):
- set_tab_clears_last_clicked_cpu_and_expanded_cpu
- process_cursor_at_y_respects_filter
- process_cursor_at_y_clamps_to_last_visible
- sort_ascending_flips_rss_order
- read_cmdline_replaces_nul_with_space
- read_cmdline_handles_missing_pid
- read_io_priority_handles_self
- read_io_priority_handles_missing_pid
- update_disk_history_reaps_exited_disks

Redox stripped binary: 4,348,776 bytes (+106 KiB from v1.37).
Compile warnings: 56 (unchanged; all pre-existing).
2026-06-21 09:50:31 +03:00

1353 lines
46 KiB
Rust

//! Process list via `procfs` (`/proc/[pid]/stat` + `/proc/[pid]/comm`).
//!
//! Linux exposes per-process state, memory, and CPU usage via procfs.
//! `/proc/[pid]/stat` is a single space-separated line with 52 fields
//! per `man 5 proc`; the second field (comm) is wrapped in
//! parentheses and may contain spaces/parens, so it must be extracted
//! by locating the LAST `)` to handle names like `(bash)` or
//! `(Web Content)`.
//!
//! `/proc/[pid]/comm` is a separate file containing the process name
//! (truncated to 15 chars + newline) — used as a fallback if the
//! parens-parsing fails.
//!
//! On Redox, no equivalent scheme exists yet, so `read()` returns an
//! empty `ProcInfo` and the render layer shows
//! `(no processes detected)`.
use std::fs;
const MAX_PROCESSES: usize = 50;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum SortMode {
#[default]
Rss,
Cpu,
Io,
IoRead,
IoWrite,
IoRate,
IoReadRate,
IoWriteRate,
RChar,
WChar,
VSize,
Pid,
Name,
}
impl SortMode {
pub fn next(self) -> Self {
match self {
SortMode::Rss => SortMode::Cpu,
SortMode::Cpu => SortMode::Io,
SortMode::Io => SortMode::IoRead,
SortMode::IoRead => SortMode::IoWrite,
SortMode::IoWrite => SortMode::IoRate,
SortMode::IoRate => SortMode::IoReadRate,
SortMode::IoReadRate => SortMode::IoWriteRate,
SortMode::IoWriteRate => SortMode::RChar,
SortMode::RChar => SortMode::WChar,
SortMode::WChar => SortMode::VSize,
SortMode::VSize => SortMode::Pid,
SortMode::Pid => SortMode::Name,
SortMode::Name => SortMode::Rss,
}
}
pub fn name(self) -> &'static str {
match self {
SortMode::Rss => "RSS",
SortMode::Cpu => "CPU%",
SortMode::Io => "IO",
SortMode::IoRead => "IO-R",
SortMode::IoWrite => "IO-W",
SortMode::IoRate => "IO/s",
SortMode::IoReadRate => "R/s",
SortMode::IoWriteRate => "W/s",
SortMode::RChar => "RChr",
SortMode::WChar => "WChr",
SortMode::VSize => "VSZ",
SortMode::Pid => "PID",
SortMode::Name => "Name",
}
}
pub fn sort(self, processes: &mut Vec<ProcessInfo>) {
// v1.38: direction is fixed at descending. The app
// calls `sort_ascending(processes, true)` for ascending
// sorts. Default false keeps backward compatibility for
// existing callers (tests, sort_tree, etc).
self.sort_ascending(processes, false)
}
/// Sort with explicit direction. `ascending = true` flips
/// the comparator for every sort mode. htop parity: the
/// `i` key toggles the App's `sort_ascending` flag and
/// re-sorts the visible processes.
pub fn sort_ascending(self, processes: &mut Vec<ProcessInfo>, ascending: bool) {
if ascending {
match self {
SortMode::Rss => processes.sort_by(|a, b| a.rss_kb.cmp(&b.rss_kb)),
SortMode::Cpu => processes.sort_by(|a, b| a.cpu_pct.partial_cmp(&b.cpu_pct).unwrap_or(std::cmp::Ordering::Equal)),
SortMode::Io => processes.sort_by(|a, b| {
let ai = a.io_total_kb();
let bi = b.io_total_kb();
match (ai, bi) {
(Some(x), Some(y)) => x.cmp(&y),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
}
}),
SortMode::IoRead => sort_by_io_field_asc(processes, |p| p.io_read_kb),
SortMode::IoWrite => sort_by_io_field_asc(processes, |p| p.io_write_kb),
SortMode::IoRate => sort_by_io_rate_field_asc(processes, |p| p.io_total_rate_kbs()),
SortMode::IoReadRate => sort_by_io_rate_field_asc(processes, |p| p.io_read_rate_kbs),
SortMode::IoWriteRate => sort_by_io_rate_field_asc(processes, |p| p.io_write_rate_kbs),
SortMode::RChar => processes.sort_by(|a, b| a.io_rchar_kb.cmp(&b.io_rchar_kb)),
SortMode::WChar => processes.sort_by(|a, b| a.io_wchar_kb.cmp(&b.io_wchar_kb)),
SortMode::VSize => processes.sort_by(|a, b| a.vsize_kb.cmp(&b.vsize_kb)),
SortMode::Pid => processes.sort_by_key(|p| p.pid),
SortMode::Name => processes.sort_by(|a, b| a.comm.cmp(&b.comm)),
}
} else {
match self {
SortMode::Rss => processes.sort_by(|a, b| b.rss_kb.cmp(&a.rss_kb)),
SortMode::Cpu => processes.sort_by(|a, b| b.cpu_pct.partial_cmp(&a.cpu_pct).unwrap_or(std::cmp::Ordering::Equal)),
SortMode::Io => processes.sort_by(|a, b| {
let ai = a.io_total_kb();
let bi = b.io_total_kb();
match (ai, bi) {
(Some(x), Some(y)) => y.cmp(&x),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
}
}),
SortMode::IoRead => sort_by_io_field(processes, |p| p.io_read_kb),
SortMode::IoWrite => sort_by_io_field(processes, |p| p.io_write_kb),
SortMode::IoRate => sort_by_io_rate_field(processes, |p| p.io_total_rate_kbs()),
SortMode::IoReadRate => sort_by_io_rate_field(processes, |p| p.io_read_rate_kbs),
SortMode::IoWriteRate => sort_by_io_rate_field(processes, |p| p.io_write_rate_kbs),
SortMode::RChar => processes.sort_by(|a, b| b.io_rchar_kb.cmp(&a.io_rchar_kb)),
SortMode::WChar => processes.sort_by(|a, b| b.io_wchar_kb.cmp(&a.io_wchar_kb)),
SortMode::VSize => processes.sort_by(|a, b| b.vsize_kb.cmp(&a.vsize_kb)),
SortMode::Pid => processes.sort_by_key(|p| p.pid),
SortMode::Name => processes.sort_by(|a, b| a.comm.cmp(&b.comm)),
}
}
}
}
fn sort_by_io_field<F>(processes: &mut Vec<ProcessInfo>, field: F)
where
F: Fn(&ProcessInfo) -> Option<u64>,
{
processes.sort_by(|a, b| {
let ai = field(a);
let bi = field(b);
match (ai, bi) {
(Some(x), Some(y)) => y.cmp(&x),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
}
});
}
/// Ascending variant of `sort_by_io_field`. v1.38: paired
/// helpers (one for each direction) keep the per-direction
/// comparator logic local and avoid a runtime branch inside
/// the closure.
fn sort_by_io_field_asc<F>(processes: &mut Vec<ProcessInfo>, field: F)
where
F: Fn(&ProcessInfo) -> Option<u64>,
{
processes.sort_by(|a, b| {
let ai = field(a);
let bi = field(b);
match (ai, bi) {
(Some(x), Some(y)) => x.cmp(&y),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
}
});
}
fn sort_by_io_rate_field<F>(processes: &mut Vec<ProcessInfo>, field: F)
where
F: Fn(&ProcessInfo) -> Option<f64>,
{
processes.sort_by(|a, b| {
let ai = field(a);
let bi = field(b);
match (ai, bi) {
(Some(x), Some(y)) => y.partial_cmp(&x).unwrap_or(std::cmp::Ordering::Equal),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
}
});
}
fn sort_by_io_rate_field_asc<F>(processes: &mut Vec<ProcessInfo>, field: F)
where
F: Fn(&ProcessInfo) -> Option<f64>,
{
processes.sort_by(|a, b| {
let ai = field(a);
let bi = field(b);
match (ai, bi) {
(Some(x), Some(y)) => x.partial_cmp(&y).unwrap_or(std::cmp::Ordering::Equal),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
}
});
}
/// Tree sort: emit each process preceded by its parent(s) so the
/// visual reading order matches the parent-child hierarchy. Roots
/// (processes whose ppid is 0 or whose parent is not in the current
/// process set) are emitted first, then each root's descendants in
/// depth-first order. Within siblings, the `sort_mode` is honored.
///
/// The input `processes` is consumed and replaced; the output uses
/// the same ProcessInfo values. Stable for processes that share the
/// same parent and sort key.
///
/// Cycle protection: a PID that is its own ancestor is not recursed
/// into (its children are still emitted once as flat children of the
/// cycle parent). This handles the rare case of `init`-style PPID
/// loops in containers.
pub fn sort_tree(processes: &mut Vec<ProcessInfo>, sort_mode: SortMode) {
use std::collections::{BTreeMap, BTreeSet};
// 1. Index PIDs and group children by ppid.
let mut by_pid: BTreeMap<u32, usize> = BTreeMap::new();
for (i, p) in processes.iter().enumerate() {
by_pid.insert(p.pid, i);
}
let mut children: BTreeMap<u32, Vec<usize>> = BTreeMap::new();
for (i, p) in processes.iter().enumerate() {
children.entry(p.ppid).or_default().push(i);
}
// 2. Find roots: ppid == 0 or ppid not in pid set.
let mut roots: Vec<usize> = (0..processes.len())
.filter(|&i| {
let p = &processes[i];
p.ppid == 0 || !by_pid.contains_key(&p.ppid)
})
.collect();
// 3. Sort roots and each sibling group by sort_mode. We
// sort the indices using a small adapter closure so the
// existing `sort_mode.sort()` (which takes Vec<ProcessInfo>)
// can be reused.
let mut roots_proc: Vec<ProcessInfo> = roots.iter().map(|&i| processes[i].clone()).collect();
sort_mode.sort(&mut roots_proc);
roots = roots_proc.iter().map(|p| by_pid[&p.pid]).collect();
for v in children.values_mut() {
let mut v_proc: Vec<ProcessInfo> = v.iter().map(|&i| processes[i].clone()).collect();
sort_mode.sort(&mut v_proc);
*v = v_proc.iter().map(|p| by_pid[&p.pid]).collect();
}
// 4. DFS from each root, building the output in tree order.
let mut out: Vec<ProcessInfo> = Vec::with_capacity(processes.len());
let mut visited: BTreeSet<u32> = BTreeSet::new();
for &root in &roots {
dfs_emit(
&processes,
&children,
root,
&mut out,
&mut visited,
);
}
// 5. Append any leftover procs (defensive — should not happen
// given step 2, but handles e.g. a ppid cycle pointing back
// into the middle of the visited set).
for (i, p) in processes.iter().enumerate() {
if !visited.contains(&p.pid) {
out.push(processes[i].clone());
}
}
*processes = out;
}
/// Remove descendants of any PID in `folded` from a tree-ordered
/// process list. The parent of a folded PID stays visible (with a
/// `▶` indicator in the render layer). Cycles are tolerated: a PID
/// that is its own ancestor's descendant is still hidden if any
/// of its real ancestors are folded.
///
/// The input must be tree-ordered (i.e. produced by `sort_tree`).
/// The function uses a `BTreeSet` of "hidden ancestors": any PID
/// whose parent is in the set is also hidden. Roots are never
/// hidden.
pub fn apply_fold(processes: Vec<ProcessInfo>, folded: &std::collections::BTreeSet<u32>) -> Vec<ProcessInfo> {
if folded.is_empty() {
return processes;
}
let mut hidden: std::collections::BTreeSet<u32> = std::collections::BTreeSet::new();
let mut out: Vec<ProcessInfo> = Vec::with_capacity(processes.len());
for p in &processes {
// Hide if this PID's parent is hidden. Roots (ppid == 0 or
// ppid not in current set) are never hidden by this rule.
if p.ppid != 0 && hidden.contains(&p.ppid) {
hidden.insert(p.pid);
continue;
}
// If this PID itself is in the fold set, insert it into
// the hidden set so its children are skipped on the next
// iteration. The PID itself is still visible.
out.push(p.clone());
if folded.contains(&p.pid) {
hidden.insert(p.pid);
}
}
out
}
fn dfs_emit(
processes: &[ProcessInfo],
children: &std::collections::BTreeMap<u32, Vec<usize>>,
idx: usize,
out: &mut Vec<ProcessInfo>,
visited: &mut std::collections::BTreeSet<u32>,
) {
let pid = processes[idx].pid;
if !visited.insert(pid) {
return; // cycle protection
}
out.push(processes[idx].clone());
if let Some(kids) = children.get(&pid) {
for &k in kids {
dfs_emit(processes, children, k, out, visited);
}
}
}
#[derive(Default, Clone, Debug)]
pub struct ProcessInfo {
pub pid: u32,
pub comm: String,
pub state: char,
/// Parent PID. Read by `sort_tree` (v1.27+) and `tree_prefix`
/// to build the parent-child ordering in tree view.
pub ppid: u32,
pub utime: u64,
pub stime: u64,
pub priority: i64,
pub nice: i64,
pub num_threads: i64,
/// Virtual address-space size in KiB. Read by `SortMode::VSize`
/// (v1.28+) to sort by VSZ. Note: VSZ includes the entire
/// mapped address space (mmap'd libraries, heap, stack,
/// reserved-but-uncommitted) and is often much larger than
/// RSS. Useful for "who is using the most address space" but
/// NOT for "who is using the most physical memory" (use RSS
/// for that).
pub vsize_kb: u64,
pub rss_kb: u64,
pub cpu_pct: f64,
/// Cumulative read bytes (KiB) from `/proc/[pid]/io:read_bytes`.
/// `None` when the file is missing or the field is absent (e.g.
/// process just exited, or `/proc/[pid]/io` requires
/// `CAP_SYS_PTRACE` for an owned UID on Linux, or the proc
/// scheme on Redox does not expose IO stats for this PID).
pub io_read_kb: Option<u64>,
/// Cumulative write bytes (KiB) from
/// `/proc/[pid]/io:write_bytes`. Same caveats as `io_read_kb`.
pub io_write_kb: Option<u64>,
/// Cumulative `rchar` bytes (KiB) from `/proc/[pid]/io:rchar`.
/// VFS-level read byte count (includes cache hits, tty, etc).
/// Always Some — defaults to 0 if the field is absent on the
/// kernel (very old kernels).
pub io_rchar_kb: u64,
/// Cumulative `wchar` bytes (KiB) from `/proc/[pid]/io:wchar`.
/// VFS-level write byte count. Always Some.
pub io_wchar_kb: u64,
/// Read throughput (KiB/s) computed as the delta of `io_read_kb`
/// across two reads divided by `dt_secs`. `None` when the prev
/// read is missing (first sample after startup) or when
/// `io_read_kb` is `None` for either prev or current.
pub io_read_rate_kbs: Option<f64>,
/// Write throughput (KiB/s) computed as the delta of
/// `io_write_kb` across two reads divided by `dt_secs`. Same
/// sentinel semantics as `io_read_rate_kbs`.
pub io_write_rate_kbs: Option<f64>,
}
impl ProcessInfo {
pub fn total_cpu_ticks(&self) -> u64 {
self.utime.saturating_add(self.stime)
}
/// Total IO bytes (read + write) in KiB. Returns `None` if either
/// field is `None` — the panel renders the row as `—` instead of
/// silently zeroing a hidden counter. Used by `SortMode::Io` and
/// by the IO column renderer.
pub fn io_total_kb(&self) -> Option<u64> {
match (self.io_read_kb, self.io_write_kb) {
(Some(r), Some(w)) => Some(r.saturating_add(w)),
_ => None,
}
}
/// Total IO throughput (read + write) in KiB/s. Returns `None`
/// if either rate field is `None`. Used by `SortMode::IoRate`.
pub fn io_total_rate_kbs(&self) -> Option<f64> {
match (self.io_read_rate_kbs, self.io_write_rate_kbs) {
(Some(r), Some(w)) => Some(r + w),
_ => None,
}
}
pub fn format_memory_kb(kb: u64) -> String {
const UNITS: &[&str] = &["KiB", "MiB", "GiB", "TiB"];
let mut value = kb as f64;
let mut unit_idx = 0;
while value >= 1024.0 && unit_idx < UNITS.len() - 1 {
value /= 1024.0;
unit_idx += 1;
}
format!("{:.1} {}", value, UNITS[unit_idx])
}
/// Format a rate (KiB/s) with human-friendly units. Uses
/// 1024-base binary units (KiB/s, MiB/s, GiB/s) for consistency
/// with `format_memory_kb`. Negative inputs are clamped to 0
/// (saturating) — a "negative rate" is meaningless and indicates
/// a clock-reset or test fixture edge case.
pub fn format_rate_kbs(kbs: f64) -> String {
const UNITS: &[&str] = &["KiB/s", "MiB/s", "GiB/s", "TiB/s"];
let mut value = kbs.max(0.0);
let mut unit_idx = 0;
while value >= 1024.0 && unit_idx < UNITS.len() - 1 {
value /= 1024.0;
unit_idx += 1;
}
format!("{:.1} {}", value, UNITS[unit_idx])
}
}
#[derive(Default, Clone, Debug)]
pub struct ProcInfo {
pub processes: Vec<ProcessInfo>,
pub total_memory_kb: u64,
pub total_count: usize,
}
fn read_comm(pid: u32) -> String {
fs::read_to_string(format!("/proc/{}/comm", pid))
.ok()
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "?".to_string())
}
/// Parse `/proc/[pid]/io` content and return the four fields
/// we care about: (read_bytes, write_bytes, rchar, wchar). The
/// two `bytes` fields are the I/O actually performed (via
/// read/write syscalls that hit storage); `rchar`/`wchar` are
/// the cumulative byte counts the kernel's VFS layer saw
/// (includes reads/writes served from page cache, terminal
/// output, etc.). Returns `None` if the file cannot be read.
/// `read_bytes`/`write_bytes` are mandatory; `rchar`/`wchar`
/// may be absent on older kernels — callers default to 0 in
/// that case.
fn read_io_file(pid: u32) -> Option<(u64, u64, u64, u64)> {
let content = fs::read_to_string(format!("/proc/{pid}/io")).ok()?;
let mut read: Option<u64> = None;
let mut write: Option<u64> = None;
let mut rchar: u64 = 0;
let mut wchar: u64 = 0;
for line in content.lines() {
if let Some(rest) = line.strip_prefix("read_bytes:") {
read = rest.trim().parse::<u64>().ok();
} else if let Some(rest) = line.strip_prefix("write_bytes:") {
write = rest.trim().parse::<u64>().ok();
} else if let Some(rest) = line.strip_prefix("rchar:") {
rchar = rest.trim().parse::<u64>().unwrap_or(0);
} else if let Some(rest) = line.strip_prefix("wchar:") {
wchar = rest.trim().parse::<u64>().unwrap_or(0);
}
}
Some((read?, write?, rchar, wchar))
}
/// Compute KiB/s rate from a prev/current sample pair. Returns `None`
/// when either sample is `None` (process just started, /proc/[pid]/io
/// became readable/unreadable, or first sample after startup) or
/// when `dt_secs <= 0` (clock skew or test fixture). `saturating_sub`
/// handles the (impossible in practice) clock-reset case.
fn compute_rate_kbs(prev: Option<u64>, now: Option<u64>, dt_secs: f64) -> Option<f64> {
if dt_secs <= 0.0 {
return None;
}
let (p, n) = (prev?, now?);
let delta_kb = n.saturating_sub(p) as f64;
Some(delta_kb / dt_secs)
}
fn parse_stat_line(line: &str) -> Option<ProcessInfo> {
let open = line.find('(')?;
let close = line.rfind(')')?;
if close <= open {
return None;
}
let comm = line[open + 1..close].to_string();
let tail = &line[close + 1..];
let fields: Vec<&str> = tail.split_whitespace().collect();
if fields.len() < 22 {
return None;
}
let pid: u32 = line[..open].trim().parse().ok()?;
let state_char = fields[0].chars().next().unwrap_or('?');
let ppid: u32 = fields[1].parse().ok()?;
let utime: u64 = fields[11].parse().ok()?;
let stime: u64 = fields[12].parse().ok()?;
let priority: i64 = fields[15].parse().ok()?;
let nice: i64 = fields[16].parse().ok()?;
let num_threads: i64 = fields[17].parse().ok()?;
let vsize_bytes: i64 = fields[20].parse().ok()?;
let rss_pages: i64 = fields[21].parse().ok()?;
let (io_read_bytes, io_write_bytes, rchar_bytes, wchar_bytes) = match read_io_file(pid) {
Some((r, w, rc, wc)) => (Some(r / 1024), Some(w / 1024), rc / 1024, wc / 1024),
None => (None, None, 0, 0),
};
Some(ProcessInfo {
pid,
comm,
state: state_char,
ppid,
utime,
stime,
priority,
nice,
num_threads,
vsize_kb: (vsize_bytes.max(0) as u64) / 1024,
rss_kb: (rss_pages.max(0) as u64) * 4,
cpu_pct: 0.0,
io_read_kb: io_read_bytes,
io_write_kb: io_write_bytes,
io_rchar_kb: rchar_bytes,
io_wchar_kb: wchar_bytes,
io_read_rate_kbs: None,
io_write_rate_kbs: None,
})
}
fn read_process(pid: u32) -> Option<ProcessInfo> {
let stat = fs::read_to_string(format!("/proc/{}/stat", pid)).ok()?;
let parsed = parse_stat_line(&stat)?;
let mut info = parsed;
if info.comm.is_empty() || info.comm == "?" {
info.comm = read_comm(pid);
}
Some(info)
}
impl ProcInfo {
pub fn read() -> Self {
Self::read_sorted(SortMode::default())
}
pub fn read_sorted(sort_mode: SortMode) -> Self {
let Ok(entries) = fs::read_dir("/proc") else { return Self::default(); };
let mut processes = Vec::new();
let mut pids: Vec<u32> = Vec::new();
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = match name.to_str() {
Some(s) => s,
None => continue,
};
if let Ok(pid) = name_str.parse::<u32>() {
pids.push(pid);
}
}
let total_count = pids.len();
for pid in pids {
if let Some(proc) = read_process(pid) {
processes.push(proc);
}
}
sort_mode.sort(&mut processes);
processes.truncate(MAX_PROCESSES);
let total_memory_kb: u64 = processes.iter().map(|p| p.rss_kb).sum();
Self { processes, total_memory_kb, total_count }
}
/// Read processes and compute CPU% and IO rates for each based on
/// the delta vs the previous read. `dt_secs` is wall-clock elapsed
/// since previous read; `num_cpus` is used to normalize per-CPU.
/// IO rate is computed in KiB/s from the delta of `/proc/[pid]/io`
/// read_bytes/write_bytes divided by `dt_secs`.
pub fn read_with_cpu_pct_sorted(prev: &ProcInfo, dt_secs: f64, num_cpus: u64, sort_mode: SortMode) -> Self {
let mut info = Self::read_sorted(sort_mode);
if dt_secs <= 0.0 || num_cpus == 0 {
return info;
}
for p in &mut info.processes {
let prev_p = prev
.processes
.iter()
.find(|q| q.pid == p.pid);
if let Some(pp) = prev_p {
let prev_ticks = pp.total_cpu_ticks();
let now_ticks = p.total_cpu_ticks();
let delta = now_ticks.saturating_sub(prev_ticks) as f64;
let ticks_per_sec = delta / dt_secs;
p.cpu_pct = (ticks_per_sec / num_cpus as f64) * 100.0;
p.io_read_rate_kbs = compute_rate_kbs(pp.io_read_kb, p.io_read_kb, dt_secs);
p.io_write_rate_kbs = compute_rate_kbs(pp.io_write_kb, p.io_write_kb, dt_secs);
}
}
// Re-sort because CPU% values may have changed
sort_mode.sort(&mut info.processes);
info
}
pub fn is_empty(&self) -> bool {
self.processes.is_empty()
}
pub fn count(&self) -> usize {
self.processes.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_memory_below_1kib() {
assert_eq!(ProcessInfo::format_memory_kb(500), "500.0 KiB");
}
#[test]
fn format_memory_1mib() {
assert_eq!(ProcessInfo::format_memory_kb(1024), "1.0 MiB");
}
#[test]
fn format_memory_1gib() {
assert_eq!(ProcessInfo::format_memory_kb(1024 * 1024), "1.0 GiB");
}
#[test]
fn format_rate_below_1kibs() {
assert_eq!(ProcessInfo::format_rate_kbs(500.0), "500.0 KiB/s");
}
#[test]
fn format_rate_1mibs() {
assert_eq!(ProcessInfo::format_rate_kbs(1024.0), "1.0 MiB/s");
}
#[test]
fn format_rate_1gibs() {
assert_eq!(ProcessInfo::format_rate_kbs(1024.0 * 1024.0), "1.0 GiB/s");
}
#[test]
fn format_rate_saturates_negative_to_zero() {
assert_eq!(ProcessInfo::format_rate_kbs(-100.0), "0.0 KiB/s");
}
#[test]
fn parse_stat_line_valid() {
// bash process: pid=1 (comm) S ppid pgrp session ...
let line = "2642164 (bash) S 3317951 2642164 2642164 0 -1 4194304 229 451 0 2 0 0 0 0 20 0 1 0 138471094 8060928 883 18446744073709551615 94645830324224 94645831153721 140735825184736 0 0 0 65536 4 65538 1 0 0 17 0 0 0";
let p = parse_stat_line(line).expect("should parse");
assert_eq!(p.pid, 2642164);
assert_eq!(p.comm, "bash");
assert_eq!(p.state, 'S');
assert_eq!(p.ppid, 3317951);
assert_eq!(p.nice, 0);
}
#[test]
fn parse_stat_line_handles_spaces_in_comm() {
// Firefox process with "(Web Content)" comm
let line = "12345 (Web Content) R 1 12345 12345 0 -1 1077936384 100 0 0 0 5 0 0 0 20 0 1 0 1000 1000 100 18446744073709551615 1 1 0 0 0 0 0 0 0 0 0 0 0 0 17 0 0 0";
let p = parse_stat_line(line).expect("should parse");
assert_eq!(p.comm, "Web Content");
assert_eq!(p.state, 'R');
}
#[test]
fn parse_stat_line_missing_parens() {
assert!(parse_stat_line("invalid").is_none());
}
#[test]
fn parse_stat_line_too_few_fields() {
let line = "1 (x) S";
assert!(parse_stat_line(line).is_none());
}
#[test]
fn proc_info_is_empty_when_no_proc() {
let info = ProcInfo::default();
assert!(info.is_empty());
assert_eq!(info.count(), 0);
}
#[test]
fn process_total_cpu_ticks() {
let p = ProcessInfo { utime: 100, stime: 50, ..Default::default() };
assert_eq!(p.total_cpu_ticks(), 150);
}
}
#[cfg(test)]
mod cpu_pct_unit_tests {
use super::*;
fn make_proc(pid: u32, utime: u64, stime: u64) -> ProcessInfo {
ProcessInfo { pid, utime, stime, cpu_pct: 0.0, ..Default::default() }
}
#[test]
fn cpu_pct_delta_formula() {
let prev_ticks = make_proc(1, 100, 50).total_cpu_ticks();
let now_ticks = make_proc(1, 200, 80).total_cpu_ticks();
let delta = now_ticks.saturating_sub(prev_ticks) as f64;
let cpu_pct = (delta / 2.0 / 4.0) * 100.0;
assert_eq!(cpu_pct, 1625.0);
}
#[test]
fn cpu_pct_zero_delta() {
let prev_ticks = make_proc(1, 100, 50).total_cpu_ticks();
let now_ticks = make_proc(1, 100, 50).total_cpu_ticks();
let delta = now_ticks.saturating_sub(prev_ticks) as f64;
let cpu_pct = (delta / 2.0 / 4.0) * 100.0;
assert_eq!(cpu_pct, 0.0);
}
#[test]
fn cpu_pct_saturating_sub_underflow() {
let now = make_proc(1, 50, 25);
let prev = make_proc(1, 100, 100);
let delta = now.total_cpu_ticks().saturating_sub(prev.total_cpu_ticks());
assert_eq!(delta, 0);
}
}
#[cfg(test)]
mod sort_unit_tests {
use super::*;
fn make_proc(pid: u32, rss: u64, cpu: f64, name: &str) -> ProcessInfo {
ProcessInfo {
pid,
comm: name.to_string(),
rss_kb: rss,
cpu_pct: cpu,
..Default::default()
}
}
#[test]
fn sort_default_is_rss_descending() {
assert_eq!(SortMode::default(), SortMode::Rss);
}
#[test]
fn sort_cycle() {
assert_eq!(SortMode::Rss.next(), SortMode::Cpu);
assert_eq!(SortMode::Cpu.next(), SortMode::Io);
assert_eq!(SortMode::Io.next(), SortMode::IoRead);
assert_eq!(SortMode::IoRead.next(), SortMode::IoWrite);
assert_eq!(SortMode::IoWrite.next(), SortMode::IoRate);
assert_eq!(SortMode::IoRate.next(), SortMode::IoReadRate);
assert_eq!(SortMode::IoReadRate.next(), SortMode::IoWriteRate);
assert_eq!(SortMode::IoWriteRate.next(), SortMode::RChar);
assert_eq!(SortMode::RChar.next(), SortMode::WChar);
assert_eq!(SortMode::WChar.next(), SortMode::VSize);
assert_eq!(SortMode::VSize.next(), SortMode::Pid);
assert_eq!(SortMode::Pid.next(), SortMode::Name);
assert_eq!(SortMode::Name.next(), SortMode::Rss);
}
#[test]
fn sort_by_rss_descending() {
let mut ps = vec![
make_proc(1, 100, 0.0, "a"),
make_proc(2, 500, 0.0, "b"),
make_proc(3, 300, 0.0, "c"),
];
SortMode::Rss.sort(&mut ps);
assert_eq!(ps[0].pid, 2);
assert_eq!(ps[1].pid, 3);
assert_eq!(ps[2].pid, 1);
}
#[test]
fn sort_by_cpu_descending() {
let mut ps = vec![
make_proc(1, 0, 10.0, "a"),
make_proc(2, 0, 50.0, "b"),
make_proc(3, 0, 30.0, "c"),
];
SortMode::Cpu.sort(&mut ps);
assert_eq!(ps[0].pid, 2);
assert_eq!(ps[1].pid, 3);
assert_eq!(ps[2].pid, 1);
}
#[test]
fn sort_by_pid_ascending() {
let mut ps = vec![
make_proc(3, 0, 0.0, "a"),
make_proc(1, 0, 0.0, "b"),
make_proc(2, 0, 0.0, "c"),
];
SortMode::Pid.sort(&mut ps);
assert_eq!(ps[0].pid, 1);
assert_eq!(ps[1].pid, 2);
assert_eq!(ps[2].pid, 3);
}
#[test]
fn sort_by_name_alphabetical() {
let mut ps = vec![
make_proc(1, 0, 0.0, "zsh"),
make_proc(2, 0, 0.0, "bash"),
make_proc(3, 0, 0.0, "firefox"),
];
SortMode::Name.sort(&mut ps);
assert_eq!(ps[0].comm, "bash");
assert_eq!(ps[1].comm, "firefox");
assert_eq!(ps[2].comm, "zsh");
}
}
#[cfg(test)]
mod filter_unit_tests {
use super::*;
#[test]
fn filter_case_insensitive() {
let needle = "FIREFOX";
let haystack = "firefox";
assert!(haystack.to_lowercase().contains(&needle.to_lowercase()));
}
#[test]
fn filter_substring_match() {
let needle = "fox";
let haystack = "firefox";
assert!(haystack.contains(needle));
}
#[test]
fn filter_no_match() {
let needle = "nonexistent";
let haystack = "firefox";
assert!(!haystack.contains(needle));
}
#[test]
fn filter_empty_needle_matches_all() {
let needle = "";
let hay = "firefox";
assert!(hay.contains(needle));
}
}
#[cfg(test)]
mod io_sort_unit_tests {
use super::*;
fn make_proc(pid: u32, io_read: u64, io_write: u64) -> ProcessInfo {
ProcessInfo {
pid,
io_read_kb: Some(io_read),
io_write_kb: Some(io_write),
..Default::default()
}
}
fn make_proc_none(pid: u32) -> ProcessInfo {
ProcessInfo {
pid,
io_read_kb: None,
io_write_kb: None,
..Default::default()
}
}
#[test]
fn io_total_sums_read_write() {
let p = make_proc(1, 100, 50);
assert_eq!(p.io_total_kb(), Some(150));
}
#[test]
fn io_total_saturates_at_u64_max() {
let mut p = make_proc(1, u64::MAX, 0);
p.io_read_kb = Some(u64::MAX);
p.io_write_kb = Some(1);
assert_eq!(p.io_total_kb(), Some(u64::MAX));
}
#[test]
fn io_total_returns_none_when_fields_missing() {
let p = make_proc_none(1);
assert_eq!(p.io_total_kb(), None);
}
#[test]
fn sort_by_io_descending() {
let mut ps = vec![
make_proc(1, 100, 0),
make_proc(2, 0, 500),
make_proc(3, 200, 200),
];
SortMode::Io.sort(&mut ps);
assert_eq!(ps[0].pid, 2); // total 500
assert_eq!(ps[1].pid, 3); // total 400
assert_eq!(ps[2].pid, 1); // total 100
}
#[test]
fn sort_by_io_pushes_missing_to_bottom() {
let mut ps = vec![
make_proc_none(1),
make_proc(2, 0, 100),
make_proc_none(3),
make_proc(4, 50, 50),
];
SortMode::Io.sort(&mut ps);
assert_eq!(ps[0].pid, 2); // 100 KiB
assert_eq!(ps[1].pid, 4); // 100 KiB (tie — stable sort, by pid)
// Remaining two are None; stable sort preserves original order
// (pid 1, 3 in the input).
assert_eq!(ps[2].pid, 1);
assert_eq!(ps[3].pid, 3);
}
#[test]
fn sort_cycle_includes_io() {
assert_eq!(SortMode::Rss.next(), SortMode::Cpu);
assert_eq!(SortMode::Cpu.next(), SortMode::Io);
assert_eq!(SortMode::Io.next(), SortMode::IoRead);
assert_eq!(SortMode::IoRead.next(), SortMode::IoWrite);
assert_eq!(SortMode::IoWrite.next(), SortMode::IoRate);
assert_eq!(SortMode::IoRate.next(), SortMode::IoReadRate);
assert_eq!(SortMode::IoReadRate.next(), SortMode::IoWriteRate);
assert_eq!(SortMode::IoWriteRate.next(), SortMode::RChar);
assert_eq!(SortMode::RChar.next(), SortMode::WChar);
assert_eq!(SortMode::WChar.next(), SortMode::VSize);
assert_eq!(SortMode::VSize.next(), SortMode::Pid);
assert_eq!(SortMode::Pid.next(), SortMode::Name);
assert_eq!(SortMode::Name.next(), SortMode::Rss);
}
#[test]
fn io_name_is_io() {
assert_eq!(SortMode::Io.name(), "IO");
assert_eq!(SortMode::IoRead.name(), "IO-R");
assert_eq!(SortMode::IoWrite.name(), "IO-W");
assert_eq!(SortMode::IoRate.name(), "IO/s");
assert_eq!(SortMode::IoReadRate.name(), "R/s");
assert_eq!(SortMode::IoWriteRate.name(), "W/s");
assert_eq!(SortMode::RChar.name(), "RChr");
assert_eq!(SortMode::WChar.name(), "WChr");
assert_eq!(SortMode::VSize.name(), "VSZ");
}
#[test]
fn sort_by_io_read_ignores_writes() {
let mut ps = vec![
make_proc(1, 100, 9999),
make_proc(2, 500, 0),
make_proc(3, 50, 50),
];
SortMode::IoRead.sort(&mut ps);
assert_eq!(ps[0].pid, 2);
assert_eq!(ps[1].pid, 1);
assert_eq!(ps[2].pid, 3);
}
#[test]
fn sort_by_io_write_ignores_reads() {
let mut ps = vec![
make_proc(1, 9999, 100),
make_proc(2, 0, 500),
make_proc(3, 50, 50),
];
SortMode::IoWrite.sort(&mut ps);
assert_eq!(ps[0].pid, 2); // write 500
assert_eq!(ps[1].pid, 1); // write 100
assert_eq!(ps[2].pid, 3); // write 50
}
#[test]
fn sort_by_io_read_pushes_missing_to_bottom() {
let mut ps = vec![
make_proc_none(1),
make_proc(2, 200, 9999),
make_proc_none(3),
make_proc(4, 100, 0),
];
SortMode::IoRead.sort(&mut ps);
assert_eq!(ps[0].pid, 2);
assert_eq!(ps[1].pid, 4);
// None entries sort below; stable sort preserves input order.
assert_eq!(ps[2].pid, 1);
assert_eq!(ps[3].pid, 3);
}
#[test]
fn sort_by_io_write_pushes_missing_to_bottom() {
let mut ps = vec![
make_proc_none(1),
make_proc(2, 9999, 200),
make_proc_none(3),
make_proc(4, 0, 100),
];
SortMode::IoWrite.sort(&mut ps);
assert_eq!(ps[0].pid, 2);
assert_eq!(ps[1].pid, 4);
assert_eq!(ps[2].pid, 1);
assert_eq!(ps[3].pid, 3);
}
#[test]
fn compute_rate_kbs_basic_delta() {
// 1024 KiB over 2.0s = 512.0 KiB/s
let r = compute_rate_kbs(Some(1000), Some(2024), 2.0);
assert_eq!(r, Some(512.0));
}
#[test]
fn compute_rate_kbs_returns_none_when_prev_missing() {
assert_eq!(compute_rate_kbs(None, Some(1000), 1.0), None);
}
#[test]
fn compute_rate_kbs_returns_none_when_now_missing() {
assert_eq!(compute_rate_kbs(Some(1000), None, 1.0), None);
}
#[test]
fn compute_rate_kbs_returns_none_when_dt_zero() {
assert_eq!(compute_rate_kbs(Some(1000), Some(2000), 0.0), None);
assert_eq!(compute_rate_kbs(Some(1000), Some(2000), -1.0), None);
}
#[test]
fn compute_rate_kbs_saturates_on_underflow() {
// Now < Prev (clock reset) should saturate to 0, not wrap.
let r = compute_rate_kbs(Some(2000), Some(1000), 1.0);
assert_eq!(r, Some(0.0));
}
#[test]
fn compute_rate_kbs_first_sample_is_zero() {
// Sample N == Sample N+1 (process idle between samples).
let r = compute_rate_kbs(Some(5000), Some(5000), 1.0);
assert_eq!(r, Some(0.0));
}
#[test]
fn io_total_rate_kbs_sums_read_write() {
let mut p = make_proc(1, 100, 50);
p.io_read_rate_kbs = Some(200.0);
p.io_write_rate_kbs = Some(300.0);
assert_eq!(p.io_total_rate_kbs(), Some(500.0));
}
#[test]
fn io_total_rate_kbs_none_when_field_missing() {
let p = make_proc(1, 100, 50);
assert_eq!(p.io_total_rate_kbs(), None);
}
#[test]
fn sort_by_io_rate_uses_total() {
let mut ps = vec![
ProcessInfo {
pid: 1,
io_read_rate_kbs: Some(100.0),
io_write_rate_kbs: Some(900.0),
..make_proc(1, 0, 0)
},
ProcessInfo {
pid: 2,
io_read_rate_kbs: Some(500.0),
io_write_rate_kbs: Some(500.0),
..make_proc(2, 0, 0)
},
];
SortMode::IoRate.sort(&mut ps);
// Both have total 1000; stable sort preserves input order.
assert_eq!(ps[0].pid, 1);
assert_eq!(ps[1].pid, 2);
}
#[test]
fn sort_by_io_read_rate_pushes_missing_to_bottom() {
let mut ps = vec![
make_proc_none(1),
ProcessInfo {
pid: 2,
io_read_rate_kbs: Some(200.0),
..make_proc(2, 0, 0)
},
make_proc_none(3),
ProcessInfo {
pid: 4,
io_read_rate_kbs: Some(100.0),
..make_proc(4, 0, 0)
},
];
SortMode::IoReadRate.sort(&mut ps);
assert_eq!(ps[0].pid, 2);
assert_eq!(ps[1].pid, 4);
assert_eq!(ps[2].pid, 1);
assert_eq!(ps[3].pid, 3);
}
fn make_v(pid: u32, vsize_kb: u64, rss_kb: u64) -> ProcessInfo {
ProcessInfo {
pid,
vsize_kb,
rss_kb,
..Default::default()
}
}
#[test]
fn sort_by_vsize_descending() {
let mut ps = vec![
make_v(1, 100_000, 1_000),
make_v(2, 500_000, 5_000),
make_v(3, 50_000, 500),
];
SortMode::VSize.sort(&mut ps);
assert_eq!(ps[0].pid, 2); // vsize 500_000
assert_eq!(ps[1].pid, 1); // vsize 100_000
assert_eq!(ps[2].pid, 3); // vsize 50_000
}
#[test]
fn sort_by_vsize_uses_vsize_not_rss() {
// The key property: SortMode::VSize sorts by VSZ, not RSS.
// Pid 1 has HUGE vsize but tiny rss; pid 2 has tiny vsize
// but HUGE rss. VSize sort puts 1 first; Rss sort would
// put 2 first.
let mut ps = vec![
make_v(1, 999_999_999, 1),
make_v(2, 1, 999_999_999),
];
SortMode::VSize.sort(&mut ps);
assert_eq!(ps[0].pid, 1);
assert_eq!(ps[1].pid, 2);
}
#[test]
fn sort_ascending_flips_rss_order() {
// v1.38: the same sort key produces opposite order
// when ascending is true. Rss sort: pid 1 (huge) first
// desc, pid 2 (small) first asc.
let mut ps = vec![
ProcessInfo { pid: 1, rss_kb: 1000, ..Default::default() },
ProcessInfo { pid: 2, rss_kb: 100, ..Default::default() },
ProcessInfo { pid: 3, rss_kb: 500, ..Default::default() },
];
SortMode::Rss.sort_ascending(&mut ps, false);
assert_eq!(ps[0].pid, 1); // 1000 (desc)
assert_eq!(ps[1].pid, 3); // 500
assert_eq!(ps[2].pid, 2); // 100
SortMode::Rss.sort_ascending(&mut ps, true);
assert_eq!(ps[0].pid, 2); // 100 (asc)
assert_eq!(ps[1].pid, 3); // 500
assert_eq!(ps[2].pid, 1); // 1000
}
#[test]
fn sort_by_rchar_descending() {
// RChar sort uses io_rchar_kb (VFS-level reads).
let mut ps = vec![
ProcessInfo {
pid: 1,
io_rchar_kb: 100,
..Default::default()
},
ProcessInfo {
pid: 2,
io_rchar_kb: 5000,
..Default::default()
},
ProcessInfo {
pid: 3,
io_rchar_kb: 0,
..Default::default()
},
];
SortMode::RChar.sort(&mut ps);
assert_eq!(ps[0].pid, 2);
assert_eq!(ps[1].pid, 1);
assert_eq!(ps[2].pid, 3);
}
#[test]
fn sort_by_wchar_descending() {
let mut ps = vec![
ProcessInfo {
pid: 1,
io_wchar_kb: 999_999,
..Default::default()
},
ProcessInfo {
pid: 2,
io_wchar_kb: 1,
..Default::default()
},
];
SortMode::WChar.sort(&mut ps);
assert_eq!(ps[0].pid, 1);
assert_eq!(ps[1].pid, 2);
}
fn make_p(ppid: u32, pid: u32) -> ProcessInfo {
ProcessInfo {
pid,
ppid,
..Default::default()
}
}
#[test]
fn sort_tree_emits_parents_before_children() {
// Tree:
// 1
// ├── 2
// │ └── 3
// └── 4
let mut ps = vec![
make_p(0, 1), // root
make_p(1, 2), // child of 1
make_p(2, 3), // child of 2
make_p(1, 4), // child of 1
];
sort_tree(&mut ps, SortMode::Pid);
let pids: Vec<u32> = ps.iter().map(|p| p.pid).collect();
// 1 must come before 2 and 4; 2 must come before 3.
let pos1 = pids.iter().position(|&p| p == 1).unwrap();
let pos2 = pids.iter().position(|&p| p == 2).unwrap();
let pos3 = pids.iter().position(|&p| p == 3).unwrap();
let pos4 = pids.iter().position(|&p| p == 4).unwrap();
assert!(pos1 < pos2);
assert!(pos1 < pos4);
assert!(pos2 < pos3);
}
#[test]
fn sort_tree_handles_orphans() {
// 1 (root), 2 (orphan: ppid=999 not in list), 3 (child of 1)
let mut ps = vec![
make_p(0, 1),
make_p(999, 2),
make_p(1, 3),
];
sort_tree(&mut ps, SortMode::Pid);
let pids: Vec<u32> = ps.iter().map(|p| p.pid).collect();
// All 3 present.
assert_eq!(pids.len(), 3);
assert!(pids.contains(&1));
assert!(pids.contains(&2));
assert!(pids.contains(&3));
// 1 still before 3.
let pos1 = pids.iter().position(|&p| p == 1).unwrap();
let pos3 = pids.iter().position(|&p| p == 3).unwrap();
assert!(pos1 < pos3);
}
#[test]
fn sort_tree_handles_cycles() {
// 1 (ppid=2), 2 (ppid=1) — cycle. Both treated as roots.
let mut ps = vec![
make_p(2, 1),
make_p(1, 2),
];
sort_tree(&mut ps, SortMode::Pid);
let pids: Vec<u32> = ps.iter().map(|p| p.pid).collect();
// Both present; no infinite loop.
assert_eq!(pids.len(), 2);
assert!(pids.contains(&1));
assert!(pids.contains(&2));
}
#[test]
fn sort_tree_empty_input() {
let mut ps: Vec<ProcessInfo> = Vec::new();
sort_tree(&mut ps, SortMode::Pid);
assert!(ps.is_empty());
}
#[test]
fn apply_fold_empty_set_is_identity() {
let input = vec![make_p(0, 1), make_p(1, 2)];
let folded = std::collections::BTreeSet::new();
let out = apply_fold(input.clone(), &folded);
assert_eq!(out.len(), 2);
}
#[test]
fn apply_fold_hides_descendants_of_folded_root() {
// Tree: 1 -> 2 -> 3 -> 4 (already in tree order from sort_tree)
let input = vec![
make_p(0, 1),
make_p(1, 2),
make_p(2, 3),
make_p(3, 4),
];
let mut folded = std::collections::BTreeSet::new();
folded.insert(1); // fold root
let out = apply_fold(input, &folded);
let pids: Vec<u32> = out.iter().map(|p| p.pid).collect();
// 1 visible (it's the fold target itself), 2/3/4 hidden.
assert_eq!(pids, vec![1]);
}
#[test]
fn apply_fold_hides_subtree_of_folded_child() {
// Tree: 1 -> 2 -> 3 (and 1 -> 4, sibling of 2)
let input = vec![
make_p(0, 1),
make_p(1, 2),
make_p(2, 3),
make_p(1, 4),
];
let mut folded = std::collections::BTreeSet::new();
folded.insert(2); // fold middle node
let out = apply_fold(input, &folded);
let pids: Vec<u32> = out.iter().map(|p| p.pid).collect();
// 1 visible, 2 visible (fold target), 3 hidden, 4 visible
// (sibling of 2, not in 2's subtree).
assert_eq!(pids, vec![1, 2, 4]);
}
#[test]
fn apply_fold_unfold_restores() {
let input = vec![make_p(0, 1), make_p(1, 2)];
let mut folded = std::collections::BTreeSet::new();
folded.insert(1);
let once = apply_fold(input.clone(), &folded);
assert_eq!(once.len(), 1);
folded.remove(&1);
let twice = apply_fold(input, &folded);
assert_eq!(twice.len(), 2);
}
}