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