Skip to main content

proc_cli/commands/
on.rs

1//! `proc on` - Port/process lookup
2//!
3//! Usage:
4//!   proc on :3000              # What process is on port 3000?
5//!   proc on :3000,:8080        # What's on multiple ports?
6//!   proc on 1234               # What ports is PID 1234 listening on?
7//!   proc on node               # What ports are node processes listening on?
8//!   proc on node --in .        # Node processes in cwd and their ports
9
10use crate::core::{
11    find_ports_for_pid, parse_target, parse_targets, resolve_in_dir, resolve_target, PortInfo,
12    Process, TargetType,
13};
14use crate::error::{ExitCode, ProcError, Result};
15use crate::ui::{format_duration, format_memory, Printer};
16use clap::Args;
17use colored::*;
18use serde::Serialize;
19use std::path::PathBuf;
20
21/// Show what's on a port, or what ports a process is on
22#[derive(Args, Debug)]
23pub struct OnCommand {
24    /// Target(s): :port, PID, or process name (comma-separated for multiple)
25    pub target: String,
26
27    /// Filter by directory (defaults to current directory if no path given)
28    #[arg(long = "in", short = 'i', num_args = 0..=1, default_missing_value = ".")]
29    pub in_dir: Option<String>,
30
31    /// Filter by process name
32    #[arg(long = "by", short = 'b')]
33    pub by_name: Option<String>,
34
35    /// Output as JSON
36    #[arg(long, short = 'j')]
37    pub json: bool,
38
39    /// Show verbose output (full command line)
40    #[arg(long, short = 'v')]
41    pub verbose: bool,
42}
43
44impl OnCommand {
45    /// Executes the on command, performing bidirectional port/process lookup.
46    pub fn execute(&self) -> Result<()> {
47        let targets = parse_targets(&self.target);
48
49        // For single target, use original behavior
50        if targets.len() == 1 {
51            return match parse_target(&targets[0]) {
52                TargetType::Port(port) => self.show_process_on_port(port),
53                TargetType::Pid(pid) => self.show_ports_for_pid(pid),
54                TargetType::Name(name) => self.show_ports_for_name(&name),
55            };
56        }
57
58        if self.json {
59            return self.show_multi_json(&targets);
60        }
61
62        // Multi-target handling
63        let mut not_found = Vec::new();
64
65        for target in &targets {
66            match parse_target(target) {
67                TargetType::Port(port) => {
68                    if let Err(e) = self.show_process_on_port(port) {
69                        println!("{} Port {}: {}", "⚠".yellow(), port, e);
70                        not_found.push(target.clone());
71                    }
72                }
73                TargetType::Pid(pid) => {
74                    if let Err(e) = self.show_ports_for_pid(pid) {
75                        println!("{} PID {}: {}", "⚠".yellow(), pid, e);
76                        not_found.push(target.clone());
77                    }
78                }
79                TargetType::Name(ref name) => {
80                    if let Err(e) = self.show_ports_for_name(name) {
81                        println!("{} '{}': {}", "⚠".yellow(), name, e);
82                        not_found.push(target.clone());
83                    }
84                }
85            }
86        }
87
88        if not_found.len() == targets.len() {
89            // Each target already printed its own warning
90            return Err(ProcError::reported(
91                ExitCode::NotFound,
92                format!("No process found matching '{}'", self.target),
93            ));
94        }
95
96        Ok(())
97    }
98
99    /// Multi-target JSON: one envelope with a result per matched process
100    fn show_multi_json(&self, targets: &[String]) -> Result<()> {
101        let mut results: Vec<TargetResult> = Vec::new();
102        let mut not_found: Vec<String> = Vec::new();
103
104        for target in targets {
105            let found = match parse_target(target) {
106                TargetType::Port(port) => {
107                    self.lookup_port(port)
108                        .map(|(port_info, process)| match process {
109                            Some(process) => vec![(process, vec![port_info])],
110                            None => Vec::new(),
111                        })
112                }
113                TargetType::Pid(pid) => self.lookup_pid(pid).map(|found| vec![found]),
114                TargetType::Name(ref name) => self.lookup_name(name),
115            };
116
117            match found {
118                Ok(found) if !found.is_empty() => {
119                    results.extend(found.into_iter().map(|(process, ports)| TargetResult {
120                        target: target.clone(),
121                        process,
122                        ports,
123                    }))
124                }
125                _ => not_found.push(target.clone()),
126            }
127        }
128
129        if results.is_empty() {
130            return Err(ProcError::ProcessNotFound(self.target.clone()));
131        }
132
133        Printer::from_flags(true, self.verbose).print_json(&MultiTargetOutput {
134            action: "on",
135            success: true,
136            count: results.len(),
137            results,
138            not_found,
139        });
140
141        Ok(())
142    }
143
144    /// Check if process matches --in filter
145    fn matches_in_filter(&self, proc: &Process) -> bool {
146        if let Some(ref dir_path) = resolve_in_dir(&self.in_dir) {
147            if let Some(ref proc_cwd) = proc.cwd {
148                let proc_path = PathBuf::from(proc_cwd);
149                proc_path.starts_with(dir_path)
150            } else {
151                false
152            }
153        } else {
154            true
155        }
156    }
157
158    /// Check if process matches --by filter
159    fn matches_by_filter(&self, proc: &Process) -> bool {
160        if let Some(ref name) = self.by_name {
161            crate::core::matches_by_filter(proc, name)
162        } else {
163            true
164        }
165    }
166
167    /// Check if process matches all filters (--in and --by)
168    fn matches_filters(&self, proc: &Process) -> bool {
169        self.matches_in_filter(proc) && self.matches_by_filter(proc)
170    }
171
172    /// Find the listener on a port and its process, applying --in/--by
173    fn lookup_port(&self, port: u16) -> Result<(PortInfo, Option<Process>)> {
174        let port_info = match PortInfo::find_by_port(port)? {
175            Some(info) => info,
176            None => return Err(ProcError::PortNotFound(port)),
177        };
178
179        let process = Process::find_by_pid(port_info.pid)?;
180
181        // Apply --in and --by filters if present
182        if let Some(ref proc) = process {
183            if !self.matches_filters(proc) {
184                return Err(ProcError::ProcessNotFound(format!(
185                    "port {} (process not in specified directory)",
186                    port
187                )));
188            }
189        }
190
191        Ok((port_info, process))
192    }
193
194    /// Show what process is on a specific port
195    fn show_process_on_port(&self, port: u16) -> Result<()> {
196        let (port_info, process) = self.lookup_port(port)?;
197
198        if self.json {
199            let printer = Printer::from_flags(true, self.verbose);
200            let output = PortLookupOutput {
201                action: "on",
202                query_type: "port_to_process",
203                success: true,
204                port: Some(port_info.port),
205                protocol: Some(format!("{:?}", port_info.protocol).to_lowercase()),
206                address: port_info.address.clone(),
207                process: process.as_ref(),
208                ports: None,
209            };
210            printer.print_json(&output);
211        } else {
212            self.print_process_on_port(&port_info, process.as_ref());
213        }
214
215        Ok(())
216    }
217
218    /// Find a process by PID and its listening ports, applying --in/--by
219    fn lookup_pid(&self, pid: u32) -> Result<(Process, Vec<PortInfo>)> {
220        let process = Process::find_by_pid(pid)?
221            .ok_or_else(|| ProcError::ProcessNotFound(pid.to_string()))?;
222
223        // Apply --in and --by filters if present
224        if !self.matches_filters(&process) {
225            return Err(ProcError::ProcessNotFound(format!(
226                "PID {} (not in specified directory)",
227                pid
228            )));
229        }
230
231        let ports = find_ports_for_pid(pid)?;
232        Ok((process, ports))
233    }
234
235    /// Show what ports a PID is listening on
236    fn show_ports_for_pid(&self, pid: u32) -> Result<()> {
237        let (process, ports) = self.lookup_pid(pid)?;
238
239        if self.json {
240            let printer = Printer::from_flags(true, self.verbose);
241            let output = PortLookupOutput {
242                action: "on",
243                query_type: "process_to_ports",
244                success: true,
245                port: None,
246                protocol: None,
247                address: None,
248                process: Some(&process),
249                ports: Some(&ports),
250            };
251            printer.print_json(&output);
252        } else {
253            self.print_ports_for_process(&process, &ports);
254        }
255
256        Ok(())
257    }
258
259    /// Find processes by name and their listening ports, applying --in/--by
260    fn lookup_name(&self, name: &str) -> Result<Vec<(Process, Vec<PortInfo>)>> {
261        let mut processes = resolve_target(name)?;
262
263        if processes.is_empty() {
264            return Err(ProcError::ProcessNotFound(name.to_string()));
265        }
266
267        // Apply --in and --by filters if present
268        if self.in_dir.is_some() || self.by_name.is_some() {
269            processes.retain(|p| self.matches_filters(p));
270            if processes.is_empty() {
271                return Err(ProcError::ProcessNotFound(format!(
272                    "'{}' (no matches with specified filters)",
273                    name
274                )));
275            }
276        }
277
278        let mut all_results: Vec<(Process, Vec<PortInfo>)> = Vec::new();
279
280        for proc in processes {
281            let ports = find_ports_for_pid(proc.pid)?;
282            all_results.push((proc, ports));
283        }
284
285        Ok(all_results)
286    }
287
288    /// Show what ports processes with a given name are listening on
289    fn show_ports_for_name(&self, name: &str) -> Result<()> {
290        let all_results = self.lookup_name(name)?;
291
292        if self.json {
293            let printer = Printer::from_flags(true, self.verbose);
294            let results: Vec<_> = all_results
295                .iter()
296                .map(|(proc, ports)| ProcessPortsJson {
297                    process: proc,
298                    ports,
299                })
300                .collect();
301            let output = MultiProcessPortsOutput {
302                action: "on",
303                success: true,
304                count: results.len(),
305                results: &results,
306            };
307            printer.print_json(&output);
308        } else {
309            for (proc, ports) in &all_results {
310                self.print_ports_for_process(proc, ports);
311            }
312        }
313
314        Ok(())
315    }
316
317    fn print_process_on_port(&self, port_info: &PortInfo, process: Option<&Process>) {
318        println!(
319            "{} Port {} is used by:",
320            "✓".green().bold(),
321            port_info.port.to_string().cyan().bold()
322        );
323        println!();
324
325        println!(
326            "  {} {} (PID {})",
327            "Process:".bright_black(),
328            port_info.process_name.white().bold(),
329            port_info.pid.to_string().cyan()
330        );
331
332        if let Some(proc) = process {
333            if let Some(ref cwd) = proc.cwd {
334                println!("  {} {}", "Directory:".bright_black(), cwd);
335            }
336            if let Some(ref path) = proc.exe_path {
337                println!("  {} {}", "Path:".bright_black(), path.bright_black());
338            }
339        }
340
341        let addr = port_info.address.as_deref().unwrap_or("*");
342        println!(
343            "  {} {} on {}",
344            "Listening:".bright_black(),
345            format!("{:?}", port_info.protocol).to_uppercase(),
346            addr
347        );
348
349        if let Some(proc) = process {
350            println!(
351                "  {} {:.1}% CPU, {}",
352                "Resources:".bright_black(),
353                proc.cpu_percent,
354                format_memory(proc.memory_mb)
355            );
356
357            if let Some(start_time) = proc.start_time {
358                let uptime = std::time::SystemTime::now()
359                    .duration_since(std::time::UNIX_EPOCH)
360                    .map(|d| d.as_secs().saturating_sub(start_time))
361                    .unwrap_or(0);
362                println!("  {} {}", "Uptime:".bright_black(), format_duration(uptime));
363            }
364
365            if self.verbose {
366                if let Some(ref cmd) = proc.command {
367                    println!("  {} {}", "Command:".bright_black(), cmd.bright_black());
368                }
369            }
370        }
371
372        println!();
373    }
374
375    fn print_ports_for_process(&self, process: &Process, ports: &[PortInfo]) {
376        println!(
377            "{} {} (PID {}) is listening on:",
378            "✓".green().bold(),
379            process.name.white().bold(),
380            process.pid.to_string().cyan().bold()
381        );
382        println!();
383
384        if ports.is_empty() {
385            println!("  {} No listening ports", "ℹ".blue());
386        } else {
387            for port_info in ports {
388                let addr = port_info.address.as_deref().unwrap_or("*");
389                println!(
390                    "  {} :{} ({} on {})",
391                    "→".bright_black(),
392                    port_info.port.to_string().cyan(),
393                    format!("{:?}", port_info.protocol).to_uppercase(),
394                    addr
395                );
396            }
397        }
398
399        if let Some(ref cwd) = process.cwd {
400            println!();
401            println!("  {} {}", "Directory:".bright_black(), cwd);
402        }
403
404        if self.verbose {
405            if let Some(ref path) = process.exe_path {
406                println!("  {} {}", "Path:".bright_black(), path.bright_black());
407            }
408            if let Some(ref cmd) = process.command {
409                println!("  {} {}", "Command:".bright_black(), cmd.bright_black());
410            }
411        }
412
413        println!();
414    }
415}
416
417#[derive(Serialize)]
418struct PortLookupOutput<'a> {
419    action: &'static str,
420    query_type: &'static str,
421    success: bool,
422    #[serde(skip_serializing_if = "Option::is_none")]
423    port: Option<u16>,
424    #[serde(skip_serializing_if = "Option::is_none")]
425    protocol: Option<String>,
426    #[serde(skip_serializing_if = "Option::is_none")]
427    address: Option<String>,
428    #[serde(skip_serializing_if = "Option::is_none")]
429    process: Option<&'a Process>,
430    #[serde(skip_serializing_if = "Option::is_none")]
431    ports: Option<&'a [PortInfo]>,
432}
433
434#[derive(Serialize)]
435struct ProcessPortsJson<'a> {
436    process: &'a Process,
437    ports: &'a [PortInfo],
438}
439
440#[derive(Serialize)]
441struct TargetResult {
442    target: String,
443    process: Process,
444    ports: Vec<PortInfo>,
445}
446
447#[derive(Serialize)]
448struct MultiTargetOutput {
449    action: &'static str,
450    success: bool,
451    count: usize,
452    results: Vec<TargetResult>,
453    not_found: Vec<String>,
454}
455
456#[derive(Serialize)]
457struct MultiProcessPortsOutput<'a> {
458    action: &'static str,
459    success: bool,
460    count: usize,
461    results: &'a Vec<ProcessPortsJson<'a>>,
462}