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