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