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