1use crate::core::{
12 matches_name, resolve_in_dir, sort_ports, NameMatch, Names, PortInfo, PortSortKey, Process,
13};
14use crate::error::Result;
15use crate::ui::{out, truncate_string, Printer};
16use clap::Args;
17use colored::*;
18use serde::Serialize;
19use std::collections::{HashMap, HashSet};
20use std::io::Write;
21use std::path::Path;
22
23fn cwd_within(proc: Option<&Process>, dir: &Path) -> bool {
25 proc.is_some_and(|p| crate::core::filters::matches_in_filter(p, dir))
26}
27
28fn port_named(port: &PortInfo, process: Option<&Process>, pattern: &str, mode: NameMatch) -> bool {
32 match process {
33 Some(process) => matches_name(pattern, &process.names(), mode),
34 None => matches_name(
35 pattern,
36 &Names {
37 name: &port.process_name,
38 ..Names::default()
39 },
40 mode,
41 ),
42 }
43}
44
45fn parse_port_range(s: &str) -> std::result::Result<(u16, u16), String> {
47 let usage = || format!("'{s}' is not a range of ports; use START-END, e.g. 8000-9000");
48 let (start, end) = s.split_once('-').ok_or_else(usage)?;
49 let port = |p: &str| p.trim().parse::<u16>().ok().filter(|&p| p > 0);
50 let (start, end) = match (port(start), port(end)) {
51 (Some(start), Some(end)) => (start, end),
52 _ => return Err(format!("{}; ports go from 1 to 65535", usage())),
53 };
54 if start > end {
55 return Err(format!(
56 "'{s}' starts after it ends; did you mean {end}-{start}?"
57 ));
58 }
59 Ok((start, end))
60}
61
62#[derive(Args, Debug)]
64pub struct PortsCommand {
65 #[arg(long = "by", short = 'b')]
67 pub by_name: Option<String>,
68
69 #[arg(long)]
71 pub cmd: bool,
72
73 #[arg(long = "in", short = 'i', num_args = 0..=1, default_missing_value = ".")]
76 pub in_dir: Option<String>,
77
78 #[arg(long, short = 'e')]
80 pub exposed: bool,
81
82 #[arg(long, short = 'l')]
84 pub local: bool,
85
86 #[arg(long, short = 'j')]
88 pub json: bool,
89
90 #[arg(long, short = 'v')]
92 pub verbose: bool,
93
94 #[arg(long, short = 's', value_enum, default_value_t = PortSortKey::Port)]
96 pub sort: PortSortKey,
97
98 #[arg(long, short = 'n')]
100 pub limit: Option<usize>,
101
102 #[arg(long, short = 'r', value_parser = parse_port_range)]
104 pub range: Option<(u16, u16)>,
105}
106
107impl PortsCommand {
108 pub fn execute(&self) -> Result<()> {
110 let mut ports = PortInfo::get_all_listening()?;
111
112 if let Some(ref name) = self.by_name {
114 let mode = NameMatch::from_flag(self.cmd);
115 let running: HashMap<u32, Process> = Process::find_all()?
116 .into_iter()
117 .map(|p| (p.pid, p))
118 .collect();
119 ports.retain(|p| port_named(p, running.get(&p.pid), name, mode));
120 }
121
122 let in_dir_filter = resolve_in_dir(&self.in_dir)?;
124 if let Some(ref dir_path) = in_dir_filter {
125 ports.retain(|p| {
126 cwd_within(
127 Process::find_by_pid(p.pid).ok().flatten().as_ref(),
128 dir_path,
129 )
130 });
131 }
132 crate::core::require_in_dir_live(&self.in_dir, &in_dir_filter)?;
135
136 if self.exposed {
138 ports.retain(PortInfo::is_exposed);
139 }
140
141 if self.local {
142 ports.retain(PortInfo::is_local);
143 }
144
145 if let Some((start, end)) = self.range {
147 ports.retain(|p| p.port >= start && p.port <= end);
148 }
149
150 sort_ports(&mut ports, self.sort);
152
153 if let Some(limit) = self.limit {
155 ports.truncate(limit);
156 }
157
158 let process_map: HashMap<u32, Process> = if self.verbose {
160 let pids: HashSet<u32> = ports.iter().map(|p| p.pid).collect();
162 pids.into_iter()
163 .filter_map(|pid| {
164 let proc = Process::find_by_pid(pid).ok().flatten()?;
165 Some((pid, proc))
166 })
167 .collect()
168 } else {
169 HashMap::new()
170 };
171
172 if self.json {
173 self.print_json(&ports, &process_map);
174 } else {
175 out::recorded(self.print_human(&mut out::stdout(), &ports, &process_map));
177 }
178
179 Ok(())
180 }
181
182 fn print_human(
183 &self,
184 out: &mut impl Write,
185 ports: &[PortInfo],
186 process_map: &HashMap<u32, Process>,
187 ) -> std::io::Result<()> {
188 if ports.is_empty() {
189 return writeln!(out, "{} No listening ports found", "⚠".yellow().bold());
190 }
191
192 writeln!(
193 out,
194 "{} Found {} listening port{}",
195 "✓".green().bold(),
196 ports.len().to_string().cyan().bold(),
197 if ports.len() == 1 { "" } else { "s" }
198 )?;
199 writeln!(out)?;
200
201 writeln!(
203 out,
204 "{:<8} {:<10} {:<8} {:<20} {:<15}",
205 "PORT".bright_blue().bold(),
206 "PROTO".bright_blue().bold(),
207 "PID".bright_blue().bold(),
208 "PROCESS".bright_blue().bold(),
209 "ADDRESS".bright_blue().bold()
210 )?;
211 writeln!(out, "{}", "─".repeat(65).bright_black())?;
212
213 for port in ports {
214 let addr = port.address.as_deref().unwrap_or("*");
215 let proto = format!("{:?}", port.protocol).to_uppercase();
216
217 writeln!(
218 out,
219 "{:<8} {:<10} {:<8} {:<20} {:<15}",
220 port.port.to_string().cyan().bold(),
221 proto.white(),
222 port.pid.to_string().cyan(),
223 truncate_string(&port.process_name, 19).white(),
224 addr.bright_black()
225 )?;
226
227 if self.verbose {
229 if let Some(proc) = process_map.get(&port.pid) {
230 if let Some(ref path) = proc.exe_path {
231 writeln!(
232 out,
233 " {} {}",
234 "↳".bright_black(),
235 truncate_string(path, 55).bright_black()
236 )?;
237 }
238 }
239 }
240 }
241 writeln!(out)
242 }
243
244 fn print_json(&self, ports: &[PortInfo], process_map: &HashMap<u32, Process>) {
245 let printer = Printer::from_flags(true, self.verbose);
246
247 let enriched: Vec<PortWithProcess> = ports
248 .iter()
249 .map(|p| PortWithProcess {
250 port: p,
251 exe_path: process_map
252 .get(&p.pid)
253 .and_then(|proc| proc.exe_path.as_deref()),
254 })
255 .collect();
256
257 printer.print_json(&PortsOutput {
258 action: "ports",
259 success: true,
260 count: ports.len(),
261 ports: enriched,
262 });
263 }
264}
265
266#[derive(Serialize)]
267struct PortWithProcess<'a> {
268 #[serde(flatten)]
269 port: &'a PortInfo,
270 #[serde(skip_serializing_if = "Option::is_none")]
271 exe_path: Option<&'a str>,
272}
273
274#[derive(Serialize)]
275struct PortsOutput<'a> {
276 action: &'static str,
277 success: bool,
278 count: usize,
279 ports: Vec<PortWithProcess<'a>>,
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285 use crate::core::{ProcessStatus, Protocol};
286 use crate::test_support::assert_stops_at_a_failed_write;
287
288 fn process(cwd: Option<&str>, exe_path: Option<&str>) -> Process {
289 Process {
290 pid: 1,
291 name: "x".to_string(),
292 exe_path: exe_path.map(String::from),
293 cwd: cwd.map(String::from),
294 command: None,
295 argv0: None,
296 raw: None,
297 cpu_percent: 0.0,
298 memory_mb: 0.0,
299 status: ProcessStatus::Sleeping,
300 user: None,
301 parent_pid: None,
302 start_time: None,
303 uptime_secs: None,
304 }
305 }
306
307 fn listed(name: &str) -> PortInfo {
308 PortInfo {
309 port: 4000,
310 protocol: Protocol::Tcp,
311 pid: 1,
312 process_name: name.to_string(),
313 address: None,
314 }
315 }
316
317 #[test]
318 fn a_port_is_named_by_its_process_else_by_the_name_it_was_listed_with() {
319 let mut node = process(None, Some("/opt/tools/serve"));
320 node.name = "node".to_string();
321 node.command = Some("node app.js --engine deno".to_string());
322 let port = listed("stale-name");
323
324 assert!(port_named(&port, Some(&node), "NODE", NameMatch::Exact));
326 assert!(port_named(&port, Some(&node), "serve", NameMatch::Exact));
327 assert!(!port_named(
328 &port,
329 Some(&node),
330 "stale-name",
331 NameMatch::Exact
332 ));
333 assert!(!port_named(&port, Some(&node), "nod", NameMatch::Exact));
335 assert!(!port_named(&port, Some(&node), "deno", NameMatch::Exact));
336 assert!(port_named(&port, Some(&node), "nod", NameMatch::Command));
337 assert!(port_named(&port, Some(&node), "deno", NameMatch::Command));
338
339 assert!(port_named(&port, None, "Stale-Name", NameMatch::Exact));
341 assert!(!port_named(&port, None, "stale", NameMatch::Exact));
342 assert!(port_named(&port, None, "stale", NameMatch::Command));
343 assert!(!port_named(&port, None, "node", NameMatch::Command));
344 }
345
346 #[test]
347 fn only_a_known_process_inside_the_directory_passes_in() {
348 let dir = Path::new("/work");
349 assert!(cwd_within(Some(&process(Some("/work/app"), None)), dir));
350 assert!(!cwd_within(Some(&process(Some("/elsewhere"), None)), dir));
351 assert!(!cwd_within(Some(&process(None, None)), dir));
354 assert!(!cwd_within(None, dir));
355 }
356
357 #[test]
358 fn verbose_listing_shows_a_path_only_when_the_process_has_one() {
359 colored::control::set_override(false);
360 let cmd = PortsCommand {
361 by_name: None,
362 cmd: false,
363 in_dir: None,
364 exposed: false,
365 local: false,
366 json: false,
367 verbose: true,
368 sort: PortSortKey::Port,
369 limit: None,
370 range: None,
371 };
372 let port = PortInfo {
373 port: 4000,
374 protocol: Protocol::Tcp,
375 pid: 1,
376 process_name: "x".to_string(),
377 address: None,
378 };
379 let listing = |map: &HashMap<u32, Process>| {
380 let mut out = Vec::new();
381 cmd.print_human(&mut out, std::slice::from_ref(&port), map)
382 .unwrap();
383 String::from_utf8(out).unwrap()
384 };
385
386 let without_entry = listing(&HashMap::new());
388 let row = without_entry
389 .lines()
390 .find(|l| l.starts_with("4000"))
391 .unwrap();
392 assert!(
393 row.contains("TCP") && row.trim_end().ends_with('*'),
394 "{row}"
395 );
396 assert!(!without_entry.contains('↳'), "{without_entry}");
397
398 let without_path = listing(&HashMap::from([(1, process(None, None))]));
399 assert!(!without_path.contains('↳'), "{without_path}");
400
401 let with_path = listing(&HashMap::from([(1, process(None, Some("/bin/x")))]));
402 assert!(with_path.contains("↳ /bin/x\n"), "{with_path}");
403 }
404
405 #[test]
406 fn an_empty_listing_says_so() {
407 colored::control::set_override(false);
408 let cmd = PortsCommand {
409 by_name: None,
410 cmd: false,
411 in_dir: None,
412 exposed: false,
413 local: false,
414 json: false,
415 verbose: false,
416 sort: PortSortKey::Port,
417 limit: None,
418 range: None,
419 };
420 let mut out = Vec::new();
421 cmd.print_human(&mut out, &[], &HashMap::new()).unwrap();
422 assert_eq!(
423 String::from_utf8(out).unwrap(),
424 "⚠ No listening ports found\n"
425 );
426 }
427
428 #[test]
429 fn a_failed_write_ends_the_listing() {
430 let cmd = PortsCommand {
431 by_name: None,
432 cmd: false,
433 in_dir: None,
434 exposed: false,
435 local: false,
436 json: false,
437 verbose: true,
438 sort: PortSortKey::Port,
439 limit: None,
440 range: None,
441 };
442 let port = PortInfo {
443 port: 4000,
444 protocol: Protocol::Tcp,
445 pid: 1,
446 process_name: "x".to_string(),
447 address: Some("127.0.0.1".to_string()),
448 };
449 let with_path = HashMap::from([(1, process(None, Some("/bin/x")))]);
450 assert_stops_at_a_failed_write(|w| {
451 cmd.print_human(w, std::slice::from_ref(&port), &with_path)
452 });
453 assert_stops_at_a_failed_write(|w| cmd.print_human(w, &[], &HashMap::new()));
454 }
455}