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