1use crate::core::{
14 find_ports_for_pid, flatten_targets, parse_target, resolve_in_dir, resolve_target, NameMatch,
15 PortInfo, Process, TargetType,
16};
17use crate::error::{ExitCode, ProcError, Result};
18use crate::ui::{format_duration, format_memory, Printer};
19use clap::Args;
20use colored::*;
21use serde::Serialize;
22use std::io::Write;
23
24const WRITE_FAILED: &str = "failed printing to stdout";
26
27#[derive(Args, Debug)]
29pub struct OnCommand {
30 #[arg(required = true, num_args = 1.., value_parser = crate::core::validate_target_arg)]
32 pub target: Vec<String>,
33
34 #[arg(long = "in", short = 'i', num_args = 0..=1, default_missing_value = ".")]
37 pub in_dir: Option<String>,
38
39 #[arg(long = "by", short = 'b')]
41 pub by_name: Option<String>,
42
43 #[arg(long)]
45 pub cmd: bool,
46
47 #[arg(long, short = 'j')]
49 pub json: bool,
50
51 #[arg(long, short = 'v')]
53 pub verbose: bool,
54}
55
56impl OnCommand {
57 pub fn execute(&self) -> Result<()> {
59 let targets = flatten_targets(&self.target);
60
61 if targets.len() == 1 {
63 return match parse_target(&targets[0]) {
64 TargetType::Port(port) => self.show_process_on_port(port),
65 TargetType::Pid(pid) => self.show_ports_for_pid(pid),
66 TargetType::Name(name) => self.show_ports_for_name(&name),
67 };
68 }
69
70 if self.json {
71 return self.show_multi_json(&targets);
72 }
73
74 let mut not_found = Vec::new();
76
77 for target in &targets {
78 match parse_target(target) {
79 TargetType::Port(port) => {
80 if let Err(e) = self.show_process_on_port(port) {
81 println!("{} Port {}: {}", "⚠".yellow(), port, e);
82 not_found.push(target.clone());
83 }
84 }
85 TargetType::Pid(pid) => {
86 if let Err(e) = self.show_ports_for_pid(pid) {
87 println!("{} PID {}: {}", "⚠".yellow(), pid, e);
88 not_found.push(target.clone());
89 }
90 }
91 TargetType::Name(ref name) => {
92 if let Err(e) = self.show_ports_for_name(name) {
93 println!("{} '{}': {}", "⚠".yellow(), name, e);
94 not_found.push(target.clone());
95 }
96 }
97 }
98 }
99
100 if not_found.len() == targets.len() {
101 return Err(ProcError::reported(
103 ExitCode::NotFound,
104 format!("No process found matching '{}'", self.target.join(" ")),
105 ));
106 }
107
108 Ok(())
109 }
110
111 fn show_multi_json(&self, targets: &[String]) -> Result<()> {
113 let mut results: Vec<TargetResult> = Vec::new();
114 let mut not_found: Vec<String> = Vec::new();
115
116 for target in targets {
117 let found = match parse_target(target) {
118 TargetType::Port(port) => self.lookup_port(port).map(port_hits),
119 TargetType::Pid(pid) => self.lookup_pid(pid).map(|found| vec![found]),
120 TargetType::Name(ref name) => self.lookup_name(name),
121 };
122
123 match found {
124 Ok(found) if !found.is_empty() => {
125 results.extend(found.into_iter().map(|(process, ports)| TargetResult {
126 target: target.clone(),
127 process,
128 ports,
129 }))
130 }
131 _ => not_found.push(target.clone()),
132 }
133 }
134
135 if results.is_empty() {
136 return Err(ProcError::ProcessNotFound(self.target.join(" ")));
137 }
138
139 Printer::from_flags(true, self.verbose).print_json(&MultiTargetOutput {
140 action: "on",
141 success: true,
142 count: results.len(),
143 results,
144 not_found,
145 });
146
147 Ok(())
148 }
149
150 fn matches_in_filter(&self, proc: &Process) -> bool {
152 if let Some(ref dir_path) = resolve_in_dir(&self.in_dir) {
153 if let Some(ref proc_cwd) = proc.cwd {
154 crate::core::filters::cwd_within(proc_cwd, dir_path)
155 } else {
156 false
157 }
158 } else {
159 true
160 }
161 }
162
163 fn matches_by_filter(&self, proc: &Process) -> bool {
165 if let Some(ref name) = self.by_name {
166 crate::core::matches_by_filter(proc, name, NameMatch::from_flag(self.cmd))
167 } else {
168 true
169 }
170 }
171
172 fn matches_filters(&self, proc: &Process) -> bool {
174 self.matches_in_filter(proc) && self.matches_by_filter(proc)
175 }
176
177 fn lookup_port(&self, port: u16) -> Result<Vec<(PortInfo, Option<Process>)>> {
179 let listeners = PortInfo::holders_of(port)?;
180 if listeners.is_empty() {
181 return Err(ProcError::PortNotFound(port));
182 }
183
184 let mut holders = Vec::new();
185 for listener in listeners {
186 let process = Process::find_by_pid(listener.pid)?;
187 holders.push((listener, process));
188 }
189 self.filter_holders(port, holders)
190 }
191
192 fn filter_holders(
197 &self,
198 port: u16,
199 holders: Vec<(PortInfo, Option<Process>)>,
200 ) -> Result<Vec<(PortInfo, Option<Process>)>> {
201 let mut kept: Vec<(PortInfo, Option<Process>)> = holders
202 .into_iter()
203 .filter(|(_, process)| process.as_ref().is_none_or(|p| self.matches_filters(p)))
204 .collect();
205 if kept.is_empty() {
206 return Err(ProcError::FilteredOut {
207 target: format!(":{port}"),
208 reason: "process not in specified directory".to_string(),
209 });
210 }
211
212 let mut live: Vec<Process> = kept.iter().filter_map(|(_, p)| p.clone()).collect();
214 Process::measure_cpu(&mut live);
215 let mut live = live.into_iter();
216 for (_, slot) in kept.iter_mut() {
217 if slot.is_some() {
218 *slot = live.next();
219 }
220 }
221 Ok(kept)
222 }
223
224 fn show_process_on_port(&self, port: u16) -> Result<()> {
226 let holders = self.lookup_port(port)?;
227
228 if self.json {
229 let printer = Printer::from_flags(true, self.verbose);
230 let (first, _) = &holders[0];
231 let processes: Vec<&Process> = holders.iter().filter_map(|(_, p)| p.as_ref()).collect();
232 let output = PortLookupOutput {
233 action: "on",
234 query_type: "port_to_process",
235 success: true,
236 port: Some(first.port),
237 protocol: Some(format!("{:?}", first.protocol).to_lowercase()),
238 address: first.address.clone(),
239 process: processes.first().copied(),
241 processes: Some(processes),
242 ports: None,
243 };
244 printer.print_json(&output);
245 } else {
246 self.print_process_on_port(&mut std::io::stdout(), port, &holders)
247 .expect(WRITE_FAILED);
248 }
249
250 Ok(())
251 }
252
253 fn lookup_pid(&self, pid: u32) -> Result<(Process, Vec<PortInfo>)> {
255 let mut process = Process::find_by_pid(pid)?
256 .ok_or_else(|| ProcError::ProcessNotFound(pid.to_string()))?;
257 Process::measure_cpu(std::slice::from_mut(&mut process));
259
260 if !self.matches_filters(&process) {
262 return Err(ProcError::FilteredOut {
263 target: pid.to_string(),
264 reason: "not in specified directory".to_string(),
265 });
266 }
267
268 let ports = find_ports_for_pid(pid)?;
269 Ok((process, ports))
270 }
271
272 fn show_ports_for_pid(&self, pid: u32) -> Result<()> {
274 let (process, ports) = self.lookup_pid(pid)?;
275
276 if self.json {
277 let printer = Printer::from_flags(true, self.verbose);
278 let output = PortLookupOutput {
279 action: "on",
280 query_type: "process_to_ports",
281 success: true,
282 port: None,
283 protocol: None,
284 address: None,
285 process: Some(&process),
286 processes: None,
287 ports: Some(&ports),
288 };
289 printer.print_json(&output);
290 } else {
291 self.print_ports_for_process(&mut std::io::stdout(), &process, &ports)
292 .expect(WRITE_FAILED);
293 }
294
295 Ok(())
296 }
297
298 fn lookup_name(&self, name: &str) -> Result<Vec<(Process, Vec<PortInfo>)>> {
300 let mut processes = resolve_target(name, NameMatch::from_flag(self.cmd))?;
302
303 if self.in_dir.is_some() || self.by_name.is_some() {
305 processes.retain(|p| self.matches_filters(p));
306 if processes.is_empty() {
307 return Err(ProcError::FilteredOut {
308 target: name.to_string(),
309 reason: "no matches with specified filters".to_string(),
310 });
311 }
312 }
313
314 Process::measure_cpu(&mut processes);
316
317 let mut all_results: Vec<(Process, Vec<PortInfo>)> = Vec::new();
318
319 for proc in processes {
320 let ports = find_ports_for_pid(proc.pid)?;
321 all_results.push((proc, ports));
322 }
323
324 Ok(all_results)
325 }
326
327 fn show_ports_for_name(&self, name: &str) -> Result<()> {
329 let all_results = self.lookup_name(name)?;
330
331 if self.json {
332 let printer = Printer::from_flags(true, self.verbose);
333 let results: Vec<_> = all_results
334 .iter()
335 .map(|(proc, ports)| ProcessPortsJson {
336 process: proc,
337 ports,
338 })
339 .collect();
340 let output = MultiProcessPortsOutput {
341 action: "on",
342 success: true,
343 count: results.len(),
344 results: &results,
345 };
346 printer.print_json(&output);
347 } else {
348 for (proc, ports) in &all_results {
349 self.print_ports_for_process(&mut std::io::stdout(), proc, ports)
350 .expect(WRITE_FAILED);
351 }
352 }
353
354 Ok(())
355 }
356
357 fn print_process_on_port(
359 &self,
360 out: &mut impl Write,
361 port: u16,
362 holders: &[(PortInfo, Option<Process>)],
363 ) -> std::io::Result<()> {
364 let who = if holders.len() > 1 {
365 format!(" {} processes", holders.len())
366 } else {
367 String::new()
368 };
369 writeln!(
370 out,
371 "{} Port {} is used by{}:",
372 "✓".green().bold(),
373 port.to_string().cyan().bold(),
374 who
375 )?;
376 writeln!(out)?;
377
378 for (port_info, process) in holders {
379 self.print_holder(out, port_info, process.as_ref())?;
380 }
381 Ok(())
382 }
383
384 fn print_holder(
385 &self,
386 out: &mut impl Write,
387 port_info: &PortInfo,
388 process: Option<&Process>,
389 ) -> std::io::Result<()> {
390 writeln!(
391 out,
392 " {} {} (PID {})",
393 "Process:".bright_black(),
394 port_info.process_name.white().bold(),
395 port_info.pid.to_string().cyan()
396 )?;
397
398 if let Some(proc) = process {
399 if let Some(ref cwd) = proc.cwd {
400 writeln!(out, " {} {}", "Directory:".bright_black(), cwd)?;
401 }
402 if let Some(ref path) = proc.exe_path {
403 writeln!(out, " {} {}", "Path:".bright_black(), path.bright_black())?;
404 }
405 }
406
407 let addr = port_info.address.as_deref().unwrap_or("*");
408 writeln!(
409 out,
410 " {} {} on {}",
411 "Listening:".bright_black(),
412 format!("{:?}", port_info.protocol).to_uppercase(),
413 addr
414 )?;
415
416 if let Some(proc) = process {
417 writeln!(
418 out,
419 " {} {:.1}% CPU, {}",
420 "Resources:".bright_black(),
421 proc.cpu_percent,
422 format_memory(proc.memory_mb)
423 )?;
424
425 if let Some(uptime) = proc.uptime_secs {
426 writeln!(
427 out,
428 " {} {}",
429 "Uptime:".bright_black(),
430 format_duration(uptime)
431 )?;
432 }
433
434 if self.verbose {
435 if let Some(ref cmd) = proc.command {
436 writeln!(
437 out,
438 " {} {}",
439 "Command:".bright_black(),
440 cmd.bright_black()
441 )?;
442 }
443 }
444 }
445
446 writeln!(out)
447 }
448
449 fn print_ports_for_process(
450 &self,
451 out: &mut impl Write,
452 process: &Process,
453 ports: &[PortInfo],
454 ) -> std::io::Result<()> {
455 writeln!(
456 out,
457 "{} {} (PID {}) is listening on:",
458 "✓".green().bold(),
459 process.name.white().bold(),
460 process.pid.to_string().cyan().bold()
461 )?;
462 writeln!(out)?;
463
464 if ports.is_empty() {
465 writeln!(out, " {} No listening ports", "ℹ".blue())?;
466 } else {
467 for port_info in ports {
468 let addr = port_info.address.as_deref().unwrap_or("*");
469 writeln!(
470 out,
471 " {} :{} ({} on {})",
472 "→".bright_black(),
473 port_info.port.to_string().cyan(),
474 format!("{:?}", port_info.protocol).to_uppercase(),
475 addr
476 )?;
477 }
478 }
479
480 if process.cwd.is_some() || process.uptime_secs.is_some() {
481 writeln!(out)?;
482 }
483 if let Some(ref cwd) = process.cwd {
484 writeln!(out, " {} {}", "Directory:".bright_black(), cwd)?;
485 }
486 if let Some(uptime) = process.uptime_secs {
487 writeln!(
488 out,
489 " {} {}",
490 "Uptime:".bright_black(),
491 format_duration(uptime)
492 )?;
493 }
494
495 if self.verbose {
496 if let Some(ref path) = process.exe_path {
497 writeln!(out, " {} {}", "Path:".bright_black(), path.bright_black())?;
498 }
499 if let Some(ref cmd) = process.command {
500 writeln!(
501 out,
502 " {} {}",
503 "Command:".bright_black(),
504 cmd.bright_black()
505 )?;
506 }
507 }
508
509 writeln!(out)
510 }
511}
512
513fn port_hits(holders: Vec<(PortInfo, Option<Process>)>) -> Vec<(Process, Vec<PortInfo>)> {
517 holders
518 .into_iter()
519 .filter_map(|(port_info, process)| process.map(|p| (p, vec![port_info])))
520 .collect()
521}
522
523#[derive(Serialize)]
524struct PortLookupOutput<'a> {
525 action: &'static str,
526 query_type: &'static str,
527 success: bool,
528 #[serde(skip_serializing_if = "Option::is_none")]
529 port: Option<u16>,
530 #[serde(skip_serializing_if = "Option::is_none")]
531 protocol: Option<String>,
532 #[serde(skip_serializing_if = "Option::is_none")]
533 address: Option<String>,
534 #[serde(skip_serializing_if = "Option::is_none")]
535 process: Option<&'a Process>,
536 #[serde(skip_serializing_if = "Option::is_none")]
538 processes: Option<Vec<&'a Process>>,
539 #[serde(skip_serializing_if = "Option::is_none")]
540 ports: Option<&'a [PortInfo]>,
541}
542
543#[derive(Serialize)]
544struct ProcessPortsJson<'a> {
545 process: &'a Process,
546 ports: &'a [PortInfo],
547}
548
549#[derive(Serialize)]
550struct TargetResult {
551 target: String,
552 process: Process,
553 ports: Vec<PortInfo>,
554}
555
556#[derive(Serialize)]
557struct MultiTargetOutput {
558 action: &'static str,
559 success: bool,
560 count: usize,
561 results: Vec<TargetResult>,
562 not_found: Vec<String>,
563}
564
565#[derive(Serialize)]
566struct MultiProcessPortsOutput<'a> {
567 action: &'static str,
568 success: bool,
569 count: usize,
570 results: &'a Vec<ProcessPortsJson<'a>>,
571}
572
573#[cfg(test)]
574mod tests {
575 use super::*;
576 use crate::core::{ProcessStatus, Protocol};
577 use crate::test_support::assert_stops_at_a_failed_write;
578
579 fn command(in_dir: Option<&str>) -> OnCommand {
580 OnCommand {
581 target: vec!["x".to_string()],
582 in_dir: in_dir.map(String::from),
583 by_name: None,
584 cmd: false,
585 json: false,
586 verbose: false,
587 }
588 }
589
590 fn without_cwd() -> Process {
593 Process {
594 pid: 1,
595 name: "x".to_string(),
596 exe_path: None,
597 cwd: None,
598 command: None,
599 argv0: None,
600 raw: None,
601 cpu_percent: 0.0,
602 memory_mb: 0.0,
603 status: ProcessStatus::Sleeping,
604 user: None,
605 parent_pid: None,
606 start_time: None,
607 uptime_secs: None,
608 }
609 }
610
611 fn listener() -> PortInfo {
612 PortInfo {
613 port: 4000,
614 protocol: Protocol::Tcp,
615 pid: 1,
616 process_name: "x".to_string(),
617 address: None,
618 }
619 }
620
621 #[test]
622 fn a_process_with_no_working_directory_fails_in() {
623 assert!(!command(Some("/")).matches_in_filter(&without_cwd()));
624 assert!(command(None).matches_in_filter(&without_cwd()));
625 }
626
627 #[test]
628 fn a_port_whose_process_ended_has_no_process_to_filter() {
629 let holders = command(Some("/"))
630 .filter_holders(4000, vec![(listener(), None)])
631 .unwrap();
632 assert_eq!(holders.len(), 1);
633 assert_eq!(holders[0].0.port, 4000);
634 assert!(holders[0].1.is_none());
635 }
636
637 #[test]
638 fn the_filters_drop_the_holders_they_do_not_match() {
639 let mut elsewhere = without_cwd();
640 elsewhere.pid = 2;
641 elsewhere.cwd = Some("/definitely/not/here".to_string());
642 let mut here = without_cwd();
643 here.pid = 3;
644 here.cwd = Some(
645 std::env::current_dir()
646 .unwrap()
647 .to_string_lossy()
648 .into_owned(),
649 );
650
651 let holders = vec![
652 (listener(), Some(elsewhere)),
653 (listener(), Some(here.clone())),
654 (listener(), None),
655 ];
656 let kept = command(Some("."))
657 .filter_holders(4000, holders.clone())
658 .unwrap();
659 let pids: Vec<_> = kept
660 .iter()
661 .map(|(_, p)| p.as_ref().map(|p| p.pid))
662 .collect();
663 assert_eq!(pids, vec![Some(3), None]);
664
665 let none = command(Some("/definitely/not/here/either"))
667 .filter_holders(4000, holders[..1].to_vec());
668 assert!(
669 matches!(&none, Err(ProcError::FilteredOut { target, .. }) if target == ":4000"),
670 "{none:?}"
671 );
672 }
673
674 #[test]
675 fn a_port_whose_process_ended_adds_nothing_to_the_json_results() {
676 assert!(port_hits(vec![(listener(), None)]).is_empty());
677 let hits = port_hits(vec![
678 (listener(), Some(without_cwd())),
679 (listener(), None),
680 (listener(), Some(without_cwd())),
681 ]);
682 assert_eq!(hits.len(), 2);
683 }
684
685 fn printed(print: impl FnOnce(&mut Vec<u8>) -> std::io::Result<()>) -> String {
687 colored::control::set_override(false);
688 let mut out = Vec::new();
689 print(&mut out).unwrap();
690 String::from_utf8(out).unwrap()
691 }
692
693 #[test]
694 fn a_port_without_a_process_still_prints() {
695 let mut cmd = command(None);
697 cmd.verbose = true;
698 let out = printed(|w| cmd.print_process_on_port(w, 4000, &[(listener(), None)]));
699 assert_eq!(
700 out,
701 "✓ Port 4000 is used by:\n\n Process: x (PID 1)\n Listening: TCP on *\n\n"
702 );
703 }
704
705 #[test]
706 fn a_port_with_several_holders_says_so_and_lists_each() {
707 let mut second = listener();
708 second.pid = 2;
709 second.address = Some("::1".to_string());
710 let out = printed(|w| {
711 command(None).print_process_on_port(w, 4000, &[(listener(), None), (second, None)])
712 });
713 assert_eq!(
714 out,
715 "✓ Port 4000 is used by 2 processes:\n\n\
716 \x20 Process: x (PID 1)\n Listening: TCP on *\n\n\
717 \x20 Process: x (PID 2)\n Listening: TCP on ::1\n\n"
718 );
719 }
720
721 #[test]
722 fn a_process_missing_its_details_prints_without_them() {
723 let mut cmd = command(None);
724 cmd.verbose = true;
725
726 let out =
727 printed(|w| cmd.print_process_on_port(w, 4000, &[(listener(), Some(without_cwd()))]));
728 assert!(out.contains("Listening: TCP on *\n"), "{out}");
729 assert!(out.contains("Resources: 0.0% CPU"), "{out}");
730 for label in ["Directory:", "Path:", "Uptime:", "Command:"] {
731 assert!(!out.contains(label), "{label} printed for nothing: {out}");
732 }
733
734 let out = printed(|w| cmd.print_ports_for_process(w, &without_cwd(), &[listener()]));
735 assert_eq!(
736 out,
737 "✓ x (PID 1) is listening on:\n\n → :4000 (TCP on *)\n\n"
738 );
739 }
740
741 fn detailed() -> Process {
743 let mut p = without_cwd();
744 p.cwd = Some("/work/app".to_string());
745 p.exe_path = Some("/usr/bin/x".to_string());
746 p.command = Some("x --serve".to_string());
747 p.uptime_secs = Some(90);
748 p
749 }
750
751 #[test]
752 fn a_failed_write_ends_the_port_lookup() {
753 let mut cmd = command(None);
754 cmd.verbose = true;
755 let p = detailed();
756 let held = [(listener(), Some(p))];
757 assert_stops_at_a_failed_write(|w| cmd.print_process_on_port(w, 4000, &held));
758 assert_stops_at_a_failed_write(|w| {
759 cmd.print_process_on_port(w, 4000, &[(listener(), None)])
760 });
761 }
762
763 #[test]
764 fn a_failed_write_ends_the_process_lookup() {
765 let mut cmd = command(None);
766 cmd.verbose = true;
767 let p = detailed();
768 assert_stops_at_a_failed_write(|w| cmd.print_ports_for_process(w, &p, &[listener()]));
769 assert_stops_at_a_failed_write(|w| cmd.print_ports_for_process(w, &p, &[]));
770 }
771}