Skip to main content

proc_cli/commands/
list.rs

1//! `proc list` - List processes
2//!
3//! Examples:
4//!   proc list                  # List all processes
5//!   proc list node             # Only the processes named node
6//!   proc list node --cmd       # ... or with node anywhere in their command line
7//!   proc list node python      # Several names: separate arguments
8//!   proc list --in             # Processes in current directory
9//!   proc list --in /project    # Processes in /project
10//!   proc list --min-cpu 10     # Processes using >10% CPU
11//!
12//! Commas still work (`proc list node,python`) but are deprecated.
13
14use crate::core::filters::resolve_dir;
15use crate::core::{
16    flatten_targets, resolve_in_dir, sort_processes, NameMatch, Process, SortKey, StatusFilter,
17};
18use crate::error::Result;
19use crate::ui::Printer;
20use clap::Args;
21use std::path::PathBuf;
22
23/// List processes
24#[derive(Args, Debug)]
25pub struct ListCommand {
26    /// Process name(s) to filter by (separate arguments for several)
27    #[arg(num_args = 0..)]
28    pub name: Vec<String>,
29
30    /// Match the names as a substring of the process name or full command line (like pkill -f)
31    #[arg(long)]
32    pub cmd: bool,
33
34    /// Filter by directory; takes the next word as the directory unless it starts with '-'
35    /// (bare --in is the current directory, --in=DIR is explicit)
36    #[arg(long = "in", short = 'i', num_args = 0..=1, default_missing_value = ".")]
37    pub in_dir: Option<String>,
38
39    /// Filter by executable path
40    #[arg(long, short = 'p')]
41    pub path: Option<String>,
42
43    /// Only show processes using more than this CPU %
44    #[arg(long)]
45    pub min_cpu: Option<f32>,
46
47    /// Only show processes using more than this memory (MB)
48    #[arg(long)]
49    pub min_mem: Option<f64>,
50
51    /// Filter by status: running, sleeping, stopped, zombie
52    #[arg(long, value_enum, ignore_case = true)]
53    pub status: Option<StatusFilter>,
54
55    /// Only show processes running longer than this (seconds)
56    #[arg(long)]
57    pub min_uptime: Option<u64>,
58
59    /// Only show children of this parent PID
60    #[arg(long)]
61    pub parent: Option<u32>,
62
63    /// Output as JSON
64    #[arg(long, short = 'j')]
65    pub json: bool,
66
67    /// Show verbose output with command line, cwd, parent PID, and uptime
68    #[arg(long, short = 'v')]
69    pub verbose: bool,
70
71    /// Print full directories and arguments instead of fitting the terminal width
72    #[arg(long, short = 'w')]
73    pub wide: bool,
74
75    /// Limit the number of results
76    #[arg(long, short = 'n')]
77    pub limit: Option<usize>,
78
79    /// Sort by: cpu, mem (biggest first); pid, name (ascending). Ties come by PID
80    #[arg(long, short = 's', value_enum, default_value_t = SortKey::Cpu)]
81    pub sort: SortKey,
82}
83
84impl ListCommand {
85    /// Executes the list command, displaying processes matching the filters.
86    pub fn execute(&self) -> Result<()> {
87        let printer = Printer::from_flags(self.json, self.verbose).wide(self.wide);
88
89        // Get base process list (several names as separate arguments; each may
90        // still be a comma-separated list)
91        let mut processes = if !self.name.is_empty() {
92            let names = flatten_targets(&self.name);
93            let mut all = Vec::new();
94            let mut seen = std::collections::HashSet::new();
95            for n in &names {
96                if let Ok(found) = Process::find_by_name(n, NameMatch::from_flag(self.cmd)) {
97                    for p in found {
98                        if seen.insert(p.pid) {
99                            all.push(p);
100                        }
101                    }
102                }
103            }
104            all
105        } else {
106            Process::find_all()?
107        };
108
109        // Real CPU readings for the table, --min-cpu and --sort cpu
110        Process::measure_cpu(&mut processes);
111
112        // Resolve --in filter path
113        let in_dir_filter = resolve_in_dir(&self.in_dir)?;
114        let in_dir_has_any = in_dir_filter.as_ref().is_some_and(|dir_path| {
115            processes
116                .iter()
117                .any(|p| crate::core::filters::matches_in_filter(p, dir_path))
118        });
119        crate::core::require_in_dir(&self.in_dir, &in_dir_filter, in_dir_has_any)?;
120
121        // Resolve path filter
122        let path_filter: Option<PathBuf> = match &self.path {
123            Some(p) => Some(resolve_dir(PathBuf::from(p))?),
124            None => None,
125        };
126
127        // Apply filters
128        processes.retain(|p| self.keeps(p, &in_dir_filter, &path_filter));
129
130        // Sort processes
131        sort_processes(&mut processes, self.sort);
132
133        // Apply limit if specified
134        if let Some(limit) = self.limit {
135            processes.truncate(limit);
136        }
137
138        // Build context string for output (e.g., "in /path/to/dir")
139        let context = in_dir_filter
140            .as_ref()
141            .map(|p| format!("in {}", p.display()));
142
143        printer.print_processes_with_context(&processes, context.as_deref());
144        Ok(())
145    }
146
147    /// Whether a process passes every filter: `--in`, `--path`, `--min-cpu`,
148    /// `--min-mem`, `--status`, `--min-uptime` and `--parent`.
149    fn keeps(
150        &self,
151        p: &Process,
152        in_dir_filter: &Option<PathBuf>,
153        path_filter: &Option<PathBuf>,
154    ) -> bool {
155        // Directory filter (--in)
156        if let Some(ref dir_path) = in_dir_filter {
157            if !crate::core::filters::matches_in_filter(p, dir_path) {
158                return false;
159            }
160        }
161
162        // Path filter (executable path)
163        if let Some(ref exe_path) = path_filter {
164            if let Some(ref proc_exe) = p.exe_path {
165                let proc_path = PathBuf::from(proc_exe);
166                if !proc_path.starts_with(exe_path) {
167                    return false;
168                }
169            } else {
170                return false;
171            }
172        }
173
174        // CPU filter
175        if let Some(min_cpu) = self.min_cpu {
176            if p.cpu_percent < min_cpu {
177                return false;
178            }
179        }
180
181        // Memory filter
182        if let Some(min_mem) = self.min_mem {
183            if p.memory_mb < min_mem {
184                return false;
185            }
186        }
187
188        // Status filter
189        if let Some(status) = self.status {
190            if !status.matches(p.status) {
191                return false;
192            }
193        }
194
195        // Uptime filter
196        if let Some(min_uptime) = self.min_uptime {
197            if let Some(start_time) = p.start_time {
198                let now = std::time::SystemTime::now()
199                    .duration_since(std::time::UNIX_EPOCH)
200                    .map(|d| d.as_secs())
201                    .unwrap_or(0);
202                if now.saturating_sub(start_time) < min_uptime {
203                    return false;
204                }
205            } else {
206                return false;
207            }
208        }
209
210        // Parent PID filter
211        if let Some(ppid) = self.parent {
212            if p.parent_pid != Some(ppid) {
213                return false;
214            }
215        }
216
217        true
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use crate::core::ProcessStatus;
225
226    fn command() -> ListCommand {
227        ListCommand {
228            name: Vec::new(),
229            cmd: false,
230            in_dir: None,
231            path: None,
232            min_cpu: None,
233            min_mem: None,
234            status: None,
235            min_uptime: None,
236            parent: None,
237            json: false,
238            verbose: false,
239            wide: false,
240            limit: None,
241            sort: SortKey::Cpu,
242        }
243    }
244
245    /// A process record with no working directory, executable or start time,
246    /// which some processes really are (a zombie has none on Linux; protected
247    /// system processes on Windows and other users' on macOS have no cwd)
248    fn bare_process() -> Process {
249        Process {
250            pid: 1,
251            name: "x".to_string(),
252            exe_path: None,
253            cwd: None,
254            command: None,
255            argv0: None,
256            raw: None,
257            cpu_percent: 0.0,
258            memory_mb: 0.0,
259            status: ProcessStatus::Sleeping,
260            user: None,
261            parent_pid: None,
262            start_time: None,
263            uptime_secs: None,
264        }
265    }
266
267    #[test]
268    fn a_process_with_no_working_directory_fails_in() {
269        let dir = Some(PathBuf::from("/"));
270        assert!(!command().keeps(&bare_process(), &dir, &None));
271        assert!(command().keeps(&bare_process(), &None, &None));
272    }
273
274    #[test]
275    fn a_process_with_no_executable_fails_path() {
276        let path = Some(PathBuf::from("/usr"));
277        assert!(!command().keeps(&bare_process(), &None, &path));
278    }
279
280    #[test]
281    fn a_process_with_no_start_time_fails_min_uptime() {
282        let cmd = ListCommand {
283            min_uptime: Some(0),
284            ..command()
285        };
286        assert!(!cmd.keeps(&bare_process(), &None, &None));
287    }
288
289    #[test]
290    fn a_process_that_passes_every_filter_is_kept() {
291        let mut process = bare_process();
292        process.cwd = Some("/srv/app".to_string());
293        process.exe_path = Some("/usr/bin/node".to_string());
294        process.start_time = Some(0);
295        process.parent_pid = Some(7);
296        let cmd = ListCommand {
297            min_cpu: Some(0.0),
298            min_mem: Some(0.0),
299            status: Some(StatusFilter::Sleeping),
300            min_uptime: Some(1),
301            parent: Some(7),
302            ..command()
303        };
304        let dir = Some(PathBuf::from("/srv"));
305        let path = Some(PathBuf::from("/usr/bin"));
306        assert!(cmd.keeps(&process, &dir, &path));
307        // and each filter on its own can still turn it away
308        let other_parent = ListCommand {
309            parent: Some(8),
310            ..command()
311        };
312        assert!(!other_parent.keeps(&process, &dir, &path));
313    }
314
315    #[test]
316    fn each_filter_turns_away_a_process_that_fails_it() {
317        let mut process = bare_process();
318        process.start_time = Some(
319            std::time::SystemTime::now()
320                .duration_since(std::time::UNIX_EPOCH)
321                .unwrap()
322                .as_secs(),
323        );
324        process.memory_mb = 1.0;
325        let turned_away = [
326            ListCommand {
327                min_cpu: Some(50.0),
328                ..command()
329            },
330            ListCommand {
331                min_mem: Some(500.0),
332                ..command()
333            },
334            ListCommand {
335                status: Some(StatusFilter::Zombie),
336                ..command()
337            },
338            ListCommand {
339                min_uptime: Some(3600),
340                ..command()
341            },
342            ListCommand {
343                parent: Some(7),
344                ..command()
345            },
346        ];
347        for cmd in &turned_away {
348            assert!(!cmd.keeps(&process, &None, &None), "{cmd:?}");
349        }
350    }
351}