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