Skip to main content

proc_cli/commands/
on.rs

1//! `proc on` - Port/process lookup
2//!
3//! Usage:
4//!   proc on :3000              # What process is on port 3000?
5//!   proc on :3000 :8080        # What's on several ports? (separate arguments)
6//!   proc on 1234               # What ports is PID 1234 listening on?
7//!   proc on node               # What ports are the processes named node listening on?
8//!   proc on node --cmd         # ... or with node anywhere in their command line
9//!   proc on node --in .        # Node processes in cwd and their ports
10//!
11//! Commas still work (`proc on :3000,:8080`) but are deprecated.
12
13use crate::core::{
14    find_ports_for_pid, flatten_targets, parse_target, resolve_in_dir, resolve_target,
15    ruled_out_by, with_ports, Guards, 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/// Show what's on a port, or what ports a process is on
26#[derive(Args, Debug, Clone)]
27pub struct OnCommand {
28    /// Target(s): :port, PID, or process name (separate arguments for several)
29    #[arg(required = true, num_args = 1.., value_parser = crate::core::validate_target_arg)]
30    pub target: Vec<String>,
31
32    /// Filter by directory; takes the next word as the directory unless it starts with '-'
33    /// (bare --in is the current directory, --in=DIR is explicit)
34    #[arg(long = "in", short = 'i', num_args = 0..=1, default_missing_value = ".")]
35    pub in_dir: Option<String>,
36
37    /// Filter by process name
38    #[arg(long = "by", short = 'b')]
39    pub by_name: Option<String>,
40
41    /// Match name targets and --by as a substring of the process name or full command line (like pkill -f)
42    #[arg(long)]
43    pub cmd: bool,
44
45    /// Output as JSON
46    #[arg(long, short = 'j')]
47    pub json: bool,
48
49    /// Show verbose output (full command line)
50    #[arg(long, short = 'v')]
51    pub verbose: bool,
52
53    /// `--in` as `execute` resolved it, absolute; every check uses this one
54    #[arg(skip)]
55    in_dir_path: Option<std::path::PathBuf>,
56}
57
58impl OnCommand {
59    /// Executes the on command, performing bidirectional port/process lookup.
60    pub fn execute(&self) -> Result<()> {
61        // Resolve --in once: a relative directory needs the current one, which
62        // may be gone (an error here), and every check after this one uses the
63        // absolute path. A missing directory with nothing running there is a
64        // typo, as on every command, not targets that are not found in it.
65        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    /// `execute` once `--in` is resolved
75    fn run(&self) -> Result<()> {
76        let targets = flatten_targets(&self.target);
77
78        // For single target, use original behavior
79        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        // Multi-target handling
92        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            // Each target already printed its own warning
119            return Err(ProcError::reported(
120                ExitCode::NotFound,
121                format!("No process found matching '{}'", self.target.join(" ")),
122            ));
123        }
124
125        Ok(())
126    }
127
128    /// Multi-target JSON: one envelope with a result per matched process
129    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    /// Check if process matches --in filter
168    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    /// Check if process matches --by filter
175    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, self.mode())
178        } else {
179            true
180        }
181    }
182
183    /// How names and `--by` match
184    fn mode(&self) -> NameMatch {
185        NameMatch::from_flag(self.cmd)
186    }
187
188    /// `--in` and `--by`, for naming the ones that rule a target out
189    fn guards(&self) -> Guards {
190        Guards::new(&self.in_dir, &self.by_name, None)
191    }
192
193    /// Check if process matches all filters (--in and --by)
194    fn matches_filters(&self, proc: &Process) -> bool {
195        self.matches_in_filter(proc) && self.matches_by_filter(proc)
196    }
197
198    /// Find every listener on a port with its process, applying --in/--by
199    fn lookup_port(&self, port: u16) -> Result<Vec<(PortInfo, Option<Process>)>> {
200        let listeners = PortInfo::holders_of(port)?;
201        if listeners.is_empty() {
202            return Err(ProcError::PortNotFound(port));
203        }
204
205        let mut holders = Vec::new();
206        for listener in listeners {
207            let process = Process::find_by_pid(listener.pid)?;
208            holders.push((listener, process));
209        }
210        self.filter_holders(port, holders)
211    }
212
213    /// Apply --in/--by to the holders of a port and take the CPU reading of
214    /// the ones that stay, all in one wait. A holder whose process is `None`
215    /// ended after its port was listed, and is kept without one. When the
216    /// filters leave no holder the port counts as not found.
217    fn filter_holders(
218        &self,
219        port: u16,
220        holders: Vec<(PortInfo, Option<Process>)>,
221    ) -> Result<Vec<(PortInfo, Option<Process>)>> {
222        let found: Vec<Process> = holders.iter().filter_map(|(_, p)| p.clone()).collect();
223        let mut kept: Vec<(PortInfo, Option<Process>)> = holders
224            .into_iter()
225            .filter(|(_, process)| process.as_ref().is_none_or(|p| self.matches_filters(p)))
226            .collect();
227        if kept.is_empty() {
228            return Err(ProcError::FilteredOut {
229                target: format!(":{port}"),
230                ruled_out_by: ruled_out_by(&found, &self.guards(), self.mode())?,
231            });
232        }
233
234        // Real CPU reading for what's shown
235        let mut live: Vec<Process> = kept.iter().filter_map(|(_, p)| p.clone()).collect();
236        Process::measure_cpu(&mut live);
237        let mut live = live.into_iter();
238        for (_, slot) in kept.iter_mut() {
239            if slot.is_some() {
240                *slot = live.next();
241            }
242        }
243        Ok(kept)
244    }
245
246    /// Show what process is on a specific port
247    fn show_process_on_port(&self, port: u16) -> Result<()> {
248        let holders = self.lookup_port(port)?;
249
250        if self.json {
251            let printer = Printer::from_flags(true, self.verbose);
252            let (first, _) = &holders[0];
253            let processes: Vec<&Process> = holders.iter().filter_map(|(_, p)| p.as_ref()).collect();
254            let output = PortLookupOutput {
255                action: "on",
256                query_type: "port_to_process",
257                success: true,
258                port: Some(first.port),
259                protocol: Some(format!("{:?}", first.protocol).to_lowercase()),
260                address: first.address.clone(),
261                // the first holder, as before `processes` existed
262                process: processes.first().copied(),
263                processes: Some(processes),
264                ports: None,
265            };
266            printer.print_json(&output);
267        } else {
268            // A failed write is recorded by `out`, which decides the exit code
269            out::recorded(self.print_process_on_port(&mut out::stdout(), port, &holders));
270        }
271
272        Ok(())
273    }
274
275    /// Find a process by PID and its listening ports, applying --in/--by
276    fn lookup_pid(&self, pid: u32) -> Result<(Process, Vec<PortInfo>)> {
277        let mut process = Process::find_by_pid(pid)?
278            .ok_or_else(|| ProcError::ProcessNotFound(pid.to_string()))?;
279        // Real CPU reading for what's shown
280        Process::measure_cpu(std::slice::from_mut(&mut process));
281
282        // Apply --in and --by filters if present
283        if !self.matches_filters(&process) {
284            return Err(ProcError::FilteredOut {
285                target: pid.to_string(),
286                ruled_out_by: ruled_out_by(&[process], &self.guards(), self.mode())?,
287            });
288        }
289
290        let ports = find_ports_for_pid(pid)?;
291        Ok((process, ports))
292    }
293
294    /// Show what ports a PID is listening on
295    fn show_ports_for_pid(&self, pid: u32) -> Result<()> {
296        let (process, ports) = self.lookup_pid(pid)?;
297
298        if self.json {
299            let printer = Printer::from_flags(true, self.verbose);
300            let output = PortLookupOutput {
301                action: "on",
302                query_type: "process_to_ports",
303                success: true,
304                port: None,
305                protocol: None,
306                address: None,
307                process: Some(&process),
308                processes: None,
309                ports: Some(&ports),
310            };
311            printer.print_json(&output);
312        } else {
313            out::recorded(self.print_ports_for_process(&mut out::stdout(), &process, &ports));
314        }
315
316        Ok(())
317    }
318
319    /// Find processes by name and their listening ports, applying --in/--by
320    fn lookup_name(&self, name: &str) -> Result<Vec<(Process, Vec<PortInfo>)>> {
321        // A name that matches nothing is an error here, never an empty list
322        let mut processes = resolve_target(name, NameMatch::from_flag(self.cmd))?;
323
324        // Apply --in and --by filters if present
325        if self.in_dir.is_some() || self.by_name.is_some() {
326            let found = processes.clone();
327            processes.retain(|p| self.matches_filters(p));
328            if processes.is_empty() {
329                return Err(ProcError::FilteredOut {
330                    target: name.to_string(),
331                    ruled_out_by: ruled_out_by(&found, &self.guards(), self.mode())?,
332                });
333            }
334        }
335
336        // Real CPU readings for what's shown, one wait for all of them
337        Process::measure_cpu(&mut processes);
338
339        with_ports(processes)
340    }
341
342    /// Show what ports processes with a given name are listening on
343    fn show_ports_for_name(&self, name: &str) -> Result<()> {
344        let all_results = self.lookup_name(name)?;
345
346        if self.json {
347            let printer = Printer::from_flags(true, self.verbose);
348            let results: Vec<_> = all_results
349                .iter()
350                .map(|(proc, ports)| ProcessPortsJson {
351                    process: proc,
352                    ports,
353                })
354                .collect();
355            let output = MultiProcessPortsOutput {
356                action: "on",
357                success: true,
358                count: results.len(),
359                results: &results,
360            };
361            printer.print_json(&output);
362        } else {
363            for (proc, ports) in &all_results {
364                out::recorded(self.print_ports_for_process(&mut out::stdout(), proc, ports));
365            }
366        }
367
368        Ok(())
369    }
370
371    /// What is on `port`: the line saying so, then each holder
372    fn print_process_on_port(
373        &self,
374        out: &mut impl Write,
375        port: u16,
376        holders: &[(PortInfo, Option<Process>)],
377    ) -> std::io::Result<()> {
378        let who = if holders.len() > 1 {
379            format!(" {} processes", holders.len())
380        } else {
381            String::new()
382        };
383        writeln!(
384            out,
385            "{} Port {} is used by{}:",
386            "✓".green().bold(),
387            port.to_string().cyan().bold(),
388            who
389        )?;
390        writeln!(out)?;
391
392        for (port_info, process) in holders {
393            self.print_holder(out, port_info, process.as_ref())?;
394        }
395        Ok(())
396    }
397
398    fn print_holder(
399        &self,
400        out: &mut impl Write,
401        port_info: &PortInfo,
402        process: Option<&Process>,
403    ) -> std::io::Result<()> {
404        writeln!(
405            out,
406            "  {} {} (PID {})",
407            "Process:".bright_black(),
408            port_info.process_name.white().bold(),
409            port_info.pid.to_string().cyan()
410        )?;
411
412        if let Some(proc) = process {
413            if let Some(ref cwd) = proc.cwd {
414                writeln!(out, "  {} {}", "Directory:".bright_black(), cwd)?;
415            }
416            if let Some(ref path) = proc.exe_path {
417                writeln!(out, "  {} {}", "Path:".bright_black(), path.bright_black())?;
418            }
419        }
420
421        let addr = port_info.address.as_deref().unwrap_or("*");
422        writeln!(
423            out,
424            "  {} {} on {}",
425            "Listening:".bright_black(),
426            format!("{:?}", port_info.protocol).to_uppercase(),
427            addr
428        )?;
429
430        if let Some(proc) = process {
431            writeln!(
432                out,
433                "  {} {:.1}% CPU, {}",
434                "Resources:".bright_black(),
435                proc.cpu_percent,
436                format_memory(proc.memory_mb)
437            )?;
438
439            if let Some(uptime) = proc.uptime_secs {
440                writeln!(
441                    out,
442                    "  {} {}",
443                    "Uptime:".bright_black(),
444                    format_duration(uptime)
445                )?;
446            }
447
448            if self.verbose {
449                if let Some(ref cmd) = proc.command {
450                    writeln!(
451                        out,
452                        "  {} {}",
453                        "Command:".bright_black(),
454                        cmd.bright_black()
455                    )?;
456                }
457            }
458        }
459
460        writeln!(out)
461    }
462
463    fn print_ports_for_process(
464        &self,
465        out: &mut impl Write,
466        process: &Process,
467        ports: &[PortInfo],
468    ) -> std::io::Result<()> {
469        writeln!(
470            out,
471            "{} {} (PID {}) is listening on:",
472            "✓".green().bold(),
473            process.name.white().bold(),
474            process.pid.to_string().cyan().bold()
475        )?;
476        writeln!(out)?;
477
478        if ports.is_empty() {
479            writeln!(out, "  {} No listening ports", "ℹ".blue())?;
480        } else {
481            for port_info in ports {
482                let addr = port_info.address.as_deref().unwrap_or("*");
483                writeln!(
484                    out,
485                    "  {} :{} ({} on {})",
486                    "→".bright_black(),
487                    port_info.port.to_string().cyan(),
488                    format!("{:?}", port_info.protocol).to_uppercase(),
489                    addr
490                )?;
491            }
492        }
493
494        if process.cwd.is_some() || process.uptime_secs.is_some() {
495            writeln!(out)?;
496        }
497        if let Some(ref cwd) = process.cwd {
498            writeln!(out, "  {} {}", "Directory:".bright_black(), cwd)?;
499        }
500        if let Some(uptime) = process.uptime_secs {
501            writeln!(
502                out,
503                "  {} {}",
504                "Uptime:".bright_black(),
505                format_duration(uptime)
506            )?;
507        }
508
509        if self.verbose {
510            if let Some(ref path) = process.exe_path {
511                writeln!(out, "  {} {}", "Path:".bright_black(), path.bright_black())?;
512            }
513            if let Some(ref cmd) = process.command {
514                writeln!(
515                    out,
516                    "  {} {}",
517                    "Command:".bright_black(),
518                    cmd.bright_black()
519                )?;
520            }
521        }
522
523        writeln!(out)
524    }
525}
526
527/// What a port lookup contributes to the multi-target JSON: each holder's
528/// process with the port it holds. A holder whose process ended after the port
529/// was listed adds nothing.
530fn port_hits(holders: Vec<(PortInfo, Option<Process>)>) -> Vec<(Process, Vec<PortInfo>)> {
531    holders
532        .into_iter()
533        .filter_map(|(port_info, process)| process.map(|p| (p, vec![port_info])))
534        .collect()
535}
536
537#[derive(Serialize)]
538struct PortLookupOutput<'a> {
539    action: &'static str,
540    query_type: &'static str,
541    success: bool,
542    #[serde(skip_serializing_if = "Option::is_none")]
543    port: Option<u16>,
544    #[serde(skip_serializing_if = "Option::is_none")]
545    protocol: Option<String>,
546    #[serde(skip_serializing_if = "Option::is_none")]
547    address: Option<String>,
548    #[serde(skip_serializing_if = "Option::is_none")]
549    process: Option<&'a Process>,
550    /// Every process holding the port (`process` is the first of them)
551    #[serde(skip_serializing_if = "Option::is_none")]
552    processes: Option<Vec<&'a Process>>,
553    #[serde(skip_serializing_if = "Option::is_none")]
554    ports: Option<&'a [PortInfo]>,
555}
556
557#[derive(Serialize)]
558struct ProcessPortsJson<'a> {
559    process: &'a Process,
560    ports: &'a [PortInfo],
561}
562
563#[derive(Serialize)]
564struct TargetResult {
565    target: String,
566    process: Process,
567    ports: Vec<PortInfo>,
568}
569
570#[derive(Serialize)]
571struct MultiTargetOutput {
572    action: &'static str,
573    success: bool,
574    count: usize,
575    results: Vec<TargetResult>,
576    not_found: Vec<String>,
577}
578
579#[derive(Serialize)]
580struct MultiProcessPortsOutput<'a> {
581    action: &'static str,
582    success: bool,
583    count: usize,
584    results: &'a Vec<ProcessPortsJson<'a>>,
585}
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590    use crate::core::{ProcessStatus, Protocol};
591    use crate::test_support::assert_stops_at_a_failed_write;
592
593    fn command(in_dir: Option<&str>) -> OnCommand {
594        let in_dir = in_dir.map(String::from);
595        OnCommand {
596            target: vec!["x".to_string()],
597            // as `execute` resolves it
598            in_dir_path: resolve_in_dir(&in_dir).unwrap(),
599            in_dir,
600            by_name: None,
601            cmd: false,
602            json: false,
603            verbose: false,
604        }
605    }
606
607    /// A process record with no working directory, which some processes really
608    /// are (a zombie has none on Linux)
609    fn without_cwd() -> Process {
610        Process {
611            pid: 1,
612            name: "x".to_string(),
613            exe_path: None,
614            cwd: None,
615            command: None,
616            argv0: None,
617            raw: None,
618            cpu_percent: 0.0,
619            memory_mb: 0.0,
620            status: ProcessStatus::Sleeping,
621            user: None,
622            parent_pid: None,
623            start_time: None,
624            uptime_secs: None,
625        }
626    }
627
628    fn listener() -> PortInfo {
629        PortInfo {
630            port: 4000,
631            protocol: Protocol::Tcp,
632            pid: 1,
633            process_name: "x".to_string(),
634            address: None,
635        }
636    }
637
638    #[test]
639    fn a_process_with_no_working_directory_fails_in() {
640        assert!(!command(Some("/")).matches_in_filter(&without_cwd()));
641        assert!(command(None).matches_in_filter(&without_cwd()));
642    }
643
644    #[test]
645    fn a_port_whose_process_ended_has_no_process_to_filter() {
646        let holders = command(Some("/"))
647            .filter_holders(4000, vec![(listener(), None)])
648            .unwrap();
649        assert_eq!(holders.len(), 1);
650        assert_eq!(holders[0].0.port, 4000);
651        assert!(holders[0].1.is_none());
652    }
653
654    #[test]
655    fn the_filters_drop_the_holders_they_do_not_match() {
656        let mut elsewhere = without_cwd();
657        elsewhere.pid = 2;
658        elsewhere.cwd = Some("/definitely/not/here".to_string());
659        let mut here = without_cwd();
660        here.pid = 3;
661        here.cwd = Some(
662            std::env::current_dir()
663                .unwrap()
664                .to_string_lossy()
665                .into_owned(),
666        );
667
668        let holders = vec![
669            (listener(), Some(elsewhere)),
670            (listener(), Some(here.clone())),
671            (listener(), None),
672        ];
673        let kept = command(Some("."))
674            .filter_holders(4000, holders.clone())
675            .unwrap();
676        let pids: Vec<_> = kept
677            .iter()
678            .map(|(_, p)| p.as_ref().map(|p| p.pid))
679            .collect();
680        assert_eq!(pids, vec![Some(3), None]);
681
682        // nothing left: the port counts as not found
683        let none = command(Some("/definitely/not/here/either"))
684            .filter_holders(4000, holders[..1].to_vec());
685        assert!(
686            matches!(&none, Err(ProcError::FilteredOut { target, .. }) if target == ":4000"),
687            "{none:?}"
688        );
689    }
690
691    #[test]
692    fn a_port_whose_process_ended_adds_nothing_to_the_json_results() {
693        assert!(port_hits(vec![(listener(), None)]).is_empty());
694        let hits = port_hits(vec![
695            (listener(), Some(without_cwd())),
696            (listener(), None),
697            (listener(), Some(without_cwd())),
698        ]);
699        assert_eq!(hits.len(), 2);
700    }
701
702    /// What a print function writes, with colours off
703    fn printed(print: impl FnOnce(&mut Vec<u8>) -> std::io::Result<()>) -> String {
704        colored::control::set_override(false);
705        let mut out = Vec::new();
706        print(&mut out).unwrap();
707        String::from_utf8(out).unwrap()
708    }
709
710    #[test]
711    fn a_port_without_a_process_still_prints() {
712        // Neither the process nor its details: the address shows as `*`
713        let mut cmd = command(None);
714        cmd.verbose = true;
715        let out = printed(|w| cmd.print_process_on_port(w, 4000, &[(listener(), None)]));
716        assert_eq!(
717            out,
718            "✓ Port 4000 is used by:\n\n  Process: x (PID 1)\n  Listening: TCP on *\n\n"
719        );
720    }
721
722    #[test]
723    fn a_port_with_several_holders_says_so_and_lists_each() {
724        let mut second = listener();
725        second.pid = 2;
726        second.address = Some("::1".to_string());
727        let out = printed(|w| {
728            command(None).print_process_on_port(w, 4000, &[(listener(), None), (second, None)])
729        });
730        assert_eq!(
731            out,
732            "✓ Port 4000 is used by 2 processes:\n\n\
733             \x20 Process: x (PID 1)\n  Listening: TCP on *\n\n\
734             \x20 Process: x (PID 2)\n  Listening: TCP on ::1\n\n"
735        );
736    }
737
738    #[test]
739    fn a_process_missing_its_details_prints_without_them() {
740        let mut cmd = command(None);
741        cmd.verbose = true;
742
743        let out =
744            printed(|w| cmd.print_process_on_port(w, 4000, &[(listener(), Some(without_cwd()))]));
745        assert!(out.contains("Listening: TCP on *\n"), "{out}");
746        assert!(out.contains("Resources: 0.0% CPU"), "{out}");
747        for label in ["Directory:", "Path:", "Uptime:", "Command:"] {
748            assert!(!out.contains(label), "{label} printed for nothing: {out}");
749        }
750
751        let out = printed(|w| cmd.print_ports_for_process(w, &without_cwd(), &[listener()]));
752        assert_eq!(
753            out,
754            "✓ x (PID 1) is listening on:\n\n  → :4000 (TCP on *)\n\n"
755        );
756    }
757
758    /// A process with every detail the printers can show
759    fn detailed() -> Process {
760        let mut p = without_cwd();
761        p.cwd = Some("/work/app".to_string());
762        p.exe_path = Some("/usr/bin/x".to_string());
763        p.command = Some("x --serve".to_string());
764        p.uptime_secs = Some(90);
765        p
766    }
767
768    #[test]
769    fn a_failed_write_ends_the_port_lookup() {
770        let mut cmd = command(None);
771        cmd.verbose = true;
772        let p = detailed();
773        let held = [(listener(), Some(p))];
774        assert_stops_at_a_failed_write(|w| cmd.print_process_on_port(w, 4000, &held));
775        assert_stops_at_a_failed_write(|w| {
776            cmd.print_process_on_port(w, 4000, &[(listener(), None)])
777        });
778    }
779
780    #[test]
781    fn a_failed_write_ends_the_process_lookup() {
782        let mut cmd = command(None);
783        cmd.verbose = true;
784        let p = detailed();
785        assert_stops_at_a_failed_write(|w| cmd.print_ports_for_process(w, &p, &[listener()]));
786        assert_stops_at_a_failed_write(|w| cmd.print_ports_for_process(w, &p, &[]));
787    }
788}