1use 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
38fn 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#[derive(Args, Debug)]
52pub struct WatchCommand {
53 #[arg(num_args = 0.., value_parser = crate::core::validate_target_arg)]
55 pub target: Vec<String>,
56
57 #[arg(long = "interval", short = 'n', default_value = "2", value_parser = parse_interval)]
59 pub interval: f64,
60
61 #[arg(long = "in", short = 'i', num_args = 0..=1, default_missing_value = ".")]
64 pub in_dir: Option<String>,
65
66 #[arg(long = "by", short = 'b')]
68 pub by_name: Option<String>,
69
70 #[arg(long)]
72 pub cmd: bool,
73
74 #[arg(long)]
76 pub min_cpu: Option<f32>,
77
78 #[arg(long)]
80 pub min_mem: Option<f64>,
81
82 #[arg(long, short = 'j')]
84 pub json: bool,
85
86 #[arg(long, short = 'v')]
88 pub verbose: bool,
89
90 #[arg(long, short = 'w')]
92 pub wide: bool,
93
94 #[arg(long, short = 'l')]
96 pub limit: Option<usize>,
97
98 #[arg(long, short = 's', value_enum, default_value_t = WatchSortKey::Cpu)]
100 pub sort: WatchSortKey,
101}
102
103#[derive(Debug, Clone, Copy, ValueEnum)]
105pub enum WatchSortKey {
106 Cpu,
108 Mem,
110 Pid,
112 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
135fn 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
144fn restore_terminal(out: &mut impl Write) {
146 let _ = terminal::disable_raw_mode();
147 let _ = execute!(out, cursor::Show, terminal::LeaveAlternateScreen);
148}
149
150fn 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
160fn 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
170fn 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 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 self.run_snapshot()
195 }
196 }
197
198 fn collect_processes(&self, sys: &System) -> Vec<Process> {
200 self.collect_with(sys, port_holders)
201 }
202
203 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 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 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 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 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 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 if let Some(min_cpu) = self.min_cpu {
288 processes.retain(|p| p.cpu_percent >= min_cpu);
289 }
290
291 if let Some(min_mem) = self.min_mem {
293 processes.retain(|p| p.memory_mb >= min_mem);
294 }
295
296 sort_processes(&mut processes, self.sort.into());
298
299 if let Some(limit) = self.limit {
301 processes.truncate(limit);
302 }
303
304 processes
305 }
306
307 fn run_tui_loop(&self) -> Result<()> {
309 let mut stdout = io::stdout();
310
311 install_panic_hook();
312
313 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 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 let (width, height) = terminal::size().unwrap_or((120, 40));
345
346 let frame = self.render_frame(&processes, width, height);
348
349 execute!(stdout, cursor::MoveTo(0, 0))
351 .map_err(|e| crate::error::ProcError::SystemError(e.to_string()))?;
352 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 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 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 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 let max_rows = (height as usize).saturating_sub(5);
427
428 if self.verbose {
429 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 let budget = process_table_budget(width);
468 let show_uptime = budget.show_uptime;
469
470 let mut table = Table::new();
471 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 fn run_json_loop(&self) -> Result<()> {
643 let mut sys = System::new_all();
644 let interval = Duration::from_secs_f64(self.interval);
645
646 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(()); }
664
665 std::thread::sleep(interval);
666 }
667 }
668
669 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 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 #[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 #[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 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 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 #[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 #[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 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 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 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 #[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 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 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}