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