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 node processes
6//!   proc watch :3000               # Watch process on port 3000
7//!   proc watch --in . --by node    # Watch node processes in current directory
8//!   proc watch -n 1 --sort mem     # 1s refresh, sorted by memory
9//!   proc watch --json | head -2    # NDJSON output
10
11use crate::core::{
12    parse_target, resolve_in_dir, sort_processes, Process, ProcessStatus, SortKey, TargetType,
13};
14use crate::error::Result;
15use crate::ui::format::{format_memory, truncate_string};
16use clap::{Args, ValueEnum};
17use comfy_table::presets::NOTHING;
18use comfy_table::{Attribute, Cell, CellAlignment, Color, ContentArrangement, Table};
19use crossterm::{
20    cursor,
21    event::{self, Event, KeyCode, KeyEvent, KeyModifiers},
22    execute, terminal,
23};
24use serde::Serialize;
25use std::collections::HashSet;
26use std::io::{self, IsTerminal, Write};
27use std::path::PathBuf;
28use std::time::{Duration, Instant};
29use sysinfo::{Pid, System};
30
31/// Watch processes in real-time
32#[derive(Args, Debug)]
33pub struct WatchCommand {
34    /// Target(s): PID, :port, or name (optional — omit for all processes)
35    pub target: Option<String>,
36
37    /// Refresh interval in seconds
38    #[arg(long = "interval", short = 'n', default_value = "2")]
39    pub interval: f64,
40
41    /// Filter by directory
42    #[arg(long = "in", short = 'i', num_args = 0..=1, default_missing_value = ".")]
43    pub in_dir: Option<String>,
44
45    /// Filter by process name
46    #[arg(long = "by", short = 'b')]
47    pub by_name: Option<String>,
48
49    /// Only show processes using more than this CPU %
50    #[arg(long)]
51    pub min_cpu: Option<f32>,
52
53    /// Only show processes using more than this memory (MB)
54    #[arg(long)]
55    pub min_mem: Option<f64>,
56
57    /// Output as JSON (newline-delimited, one object per refresh)
58    #[arg(long, short = 'j')]
59    pub json: bool,
60
61    /// Show verbose output
62    #[arg(long, short = 'v')]
63    pub verbose: bool,
64
65    /// Limit the number of results shown
66    #[arg(long, short = 'l')]
67    pub limit: Option<usize>,
68
69    /// Sort by: cpu, mem, pid, name
70    #[arg(long, short = 's', value_enum, default_value_t = WatchSortKey::Cpu)]
71    pub sort: WatchSortKey,
72}
73
74/// Sort key for watch command (mirrors SortKey but with independent default)
75#[derive(Debug, Clone, Copy, ValueEnum)]
76pub enum WatchSortKey {
77    /// Sort by CPU usage (descending)
78    Cpu,
79    /// Sort by memory usage (descending)
80    Mem,
81    /// Sort by process ID (ascending)
82    Pid,
83    /// Sort by process name (ascending)
84    Name,
85}
86
87impl From<WatchSortKey> for SortKey {
88    fn from(key: WatchSortKey) -> Self {
89        match key {
90            WatchSortKey::Cpu => SortKey::Cpu,
91            WatchSortKey::Mem => SortKey::Mem,
92            WatchSortKey::Pid => SortKey::Pid,
93            WatchSortKey::Name => SortKey::Name,
94        }
95    }
96}
97
98#[derive(Serialize)]
99struct WatchJsonOutput {
100    action: &'static str,
101    count: usize,
102    processes: Vec<Process>,
103}
104
105impl WatchCommand {
106    /// Execute the watch command — real-time process monitoring.
107    pub fn execute(&self) -> Result<()> {
108        let is_tty = io::stdout().is_terminal();
109
110        if self.json {
111            self.run_json_loop()
112        } else if is_tty {
113            self.run_tui_loop()
114        } else {
115            // Non-TTY: single snapshot like `list`
116            self.run_snapshot()
117        }
118    }
119
120    /// Collect processes for one cycle using the persistent System instance
121    fn collect_processes(&self, sys: &System) -> Vec<Process> {
122        let self_pid = Pid::from_u32(std::process::id());
123        let in_dir_filter = resolve_in_dir(&self.in_dir);
124
125        // Parse targets once
126        let targets: Vec<TargetType> = self
127            .target
128            .as_ref()
129            .map(|t| {
130                t.split(',')
131                    .map(|s| s.trim())
132                    .filter(|s| !s.is_empty())
133                    .map(parse_target)
134                    .collect()
135            })
136            .unwrap_or_default();
137
138        let mut seen_pids = HashSet::new();
139        let mut processes = Vec::new();
140
141        if targets.is_empty() {
142            // No target: all processes
143            for (pid, proc) in sys.processes() {
144                if *pid == self_pid {
145                    continue;
146                }
147                if seen_pids.insert(pid.as_u32()) {
148                    processes.push(Process::from_sysinfo(*pid, proc));
149                }
150            }
151        } else {
152            for target in &targets {
153                match target {
154                    TargetType::Port(port) => {
155                        if let Ok(Some(port_info)) = crate::core::PortInfo::find_by_port(*port) {
156                            let pid = Pid::from_u32(port_info.pid);
157                            if let Some(proc) = sys.process(pid) {
158                                if seen_pids.insert(port_info.pid) {
159                                    processes.push(Process::from_sysinfo(pid, proc));
160                                }
161                            }
162                        }
163                    }
164                    TargetType::Pid(pid) => {
165                        let sysinfo_pid = Pid::from_u32(*pid);
166                        if let Some(proc) = sys.process(sysinfo_pid) {
167                            if seen_pids.insert(*pid) {
168                                processes.push(Process::from_sysinfo(sysinfo_pid, proc));
169                            }
170                        }
171                    }
172                    TargetType::Name(name) => {
173                        let pattern_lower = name.to_lowercase();
174                        for (pid, proc) in sys.processes() {
175                            if *pid == self_pid {
176                                continue;
177                            }
178                            let proc_name = proc.name().to_string_lossy().to_string();
179                            let cmd: String = proc
180                                .cmd()
181                                .iter()
182                                .map(|s| s.to_string_lossy())
183                                .collect::<Vec<_>>()
184                                .join(" ");
185
186                            if (proc_name.to_lowercase().contains(&pattern_lower)
187                                || cmd.to_lowercase().contains(&pattern_lower))
188                                && seen_pids.insert(pid.as_u32())
189                            {
190                                processes.push(Process::from_sysinfo(*pid, proc));
191                            }
192                        }
193                    }
194                }
195            }
196        }
197
198        // Apply --by filter
199        if let Some(ref by_name) = self.by_name {
200            let pattern = by_name.to_lowercase();
201            processes.retain(|p| {
202                p.name.to_lowercase().contains(&pattern)
203                    || p.command
204                        .as_ref()
205                        .map(|c| c.to_lowercase().contains(&pattern))
206                        .unwrap_or(false)
207            });
208        }
209
210        // Apply --in filter
211        if let Some(ref dir_path) = in_dir_filter {
212            processes.retain(|p| {
213                if let Some(ref proc_cwd) = p.cwd {
214                    PathBuf::from(proc_cwd).starts_with(dir_path)
215                } else {
216                    false
217                }
218            });
219        }
220
221        // Apply --min-cpu filter
222        if let Some(min_cpu) = self.min_cpu {
223            processes.retain(|p| p.cpu_percent >= min_cpu);
224        }
225
226        // Apply --min-mem filter
227        if let Some(min_mem) = self.min_mem {
228            processes.retain(|p| p.memory_mb >= min_mem);
229        }
230
231        // Sort
232        sort_processes(&mut processes, self.sort.into());
233
234        // Apply limit
235        if let Some(limit) = self.limit {
236            processes.truncate(limit);
237        }
238
239        processes
240    }
241
242    /// TUI loop: alternate screen + raw mode
243    fn run_tui_loop(&self) -> Result<()> {
244        let mut stdout = io::stdout();
245
246        // Install panic hook to restore terminal on crash
247        let original_hook = std::panic::take_hook();
248        std::panic::set_hook(Box::new(move |panic_info| {
249            let _ = terminal::disable_raw_mode();
250            let _ = execute!(io::stdout(), cursor::Show, terminal::LeaveAlternateScreen);
251            original_hook(panic_info);
252        }));
253
254        // Enter alternate screen + raw mode
255        execute!(stdout, terminal::EnterAlternateScreen, cursor::Hide)
256            .map_err(|e| crate::error::ProcError::SystemError(e.to_string()))?;
257        terminal::enable_raw_mode()
258            .map_err(|e| crate::error::ProcError::SystemError(e.to_string()))?;
259
260        let mut sys = System::new_all();
261        let interval = Duration::from_secs_f64(self.interval);
262
263        // Initial refresh to get baseline CPU readings
264        sys.refresh_all();
265        std::thread::sleep(Duration::from_millis(250));
266
267        let result = self.tui_event_loop(&mut sys, &mut stdout, interval);
268
269        // Restore terminal
270        let _ = terminal::disable_raw_mode();
271        let _ = execute!(stdout, cursor::Show, terminal::LeaveAlternateScreen);
272
273        result
274    }
275
276    fn tui_event_loop(
277        &self,
278        sys: &mut System,
279        stdout: &mut io::Stdout,
280        interval: Duration,
281    ) -> Result<()> {
282        loop {
283            sys.refresh_all();
284            let processes = self.collect_processes(sys);
285
286            // Get terminal size
287            let (width, height) = terminal::size().unwrap_or((120, 40));
288
289            // Build frame
290            let frame = self.render_frame(&processes, width, height);
291
292            // Write frame with \r\n line endings (required in raw mode)
293            execute!(stdout, cursor::MoveTo(0, 0))
294                .map_err(|e| crate::error::ProcError::SystemError(e.to_string()))?;
295            // Clear screen
296            execute!(stdout, terminal::Clear(terminal::ClearType::All))
297                .map_err(|e| crate::error::ProcError::SystemError(e.to_string()))?;
298            execute!(stdout, cursor::MoveTo(0, 0))
299                .map_err(|e| crate::error::ProcError::SystemError(e.to_string()))?;
300
301            for line in frame.lines() {
302                write!(stdout, "{}\r\n", line)
303                    .map_err(|e| crate::error::ProcError::SystemError(e.to_string()))?;
304            }
305            stdout
306                .flush()
307                .map_err(|e| crate::error::ProcError::SystemError(e.to_string()))?;
308
309            // Wait for interval or keypress
310            let deadline = Instant::now() + interval;
311            loop {
312                let remaining = deadline.saturating_duration_since(Instant::now());
313                if remaining.is_zero() {
314                    break;
315                }
316
317                if event::poll(remaining)
318                    .map_err(|e| crate::error::ProcError::SystemError(e.to_string()))?
319                {
320                    if let Event::Key(KeyEvent {
321                        code, modifiers, ..
322                    }) = event::read()
323                        .map_err(|e| crate::error::ProcError::SystemError(e.to_string()))?
324                    {
325                        match code {
326                            KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
327                            KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
328                                return Ok(())
329                            }
330                            _ => {}
331                        }
332                    }
333                }
334            }
335        }
336    }
337
338    /// Render a frame for the TUI
339    fn render_frame(&self, processes: &[Process], width: u16, height: u16) -> String {
340        let sort_label = match self.sort {
341            WatchSortKey::Cpu => "CPU",
342            WatchSortKey::Mem => "Memory",
343            WatchSortKey::Pid => "PID",
344            WatchSortKey::Name => "Name",
345        };
346
347        let target_label = self.target.as_deref().unwrap_or("all");
348
349        // Header line
350        let header = format!(
351            " Watching {} | {} processes | Sort: {} | Refresh: {:.1}s | q to exit",
352            target_label,
353            processes.len(),
354            sort_label,
355            self.interval,
356        );
357
358        let mut output = String::new();
359        output.push_str(&header);
360        output.push('\n');
361        output.push('\n');
362
363        if processes.is_empty() {
364            output.push_str(" No matching processes found.");
365            return output;
366        }
367
368        // Cap rows to terminal height minus header (2 lines) + table header (1) + padding (1)
369        let max_rows = (height as usize).saturating_sub(5);
370
371        if self.verbose {
372            // Verbose mode: detailed per-process output
373            for (i, proc) in processes.iter().enumerate() {
374                if i >= max_rows {
375                    output.push_str(&format!(" ... and {} more", processes.len() - i));
376                    break;
377                }
378                let status_str = format!("{:?}", proc.status);
379                output.push_str(&format!(
380                    " {} {} [{}]  {:.1}% CPU  {}  {}",
381                    proc.pid,
382                    proc.name,
383                    status_str,
384                    proc.cpu_percent,
385                    format_memory(proc.memory_mb),
386                    proc.user.as_deref().unwrap_or("-")
387                ));
388                output.push('\n');
389                if let Some(ref cmd) = proc.command {
390                    output.push_str(&format!("    cmd: {}", cmd));
391                    output.push('\n');
392                }
393                if let Some(ref path) = proc.exe_path {
394                    output.push_str(&format!("    exe: {}", path));
395                    output.push('\n');
396                }
397                if let Some(ref cwd) = proc.cwd {
398                    output.push_str(&format!("    cwd: {}", cwd));
399                    output.push('\n');
400                }
401                output.push('\n');
402            }
403        } else {
404            // Table mode
405            let mut table = Table::new();
406            table
407                .load_preset(NOTHING)
408                .set_content_arrangement(ContentArrangement::Dynamic)
409                .set_width(width);
410
411            table.set_header(vec![
412                Cell::new("PID")
413                    .fg(Color::Blue)
414                    .add_attribute(Attribute::Bold),
415                Cell::new("DIR")
416                    .fg(Color::Blue)
417                    .add_attribute(Attribute::Bold),
418                Cell::new("NAME")
419                    .fg(Color::Blue)
420                    .add_attribute(Attribute::Bold),
421                Cell::new("ARGS")
422                    .fg(Color::Blue)
423                    .add_attribute(Attribute::Bold),
424                Cell::new("CPU%")
425                    .fg(Color::Blue)
426                    .add_attribute(Attribute::Bold)
427                    .set_alignment(CellAlignment::Right),
428                Cell::new("MEM")
429                    .fg(Color::Blue)
430                    .add_attribute(Attribute::Bold)
431                    .set_alignment(CellAlignment::Right),
432                Cell::new("STATUS")
433                    .fg(Color::Blue)
434                    .add_attribute(Attribute::Bold)
435                    .set_alignment(CellAlignment::Right),
436            ]);
437
438            use comfy_table::ColumnConstraint::*;
439            use comfy_table::Width::*;
440
441            let args_max = (width / 2).max(30);
442
443            table
444                .column_mut(0)
445                .expect("PID column")
446                .set_constraint(Absolute(Fixed(8)));
447            table
448                .column_mut(1)
449                .expect("DIR column")
450                .set_constraint(LowerBoundary(Fixed(20)));
451            table
452                .column_mut(2)
453                .expect("NAME column")
454                .set_constraint(LowerBoundary(Fixed(10)));
455            table
456                .column_mut(3)
457                .expect("ARGS column")
458                .set_constraint(UpperBoundary(Fixed(args_max)));
459            table
460                .column_mut(4)
461                .expect("CPU% column")
462                .set_constraint(Absolute(Fixed(8)));
463            table
464                .column_mut(5)
465                .expect("MEM column")
466                .set_constraint(Absolute(Fixed(11)));
467            table
468                .column_mut(6)
469                .expect("STATUS column")
470                .set_constraint(Absolute(Fixed(12)));
471
472            let display_count = processes.len().min(max_rows);
473            for proc in processes.iter().take(display_count) {
474                let status_str = format!("{:?}", proc.status);
475
476                let path_display = proc.cwd.as_deref().unwrap_or("-").to_string();
477
478                let cmd_display = proc
479                    .command
480                    .as_ref()
481                    .map(|c| {
482                        let parts: Vec<&str> = c.split_whitespace().collect();
483                        if parts.len() > 1 {
484                            let args: Vec<String> = parts[1..]
485                                .iter()
486                                .map(|arg| {
487                                    if arg.contains('/') && !arg.starts_with('-') {
488                                        std::path::Path::new(arg)
489                                            .file_name()
490                                            .map(|f| f.to_string_lossy().to_string())
491                                            .unwrap_or_else(|| arg.to_string())
492                                    } else {
493                                        arg.to_string()
494                                    }
495                                })
496                                .collect();
497                            let result = args.join(" ");
498                            if result.is_empty() {
499                                "-".to_string()
500                            } else {
501                                truncate_string(&result, (args_max as usize).saturating_sub(2))
502                            }
503                        } else {
504                            "-".to_string()
505                        }
506                    })
507                    .unwrap_or_else(|| "-".to_string());
508
509                let mem_display = format_memory(proc.memory_mb);
510
511                let status_color = match proc.status {
512                    ProcessStatus::Running => Color::Green,
513                    ProcessStatus::Sleeping => Color::Blue,
514                    ProcessStatus::Stopped => Color::Yellow,
515                    ProcessStatus::Zombie => Color::Red,
516                    _ => Color::White,
517                };
518
519                table.add_row(vec![
520                    Cell::new(proc.pid).fg(Color::Cyan),
521                    Cell::new(&path_display).fg(Color::DarkGrey),
522                    Cell::new(&proc.name).fg(Color::White),
523                    Cell::new(&cmd_display).fg(Color::DarkGrey),
524                    Cell::new(format!("{:.1}", proc.cpu_percent))
525                        .set_alignment(CellAlignment::Right),
526                    Cell::new(&mem_display).set_alignment(CellAlignment::Right),
527                    Cell::new(&status_str)
528                        .fg(status_color)
529                        .set_alignment(CellAlignment::Right),
530                ]);
531            }
532
533            output.push_str(&table.to_string());
534
535            if processes.len() > display_count {
536                output.push('\n');
537                output.push_str(&format!(
538                    " ... and {} more (use --limit to control)",
539                    processes.len() - display_count
540                ));
541            }
542        }
543
544        output
545    }
546
547    /// JSON loop: NDJSON output, one line per refresh
548    fn run_json_loop(&self) -> Result<()> {
549        let mut sys = System::new_all();
550        let interval = Duration::from_secs_f64(self.interval);
551
552        // Initial refresh for baseline CPU
553        sys.refresh_all();
554        std::thread::sleep(Duration::from_millis(250));
555
556        loop {
557            sys.refresh_all();
558            let processes = self.collect_processes(&sys);
559
560            let output = WatchJsonOutput {
561                action: "watch",
562                count: processes.len(),
563                processes,
564            };
565
566            match serde_json::to_string(&output) {
567                Ok(json) => {
568                    // Use writeln to detect broken pipe
569                    if writeln!(io::stdout(), "{}", json).is_err() {
570                        return Ok(()); // Broken pipe — exit cleanly
571                    }
572                    if io::stdout().flush().is_err() {
573                        return Ok(());
574                    }
575                }
576                Err(e) => {
577                    eprintln!("JSON serialization error: {}", e);
578                }
579            }
580
581            std::thread::sleep(interval);
582        }
583    }
584
585    /// Single snapshot (non-TTY mode)
586    fn run_snapshot(&self) -> Result<()> {
587        let mut sys = System::new_all();
588        sys.refresh_all();
589        std::thread::sleep(Duration::from_millis(250));
590        sys.refresh_all();
591
592        let processes = self.collect_processes(&sys);
593
594        if self.json {
595            let output = WatchJsonOutput {
596                action: "watch",
597                count: processes.len(),
598                processes,
599            };
600            if let Ok(json) = serde_json::to_string_pretty(&output) {
601                println!("{}", json);
602            }
603        } else {
604            let printer = crate::ui::Printer::new(crate::ui::OutputFormat::Human, self.verbose);
605            printer.print_processes(&processes);
606        }
607
608        Ok(())
609    }
610}