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