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