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::{uptime_label, 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                            uptime_secs: proc.uptime_secs,
99                        },
100                        ports,
101                        ancestry: chain,
102                    }
103                }));
104            } else {
105                // Print port header if applicable
106                if let Some(ref port_info) = port_context {
107                    println!(
108                        "{} Port {} ({}):",
109                        "✓".green().bold(),
110                        port_info.port.to_string().cyan().bold(),
111                        format!("{:?}", port_info.protocol).to_uppercase()
112                    );
113                } else {
114                    println!(
115                        "{} Ancestry for '{}':",
116                        "✓".green().bold(),
117                        target_str.cyan()
118                    );
119                }
120                println!();
121
122                for proc in &target_processes {
123                    self.print_ancestry_with_context(proc, &pid_map);
124                    println!();
125                }
126            }
127        }
128
129        if !found_any {
130            let error =
131                first_error.unwrap_or_else(|| ProcError::ProcessNotFound(self.target.clone()));
132            if self.json {
133                return Err(error);
134            }
135            // Humans already saw a warning per target
136            return Err(ProcError::reported(
137                ExitCode::from(&error),
138                error.to_string(),
139            ));
140        }
141
142        if self.json {
143            printer.print_json(&WhyEnvelope {
144                action: "why",
145                success: true,
146                count: json_results.len(),
147                results: json_results,
148            });
149        }
150
151        Ok(())
152    }
153
154    /// Build the ancestor chain from root down to target
155    fn build_ancestry_chain(
156        &self,
157        target: &Process,
158        pid_map: &HashMap<u32, &Process>,
159    ) -> Vec<AncestryEntry> {
160        let mut chain: Vec<AncestryEntry> = Vec::new();
161        let mut current_pid = Some(target.pid);
162
163        while let Some(pid) = current_pid {
164            if let Some(proc) = pid_map.get(&pid) {
165                chain.push(AncestryEntry {
166                    pid: proc.pid,
167                    name: proc.name.clone(),
168                    command: proc.command.clone(),
169                    cwd: proc.cwd.clone(),
170                    status: proc.status,
171                    uptime_secs: proc.uptime_secs,
172                    is_target: proc.pid == target.pid,
173                });
174                current_pid = proc.parent_pid;
175                if chain.len() > 100 {
176                    break;
177                }
178            } else {
179                break;
180            }
181        }
182
183        chain.reverse();
184        chain
185    }
186
187    /// Print ancestry tree with working directory and command context
188    fn print_ancestry_with_context(&self, target: &Process, pid_map: &HashMap<u32, &Process>) {
189        // Build the ancestor chain (from target up to root)
190        let mut chain: Vec<&Process> = Vec::new();
191        let mut current_pid = Some(target.pid);
192
193        while let Some(pid) = current_pid {
194            if let Some(proc) = pid_map.get(&pid) {
195                chain.push(proc);
196                current_pid = proc.parent_pid;
197                if chain.len() > 100 {
198                    break;
199                }
200            } else {
201                break;
202            }
203        }
204
205        // Reverse to print from root to target
206        chain.reverse();
207
208        for (i, proc) in chain.iter().enumerate() {
209            let is_target = proc.pid == target.pid;
210            let indent = "    ".repeat(i);
211            let connector = if i == 0 { "" } else { "└── " };
212
213            let status_indicator = match proc.status {
214                ProcessStatus::Running => "●".green(),
215                ProcessStatus::Sleeping => "○".blue(),
216                ProcessStatus::Stopped => "◐".yellow(),
217                ProcessStatus::Zombie => "✗".red(),
218                _ => "?".white(),
219            };
220
221            // Build the command summary (skip exe name, show args)
222            let cmd_summary = proc
223                .command
224                .as_ref()
225                .and_then(|c| {
226                    let parts: Vec<&str> = c.split_whitespace().collect();
227                    if parts.len() > 1 {
228                        Some(parts[1..].join(" "))
229                    } else {
230                        None
231                    }
232                })
233                .unwrap_or_default();
234
235            if is_target {
236                let cmd_part = if cmd_summary.is_empty() {
237                    String::new()
238                } else {
239                    format!(" {}", cmd_summary)
240                };
241                println!(
242                    "{}{}{} {} [{}]{}{}  {}",
243                    indent.bright_black(),
244                    connector.bright_black(),
245                    status_indicator,
246                    proc.name.cyan().bold(),
247                    proc.pid.to_string().cyan().bold(),
248                    cmd_part,
249                    uptime_label(proc.uptime_secs).bright_black(),
250                    "← target".yellow()
251                );
252                // Show working directory for the target
253                if let Some(ref cwd) = proc.cwd {
254                    let dir_indent = "    ".repeat(i + 1);
255                    println!(
256                        "{}{}",
257                        dir_indent.bright_black(),
258                        format!("dir: {}", cwd).bright_black()
259                    );
260                }
261            } else {
262                let cmd_part = if cmd_summary.is_empty() {
263                    String::new()
264                } else {
265                    format!(" {}", cmd_summary)
266                };
267                println!(
268                    "{}{}{} {} [{}]{}{}",
269                    indent.bright_black(),
270                    connector.bright_black(),
271                    status_indicator,
272                    proc.name.white(),
273                    proc.pid.to_string().cyan(),
274                    cmd_part.bright_black(),
275                    uptime_label(proc.uptime_secs).bright_black()
276                );
277            }
278        }
279    }
280}
281
282#[derive(Serialize)]
283struct WhyEnvelope {
284    action: &'static str,
285    success: bool,
286    count: usize,
287    results: Vec<WhyOutput>,
288}
289
290#[derive(Serialize)]
291struct WhyOutput {
292    target: String,
293    #[serde(skip_serializing_if = "Option::is_none")]
294    port: Option<u16>,
295    #[serde(skip_serializing_if = "Option::is_none")]
296    protocol: Option<Protocol>,
297    process: WhyProcessInfo,
298    ports: Vec<PortInfo>,
299    ancestry: Vec<AncestryEntry>,
300}
301
302#[derive(Serialize)]
303struct WhyProcessInfo {
304    pid: u32,
305    name: String,
306    #[serde(skip_serializing_if = "Option::is_none")]
307    command: Option<String>,
308    #[serde(skip_serializing_if = "Option::is_none")]
309    cwd: Option<String>,
310    status: ProcessStatus,
311    #[serde(skip_serializing_if = "Option::is_none")]
312    uptime_secs: Option<u64>,
313}
314
315#[derive(Serialize)]
316struct AncestryEntry {
317    pid: u32,
318    name: String,
319    #[serde(skip_serializing_if = "Option::is_none")]
320    command: Option<String>,
321    #[serde(skip_serializing_if = "Option::is_none")]
322    cwd: Option<String>,
323    status: ProcessStatus,
324    #[serde(skip_serializing_if = "Option::is_none")]
325    uptime_secs: Option<u64>,
326    is_target: bool,
327}