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