Skip to main content

proc_cli/commands/
why.rs

1//! `proc why` - Explain why a port is busy or trace process ancestry
2//!
3//! Examples:
4//!   proc why :3000              # Why is port 3000 busy? (ancestry + port context)
5//!   proc why node               # Show ancestry for the processes named node
6//!   proc why node --cmd         # ... or with node anywhere in their command line
7//!   proc why 1234               # Show ancestry for PID 1234
8//!   proc why :3000 :8080        # Several targets: separate arguments
9//!   proc why :3000 --json       # JSON output with port context
10//!
11//! Commas still work (`proc why :3000,:8080`) but are deprecated.
12
13use crate::core::{
14    flatten_targets, parse_target, resolve_target, NameMatch, PortInfo, Process, ProcessStatus,
15    Protocol, TargetType,
16};
17use crate::error::{ExitCode, ProcError, Result};
18use crate::out_println;
19use crate::ui::{uptime_label, Printer};
20use clap::Args;
21use colored::*;
22use serde::Serialize;
23use std::collections::HashMap;
24
25/// Trace why a port is busy or show process ancestry
26#[derive(Args, Debug)]
27pub struct WhyCommand {
28    /// Target(s): :port, PID, or process name (separate arguments for several)
29    #[arg(required = true, num_args = 1.., value_parser = crate::core::validate_target_arg)]
30    pub target: Vec<String>,
31
32    /// Match name targets as a substring of the process name or full command line (like pkill -f)
33    #[arg(long)]
34    pub cmd: bool,
35
36    /// Output as JSON
37    #[arg(long, short = 'j')]
38    pub json: bool,
39
40    /// Show verbose output
41    #[arg(long, short = 'v')]
42    pub verbose: bool,
43}
44
45impl WhyCommand {
46    /// Executes the why command, showing ancestry and port context.
47    pub fn execute(&self) -> Result<()> {
48        let printer = Printer::from_flags(self.json, self.verbose);
49
50        let all_processes = Process::find_all()?;
51        let pid_map: HashMap<u32, &Process> = all_processes.iter().map(|p| (p.pid, p)).collect();
52
53        let targets = flatten_targets(&self.target);
54        let mode = NameMatch::from_flag(self.cmd);
55
56        // JSON results are collected across targets and printed as one document
57        let mut json_results: Vec<WhyOutput> = Vec::new();
58        let mut found_any = false;
59        let mut first_error: Option<ProcError> = None;
60        let mut not_found: Vec<String> = Vec::new();
61        // Read once for every process shown (see `with_ports`)
62        let listening = std::cell::OnceCell::new();
63
64        for (i, target_str) in targets.iter().enumerate() {
65            if i > 0 && !self.json {
66                out_println!();
67            }
68
69            let parsed = parse_target(target_str);
70
71            // Collect port context for port targets; every holder of the port is
72            // traced below, the context is the port they share
73            let port_context: Option<PortInfo> = if let TargetType::Port(port) = &parsed {
74                PortInfo::holders_of(*port)
75                    .ok()
76                    .and_then(|holders| holders.into_iter().next())
77            } else {
78                None
79            };
80
81            // Resolve to processes
82            let target_processes = match found_processes(target_str, |t| resolve_target(t, mode)) {
83                Ok(procs) => procs,
84                Err((error, warning)) => {
85                    printer.warning(&warning);
86                    first_error.get_or_insert(error);
87                    not_found.push(target_str.clone());
88                    continue;
89                }
90            };
91            found_any = true;
92
93            if self.json {
94                json_results.extend(target_processes.iter().map(|proc| {
95                    let chain = self.build_ancestry_chain(proc, &pid_map);
96                    let ports = listening
97                        .get_or_init(|| PortInfo::get_all_listening().unwrap_or_default())
98                        .iter()
99                        .filter(|p| p.pid == proc.pid)
100                        .cloned()
101                        .collect();
102                    WhyOutput {
103                        target: target_str.clone(),
104                        port: port_context.as_ref().map(|p| p.port),
105                        protocol: port_context.as_ref().map(|p| p.protocol),
106                        process: WhyProcessInfo {
107                            pid: proc.pid,
108                            name: proc.name.clone(),
109                            command: proc.command.clone(),
110                            cwd: proc.cwd.clone(),
111                            status: proc.status,
112                            uptime_secs: proc.uptime_secs,
113                        },
114                        ports,
115                        ancestry: chain,
116                    }
117                }));
118            } else {
119                // Print port header if applicable
120                if let Some(ref port_info) = port_context {
121                    out_println!(
122                        "{} Port {} ({}):",
123                        "✓".green().bold(),
124                        port_info.port.to_string().cyan().bold(),
125                        format!("{:?}", port_info.protocol).to_uppercase()
126                    );
127                } else {
128                    out_println!(
129                        "{} Ancestry for '{}':",
130                        "✓".green().bold(),
131                        target_str.cyan()
132                    );
133                }
134                out_println!();
135
136                for proc in &target_processes {
137                    self.print_ancestry_with_context(proc, &pid_map);
138                    out_println!();
139                }
140            }
141        }
142
143        if !found_any {
144            let error =
145                first_error.unwrap_or_else(|| ProcError::ProcessNotFound(self.target.join(" ")));
146            if self.json {
147                return Err(error);
148            }
149            // Humans already saw a warning per target
150            return Err(ProcError::reported(
151                ExitCode::from(&error),
152                error.to_string(),
153            ));
154        }
155
156        if self.json {
157            printer.print_json(&WhyEnvelope {
158                action: "why",
159                success: true,
160                count: json_results.len(),
161                not_found,
162                results: json_results,
163            });
164        }
165
166        Ok(())
167    }
168
169    /// Build the ancestor chain from root down to target
170    fn build_ancestry_chain(
171        &self,
172        target: &Process,
173        pid_map: &HashMap<u32, &Process>,
174    ) -> Vec<AncestryEntry> {
175        ancestor_chain(target, pid_map)
176            .into_iter()
177            .map(|proc| AncestryEntry {
178                pid: proc.pid,
179                name: proc.name.clone(),
180                command: proc.command.clone(),
181                cwd: proc.cwd.clone(),
182                status: proc.status,
183                uptime_secs: proc.uptime_secs,
184                is_target: proc.pid == target.pid,
185            })
186            .collect()
187    }
188
189    /// Print ancestry tree with working directory and command context
190    fn print_ancestry_with_context(&self, target: &Process, pid_map: &HashMap<u32, &Process>) {
191        let chain = ancestor_chain(target, pid_map);
192
193        for (i, proc) in chain.iter().enumerate() {
194            let is_target = proc.pid == target.pid;
195            let indent = "    ".repeat(i);
196            let connector = if i == 0 { "" } else { "└── " };
197
198            let symbol = status_indicator(proc.status);
199
200            // Build the command summary (skip exe name, show args)
201            let cmd_summary = proc
202                .command
203                .as_ref()
204                .and_then(|c| {
205                    let parts: Vec<&str> = c.split_whitespace().collect();
206                    if parts.len() > 1 {
207                        Some(parts[1..].join(" "))
208                    } else {
209                        None
210                    }
211                })
212                .unwrap_or_default();
213
214            if is_target {
215                let cmd_part = if cmd_summary.is_empty() {
216                    String::new()
217                } else {
218                    format!(" {}", cmd_summary)
219                };
220                out_println!(
221                    "{}{}{} {} [{}]{}{}  {}",
222                    indent.bright_black(),
223                    connector.bright_black(),
224                    symbol,
225                    proc.name.cyan().bold(),
226                    proc.pid.to_string().cyan().bold(),
227                    cmd_part,
228                    uptime_label(proc.uptime_secs).bright_black(),
229                    "← target".yellow()
230                );
231                // Show working directory for the target
232                if let Some(ref cwd) = proc.cwd {
233                    let dir_indent = "    ".repeat(i + 1);
234                    out_println!(
235                        "{}{}",
236                        dir_indent.bright_black(),
237                        format!("dir: {}", cwd).bright_black()
238                    );
239                }
240            } else {
241                let cmd_part = if cmd_summary.is_empty() {
242                    String::new()
243                } else {
244                    format!(" {}", cmd_summary)
245                };
246                out_println!(
247                    "{}{}{} {} [{}]{}{}",
248                    indent.bright_black(),
249                    connector.bright_black(),
250                    symbol,
251                    proc.name.white(),
252                    proc.pid.to_string().cyan(),
253                    cmd_part.bright_black(),
254                    uptime_label(proc.uptime_secs).bright_black()
255                );
256            }
257        }
258    }
259}
260
261/// Resolve one target to the processes it names. An error carries the warning
262/// to show a person: the resolve error's own text, or a plain "no process found"
263/// if the target resolved to nothing.
264fn found_processes(
265    target: &str,
266    resolve: impl Fn(&str) -> Result<Vec<Process>>,
267) -> std::result::Result<Vec<Process>, (ProcError, String)> {
268    match resolve(target) {
269        Ok(processes) if processes.is_empty() => Err((
270            ProcError::ProcessNotFound(target.to_string()),
271            format!("No process found for '{}'", target),
272        )),
273        Ok(processes) => Ok(processes),
274        Err(e) => {
275            let warning = e.to_string();
276            Err((e, warning))
277        }
278    }
279}
280
281/// The target and its ancestors, from the root down to the target. Stops at a
282/// parent that isn't running any more (or isn't listed) and after 100 levels,
283/// so a loop in the parent links can't run forever.
284fn ancestor_chain<'a>(target: &Process, pid_map: &HashMap<u32, &'a Process>) -> Vec<&'a Process> {
285    let mut chain: Vec<&Process> = Vec::new();
286    let mut current_pid = Some(target.pid);
287
288    while let Some(pid) = current_pid {
289        let Some(proc) = pid_map.get(&pid) else {
290            break;
291        };
292        chain.push(proc);
293        current_pid = proc.parent_pid;
294        if chain.len() > 100 {
295            break;
296        }
297    }
298
299    chain.reverse();
300    chain
301}
302
303/// The symbol in front of a process in the ancestry tree
304fn status_indicator(status: ProcessStatus) -> ColoredString {
305    match status {
306        ProcessStatus::Running => "●".green(),
307        ProcessStatus::Sleeping => "○".blue(),
308        ProcessStatus::Stopped => "◐".yellow(),
309        ProcessStatus::Zombie => "✗".red(),
310        _ => "?".white(),
311    }
312}
313
314#[derive(Serialize)]
315struct WhyEnvelope {
316    action: &'static str,
317    success: bool,
318    count: usize,
319    /// The targets that matched nothing
320    not_found: Vec<String>,
321    results: Vec<WhyOutput>,
322}
323
324#[derive(Serialize)]
325struct WhyOutput {
326    target: String,
327    #[serde(skip_serializing_if = "Option::is_none")]
328    port: Option<u16>,
329    #[serde(skip_serializing_if = "Option::is_none")]
330    protocol: Option<Protocol>,
331    process: WhyProcessInfo,
332    ports: Vec<PortInfo>,
333    ancestry: Vec<AncestryEntry>,
334}
335
336#[derive(Serialize)]
337struct WhyProcessInfo {
338    pid: u32,
339    name: String,
340    #[serde(skip_serializing_if = "Option::is_none")]
341    command: Option<String>,
342    #[serde(skip_serializing_if = "Option::is_none")]
343    cwd: Option<String>,
344    status: ProcessStatus,
345    #[serde(skip_serializing_if = "Option::is_none")]
346    uptime_secs: Option<u64>,
347}
348
349#[derive(Serialize)]
350struct AncestryEntry {
351    pid: u32,
352    name: String,
353    #[serde(skip_serializing_if = "Option::is_none")]
354    command: Option<String>,
355    #[serde(skip_serializing_if = "Option::is_none")]
356    cwd: Option<String>,
357    status: ProcessStatus,
358    #[serde(skip_serializing_if = "Option::is_none")]
359    uptime_secs: Option<u64>,
360    is_target: bool,
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366
367    fn process(pid: u32, parent_pid: Option<u32>) -> Process {
368        Process {
369            pid,
370            name: format!("p{pid}"),
371            exe_path: None,
372            cwd: None,
373            command: None,
374            argv0: None,
375            raw: None,
376            cpu_percent: 0.0,
377            memory_mb: 0.0,
378            status: ProcessStatus::Sleeping,
379            user: None,
380            parent_pid,
381            start_time: None,
382            uptime_secs: None,
383        }
384    }
385
386    fn pids(chain: &[&Process]) -> Vec<u32> {
387        chain.iter().map(|p| p.pid).collect()
388    }
389
390    #[test]
391    fn a_chain_runs_from_the_root_down_to_the_target() {
392        let (root, mid, leaf) = (process(1, None), process(2, Some(1)), process(3, Some(2)));
393        let map: HashMap<u32, &Process> =
394            [&root, &mid, &leaf].iter().map(|p| (p.pid, *p)).collect();
395        assert_eq!(pids(&ancestor_chain(&leaf, &map)), vec![1, 2, 3]);
396    }
397
398    #[test]
399    fn a_chain_stops_at_a_parent_that_is_not_listed() {
400        // The parent exited, or belongs to another user and can't be read
401        let (mid, leaf) = (process(2, Some(1)), process(3, Some(2)));
402        let map: HashMap<u32, &Process> = [&mid, &leaf].iter().map(|p| (p.pid, *p)).collect();
403        assert_eq!(pids(&ancestor_chain(&leaf, &map)), vec![2, 3]);
404    }
405
406    #[test]
407    fn a_target_that_is_not_listed_has_no_chain() {
408        let map: HashMap<u32, &Process> = HashMap::new();
409        assert!(ancestor_chain(&process(3, Some(2)), &map).is_empty());
410    }
411
412    #[test]
413    fn a_loop_in_the_parent_links_ends_after_101_entries() {
414        let (a, b) = (process(1, Some(2)), process(2, Some(1)));
415        let map: HashMap<u32, &Process> = [&a, &b].iter().map(|p| (p.pid, *p)).collect();
416        let chain = ancestor_chain(&a, &map);
417        assert_eq!(chain.len(), 101);
418        // Walked up from the target, so the chain ends on the target
419        assert_eq!(chain.last().map(|p| p.pid), Some(1));
420    }
421
422    #[test]
423    fn each_status_has_its_own_symbol() {
424        let symbol = |status| (*status_indicator(status)).to_string();
425        assert_eq!(symbol(ProcessStatus::Running), "●");
426        assert_eq!(symbol(ProcessStatus::Sleeping), "○");
427        assert_eq!(symbol(ProcessStatus::Stopped), "◐");
428        assert_eq!(symbol(ProcessStatus::Zombie), "✗");
429        assert_eq!(symbol(ProcessStatus::Dead), "?");
430        assert_eq!(symbol(ProcessStatus::Unknown), "?");
431    }
432
433    #[test]
434    fn a_target_that_resolves_to_nothing_is_not_found() {
435        let (error, warning) = found_processes("ghost", |_| Ok(Vec::new())).unwrap_err();
436        assert!(
437            matches!(&error, ProcError::ProcessNotFound(t) if t == "ghost"),
438            "{error:?}"
439        );
440        assert_eq!(warning, "No process found for 'ghost'");
441    }
442
443    #[test]
444    fn a_target_that_fails_to_resolve_warns_with_the_error() {
445        let (error, warning) =
446            found_processes("ghost", |_| Err(ProcError::PortNotFound(1))).unwrap_err();
447        assert!(matches!(error, ProcError::PortNotFound(1)), "{error:?}");
448        assert_eq!(warning, ProcError::PortNotFound(1).to_string());
449    }
450
451    #[test]
452    fn a_target_that_resolves_gives_its_processes() {
453        let found = found_processes("p", |_| Ok(vec![process(5, None)])).unwrap();
454        assert_eq!(found.len(), 1);
455        assert_eq!(found[0].pid, 5);
456    }
457}