Skip to main content

proc_cli/commands/
ports.rs

1//! `proc ports` - List all listening ports
2//!
3//! Examples:
4//!   proc ports              # Show all listening ports
5//!   proc ports --by node     # Filter by process name
6//!   proc ports --exposed    # Only network-accessible ports (0.0.0.0)
7//!   proc ports --local      # Only localhost ports (127.0.0.1)
8//!   proc ports -v           # Show with executable paths
9
10use crate::core::{PortInfo, Process};
11use crate::error::Result;
12use crate::ui::{OutputFormat, Printer};
13use clap::Args;
14use colored::*;
15use serde::Serialize;
16use std::collections::HashMap;
17use std::path::PathBuf;
18
19/// List all listening ports
20#[derive(Args, Debug)]
21pub struct PortsCommand {
22    /// Filter by process name
23    #[arg(long = "by", short = 'b')]
24    pub by_name: Option<String>,
25
26    /// Filter by directory (defaults to current directory if no path given)
27    #[arg(long = "in", short = 'i', num_args = 0..=1, default_missing_value = ".")]
28    pub in_dir: Option<String>,
29
30    /// Only show network-exposed ports (0.0.0.0, ::)
31    #[arg(long, short = 'e')]
32    pub exposed: bool,
33
34    /// Only show localhost ports (127.0.0.1, ::1)
35    #[arg(long, short = 'l')]
36    pub local: bool,
37
38    /// Output as JSON
39    #[arg(long, short = 'j')]
40    pub json: bool,
41
42    /// Show verbose output (includes executable path)
43    #[arg(long, short = 'v')]
44    pub verbose: bool,
45
46    /// Sort by: port, pid, name
47    #[arg(long, short = 's', default_value = "port")]
48    pub sort: String,
49}
50
51impl PortsCommand {
52    /// Executes the ports command, listing all listening network ports.
53    pub fn execute(&self) -> Result<()> {
54        let mut ports = PortInfo::get_all_listening()?;
55
56        // Filter by process name if specified
57        if let Some(ref name) = self.by_name {
58            let name_lower = name.to_lowercase();
59            ports.retain(|p| p.process_name.to_lowercase().contains(&name_lower));
60        }
61
62        // Filter by directory if specified
63        if let Some(ref _dir) = self.in_dir {
64            let in_dir_filter = resolve_in_dir(&self.in_dir);
65            if let Some(ref dir_path) = in_dir_filter {
66                ports.retain(|p| {
67                    if let Ok(Some(proc)) = Process::find_by_pid(p.pid) {
68                        if let Some(ref cwd) = proc.cwd {
69                            return PathBuf::from(cwd).starts_with(dir_path);
70                        }
71                    }
72                    false
73                });
74            }
75        }
76
77        // Filter by address exposure
78        if self.exposed {
79            ports.retain(|p| {
80                p.address
81                    .as_ref()
82                    .map(|a| a == "0.0.0.0" || a == "::" || a == "*")
83                    .unwrap_or(true)
84            });
85        }
86
87        if self.local {
88            ports.retain(|p| {
89                p.address
90                    .as_ref()
91                    .map(|a| a == "127.0.0.1" || a == "::1" || a.starts_with("[::1]"))
92                    .unwrap_or(false)
93            });
94        }
95
96        // Sort ports
97        match self.sort.to_lowercase().as_str() {
98            "port" => ports.sort_by_key(|p| p.port),
99            "pid" => ports.sort_by_key(|p| p.pid),
100            "name" => ports.sort_by(|a, b| {
101                a.process_name
102                    .to_lowercase()
103                    .cmp(&b.process_name.to_lowercase())
104            }),
105            _ => ports.sort_by_key(|p| p.port),
106        }
107
108        // In verbose mode, fetch process info for paths
109        let process_map: HashMap<u32, Process> = if self.verbose {
110            let mut map = HashMap::new();
111            for port in &ports {
112                if let std::collections::hash_map::Entry::Vacant(e) = map.entry(port.pid) {
113                    if let Ok(Some(proc)) = Process::find_by_pid(port.pid) {
114                        e.insert(proc);
115                    }
116                }
117            }
118            map
119        } else {
120            HashMap::new()
121        };
122
123        if self.json {
124            self.print_json(&ports, &process_map);
125        } else {
126            self.print_human(&ports, &process_map);
127        }
128
129        Ok(())
130    }
131
132    fn print_human(&self, ports: &[PortInfo], process_map: &HashMap<u32, Process>) {
133        if ports.is_empty() {
134            println!("{} No listening ports found", "⚠".yellow().bold());
135            return;
136        }
137
138        println!(
139            "{} Found {} listening port{}",
140            "✓".green().bold(),
141            ports.len().to_string().cyan().bold(),
142            if ports.len() == 1 { "" } else { "s" }
143        );
144        println!();
145
146        // Header
147        println!(
148            "{:<8} {:<10} {:<8} {:<20} {:<15}",
149            "PORT".bright_blue().bold(),
150            "PROTO".bright_blue().bold(),
151            "PID".bright_blue().bold(),
152            "PROCESS".bright_blue().bold(),
153            "ADDRESS".bright_blue().bold()
154        );
155        println!("{}", "─".repeat(65).bright_black());
156
157        for port in ports {
158            let addr = port.address.as_deref().unwrap_or("*");
159            let proto = format!("{:?}", port.protocol).to_uppercase();
160
161            println!(
162                "{:<8} {:<10} {:<8} {:<20} {:<15}",
163                port.port.to_string().cyan().bold(),
164                proto.white(),
165                port.pid.to_string().cyan(),
166                truncate_string(&port.process_name, 19).white(),
167                addr.bright_black()
168            );
169
170            // In verbose mode, show path
171            if self.verbose {
172                if let Some(proc) = process_map.get(&port.pid) {
173                    if let Some(ref path) = proc.exe_path {
174                        println!(
175                            "         {} {}",
176                            "↳".bright_black(),
177                            truncate_string(path, 55).bright_black()
178                        );
179                    }
180                }
181            }
182        }
183        println!();
184    }
185
186    fn print_json(&self, ports: &[PortInfo], process_map: &HashMap<u32, Process>) {
187        let printer = Printer::new(OutputFormat::Json, self.verbose);
188
189        #[derive(Serialize)]
190        struct PortWithProcess<'a> {
191            #[serde(flatten)]
192            port: &'a PortInfo,
193            #[serde(skip_serializing_if = "Option::is_none")]
194            exe_path: Option<&'a str>,
195        }
196
197        let enriched: Vec<PortWithProcess> = ports
198            .iter()
199            .map(|p| PortWithProcess {
200                port: p,
201                exe_path: process_map
202                    .get(&p.pid)
203                    .and_then(|proc| proc.exe_path.as_deref()),
204            })
205            .collect();
206
207        #[derive(Serialize)]
208        struct Output<'a> {
209            action: &'static str,
210            success: bool,
211            count: usize,
212            ports: Vec<PortWithProcess<'a>>,
213        }
214
215        printer.print_json(&Output {
216            action: "ports",
217            success: true,
218            count: ports.len(),
219            ports: enriched,
220        });
221    }
222}
223
224fn truncate_string(s: &str, max_len: usize) -> String {
225    if s.len() <= max_len {
226        s.to_string()
227    } else {
228        format!("{}...", &s[..max_len.saturating_sub(3)])
229    }
230}
231
232fn resolve_in_dir(in_dir: &Option<String>) -> Option<PathBuf> {
233    in_dir.as_ref().map(|p| {
234        if p == "." {
235            std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
236        } else {
237            let path = PathBuf::from(p);
238            if path.is_relative() {
239                std::env::current_dir()
240                    .unwrap_or_else(|_| PathBuf::from("."))
241                    .join(path)
242            } else {
243                path
244            }
245        }
246    })
247}