Skip to main content

proc_cli/commands/
watch.rs

1//! `proc watch` - Real-time process monitoring
2//!
3//! Examples:
4//!   proc watch                     # Watch all processes (alias: proc top)
5//!   proc watch node                # Watch the processes named node
6//!   proc watch node --cmd          # ... or with node anywhere in their command line
7//!   proc watch :3000               # Watch process on port 3000
8//!   proc watch :3000 :8080 node    # Watch several targets: separate arguments
9//!   proc watch --in . --by node    # Watch node processes in current directory
10//!   proc watch -n 1 --sort mem     # 1s refresh, sorted by memory
11//!   proc watch --json | head -2    # NDJSON output
12//!
13//! Commas still work (`proc watch :3000,:8080`) but are deprecated.
14
15use crate::core::process::{classify_sysinfo, self_and_ancestors};
16use crate::core::{
17    flatten_targets, parse_target, resolve_in_dir, sort_processes, Hit, NameMatch, Process,
18    ProcessStatus, SortKey, TargetType,
19};
20use crate::error::Result;
21use crate::ui::format::{
22    format_duration, format_memory, process_table_budget, truncate_path, truncate_string,
23};
24use crate::ui::{interrupt, out};
25use clap::{Args, ValueEnum};
26use comfy_table::presets::NOTHING;
27use comfy_table::{Attribute, Cell, CellAlignment, Color, ContentArrangement, Table};
28use crossterm::{
29    cursor,
30    event::{self, Event, KeyCode, KeyEvent, KeyModifiers},
31    execute, terminal,
32};
33use serde::Serialize;
34use std::collections::HashSet;
35use std::io::{IsTerminal, Write};
36use std::time::{Duration, Instant};
37use sysinfo::{Pid, System};
38
39/// A refresh interval must be a real number of seconds, and not so small that
40/// watch spins in a busy loop (0, NaN, infinity and negatives used to panic or spin)
41fn parse_interval(s: &str) -> std::result::Result<f64, String> {
42    let secs: f64 = s
43        .parse()
44        .map_err(|_| format!("'{s}' is not a number of seconds"))?;
45    if !secs.is_finite() || secs < 0.1 {
46        return Err(format!("must be at least 0.1 seconds, got '{s}'"));
47    }
48    Ok(secs)
49}
50
51/// Watch processes in real-time
52#[derive(Args, Debug, Clone)]
53pub struct WatchCommand {
54    /// Target(s): PID, :port, or name (separate arguments for several; omit for all processes)
55    #[arg(num_args = 0.., value_parser = crate::core::validate_target_arg)]
56    pub target: Vec<String>,
57
58    /// Refresh interval in seconds (at least 0.1)
59    #[arg(long = "interval", short = 'n', default_value = "2", value_parser = parse_interval)]
60    pub interval: f64,
61
62    /// Filter by directory; takes the next word as the directory unless it starts with '-'
63    /// (bare --in is the current directory, --in=DIR is explicit)
64    #[arg(long = "in", short = 'i', num_args = 0..=1, default_missing_value = ".")]
65    pub in_dir: Option<String>,
66
67    /// Filter by process name
68    #[arg(long = "by", short = 'b')]
69    pub by_name: Option<String>,
70
71    /// Match name targets and --by as a substring of the process name or full command line (like pkill -f)
72    #[arg(long)]
73    pub cmd: bool,
74
75    /// Only show processes using more than this CPU %
76    #[arg(long)]
77    pub min_cpu: Option<f32>,
78
79    /// Only show processes using more than this memory (MB)
80    #[arg(long)]
81    pub min_mem: Option<f64>,
82
83    /// Output as JSON (newline-delimited, one object per refresh)
84    #[arg(long, short = 'j')]
85    pub json: bool,
86
87    /// Show verbose output
88    #[arg(long, short = 'v')]
89    pub verbose: bool,
90
91    /// Print full directories and arguments instead of fitting the terminal width
92    #[arg(long, short = 'w')]
93    pub wide: bool,
94
95    /// Limit the number of results shown
96    #[arg(long, short = 'l')]
97    pub limit: Option<usize>,
98
99    /// Sort by: cpu, mem (biggest first); pid, name (ascending). Ties come by PID
100    #[arg(long, short = 's', value_enum, default_value_t = WatchSortKey::Cpu)]
101    pub sort: WatchSortKey,
102
103    /// `--in` as `execute` resolved it, absolute; every refresh uses this one
104    #[arg(skip)]
105    in_dir_path: Option<std::path::PathBuf>,
106}
107
108/// Sort key for watch command (mirrors SortKey but with independent default)
109#[derive(Debug, Clone, Copy, ValueEnum)]
110pub enum WatchSortKey {
111    /// Sort by CPU usage (descending)
112    Cpu,
113    /// Sort by memory usage (descending)
114    Mem,
115    /// Sort by process ID (ascending)
116    Pid,
117    /// Sort by process name (ascending)
118    Name,
119}
120
121impl From<WatchSortKey> for SortKey {
122    fn from(key: WatchSortKey) -> Self {
123        match key {
124            WatchSortKey::Cpu => SortKey::Cpu,
125            WatchSortKey::Mem => SortKey::Mem,
126            WatchSortKey::Pid => SortKey::Pid,
127            WatchSortKey::Name => SortKey::Name,
128        }
129    }
130}
131
132#[derive(Serialize)]
133struct WatchJsonOutput {
134    action: &'static str,
135    success: bool,
136    count: usize,
137    processes: Vec<Process>,
138}
139
140/// The PIDs of the processes listening on `port`, none if nothing does
141fn port_holders(port: u16) -> Vec<u32> {
142    crate::core::PortInfo::holders_of(port)
143        .unwrap_or_default()
144        .into_iter()
145        .map(|info| info.pid)
146        .collect()
147}
148
149/// Restore the terminal if watch panics, so the shell is not left in raw mode
150/// on the alternate screen, then run the hook that was there before. (A signal
151/// is handled by `interrupt`.) The codes are written whether or not an earlier
152/// write failed: the terminal may well still be there.
153fn install_panic_hook() {
154    let original_hook = std::panic::take_hook();
155    std::panic::set_hook(Box::new(move |panic_info| {
156        interrupt::restore_screen(&mut out::stdout_unrecorded());
157        original_hook(panic_info);
158    }));
159}
160
161/// Whether a key press ends the watch: q, Esc or Ctrl+C (raw mode turns Ctrl+C
162/// into a key instead of a signal)
163fn is_quit_key(code: KeyCode, modifiers: KeyModifiers) -> bool {
164    match code {
165        KeyCode::Char('q') | KeyCode::Esc => true,
166        KeyCode::Char('c') => modifiers.contains(KeyModifiers::CONTROL),
167        _ => false,
168    }
169}
170
171/// Draw one frame: clear the screen and write each line, which needs `\r\n`
172/// in raw mode
173fn draw(out: &mut impl Write, frame: &str) -> std::io::Result<()> {
174    execute!(out, cursor::MoveTo(0, 0))?;
175    execute!(out, terminal::Clear(terminal::ClearType::All))?;
176    execute!(out, cursor::MoveTo(0, 0))?;
177    for line in frame.lines() {
178        write!(out, "{}\r\n", line)?;
179    }
180    out.flush()
181}
182
183/// Write one NDJSON line and flush it. Returns false once the reader is gone
184/// (a broken pipe), which ends the stream; a frame that cannot be serialized is
185/// reported on `err` and skipped.
186fn write_json_frame(out: &mut impl Write, err: &mut impl Write, frame: &impl Serialize) -> bool {
187    match serde_json::to_string(frame) {
188        Ok(json) => writeln!(out, "{}", json).is_ok() && out.flush().is_ok(),
189        Err(e) => {
190            let _ = writeln!(err, "JSON serialization error: {}", e);
191            true
192        }
193    }
194}
195
196impl WatchCommand {
197    /// Execute the watch command — real-time process monitoring.
198    pub fn execute(&self) -> Result<()> {
199        // A missing --in directory is an error before the first frame, not a
200        // view of nothing that looks like an answer. A deleted directory that
201        // processes still run in is kept, so its leftovers can be watched.
202        let in_dir_path = resolve_in_dir(&self.in_dir)?;
203        crate::core::require_in_dir_live(&self.in_dir, &in_dir_path)?;
204        // Every refresh uses the directory as resolved now, so a relative --in
205        // never depends on the current directory still existing later
206        let resolved = WatchCommand {
207            in_dir_path,
208            ..self.clone()
209        };
210        resolved.run()
211    }
212
213    /// `execute` once `--in` is resolved: a JSON stream, the live view, or one snapshot
214    fn run(&self) -> Result<()> {
215        let is_tty = out::stdout_is_terminal();
216
217        if self.json {
218            self.run_json_loop()
219        } else if is_tty && std::io::stdin().is_terminal() {
220            self.run_tui_loop()
221        } else {
222            // Without a terminal on both ends there is no screen to draw on or
223            // key to quit with: one snapshot, like `list`. Say so when stdout
224            // is a terminal, where a person is waiting for a live view.
225            if is_tty {
226                crate::ui::print_note(
227                    "stdin is not a terminal, so there is no live view to quit with a key; \
228                     showing one snapshot (--json streams one line per refresh)",
229                );
230            }
231            self.run_snapshot()
232        }
233    }
234
235    /// Collect processes for one cycle using the persistent System instance
236    fn collect_processes(&self, sys: &System) -> Vec<Process> {
237        self.collect_with(sys, port_holders)
238    }
239
240    /// `collect_processes` with the port lookup passed in: the holders of a port
241    /// are read at a different moment than `sys`, so one can be a process `sys`
242    /// has not seen
243    fn collect_with(&self, sys: &System, port_holders: impl Fn(u16) -> Vec<u32>) -> Vec<Process> {
244        let self_pid = Pid::from_u32(std::process::id());
245        // a name never finds proc's ancestor shells, whose command lines hold
246        // proc's own arguments (`sh -c 'proc watch node'`)
247        let ancestors = self_and_ancestors(sys);
248        let in_dir_filter = &self.in_dir_path;
249        let mode = NameMatch::from_flag(self.cmd);
250
251        // Parse targets once
252        let targets: Vec<TargetType> = flatten_targets(&self.target)
253            .iter()
254            .map(|t| parse_target(t))
255            .collect();
256
257        let mut seen_pids = HashSet::new();
258        let mut processes = Vec::new();
259
260        if targets.is_empty() {
261            // No target: all processes
262            for (pid, proc) in sys.processes() {
263                if *pid == self_pid || crate::core::is_thread(proc) {
264                    continue;
265                }
266                if seen_pids.insert(pid.as_u32()) {
267                    processes.push(Process::from_sysinfo(*pid, proc));
268                }
269            }
270        } else {
271            for target in &targets {
272                match target {
273                    TargetType::Port(port) => {
274                        for owner in port_holders(*port) {
275                            let pid = Pid::from_u32(owner);
276                            if let Some(proc) = sys.process(pid) {
277                                if seen_pids.insert(owner) {
278                                    processes.push(Process::from_sysinfo(pid, proc));
279                                }
280                            }
281                        }
282                    }
283                    TargetType::Pid(pid) => {
284                        let sysinfo_pid = Pid::from_u32(*pid);
285                        if let Some(proc) = sys.process(sysinfo_pid) {
286                            if seen_pids.insert(*pid) {
287                                processes.push(Process::from_sysinfo(sysinfo_pid, proc));
288                            }
289                        }
290                    }
291                    TargetType::Name(name) => {
292                        for (pid, proc) in sys.processes() {
293                            if ancestors.contains(pid) || crate::core::is_thread(proc) {
294                                continue;
295                            }
296                            if classify_sysinfo(name, proc, mode) == Hit::Match
297                                && seen_pids.insert(pid.as_u32())
298                            {
299                                processes.push(Process::from_sysinfo(*pid, proc));
300                            }
301                        }
302                    }
303                }
304            }
305        }
306
307        // Apply --by filter
308        if let Some(ref by_name) = self.by_name {
309            processes.retain(|p| crate::core::matches_by_filter(p, by_name, mode));
310        }
311
312        // Apply --in filter
313        if let Some(ref dir_path) = in_dir_filter {
314            processes.retain(|p| crate::core::filters::matches_in_filter(p, dir_path));
315        }
316
317        // Apply --min-cpu filter
318        if let Some(min_cpu) = self.min_cpu {
319            processes.retain(|p| p.cpu_percent >= min_cpu);
320        }
321
322        // Apply --min-mem filter
323        if let Some(min_mem) = self.min_mem {
324            processes.retain(|p| p.memory_mb >= min_mem);
325        }
326
327        // Sort
328        sort_processes(&mut processes, self.sort.into());
329
330        // Apply limit
331        if let Some(limit) = self.limit {
332            processes.truncate(limit);
333        }
334
335        processes
336    }
337
338    /// TUI loop: alternate screen + raw mode
339    fn run_tui_loop(&self) -> Result<()> {
340        let mut stdout = out::stdout();
341
342        install_panic_hook();
343        // A signal (kill, closing the window) puts the screen back too; armed
344        // before the screen changes, and dropped once it is back
345        let _interrupts = interrupt::protect_screen();
346
347        // Enter alternate screen + raw mode
348        execute!(stdout, terminal::EnterAlternateScreen, cursor::Hide)
349            .map_err(|e| crate::error::ProcError::SystemError(e.to_string()))?;
350        terminal::enable_raw_mode()
351            .map_err(|e| crate::error::ProcError::SystemError(e.to_string()))?;
352
353        let mut sys = System::new_all();
354        let interval = Duration::from_secs_f64(self.interval);
355
356        // Initial refresh to get baseline CPU readings
357        sys.refresh_all();
358        std::thread::sleep(Duration::from_millis(250));
359
360        let result = self.tui_event_loop(&mut sys, &mut stdout, interval);
361
362        // Whether or not a write failed on the way: the terminal may be fine
363        interrupt::restore_screen(&mut out::stdout_unrecorded());
364
365        result
366    }
367
368    fn tui_event_loop(
369        &self,
370        sys: &mut System,
371        stdout: &mut impl Write,
372        interval: Duration,
373    ) -> Result<()> {
374        loop {
375            sys.refresh_all();
376            let processes = self.collect_processes(sys);
377
378            // Get terminal size
379            let (width, height) = terminal::size().unwrap_or((120, 40));
380
381            // Build frame
382            let frame = self.render_frame(&processes, width, height);
383
384            // A write that fails ends the watch. `out` has the policy for what
385            // that means: a reader that left changes nothing, and any other
386            // failure it recorded becomes the exit code and the message.
387            if draw(stdout, &frame).is_err() {
388                return Ok(());
389            }
390
391            // Wait for interval or keypress
392            let deadline = Instant::now() + interval;
393            loop {
394                let remaining = deadline.saturating_duration_since(Instant::now());
395                if remaining.is_zero() {
396                    break;
397                }
398
399                if event::poll(remaining)
400                    .map_err(|e| crate::error::ProcError::SystemError(e.to_string()))?
401                {
402                    if let Event::Key(KeyEvent {
403                        code, modifiers, ..
404                    }) = event::read()
405                        .map_err(|e| crate::error::ProcError::SystemError(e.to_string()))?
406                    {
407                        if is_quit_key(code, modifiers) {
408                            return Ok(());
409                        }
410                    }
411                }
412            }
413        }
414    }
415
416    /// Render a frame for the TUI
417    fn render_frame(&self, processes: &[Process], width: u16, height: u16) -> String {
418        let sort_label = match self.sort {
419            WatchSortKey::Cpu => "CPU",
420            WatchSortKey::Mem => "Memory",
421            WatchSortKey::Pid => "PID",
422            WatchSortKey::Name => "Name",
423        };
424
425        let target_label = if self.target.is_empty() {
426            "all".to_string()
427        } else {
428            self.target.join(" ")
429        };
430
431        // Header line
432        let header = format!(
433            " Watching {} | {} processes | Sort: {} | Refresh: {:.1}s | q to exit",
434            target_label,
435            processes.len(),
436            sort_label,
437            self.interval,
438        );
439
440        let mut output = String::new();
441        output.push_str(&header);
442        output.push('\n');
443        output.push('\n');
444
445        if processes.is_empty() {
446            output.push_str(" No matching processes found.");
447            return output;
448        }
449
450        // Cap rows to terminal height minus header (2 lines) + table header (1) + padding (1)
451        let max_rows = (height as usize).saturating_sub(5);
452
453        if self.verbose {
454            // Verbose mode: detailed per-process output
455            for (i, proc) in processes.iter().enumerate() {
456                if i >= max_rows {
457                    output.push_str(&format!(" ... and {} more", processes.len() - i));
458                    break;
459                }
460                let status_str = format!("{:?}", proc.status);
461                output.push_str(&format!(
462                    " {} {} [{}]  {:.1}% CPU  {}  {}",
463                    proc.pid,
464                    proc.name,
465                    status_str,
466                    proc.cpu_percent,
467                    format_memory(proc.memory_mb),
468                    proc.user.as_deref().unwrap_or("-")
469                ));
470                output.push('\n');
471                if let Some(ref cmd) = proc.command {
472                    output.push_str(&format!("    cmd: {}", cmd));
473                    output.push('\n');
474                }
475                if let Some(ref path) = proc.exe_path {
476                    output.push_str(&format!("    exe: {}", path));
477                    output.push('\n');
478                }
479                if let Some(ref cwd) = proc.cwd {
480                    output.push_str(&format!("    cwd: {}", cwd));
481                    output.push('\n');
482                }
483                if let Some(uptime) = proc.uptime_secs {
484                    output.push_str(&format!("    uptime: {}", format_duration(uptime)));
485                    output.push('\n');
486                }
487                output.push('\n');
488            }
489        } else {
490            // Table mode
491            // Same budget as the other process table, so the two cannot drift.
492            let budget = process_table_budget(width);
493            let show_uptime = budget.show_uptime;
494
495            let mut table = Table::new();
496            // Disabled never reflows a cell, so one process is always one line.
497            // Fitting the terminal is done by truncating content below, not by
498            // letting comfy-table wrap.
499            table
500                .load_preset(NOTHING)
501                .set_content_arrangement(ContentArrangement::Disabled);
502
503            let mut header = vec![
504                Cell::new("PID")
505                    .fg(Color::Blue)
506                    .add_attribute(Attribute::Bold),
507                Cell::new("DIR")
508                    .fg(Color::Blue)
509                    .add_attribute(Attribute::Bold),
510                Cell::new("NAME")
511                    .fg(Color::Blue)
512                    .add_attribute(Attribute::Bold),
513                Cell::new("ARGS")
514                    .fg(Color::Blue)
515                    .add_attribute(Attribute::Bold),
516                Cell::new("CPU%")
517                    .fg(Color::Blue)
518                    .add_attribute(Attribute::Bold)
519                    .set_alignment(CellAlignment::Right),
520                Cell::new("MEM")
521                    .fg(Color::Blue)
522                    .add_attribute(Attribute::Bold)
523                    .set_alignment(CellAlignment::Right),
524            ];
525            if show_uptime {
526                header.push(
527                    Cell::new("UPTIME")
528                        .fg(Color::Blue)
529                        .add_attribute(Attribute::Bold)
530                        .set_alignment(CellAlignment::Right),
531                );
532            }
533            header.push(
534                Cell::new("STATUS")
535                    .fg(Color::Blue)
536                    .add_attribute(Attribute::Bold)
537                    .set_alignment(CellAlignment::Right),
538            );
539            table.set_header(header);
540
541            if !self.wide {
542                use comfy_table::ColumnConstraint::*;
543                use comfy_table::Width::*;
544                table
545                    .column_mut(0)
546                    .expect("PID column")
547                    .set_constraint(Absolute(Fixed(budget.pid as u16)));
548                table
549                    .column_mut(4)
550                    .expect("CPU% column")
551                    .set_constraint(Absolute(Fixed(budget.cpu as u16)));
552                table
553                    .column_mut(5)
554                    .expect("MEM column")
555                    .set_constraint(Absolute(Fixed(budget.mem as u16)));
556                let status_column = if show_uptime {
557                    table
558                        .column_mut(6)
559                        .expect("UPTIME column")
560                        .set_constraint(Absolute(Fixed(budget.uptime as u16)));
561                    7
562                } else {
563                    6
564                };
565                table
566                    .column_mut(status_column)
567                    .expect("STATUS column")
568                    .set_constraint(Absolute(Fixed(budget.status as u16)));
569            }
570
571            let display_count = processes.len().min(max_rows);
572            for proc in processes.iter().take(display_count) {
573                let status_str = format!("{:?}", proc.status);
574
575                let cwd = proc.cwd.as_deref().unwrap_or("-");
576                let path_display = if self.wide {
577                    cwd.to_string()
578                } else {
579                    truncate_path(cwd, budget.dir)
580                };
581                let name_display = if self.wide {
582                    proc.name.clone()
583                } else {
584                    truncate_string(&proc.name, budget.name)
585                };
586
587                let cmd_display = proc
588                    .command
589                    .as_ref()
590                    .map(|c| {
591                        let parts: Vec<&str> = c.split_whitespace().collect();
592                        if parts.len() > 1 {
593                            let args: Vec<String> = parts[1..]
594                                .iter()
595                                .map(|arg| {
596                                    if arg.contains('/') && !arg.starts_with('-') {
597                                        std::path::Path::new(arg)
598                                            .file_name()
599                                            .map(|f| f.to_string_lossy().to_string())
600                                            .unwrap_or_else(|| arg.to_string())
601                                    } else {
602                                        arg.to_string()
603                                    }
604                                })
605                                .collect();
606                            let result = args.join(" ");
607                            if self.wide {
608                                result
609                            } else {
610                                truncate_string(&result, budget.args)
611                            }
612                        } else {
613                            "-".to_string()
614                        }
615                    })
616                    .unwrap_or_else(|| "-".to_string());
617
618                let mem_display = format_memory(proc.memory_mb);
619
620                let status_color = match proc.status {
621                    ProcessStatus::Running => Color::Green,
622                    ProcessStatus::Sleeping => Color::Blue,
623                    ProcessStatus::Stopped => Color::Yellow,
624                    ProcessStatus::Zombie => Color::Red,
625                    _ => Color::White,
626                };
627
628                let mut row = vec![
629                    Cell::new(proc.pid).fg(Color::Cyan),
630                    Cell::new(&path_display).fg(Color::DarkGrey),
631                    Cell::new(&name_display).fg(Color::White),
632                    Cell::new(&cmd_display).fg(Color::DarkGrey),
633                    Cell::new(format!("{:.1}", proc.cpu_percent))
634                        .set_alignment(CellAlignment::Right),
635                    Cell::new(&mem_display).set_alignment(CellAlignment::Right),
636                ];
637                if show_uptime {
638                    let uptime_display = proc
639                        .uptime_secs
640                        .map(format_duration)
641                        .unwrap_or_else(|| "-".to_string());
642                    row.push(Cell::new(uptime_display).set_alignment(CellAlignment::Right));
643                }
644                row.push(
645                    Cell::new(&status_str)
646                        .fg(status_color)
647                        .set_alignment(CellAlignment::Right),
648                );
649                table.add_row(row);
650            }
651
652            output.push_str(&table.to_string());
653
654            if processes.len() > display_count {
655                output.push('\n');
656                output.push_str(&format!(
657                    " ... and {} more (use --limit to control)",
658                    processes.len() - display_count
659                ));
660            }
661        }
662
663        output
664    }
665
666    /// JSON loop: NDJSON output, one line per refresh
667    fn run_json_loop(&self) -> Result<()> {
668        let mut sys = System::new_all();
669        let interval = Duration::from_secs_f64(self.interval);
670
671        // Initial refresh for baseline CPU
672        sys.refresh_all();
673        std::thread::sleep(Duration::from_millis(250));
674
675        loop {
676            sys.refresh_all();
677            let processes = self.collect_processes(&sys);
678
679            let output = WatchJsonOutput {
680                action: "watch",
681                success: true,
682                count: processes.len(),
683                processes,
684            };
685
686            if !write_json_frame(&mut out::stdout(), &mut out::stderr(), &output) {
687                return Ok(()); // Broken pipe — exit cleanly
688            }
689
690            // Not a plain sleep: a reader that leaves is noticed at once, not a
691            // whole interval later (see `out::reader_gone_within`)
692            if out::reader_gone_within(interval) {
693                return Ok(());
694            }
695        }
696    }
697
698    /// Single snapshot (non-TTY mode)
699    fn run_snapshot(&self) -> Result<()> {
700        let mut sys = System::new_all();
701        sys.refresh_all();
702        std::thread::sleep(Duration::from_millis(250));
703        sys.refresh_all();
704
705        let processes = self.collect_processes(&sys);
706
707        let printer = crate::ui::Printer::from_flags(false, self.verbose);
708        printer.print_processes(&processes);
709
710        Ok(())
711    }
712}
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717    use crate::test_support::FailsAfter;
718    use serde::ser::Error as _;
719    use std::io;
720    use std::sync::atomic::{AtomicBool, Ordering};
721    use std::sync::Arc;
722
723    fn watch_args() -> WatchCommand {
724        WatchCommand {
725            target: Vec::new(),
726            interval: 2.0,
727            in_dir: None,
728            by_name: None,
729            cmd: false,
730            min_cpu: None,
731            min_mem: None,
732            json: false,
733            verbose: false,
734            wide: false,
735            limit: None,
736            sort: WatchSortKey::Cpu,
737            in_dir_path: None,
738        }
739    }
740
741    fn process(pid: u32, name: &str) -> Process {
742        Process {
743            pid,
744            name: name.to_string(),
745            exe_path: None,
746            cwd: None,
747            command: None,
748            argv0: None,
749            raw: None,
750            cpu_percent: 1.5,
751            memory_mb: 12.0,
752            status: ProcessStatus::Sleeping,
753            user: None,
754            parent_pid: None,
755            start_time: None,
756            uptime_secs: None,
757        }
758    }
759
760    /// The text of a frame without colour codes, which comfy-table adds only
761    /// when stdout is a terminal
762    fn plain(frame: &str) -> String {
763        let mut out = String::new();
764        let mut chars = frame.chars();
765        while let Some(c) = chars.next() {
766            if c == '\x1b' {
767                for end in chars.by_ref() {
768                    if end.is_ascii_alphabetic() {
769                        break;
770                    }
771                }
772            } else {
773                out.push(c);
774            }
775        }
776        out
777    }
778
779    #[test]
780    fn colour_codes_are_stripped_from_a_frame() {
781        assert_eq!(
782            plain("\x1b[1;36m4242\x1b[0m  \x1b[32mRunning\x1b[0m"),
783            "4242  Running"
784        );
785    }
786
787    fn lines_with<'a>(frame: &'a str, needle: &str) -> Vec<&'a str> {
788        frame.lines().filter(|l| l.contains(needle)).collect()
789    }
790
791    // ---- keys ----
792
793    #[test]
794    fn q_escape_and_ctrl_c_quit() {
795        assert!(is_quit_key(KeyCode::Char('q'), KeyModifiers::NONE));
796        assert!(is_quit_key(KeyCode::Esc, KeyModifiers::NONE));
797        assert!(is_quit_key(KeyCode::Char('c'), KeyModifiers::CONTROL));
798    }
799
800    #[test]
801    fn other_keys_do_not_quit() {
802        assert!(!is_quit_key(KeyCode::Char('c'), KeyModifiers::NONE));
803        assert!(!is_quit_key(KeyCode::Char('x'), KeyModifiers::NONE));
804        assert!(!is_quit_key(KeyCode::Enter, KeyModifiers::CONTROL));
805        assert!(!is_quit_key(KeyCode::Char('x'), KeyModifiers::CONTROL));
806    }
807
808    // ---- the terminal ----
809
810    #[test]
811    fn a_frame_is_drawn_from_the_top_with_raw_mode_line_ends() {
812        let mut out = Vec::new();
813        draw(&mut out, "one\ntwo").unwrap();
814        let text = String::from_utf8(out).unwrap();
815        assert!(text.starts_with("\x1b[1;1H\x1b[2J\x1b[1;1H"), "{text:?}");
816        assert!(text.ends_with("one\r\ntwo\r\n"), "{text:?}");
817    }
818
819    #[test]
820    fn a_frame_stops_at_the_first_failed_write() {
821        let frame = "one\ntwo";
822        let mut all = FailsAfter {
823            left: usize::MAX,
824            written: Vec::new(),
825        };
826        draw(&mut all, frame).unwrap();
827        for limit in 0..all.written.len() {
828            let mut out = FailsAfter {
829                left: limit,
830                written: Vec::new(),
831            };
832            let err = draw(&mut out, frame).expect_err(&format!("limit {limit}"));
833            assert_eq!(err.to_string(), "disk full", "limit {limit}");
834            assert_eq!(out.written, all.written[..limit], "limit {limit}");
835        }
836    }
837
838    // Unix only: on Windows crossterm may draw through the console instead of the writer
839    #[cfg(unix)]
840    #[test]
841    fn a_screen_that_cannot_be_written_ends_the_watch_without_an_error() {
842        let mut gone = FailsAfter {
843            left: 0,
844            written: Vec::new(),
845        };
846        let ended = watch_args().tui_event_loop(&mut System::new(), &mut gone, Duration::MAX);
847        assert!(ended.is_ok());
848        assert!(gone.written.is_empty());
849    }
850
851    #[test]
852    fn a_panic_runs_the_hook_that_was_there_before() {
853        let before = std::panic::take_hook();
854        let ran = Arc::new(AtomicBool::new(false));
855        let flag = Arc::clone(&ran);
856        std::panic::set_hook(Box::new(move |_| flag.store(true, Ordering::SeqCst)));
857
858        install_panic_hook();
859        let caught = std::panic::catch_unwind(|| panic!("watch crashed"));
860        // Drop ours, put back what the test run had
861        let _ = std::panic::take_hook();
862        std::panic::set_hook(before);
863
864        assert!(caught.is_err());
865        assert!(
866            ran.load(Ordering::SeqCst),
867            "the earlier hook runs after the terminal is restored"
868        );
869    }
870
871    // ---- the stream ----
872
873    struct FlushFails;
874
875    impl Write for FlushFails {
876        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
877            Ok(buf.len())
878        }
879        fn flush(&mut self) -> io::Result<()> {
880            Err(io::Error::from(io::ErrorKind::BrokenPipe))
881        }
882    }
883
884    struct CannotSerialize;
885
886    impl Serialize for CannotSerialize {
887        fn serialize<S: serde::Serializer>(&self, _: S) -> std::result::Result<S::Ok, S::Error> {
888            Err(S::Error::custom("no frame today"))
889        }
890    }
891
892    fn frame_of(processes: Vec<Process>) -> WatchJsonOutput {
893        WatchJsonOutput {
894            action: "watch",
895            success: true,
896            count: processes.len(),
897            processes,
898        }
899    }
900
901    #[test]
902    fn a_frame_is_one_json_line() {
903        let mut out = Vec::new();
904        let mut err = Vec::new();
905        assert!(write_json_frame(
906            &mut out,
907            &mut err,
908            &frame_of(vec![process(7, "sh")])
909        ));
910        let text = String::from_utf8(out).unwrap();
911        assert!(text.ends_with('\n'));
912        assert_eq!(text.matches('\n').count(), 1);
913        let json: serde_json::Value = serde_json::from_str(&text).unwrap();
914        assert_eq!(json["action"], "watch");
915        assert_eq!(json["count"], 1);
916        assert_eq!(json["processes"][0]["pid"], 7);
917        assert!(err.is_empty());
918    }
919
920    #[test]
921    fn a_closed_reader_ends_the_stream() {
922        let mut err = Vec::new();
923        let mut closed = FailsAfter {
924            left: 0,
925            written: Vec::new(),
926        };
927        assert!(!write_json_frame(&mut closed, &mut err, &frame_of(vec![])));
928        assert!(err.is_empty());
929    }
930
931    #[test]
932    fn a_reader_that_goes_away_during_the_flush_ends_the_stream() {
933        let mut err = Vec::new();
934        assert!(!write_json_frame(
935            &mut FlushFails,
936            &mut err,
937            &frame_of(vec![])
938        ));
939        assert!(err.is_empty());
940    }
941
942    #[test]
943    fn a_frame_that_cannot_be_serialized_is_reported_and_skipped() {
944        let mut out = Vec::new();
945        let mut err = Vec::new();
946        assert!(
947            write_json_frame(&mut out, &mut err, &CannotSerialize),
948            "the stream goes on"
949        );
950        assert!(out.is_empty());
951        assert_eq!(
952            String::from_utf8(err).unwrap(),
953            "JSON serialization error: no frame today\n"
954        );
955    }
956
957    // ---- the sort keys ----
958
959    #[test]
960    fn each_sort_key_maps_to_the_shared_one() {
961        assert!(matches!(SortKey::from(WatchSortKey::Cpu), SortKey::Cpu));
962        assert!(matches!(SortKey::from(WatchSortKey::Mem), SortKey::Mem));
963        assert!(matches!(SortKey::from(WatchSortKey::Pid), SortKey::Pid));
964        assert!(matches!(SortKey::from(WatchSortKey::Name), SortKey::Name));
965    }
966
967    // ---- the frame ----
968
969    #[test]
970    fn the_header_names_the_target_sort_and_refresh() {
971        let mut cmd = watch_args();
972        cmd.target = vec![":3000".into()];
973        cmd.interval = 0.5;
974        for (sort, label) in [
975            (WatchSortKey::Cpu, "Sort: CPU"),
976            (WatchSortKey::Mem, "Sort: Memory"),
977            (WatchSortKey::Pid, "Sort: PID"),
978            (WatchSortKey::Name, "Sort: Name"),
979        ] {
980            cmd.sort = sort;
981            let frame = cmd.render_frame(&[process(1, "a"), process(2, "b")], 120, 40);
982            let first = frame.lines().next().unwrap();
983            assert_eq!(
984                first,
985                format!(" Watching :3000 | 2 processes | {label} | Refresh: 0.5s | q to exit")
986            );
987        }
988    }
989
990    #[test]
991    fn the_header_names_several_targets_as_typed_joined_by_spaces() {
992        let mut cmd = watch_args();
993        cmd.target = vec![":3000".into(), "node,python".into()];
994        let frame = cmd.render_frame(&[process(1, "a")], 120, 40);
995        assert!(
996            frame.starts_with(" Watching :3000 node,python | 1 processes |"),
997            "{frame}"
998        );
999    }
1000
1001    #[test]
1002    fn with_no_target_the_header_says_all() {
1003        let frame = watch_args().render_frame(&[process(1, "a")], 120, 40);
1004        assert!(
1005            frame.starts_with(" Watching all | 1 processes |"),
1006            "{frame}"
1007        );
1008    }
1009
1010    #[test]
1011    fn no_processes_says_so() {
1012        let frame = watch_args().render_frame(&[], 120, 40);
1013        assert_eq!(
1014            frame.lines().last().unwrap(),
1015            " No matching processes found."
1016        );
1017        assert!(frame.contains("0 processes"));
1018    }
1019
1020    #[test]
1021    fn the_table_lists_each_process_with_its_columns() {
1022        let mut p = process(4242, "server");
1023        p.cwd = Some("/srv/app".into());
1024        p.command = Some("/usr/bin/server --port 80".into());
1025        p.uptime_secs = Some(3725);
1026        p.cpu_percent = 12.34;
1027        p.memory_mb = 256.0;
1028        let frame = plain(&watch_args().render_frame(&[p], 120, 40));
1029        let header = lines_with(&frame, "PID")[0];
1030        for column in [
1031            "PID", "DIR", "NAME", "ARGS", "CPU%", "MEM", "UPTIME", "STATUS",
1032        ] {
1033            assert!(header.contains(column), "{column} in {header:?}");
1034        }
1035        let row = lines_with(&frame, "4242")[0];
1036        for cell in [
1037            "/srv/app",
1038            "server",
1039            "--port 80",
1040            "12.3",
1041            "256.0MB",
1042            "1h 2m",
1043            "Sleeping",
1044        ] {
1045            assert!(row.contains(cell), "{cell} in {row:?}");
1046        }
1047    }
1048
1049    #[test]
1050    fn a_narrow_terminal_drops_the_uptime_column() {
1051        let mut p = process(4242, "server");
1052        p.uptime_secs = Some(3725);
1053        let narrow = plain(&watch_args().render_frame(&[p.clone()], 99, 40));
1054        assert!(!narrow.contains("UPTIME"), "{narrow}");
1055        assert!(!narrow.contains("1h 2m"), "{narrow}");
1056        let wide = plain(&watch_args().render_frame(&[p], 100, 40));
1057        assert!(wide.contains("UPTIME"), "{wide}");
1058        assert!(wide.contains("1h 2m"), "{wide}");
1059    }
1060
1061    #[test]
1062    fn an_unknown_uptime_is_a_dash() {
1063        let frame = plain(&watch_args().render_frame(&[process(4242, "server")], 120, 40));
1064        let row = lines_with(&frame, "4242")[0];
1065        // directory, args and uptime are all unknown
1066        assert_eq!(
1067            row.split_whitespace().collect::<Vec<_>>(),
1068            ["4242", "-", "server", "-", "1.5", "12.0MB", "-", "Sleeping"]
1069        );
1070    }
1071
1072    #[test]
1073    fn arguments_show_file_names_not_paths() {
1074        let mut p = process(4242, "node");
1075        p.command = Some("/usr/bin/node /srv/app/server.js --flag ./cfg/x.json plain".into());
1076        let mut cmd = watch_args();
1077        cmd.wide = true;
1078        let frame = plain(&cmd.render_frame(&[p], 120, 40));
1079        let row = lines_with(&frame, "4242")[0];
1080        assert!(row.contains("server.js --flag x.json plain"), "{row:?}");
1081        assert!(!row.contains("/srv/app"), "{row:?}");
1082    }
1083
1084    #[test]
1085    fn a_command_with_no_arguments_has_a_dash_for_args() {
1086        let mut p = process(4242, "tool");
1087        p.command = Some("/usr/bin/tool".into());
1088        let frame = plain(&watch_args().render_frame(&[p], 120, 40));
1089        let row = lines_with(&frame, "4242")[0];
1090        let after_name = row.split("tool").nth(1).unwrap();
1091        assert!(after_name.trim_start().starts_with('-'), "{row:?}");
1092    }
1093
1094    #[test]
1095    fn long_text_is_cut_to_the_terminal_unless_wide() {
1096        let long_dir = format!("/{}/deep", "d".repeat(80));
1097        let long_name = "n".repeat(40);
1098        let long_arg = "a".repeat(80);
1099        let mut p = process(4242, &long_name);
1100        p.cwd = Some(long_dir.clone());
1101        p.command = Some(format!("tool {long_arg}"));
1102
1103        let cut = plain(&watch_args().render_frame(&[p.clone()], 80, 40));
1104        let row = lines_with(&cut, "4242")[0];
1105        assert!(row.contains('…'), "{row:?}");
1106        assert!(!row.contains(&long_name), "{row:?}");
1107        assert!(!row.contains(&long_arg), "{row:?}");
1108
1109        let mut wide = watch_args();
1110        wide.wide = true;
1111        let full = plain(&wide.render_frame(&[p], 80, 40));
1112        let row = lines_with(&full, "4242")[0];
1113        assert!(row.contains(&long_dir), "{row:?}");
1114        assert!(row.contains(&long_name), "{row:?}");
1115        assert!(row.contains(&long_arg), "{row:?}");
1116        assert!(!row.contains('…'), "{row:?}");
1117    }
1118
1119    #[test]
1120    fn every_status_is_spelled_out() {
1121        for (status, word) in [
1122            (ProcessStatus::Running, "Running"),
1123            (ProcessStatus::Sleeping, "Sleeping"),
1124            (ProcessStatus::Stopped, "Stopped"),
1125            (ProcessStatus::Zombie, "Zombie"),
1126            (ProcessStatus::Dead, "Dead"),
1127            (ProcessStatus::Unknown, "Unknown"),
1128        ] {
1129            let mut p = process(4242, "x");
1130            p.status = status;
1131            let frame = plain(&watch_args().render_frame(&[p], 120, 40));
1132            assert!(lines_with(&frame, "4242")[0].contains(word), "{word}");
1133        }
1134    }
1135
1136    #[test]
1137    fn rows_beyond_the_screen_are_counted_not_drawn() {
1138        let processes: Vec<Process> = (1..=10).map(|i| process(1000 + i, "p")).collect();
1139        // 12 rows leave 7 for processes
1140        let frame = plain(&watch_args().render_frame(&processes, 120, 12));
1141        assert_eq!(lines_with(&frame, " p ").len(), 7, "{frame}");
1142        assert_eq!(
1143            frame.lines().last().unwrap(),
1144            " ... and 3 more (use --limit to control)"
1145        );
1146        let all = plain(&watch_args().render_frame(&processes, 120, 40));
1147        assert!(!all.contains("more"), "{all}");
1148    }
1149
1150    #[test]
1151    fn verbose_lists_every_field_of_each_process() {
1152        let mut p = process(4242, "server");
1153        p.status = ProcessStatus::Running;
1154        p.user = Some("zee".into());
1155        p.command = Some("/usr/bin/server --port 80".into());
1156        p.exe_path = Some("/usr/bin/server".into());
1157        p.cwd = Some("/srv/app".into());
1158        p.uptime_secs = Some(65);
1159        let mut cmd = watch_args();
1160        cmd.verbose = true;
1161        let frame = cmd.render_frame(&[p], 120, 40);
1162        let lines: Vec<&str> = frame.lines().collect();
1163        assert_eq!(
1164            lines[2..],
1165            [
1166                " 4242 server [Running]  1.5% CPU  12.0MB  zee",
1167                "    cmd: /usr/bin/server --port 80",
1168                "    exe: /usr/bin/server",
1169                "    cwd: /srv/app",
1170                "    uptime: 1m 5s",
1171                "",
1172            ]
1173        );
1174    }
1175
1176    #[test]
1177    fn verbose_leaves_out_what_a_process_does_not_have() {
1178        let mut cmd = watch_args();
1179        cmd.verbose = true;
1180        let frame = cmd.render_frame(&[process(4242, "bare")], 120, 40);
1181        let lines: Vec<&str> = frame.lines().collect();
1182        assert_eq!(
1183            lines[2..],
1184            [" 4242 bare [Sleeping]  1.5% CPU  12.0MB  -", ""]
1185        );
1186    }
1187
1188    #[test]
1189    fn verbose_counts_the_processes_that_do_not_fit() {
1190        let processes: Vec<Process> = (1..=5).map(|i| process(1000 + i, "p")).collect();
1191        let mut cmd = watch_args();
1192        cmd.verbose = true;
1193        // 7 rows leave 2 for processes
1194        let frame = cmd.render_frame(&processes, 120, 7);
1195        assert_eq!(lines_with(&frame, "[Sleeping]").len(), 2, "{frame}");
1196        assert!(frame.ends_with(" ... and 3 more"), "{frame:?}");
1197    }
1198
1199    // ---- what is collected ----
1200
1201    #[test]
1202    fn collecting_applies_the_sort_and_the_limit() {
1203        let mut sys = System::new_all();
1204        sys.refresh_all();
1205        let mut cmd = watch_args();
1206        cmd.sort = WatchSortKey::Pid;
1207        let all = cmd.collect_processes(&sys);
1208        assert!(all.len() > 1);
1209        assert!(all.windows(2).all(|w| w[0].pid <= w[1].pid));
1210        assert!(all.iter().all(|p| p.pid != std::process::id()));
1211
1212        cmd.limit = Some(1);
1213        let first = cmd.collect_processes(&sys);
1214        assert_eq!(first.len(), 1);
1215        assert_eq!(first[0].pid, all[0].pid);
1216    }
1217
1218    #[test]
1219    fn a_port_holder_the_snapshot_has_not_seen_is_left_out() {
1220        let mut sys = System::new_all();
1221        sys.refresh_all();
1222        let mut cmd = watch_args();
1223        cmd.target = vec![":8080".into()];
1224        // This test's own process is in the snapshot; PID 4294967294 is not
1225        let me = std::process::id();
1226        let known = cmd.collect_with(&sys, |port| if port == 8080 { vec![me] } else { vec![] });
1227        assert_eq!(known.iter().map(|p| p.pid).collect::<Vec<_>>(), [me]);
1228        let unseen = cmd.collect_with(&sys, |_| vec![u32::MAX - 1]);
1229        assert!(unseen.is_empty(), "{unseen:?}");
1230        let nobody = cmd.collect_with(&sys, |_| Vec::new());
1231        assert!(nobody.is_empty(), "{nobody:?}");
1232    }
1233
1234    #[test]
1235    fn a_name_never_finds_the_processes_that_ran_watch() {
1236        let mut sys = System::new_all();
1237        sys.refresh_all();
1238        let me = Pid::from_u32(std::process::id());
1239        let parent = sys.process(me).and_then(|p| p.parent()).expect("a parent");
1240        let parent_name = sys
1241            .process(parent)
1242            .unwrap()
1243            .name()
1244            .to_string_lossy()
1245            .into_owned();
1246
1247        let mut cmd = watch_args();
1248        cmd.target = vec![parent_name];
1249        let found = cmd.collect_processes(&sys);
1250        assert!(
1251            found.iter().all(|p| p.pid != parent.as_u32()),
1252            "listed the parent: {found:?}"
1253        );
1254    }
1255
1256    #[test]
1257    fn every_holder_of_a_port_is_watched_once() {
1258        let mut sys = System::new_all();
1259        sys.refresh_all();
1260        let mut cmd = watch_args();
1261        cmd.target = vec![":8080".into(), std::process::id().to_string()];
1262        let me = std::process::id();
1263        // the parent is in the snapshot too; this process is named twice (as a
1264        // holder of the port and by PID) and listed once
1265        let parent = sys
1266            .process(Pid::from_u32(me))
1267            .and_then(|p| p.parent())
1268            .expect("a parent")
1269            .as_u32();
1270        let found = cmd.collect_with(&sys, |_| vec![me, parent]);
1271        let mut pids: Vec<u32> = found.iter().map(|p| p.pid).collect();
1272        pids.sort_unstable();
1273        let mut expected = vec![me, parent];
1274        expected.sort_unstable();
1275        assert_eq!(pids, expected);
1276    }
1277
1278    #[test]
1279    fn the_holders_of_a_port_are_the_processes_listening_on_it() {
1280        let me = std::process::id();
1281        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1282        let port = listener.local_addr().unwrap().port();
1283        // Not exactly [me]: macOS has no SOCK_CLOEXEC, so a child another test
1284        // spawns between the socket's creation and its close-on-exec flag
1285        // inherits it and holds the port too (seen in CI: two extra holders)
1286        assert!(port_holders(port).contains(&me));
1287        drop(listener);
1288        assert!(!port_holders(port).contains(&me));
1289    }
1290}