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 node processes
6//!   proc why 1234               # Show ancestry for PID 1234
7//!   proc why :3000 --json       # JSON output with port context
8
9use crate::core::{
10    find_ports_for_pid, parse_target, parse_targets, resolve_target, PortInfo, Process,
11    ProcessStatus, Protocol, TargetType,
12};
13use crate::error::{ExitCode, ProcError, Result};
14use crate::ui::Printer;
15use clap::Args;
16use colored::*;
17use serde::Serialize;
18use std::collections::HashMap;
19
20/// Trace why a port is busy or show process ancestry
21#[derive(Args, Debug)]
22pub struct WhyCommand {
23    /// Target(s): :port, PID, or process name (comma-separated for multiple)
24    #[arg(required = true)]
25    pub target: String,
26
27    /// Output as JSON
28    #[arg(long, short = 'j')]
29    pub json: bool,
30
31    /// Show verbose output
32    #[arg(long, short = 'v')]
33    pub verbose: bool,
34}
35
36impl WhyCommand {
37    /// Executes the why command, showing ancestry and port context.
38    pub fn execute(&self) -> Result<()> {
39        let printer = Printer::from_flags(self.json, self.verbose);
40
41        let all_processes = Process::find_all()?;
42        let pid_map: HashMap<u32, &Process> = all_processes.iter().map(|p| (p.pid, p)).collect();
43
44        let targets = parse_targets(&self.target);
45
46        // JSON results are collected across targets and printed as one document
47        let mut json_results: Vec<WhyOutput> = Vec::new();
48        let mut found_any = false;
49        let mut first_error: Option<ProcError> = None;
50
51        for (i, target_str) in targets.iter().enumerate() {
52            if i > 0 && !self.json {
53                println!();
54            }
55
56            let parsed = parse_target(target_str);
57
58            // Collect port context for port targets
59            let port_context: Option<PortInfo> = if let TargetType::Port(port) = &parsed {
60                PortInfo::find_by_port(*port).ok().flatten()
61            } else {
62                None
63            };
64
65            // Resolve to processes
66            let target_processes = match resolve_target(target_str) {
67                Ok(procs) => procs,
68                Err(e) => {
69                    if !self.json {
70                        printer.warning(&format!("{}", e));
71                    }
72                    first_error.get_or_insert(e);
73                    continue;
74                }
75            };
76
77            if target_processes.is_empty() {
78                printer.warning(&format!("No process found for '{}'", target_str));
79                first_error.get_or_insert(ProcError::ProcessNotFound(target_str.clone()));
80                continue;
81            }
82            found_any = true;
83
84            if self.json {
85                json_results.extend(target_processes.iter().map(|proc| {
86                    let chain = self.build_ancestry_chain(proc, &pid_map);
87                    let ports = find_ports_for_pid(proc.pid).unwrap_or_default();
88                    WhyOutput {
89                        target: target_str.clone(),
90                        port: port_context.as_ref().map(|p| p.port),
91                        protocol: port_context.as_ref().map(|p| p.protocol),
92                        process: WhyProcessInfo {
93                            pid: proc.pid,
94                            name: proc.name.clone(),
95                            command: proc.command.clone(),
96                            cwd: proc.cwd.clone(),
97                            status: proc.status,
98                        },
99                        ports,
100                        ancestry: chain,
101                    }
102                }));
103            } else {
104                // Print port header if applicable
105                if let Some(ref port_info) = port_context {
106                    println!(
107                        "{} Port {} ({}):",
108                        "✓".green().bold(),
109                        port_info.port.to_string().cyan().bold(),
110                        format!("{:?}", port_info.protocol).to_uppercase()
111                    );
112                } else {
113                    println!(
114                        "{} Ancestry for '{}':",
115                        "✓".green().bold(),
116                        target_str.cyan()
117                    );
118                }
119                println!();
120
121                for proc in &target_processes {
122                    self.print_ancestry_with_context(proc, &pid_map);
123                    println!();
124                }
125            }
126        }
127
128        if !found_any {
129            let error =
130                first_error.unwrap_or_else(|| ProcError::ProcessNotFound(self.target.clone()));
131            if self.json {
132                return Err(error);
133            }
134            // Humans already saw a warning per target
135            return Err(ProcError::reported(
136                ExitCode::from(&error),
137                error.to_string(),
138            ));
139        }
140
141        if self.json {
142            printer.print_json(&WhyEnvelope {
143                action: "why",
144                success: true,
145                count: json_results.len(),
146                results: json_results,
147            });
148        }
149
150        Ok(())
151    }
152
153    /// Build the ancestor chain from root down to target
154    fn build_ancestry_chain(
155        &self,
156        target: &Process,
157        pid_map: &HashMap<u32, &Process>,
158    ) -> Vec<AncestryEntry> {
159        let mut chain: Vec<AncestryEntry> = Vec::new();
160        let mut current_pid = Some(target.pid);
161
162        while let Some(pid) = current_pid {
163            if let Some(proc) = pid_map.get(&pid) {
164                chain.push(AncestryEntry {
165                    pid: proc.pid,
166                    name: proc.name.clone(),
167                    command: proc.command.clone(),
168                    cwd: proc.cwd.clone(),
169                    status: proc.status,
170                    is_target: proc.pid == target.pid,
171                });
172                current_pid = proc.parent_pid;
173                if chain.len() > 100 {
174                    break;
175                }
176            } else {
177                break;
178            }
179        }
180
181        chain.reverse();
182        chain
183    }
184
185    /// Print ancestry tree with working directory and command context
186    fn print_ancestry_with_context(&self, target: &Process, pid_map: &HashMap<u32, &Process>) {
187        // Build the ancestor chain (from target up to root)
188        let mut chain: Vec<&Process> = Vec::new();
189        let mut current_pid = Some(target.pid);
190
191        while let Some(pid) = current_pid {
192            if let Some(proc) = pid_map.get(&pid) {
193                chain.push(proc);
194                current_pid = proc.parent_pid;
195                if chain.len() > 100 {
196                    break;
197                }
198            } else {
199                break;
200            }
201        }
202
203        // Reverse to print from root to target
204        chain.reverse();
205
206        for (i, proc) in chain.iter().enumerate() {
207            let is_target = proc.pid == target.pid;
208            let indent = "    ".repeat(i);
209            let connector = if i == 0 { "" } else { "└── " };
210
211            let status_indicator = match proc.status {
212                ProcessStatus::Running => "●".green(),
213                ProcessStatus::Sleeping => "○".blue(),
214                ProcessStatus::Stopped => "◐".yellow(),
215                ProcessStatus::Zombie => "✗".red(),
216                _ => "?".white(),
217            };
218
219            // Build the command summary (skip exe name, show args)
220            let cmd_summary = proc
221                .command
222                .as_ref()
223                .and_then(|c| {
224                    let parts: Vec<&str> = c.split_whitespace().collect();
225                    if parts.len() > 1 {
226                        Some(parts[1..].join(" "))
227                    } else {
228                        None
229                    }
230                })
231                .unwrap_or_default();
232
233            if is_target {
234                let cmd_part = if cmd_summary.is_empty() {
235                    String::new()
236                } else {
237                    format!(" {}", cmd_summary)
238                };
239                println!(
240                    "{}{}{} {} [{}]{}  {}",
241                    indent.bright_black(),
242                    connector.bright_black(),
243                    status_indicator,
244                    proc.name.cyan().bold(),
245                    proc.pid.to_string().cyan().bold(),
246                    cmd_part,
247                    "← target".yellow()
248                );
249                // Show working directory for the target
250                if let Some(ref cwd) = proc.cwd {
251                    let dir_indent = "    ".repeat(i + 1);
252                    println!(
253                        "{}{}",
254                        dir_indent.bright_black(),
255                        format!("dir: {}", cwd).bright_black()
256                    );
257                }
258            } else {
259                let cmd_part = if cmd_summary.is_empty() {
260                    String::new()
261                } else {
262                    format!(" {}", cmd_summary)
263                };
264                println!(
265                    "{}{}{} {} [{}]{}",
266                    indent.bright_black(),
267                    connector.bright_black(),
268                    status_indicator,
269                    proc.name.white(),
270                    proc.pid.to_string().cyan(),
271                    cmd_part.bright_black()
272                );
273            }
274        }
275    }
276}
277
278#[derive(Serialize)]
279struct WhyEnvelope {
280    action: &'static str,
281    success: bool,
282    count: usize,
283    results: Vec<WhyOutput>,
284}
285
286#[derive(Serialize)]
287struct WhyOutput {
288    target: String,
289    #[serde(skip_serializing_if = "Option::is_none")]
290    port: Option<u16>,
291    #[serde(skip_serializing_if = "Option::is_none")]
292    protocol: Option<Protocol>,
293    process: WhyProcessInfo,
294    ports: Vec<PortInfo>,
295    ancestry: Vec<AncestryEntry>,
296}
297
298#[derive(Serialize)]
299struct WhyProcessInfo {
300    pid: u32,
301    name: String,
302    #[serde(skip_serializing_if = "Option::is_none")]
303    command: Option<String>,
304    #[serde(skip_serializing_if = "Option::is_none")]
305    cwd: Option<String>,
306    status: ProcessStatus,
307}
308
309#[derive(Serialize)]
310struct AncestryEntry {
311    pid: u32,
312    name: String,
313    #[serde(skip_serializing_if = "Option::is_none")]
314    command: Option<String>,
315    #[serde(skip_serializing_if = "Option::is_none")]
316    cwd: Option<String>,
317    status: ProcessStatus,
318    is_target: bool,
319}