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