Skip to main content

muxtop_core/
process.rs

1use bincode::{Decode, Encode};
2use serde::{Deserialize, Serialize};
3
4/// Information about a single process.
5#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Encode, Decode)]
6pub struct ProcessInfo {
7    pub pid: u32,
8    pub parent_pid: Option<u32>,
9    pub name: String,
10    pub command: String,
11    pub user: String,
12    pub cpu_percent: f32,
13    pub memory_bytes: u64,
14    pub memory_percent: f32,
15    pub status: String,
16}
17
18/// Fields by which a process list can be sorted.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum SortField {
21    Cpu,
22    Mem,
23    Pid,
24    Name,
25    User,
26}
27
28impl std::fmt::Display for SortField {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        match self {
31            SortField::Cpu => write!(f, "cpu"),
32            SortField::Mem => write!(f, "mem"),
33            SortField::Pid => write!(f, "pid"),
34            SortField::Name => write!(f, "name"),
35            SortField::User => write!(f, "user"),
36        }
37    }
38}
39
40impl std::str::FromStr for SortField {
41    type Err = String;
42
43    fn from_str(s: &str) -> Result<Self, Self::Err> {
44        match s.to_lowercase().as_str() {
45            "cpu" => Ok(SortField::Cpu),
46            "mem" | "memory" => Ok(SortField::Mem),
47            "pid" => Ok(SortField::Pid),
48            "name" => Ok(SortField::Name),
49            "user" => Ok(SortField::User),
50            _ => Err(format!(
51                "invalid sort field '{s}': expected one of cpu, mem, pid, name, user"
52            )),
53        }
54    }
55}
56
57/// Direction of a sort operation.
58#[derive(Debug, Clone, Copy)]
59pub enum SortOrder {
60    Asc,
61    Desc,
62}
63
64/// Sorts `procs` in-place by `field` in the given `order`.
65/// Name and User comparisons are case-insensitive.
66pub fn sort_processes(procs: &mut [ProcessInfo], field: SortField, order: SortOrder) {
67    // Name/User use `sort_by_cached_key` so `to_lowercase()` runs O(n) instead
68    // of O(n log n) times — a comparator-based sort reallocates the lowercased
69    // key on every comparison.
70    match field {
71        SortField::Name => procs.sort_by_cached_key(|p| p.name.to_lowercase()),
72        SortField::User => procs.sort_by_cached_key(|p| p.user.to_lowercase()),
73        SortField::Cpu => procs.sort_unstable_by(|a, b| {
74            a.cpu_percent
75                .partial_cmp(&b.cpu_percent)
76                .unwrap_or(std::cmp::Ordering::Equal)
77        }),
78        SortField::Mem => procs.sort_unstable_by_key(|p| p.memory_bytes),
79        SortField::Pid => procs.sort_unstable_by_key(|p| p.pid),
80    }
81
82    if matches!(order, SortOrder::Desc) {
83        procs.reverse();
84    }
85}
86
87/// Returns a new `Vec` containing every process whose `name` or `command`
88/// contains `pattern` (case-insensitive). An empty pattern matches everything.
89pub fn filter_processes(procs: &[ProcessInfo], pattern: &str) -> Vec<ProcessInfo> {
90    if pattern.is_empty() {
91        return procs.to_vec();
92    }
93    let lower = pattern.to_lowercase();
94    procs
95        .iter()
96        .filter(|p| {
97            p.name.to_lowercase().contains(&lower) || p.command.to_lowercase().contains(&lower)
98        })
99        .cloned()
100        .collect()
101}
102
103/// A node in the process tree — wraps a `ProcessInfo` with children and depth.
104#[derive(Debug, Clone)]
105pub struct TreeNode {
106    pub process: ProcessInfo,
107    pub children: Vec<TreeNode>,
108    pub depth: usize,
109}
110
111/// Build a process tree from a flat list.
112///
113/// Processes whose `parent_pid` matches another process's `pid` become children
114/// of that parent. Processes with no parent in the list (orphans), `parent_pid`
115/// of `None`, or `parent_pid` of `Some(0)` become root nodes at depth 0.
116pub fn build_process_tree(procs: &[ProcessInfo]) -> Vec<TreeNode> {
117    use std::collections::HashMap;
118
119    if procs.is_empty() {
120        return Vec::new();
121    }
122
123    // Index: pid → list of child ProcessInfo indices.
124    let mut children_map: HashMap<u32, Vec<usize>> = HashMap::with_capacity(procs.len());
125    let pid_set: std::collections::HashSet<u32> = procs.iter().map(|p| p.pid).collect();
126
127    for (i, p) in procs.iter().enumerate() {
128        match p.parent_pid {
129            Some(ppid) if ppid > 0 && pid_set.contains(&ppid) => {
130                children_map.entry(ppid).or_default().push(i);
131            }
132            _ => {} // root or orphan — handled below
133        }
134    }
135
136    // Identify roots: no parent, parent_pid == 0/None, or parent not in list.
137    let roots: Vec<usize> = procs
138        .iter()
139        .enumerate()
140        .filter(|(_, p)| match p.parent_pid {
141            None | Some(0) => true,
142            Some(ppid) => !pid_set.contains(&ppid),
143        })
144        .map(|(i, _)| i)
145        .collect();
146
147    const MAX_DEPTH: usize = 256;
148
149    fn build_subtree(
150        idx: usize,
151        depth: usize,
152        procs: &[ProcessInfo],
153        children_map: &HashMap<u32, Vec<usize>>,
154    ) -> TreeNode {
155        let p = &procs[idx];
156        let children = if depth < MAX_DEPTH {
157            children_map
158                .get(&p.pid)
159                .map(|indices| indices.as_slice())
160                .unwrap_or_default()
161                .iter()
162                .map(|&ci| build_subtree(ci, depth + 1, procs, children_map))
163                .collect()
164        } else {
165            Vec::new() // Truncate at max depth to prevent stack overflow.
166        };
167        TreeNode {
168            process: p.clone(),
169            children,
170            depth,
171        }
172    }
173
174    roots
175        .iter()
176        .map(|&ri| build_subtree(ri, 0, procs, &children_map))
177        .collect()
178}
179
180/// Flatten a tree into a depth-first ordered list of `(ProcessInfo, depth)` pairs.
181/// Useful for rendering the tree view in the TUI.
182pub fn flatten_tree(roots: &[TreeNode]) -> Vec<(ProcessInfo, usize)> {
183    let mut result = Vec::new();
184    fn walk(node: &TreeNode, out: &mut Vec<(ProcessInfo, usize)>) {
185        out.push((node.process.clone(), node.depth));
186        for child in &node.children {
187            walk(child, out);
188        }
189    }
190    for root in roots {
191        walk(root, &mut result);
192    }
193    result
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    fn make_proc(pid: u32, name: &str, cmd: &str, user: &str, cpu: f32, mem: u64) -> ProcessInfo {
201        ProcessInfo {
202            pid,
203            parent_pid: None,
204            name: name.to_string(),
205            command: cmd.to_string(),
206            user: user.to_string(),
207            cpu_percent: cpu,
208            memory_bytes: mem,
209            memory_percent: 0.0,
210            status: "Running".to_string(),
211        }
212    }
213
214    fn cpu_procs() -> Vec<ProcessInfo> {
215        vec![
216            make_proc(1, "a", "", "alice", 10.0, 100),
217            make_proc(2, "b", "", "bob", 50.0, 200),
218            make_proc(3, "c", "", "carol", 30.0, 300),
219            make_proc(4, "d", "", "dave", 90.0, 400),
220            make_proc(5, "e", "", "eve", 20.0, 500),
221        ]
222    }
223
224    #[test]
225    fn test_sort_by_cpu_desc() {
226        let mut procs = cpu_procs();
227        sort_processes(&mut procs, SortField::Cpu, SortOrder::Desc);
228        let cpus: Vec<f32> = procs.iter().map(|p| p.cpu_percent).collect();
229        assert_eq!(cpus, vec![90.0, 50.0, 30.0, 20.0, 10.0]);
230    }
231
232    #[test]
233    fn test_sort_by_cpu_asc() {
234        let mut procs = cpu_procs();
235        sort_processes(&mut procs, SortField::Cpu, SortOrder::Asc);
236        let cpus: Vec<f32> = procs.iter().map(|p| p.cpu_percent).collect();
237        assert_eq!(cpus, vec![10.0, 20.0, 30.0, 50.0, 90.0]);
238    }
239
240    #[test]
241    fn test_sort_by_name_asc() {
242        let mut procs = vec![
243            make_proc(1, "Zsh", "", "u", 0.0, 0),
244            make_proc(2, "apache", "", "u", 0.0, 0),
245            make_proc(3, "Bash", "", "u", 0.0, 0),
246        ];
247        sort_processes(&mut procs, SortField::Name, SortOrder::Asc);
248        let names: Vec<&str> = procs.iter().map(|p| p.name.as_str()).collect();
249        // case-insensitive: apache < Bash < Zsh
250        assert_eq!(names, vec!["apache", "Bash", "Zsh"]);
251    }
252
253    #[test]
254    fn test_sort_by_mem_desc() {
255        let mut procs = cpu_procs();
256        sort_processes(&mut procs, SortField::Mem, SortOrder::Desc);
257        let mems: Vec<u64> = procs.iter().map(|p| p.memory_bytes).collect();
258        assert_eq!(mems, vec![500, 400, 300, 200, 100]);
259    }
260
261    #[test]
262    fn test_sort_by_pid_asc() {
263        let mut procs = vec![
264            make_proc(30, "c", "", "u", 0.0, 0),
265            make_proc(10, "a", "", "u", 0.0, 0),
266            make_proc(20, "b", "", "u", 0.0, 0),
267        ];
268        sort_processes(&mut procs, SortField::Pid, SortOrder::Asc);
269        let pids: Vec<u32> = procs.iter().map(|p| p.pid).collect();
270        assert_eq!(pids, vec![10, 20, 30]);
271    }
272
273    #[test]
274    fn test_sort_by_user_asc() {
275        let mut procs = vec![
276            make_proc(1, "a", "", "Zara", 0.0, 0),
277            make_proc(2, "b", "", "alice", 0.0, 0),
278            make_proc(3, "c", "", "Bob", 0.0, 0),
279        ];
280        sort_processes(&mut procs, SortField::User, SortOrder::Asc);
281        let users: Vec<&str> = procs.iter().map(|p| p.user.as_str()).collect();
282        assert_eq!(users, vec!["alice", "Bob", "Zara"]);
283    }
284
285    #[test]
286    fn test_sort_empty_list() {
287        let mut procs: Vec<ProcessInfo> = vec![];
288        // Must not panic.
289        sort_processes(&mut procs, SortField::Cpu, SortOrder::Desc);
290        assert!(procs.is_empty());
291    }
292
293    #[test]
294    fn test_sort_single_element() {
295        let mut procs = vec![make_proc(42, "solo", "/bin/solo", "root", 5.0, 1024)];
296        sort_processes(&mut procs, SortField::Cpu, SortOrder::Desc);
297        assert_eq!(procs.len(), 1);
298        assert_eq!(procs[0].pid, 42);
299    }
300
301    #[test]
302    fn test_filter_case_insensitive() {
303        let procs = vec![
304            make_proc(1, "Firefox", "/usr/bin/firefox", "u", 0.0, 0),
305            make_proc(2, "firefox-esr", "/usr/bin/firefox-esr", "u", 0.0, 0),
306            make_proc(3, "bash", "/bin/bash", "u", 0.0, 0),
307        ];
308        let result = filter_processes(&procs, "fire");
309        assert_eq!(result.len(), 2);
310        let names: Vec<&str> = result.iter().map(|p| p.name.as_str()).collect();
311        assert!(names.contains(&"Firefox"));
312        assert!(names.contains(&"firefox-esr"));
313    }
314
315    #[test]
316    fn test_filter_empty_pattern() {
317        let procs = cpu_procs();
318        let result = filter_processes(&procs, "");
319        assert_eq!(result.len(), procs.len());
320    }
321
322    #[test]
323    fn test_filter_no_match() {
324        let procs = cpu_procs();
325        let result = filter_processes(&procs, "zzznomatch");
326        assert!(result.is_empty());
327    }
328
329    #[test]
330    fn test_filter_matches_command_field() {
331        let procs = vec![
332            make_proc(1, "proc1", "/usr/bin/rustc --edition 2021", "u", 0.0, 0),
333            make_proc(2, "proc2", "/bin/bash", "u", 0.0, 0),
334        ];
335        // "rustc" is only in the command field, not the name.
336        let result = filter_processes(&procs, "rustc");
337        assert_eq!(result.len(), 1);
338        assert_eq!(result[0].pid, 1);
339    }
340
341    // ---- Tree tests (STORY-04) ----
342
343    fn make_proc_with_parent(pid: u32, ppid: Option<u32>, name: &str) -> ProcessInfo {
344        ProcessInfo {
345            pid,
346            parent_pid: ppid,
347            name: name.to_string(),
348            command: String::new(),
349            user: "u".to_string(),
350            cpu_percent: 0.0,
351            memory_bytes: 0,
352            memory_percent: 0.0,
353            status: "Running".to_string(),
354        }
355    }
356
357    #[test]
358    fn test_tree_parent_child() {
359        let procs = vec![
360            make_proc_with_parent(1, Some(0), "init"),
361            make_proc_with_parent(100, Some(1), "sshd"),
362            make_proc_with_parent(200, Some(100), "bash"),
363        ];
364        let tree = build_process_tree(&procs);
365        assert_eq!(tree.len(), 1, "should have one root (init)");
366        assert_eq!(tree[0].process.pid, 1);
367        assert_eq!(tree[0].children.len(), 1, "init should have 1 child");
368        assert_eq!(tree[0].children[0].process.pid, 100);
369        assert_eq!(
370            tree[0].children[0].children.len(),
371            1,
372            "sshd should have 1 child"
373        );
374        assert_eq!(tree[0].children[0].children[0].process.pid, 200);
375    }
376
377    #[test]
378    fn test_tree_depth_increments() {
379        let procs = vec![
380            make_proc_with_parent(1, Some(0), "init"),
381            make_proc_with_parent(10, Some(1), "child"),
382            make_proc_with_parent(100, Some(10), "grandchild"),
383        ];
384        let tree = build_process_tree(&procs);
385        assert_eq!(tree[0].depth, 0);
386        assert_eq!(tree[0].children[0].depth, 1);
387        assert_eq!(tree[0].children[0].children[0].depth, 2);
388    }
389
390    #[test]
391    fn test_tree_orphan_as_root() {
392        let procs = vec![make_proc_with_parent(500, Some(999), "orphan")];
393        let tree = build_process_tree(&procs);
394        assert_eq!(tree.len(), 1, "orphan with missing parent should be a root");
395        assert_eq!(tree[0].process.pid, 500);
396        assert_eq!(tree[0].depth, 0);
397    }
398
399    #[test]
400    fn test_tree_multiple_roots() {
401        let procs = vec![
402            make_proc_with_parent(1, Some(0), "init"),
403            make_proc_with_parent(2, Some(0), "kthreadd"),
404        ];
405        let tree = build_process_tree(&procs);
406        assert_eq!(
407            tree.len(),
408            2,
409            "two processes with PPID=0 should both be roots"
410        );
411    }
412
413    #[test]
414    fn test_tree_empty_list() {
415        let tree = build_process_tree(&[]);
416        assert!(tree.is_empty());
417    }
418
419    #[test]
420    fn test_tree_single_process() {
421        let procs = vec![make_proc_with_parent(42, None, "solo")];
422        let tree = build_process_tree(&procs);
423        assert_eq!(tree.len(), 1);
424        assert!(tree[0].children.is_empty());
425    }
426
427    #[test]
428    fn test_flatten_tree_order() {
429        let procs = vec![
430            make_proc_with_parent(1, Some(0), "init"),
431            make_proc_with_parent(10, Some(1), "child_a"),
432            make_proc_with_parent(20, Some(1), "child_b"),
433            make_proc_with_parent(100, Some(10), "grandchild"),
434        ];
435        let tree = build_process_tree(&procs);
436        let flat = flatten_tree(&tree);
437        let pids: Vec<u32> = flat.iter().map(|(p, _)| p.pid).collect();
438        // DFS: init → child_a → grandchild → child_b
439        assert_eq!(pids, vec![1, 10, 100, 20]);
440    }
441
442    #[test]
443    fn test_flatten_tree_depth_values() {
444        let procs = vec![
445            make_proc_with_parent(1, Some(0), "init"),
446            make_proc_with_parent(10, Some(1), "child"),
447            make_proc_with_parent(100, Some(10), "grandchild"),
448        ];
449        let tree = build_process_tree(&procs);
450        let flat = flatten_tree(&tree);
451        let depths: Vec<usize> = flat.iter().map(|(_, d)| *d).collect();
452        assert_eq!(depths, vec![0, 1, 2]);
453    }
454
455    // ---- SortField FromStr tests (STORY-01) ----
456
457    #[test]
458    fn test_sort_field_from_str_valid() {
459        assert_eq!("cpu".parse::<SortField>().unwrap(), SortField::Cpu);
460        assert_eq!("mem".parse::<SortField>().unwrap(), SortField::Mem);
461        assert_eq!("memory".parse::<SortField>().unwrap(), SortField::Mem);
462        assert_eq!("pid".parse::<SortField>().unwrap(), SortField::Pid);
463        assert_eq!("name".parse::<SortField>().unwrap(), SortField::Name);
464        assert_eq!("user".parse::<SortField>().unwrap(), SortField::User);
465    }
466
467    #[test]
468    fn test_sort_field_from_str_case_insensitive() {
469        assert_eq!("CPU".parse::<SortField>().unwrap(), SortField::Cpu);
470        assert_eq!("Mem".parse::<SortField>().unwrap(), SortField::Mem);
471        assert_eq!("PID".parse::<SortField>().unwrap(), SortField::Pid);
472    }
473
474    #[test]
475    fn test_sort_field_from_str_invalid() {
476        let err = "invalid".parse::<SortField>().unwrap_err();
477        assert!(err.contains("invalid sort field"));
478        assert!(err.contains("cpu, mem, pid, name, user"));
479    }
480
481    #[test]
482    fn test_sort_field_display_roundtrip() {
483        for &field in &[
484            SortField::Cpu,
485            SortField::Mem,
486            SortField::Pid,
487            SortField::Name,
488            SortField::User,
489        ] {
490            let s = field.to_string();
491            assert_eq!(s.parse::<SortField>().unwrap(), field);
492        }
493    }
494
495    #[test]
496    fn test_tree_preserves_all_processes() {
497        let procs = vec![
498            make_proc_with_parent(1, Some(0), "init"),
499            make_proc_with_parent(2, Some(0), "kthreadd"),
500            make_proc_with_parent(10, Some(1), "a"),
501            make_proc_with_parent(11, Some(1), "b"),
502            make_proc_with_parent(20, Some(2), "c"),
503            make_proc_with_parent(100, Some(10), "d"),
504            make_proc_with_parent(101, Some(10), "e"),
505            make_proc_with_parent(200, Some(20), "f"),
506            make_proc_with_parent(500, Some(999), "orphan"),
507            make_proc_with_parent(42, None, "none_parent"),
508        ];
509        let tree = build_process_tree(&procs);
510        let flat = flatten_tree(&tree);
511        assert_eq!(flat.len(), procs.len(), "all processes must be in the tree");
512    }
513}