Skip to main content

proc_cli/commands/
info.rs

1//! Info command - Get detailed process information
2//!
3//! Usage:
4//!   proc info 1234              # Info for PID
5//!   proc info :3000             # Info for process on port 3000
6//!   proc info node              # Info for processes named node
7//!   proc info node --cmd        # ... or with node anywhere in their command line
8//!   proc info :3000 :8080       # Info for several targets: separate arguments
9//!   proc info :3000 1234 node   # Mixed targets (port + PID + name)
10//!
11//! Commas still work (`proc info :3000,:8080`) but are deprecated.
12
13use crate::core::{apply_filters, flatten_targets, resolve_target, NameMatch, Process, Resolved};
14use crate::error::{ExitCode, ProcError, Result};
15use crate::out_print;
16use crate::ui::{colorize_status, format_duration, format_memory, Printer};
17use clap::Args;
18use colored::*;
19use serde::Serialize;
20use std::fmt::Write;
21
22/// Show detailed process information
23#[derive(Args, Debug)]
24pub struct InfoCommand {
25    /// Target(s): PID, :port, or name (separate arguments for several)
26    #[arg(required = true, num_args = 1.., value_parser = crate::core::validate_target_arg)]
27    targets: Vec<String>,
28
29    /// Output as JSON
30    #[arg(long, short = 'j')]
31    json: bool,
32
33    /// Show extra details
34    #[arg(long, short = 'v')]
35    verbose: bool,
36
37    /// Filter by directory; takes the next word as the directory unless it starts with '-'
38    /// (bare --in is the current directory, --in=DIR is explicit)
39    #[arg(long = "in", short = 'i', num_args = 0..=1, default_missing_value = ".")]
40    pub in_dir: Option<String>,
41
42    /// Filter by process name
43    #[arg(long = "by", short = 'b')]
44    pub by_name: Option<String>,
45
46    /// Match name targets and --by as a substring of the process name or full command line (like pkill -f)
47    #[arg(long)]
48    pub cmd: bool,
49}
50
51impl InfoCommand {
52    /// Executes the info command, displaying detailed process information.
53    pub fn execute(&self) -> Result<()> {
54        let printer = Printer::from_flags(self.json, self.verbose);
55
56        // Separate arguments, each of which may still be a comma-separated list
57        let all_targets = flatten_targets(&self.targets);
58
59        let mode = NameMatch::from_flag(self.cmd);
60        let resolved = resolve_all(&all_targets, |target| resolve_target(target, mode));
61        let warning = resolved.warning();
62        let Resolved {
63            processes: mut found,
64            not_found,
65            ..
66        } = resolved;
67
68        // Apply --in and --by filters
69        apply_filters(&mut found, &self.in_dir, &self.by_name, mode)?;
70
71        // Real CPU readings for what's shown
72        Process::measure_cpu(&mut found);
73
74        if self.json {
75            printer.print_json(&InfoOutput {
76                action: "info",
77                success: !found.is_empty(),
78                found_count: found.len(),
79                not_found_count: not_found.len(),
80                processes: &found,
81                not_found: &not_found,
82            });
83        } else {
84            for proc in &found {
85                self.print_process_info(proc);
86            }
87
88            if let Some(warning) = warning {
89                printer.warning(&warning);
90            }
91        }
92
93        if found.is_empty() {
94            return Err(ProcError::reported(
95                ExitCode::NotFound,
96                format!("No process found matching '{}'", self.targets.join(" ")),
97            ));
98        }
99
100        Ok(())
101    }
102
103    fn print_process_info(&self, proc: &Process) {
104        let now_secs = std::time::SystemTime::now()
105            .duration_since(std::time::UNIX_EPOCH)
106            .map(|d| d.as_secs())
107            .unwrap_or(0);
108        out_print!("{}", render_process_info(proc, self.verbose, now_secs));
109    }
110}
111
112/// The block `info` prints for one process: one line per fact that is known
113/// (`now_secs` is the time since the Unix epoch, for the uptime).
114fn render_process_info(proc: &Process, verbose: bool, now_secs: u64) -> String {
115    // Writing to a String can't fail
116    let mut out = String::new();
117    let _ = writeln!(
118        out,
119        "{} Process {}",
120        "✓".green().bold(),
121        proc.pid.to_string().cyan().bold()
122    );
123    let _ = writeln!(out);
124    let _ = writeln!(
125        out,
126        "  {} {}",
127        "Name:".bright_black(),
128        proc.name.white().bold()
129    );
130    let _ = writeln!(
131        out,
132        "  {} {}",
133        "PID:".bright_black(),
134        proc.pid.to_string().cyan()
135    );
136
137    if let Some(ref cwd) = proc.cwd {
138        let _ = writeln!(out, "  {} {}", "Directory:".bright_black(), cwd);
139    }
140
141    if let Some(ref path) = proc.exe_path {
142        let _ = writeln!(out, "  {} {}", "Path:".bright_black(), path);
143    }
144
145    if let Some(ref user) = proc.user {
146        let _ = writeln!(out, "  {} {}", "User:".bright_black(), user);
147    }
148
149    if let Some(ppid) = proc.parent_pid {
150        let _ = writeln!(
151            out,
152            "  {} {}",
153            "Parent PID:".bright_black(),
154            ppid.to_string().cyan()
155        );
156    }
157
158    let status_str = format!("{:?}", proc.status);
159    let status_colored = colorize_status(&proc.status, &status_str);
160    let _ = writeln!(out, "  {} {}", "Status:".bright_black(), status_colored);
161
162    let _ = writeln!(out, "  {} {:.1}%", "CPU:".bright_black(), proc.cpu_percent);
163    let _ = writeln!(
164        out,
165        "  {} {}",
166        "Memory:".bright_black(),
167        format_memory(proc.memory_mb)
168    );
169
170    if let Some(start_time) = proc.start_time {
171        let uptime = format_duration(now_secs.saturating_sub(start_time));
172        let _ = writeln!(out, "  {} {}", "Uptime:".bright_black(), uptime);
173    }
174
175    if verbose {
176        if let Some(ref cmd) = proc.command {
177            let _ = writeln!(
178                out,
179                "  {} {}",
180                "Command:".bright_black(),
181                cmd.bright_black()
182            );
183        }
184    }
185
186    let _ = writeln!(out);
187    out
188}
189
190/// Resolve every target. Returns the processes found, each once however many
191/// targets name it, and the targets that matched nothing.
192fn resolve_all(targets: &[String], resolve: impl Fn(&str) -> Result<Vec<Process>>) -> Resolved {
193    let mut found = Vec::new();
194    let mut not_found = Vec::new();
195    let mut hints = Vec::new();
196    let mut seen_pids = std::collections::HashSet::new();
197
198    for target in targets {
199        match resolve(target) {
200            Ok(processes) => {
201                if processes.is_empty() {
202                    not_found.push(target.clone());
203                } else {
204                    for proc in processes {
205                        // Deduplicate by PID
206                        if seen_pids.insert(proc.pid) {
207                            found.push(proc);
208                        }
209                    }
210                }
211            }
212            Err(error) => {
213                not_found.push(target.clone());
214                hints.extend(error.command_line_hint());
215            }
216        }
217    }
218
219    Resolved {
220        processes: found,
221        not_found,
222        hints,
223    }
224}
225
226#[derive(Serialize)]
227struct InfoOutput<'a> {
228    action: &'static str,
229    success: bool,
230    found_count: usize,
231    not_found_count: usize,
232    processes: &'a [Process],
233    not_found: &'a [String],
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use crate::core::ProcessStatus;
240
241    fn process(pid: u32) -> Process {
242        Process {
243            pid,
244            name: format!("p{pid}"),
245            exe_path: None,
246            cwd: None,
247            command: None,
248            argv0: None,
249            raw: None,
250            cpu_percent: 0.0,
251            memory_mb: 0.0,
252            status: ProcessStatus::Sleeping,
253            user: None,
254            parent_pid: None,
255            start_time: None,
256            uptime_secs: None,
257        }
258    }
259
260    fn targets(list: &[&str]) -> Vec<String> {
261        list.iter().map(|t| t.to_string()).collect()
262    }
263
264    #[test]
265    fn a_target_that_resolves_to_nothing_is_not_found() {
266        let resolved = resolve_all(&targets(&["ghost"]), |_| Ok(Vec::new()));
267        assert!(resolved.processes.is_empty());
268        assert_eq!(resolved.not_found, vec!["ghost"]);
269    }
270
271    #[test]
272    fn a_target_that_fails_to_resolve_is_not_found() {
273        let resolved = resolve_all(&targets(&["a", "b"]), |t| match t {
274            "a" => Ok(vec![process(1)]),
275            _ => Err(ProcError::ProcessNotFound(t.to_string())),
276        });
277        assert_eq!(resolved.processes.len(), 1);
278        assert_eq!(resolved.not_found, vec!["b"]);
279        assert!(resolved.hints.is_empty());
280        assert_eq!(resolved.warning().unwrap(), "Not found: b");
281    }
282
283    #[test]
284    fn a_name_in_command_lines_only_is_not_found_with_a_hint() {
285        let resolved = resolve_all(&targets(&["serve"]), |t| {
286            Err(ProcError::NameNotFound {
287                name: t.to_string(),
288                in_command_line: 2,
289            })
290        });
291        assert_eq!(resolved.not_found, vec!["serve"]);
292        assert_eq!(
293            resolved.warning().unwrap(),
294            "Not found: serve\n  2 processes have 'serve' in their command line (add --cmd to match those)"
295        );
296    }
297
298    #[test]
299    fn a_process_two_targets_name_is_found_once() {
300        let resolved = resolve_all(&targets(&["a", "b", "c"]), |t| match t {
301            "a" => Ok(vec![process(1), process(2)]),
302            "b" => Ok(vec![process(2), process(3)]),
303            _ => Ok(vec![process(1)]),
304        });
305        let pids: Vec<u32> = resolved.processes.iter().map(|p| p.pid).collect();
306        assert_eq!(pids, vec![1, 2, 3]);
307        assert!(resolved.not_found.is_empty());
308        assert_eq!(resolved.warning(), None);
309    }
310
311    fn bare(pid: u32) -> Process {
312        Process {
313            name: "worker".to_string(),
314            ..process(pid)
315        }
316    }
317
318    fn render(proc: &Process, verbose: bool, now_secs: u64) -> String {
319        colored::control::set_override(false);
320        render_process_info(proc, verbose, now_secs)
321    }
322
323    #[test]
324    fn a_process_with_nothing_known_shows_only_what_always_is() {
325        let out = render(&bare(7), true, 1_000);
326        assert_eq!(
327            out,
328            "✓ Process 7\n\n  Name: worker\n  PID: 7\n  Status: Sleeping\n  CPU: 0.0%\n  Memory: 0KB\n\n"
329        );
330    }
331
332    #[test]
333    fn every_known_fact_gets_a_line() {
334        let proc = Process {
335            cwd: Some("/work".to_string()),
336            exe_path: Some("/bin/worker".to_string()),
337            user: Some("zee".to_string()),
338            parent_pid: Some(1),
339            command: Some("/bin/worker --flag".to_string()),
340            start_time: Some(940),
341            ..bare(7)
342        };
343        let out = render(&proc, false, 1_000);
344        for line in [
345            "  Directory: /work\n",
346            "  Path: /bin/worker\n",
347            "  User: zee\n",
348            "  Parent PID: 1\n",
349            "  Uptime: 1m 0s\n",
350        ] {
351            assert!(out.contains(line), "{line:?} missing from\n{out}");
352        }
353        // The command line is for --verbose
354        assert!(!out.contains("Command:"), "{out}");
355        let verbose = render(&proc, true, 1_000);
356        assert!(
357            verbose.contains("  Command: /bin/worker --flag\n"),
358            "{verbose}"
359        );
360    }
361
362    #[test]
363    fn a_start_time_in_the_future_reads_as_no_time_at_all() {
364        // A clock behind the process's start must not underflow
365        let proc = Process {
366            start_time: Some(5_000),
367            ..bare(7)
368        };
369        let out = render(&proc, false, 1_000);
370        assert!(out.contains("  Uptime: 0s\n"), "{out}");
371    }
372}