1use crate::core::Process;
6use crate::error::{ExitCode, ProcError, Result};
7use crate::out_println;
8use crate::ui::format::{
9 colorize_status, format_duration, format_memory, plural, process_table_budget, truncate_path,
10 truncate_string,
11};
12use crate::ui::{out, prompt};
13use colored::*;
14use comfy_table::presets::NOTHING;
15use comfy_table::{Attribute, Cell, CellAlignment, Color, ContentArrangement, Table};
16use serde::Serialize;
17use std::io::Write;
18
19fn to_stdout(write: impl FnOnce(&mut out::Out) -> std::io::Result<()>) {
22 out::recorded(write(&mut out::stdout()));
23}
24
25pub fn must_confirm(
39 action: &str,
40 yes: bool,
41 json: bool,
42 processes: &[Process],
43 not_found: Option<&[String]>,
44) -> Result<bool> {
45 use std::io::IsTerminal;
46 if yes {
47 return Ok(false);
48 }
49 if json {
50 return Err(ProcError::ConfirmationRequired {
51 message: format!(
52 "{action} changes processes, so it needs --yes, with --json too \
53 (or --dry-run to preview)"
54 ),
55 processes: processes.to_vec(),
56 not_found: not_found.map(<[String]>::to_vec),
57 });
58 }
59 if !std::io::stdin().is_terminal() || !out::stderr_is_terminal() {
60 return Err(ProcError::ConfirmationRequired {
61 message: format!(
62 "{action} asks before changing processes, and there's no terminal \
63 to ask in; pass --yes (or --dry-run to preview)"
64 ),
65 processes: processes.to_vec(),
66 not_found: not_found.map(<[String]>::to_vec),
67 });
68 }
69 Ok(true)
70}
71
72fn note_in_colour(stderr_is_terminal: bool, no_color: Option<std::ffi::OsString>) -> bool {
75 stderr_is_terminal && no_color.is_none_or(|value| value.is_empty())
76}
77
78fn write_note(out: &mut impl Write, message: &str, colour: bool) -> std::io::Result<()> {
79 if colour {
80 writeln!(out, "\x1b[1;36mnote:\x1b[0m {message}")
81 } else {
82 writeln!(out, "note: {message}")
83 }
84}
85
86pub fn print_note(message: &str) {
91 let colour = note_in_colour(out::stderr_is_terminal(), std::env::var_os("NO_COLOR"));
92 out::recorded(write_note(&mut out::stderr(), message, colour));
94}
95
96#[derive(Debug, Clone, Copy, Default)]
98pub enum OutputFormat {
99 #[default]
101 Human,
102 Json,
104}
105
106pub struct Printer {
108 format: OutputFormat,
109 verbose: bool,
110 wide: bool,
112 not_found: Option<Vec<String>>,
115}
116
117fn terminal_width() -> u16 {
119 crossterm::terminal::size().map(|(w, _)| w).unwrap_or(120)
120}
121
122impl Printer {
123 pub fn new(format: OutputFormat, verbose: bool) -> Self {
125 Self {
126 format,
127 verbose,
128 wide: false,
129 not_found: None,
130 }
131 }
132
133 pub fn with_not_found(mut self, not_found: &[String]) -> Self {
137 self.not_found = Some(not_found.to_vec());
138 self
139 }
140
141 pub fn wide(mut self, wide: bool) -> Self {
144 self.wide = wide;
145 self
146 }
147
148 pub fn from_flags(json: bool, verbose: bool) -> Self {
150 Self::new(
151 if json {
152 OutputFormat::Json
153 } else {
154 OutputFormat::Human
155 },
156 verbose,
157 )
158 }
159
160 pub fn success(&self, message: &str) {
162 to_stdout(|out| self.write_success(out, message));
163 }
164
165 fn write_success(&self, out: &mut impl Write, message: &str) -> std::io::Result<()> {
166 match self.format {
167 OutputFormat::Human => writeln!(out, "{} {}", "✓".green().bold(), message.green()),
168 OutputFormat::Json => Ok(()),
170 }
171 }
172
173 pub fn print_empty_result(&self, action: &str, message: &str) {
178 match self.format {
179 OutputFormat::Human => self.success(message),
180 OutputFormat::Json => self.print_json(&EmptyResult {
181 action,
182 success: true,
183 count: 0,
184 message,
185 not_found: self.not_found.as_deref(),
186 }),
187 }
188 }
189
190 pub fn warning(&self, message: &str) {
192 to_stdout(|out| self.write_warning(out, message));
193 }
194
195 fn write_warning(&self, out: &mut impl Write, message: &str) -> std::io::Result<()> {
196 match self.format {
197 OutputFormat::Human => writeln!(out, "{} {}", "⚠".yellow().bold(), message.yellow()),
198 OutputFormat::Json => Ok(()),
200 }
201 }
202
203 pub fn print_processes_as(&self, action: &str, processes: &[Process], context: Option<&str>) {
207 match self.format {
208 OutputFormat::Human => self.print_processes_human(processes, context),
209 OutputFormat::Json => self.print_json(&ProcessListOutput {
210 action,
211 success: true,
212 count: processes.len(),
213 processes,
214 }),
215 }
216 }
217
218 pub fn print_processes_with_context(&self, processes: &[Process], context: Option<&str>) {
221 self.print_processes_as("list", processes, context)
222 }
223
224 pub fn print_processes(&self, processes: &[Process]) {
226 self.print_processes_with_context(processes, None)
227 }
228
229 fn print_processes_human(&self, processes: &[Process], context: Option<&str>) {
230 to_stdout(|out| self.write_processes_human(out, terminal_width(), processes, context));
231 }
232
233 fn write_processes_human(
235 &self,
236 out: &mut impl Write,
237 width: u16,
238 processes: &[Process],
239 context: Option<&str>,
240 ) -> std::io::Result<()> {
241 if processes.is_empty() {
242 let msg = match context {
243 Some(ctx) => format!("No processes found {}", ctx),
244 None => "No processes found".to_string(),
245 };
246 return self.write_warning(out, &msg);
247 }
248
249 let context_str = context.map(|c| format!(" {}", c)).unwrap_or_default();
250 writeln!(
251 out,
252 "{} Found {} process{}{}",
253 "✓".green().bold(),
254 processes.len().to_string().cyan().bold(),
255 if processes.len() == 1 { "" } else { "es" },
256 context_str.bright_black()
257 )?;
258 writeln!(out)?;
259
260 self.write_process_rows(out, width, processes)
261 }
262
263 pub fn print_processes_under_headline(&self, action: &str, processes: &[Process]) {
267 match self.format {
268 OutputFormat::Human => to_stdout(|out| {
269 writeln!(out)?;
270 self.write_process_rows(out, terminal_width(), processes)
271 }),
272 OutputFormat::Json => self.print_processes_as(action, processes, None),
273 }
274 }
275
276 fn write_process_rows(
279 &self,
280 out: &mut impl Write,
281 width: u16,
282 processes: &[Process],
283 ) -> std::io::Result<()> {
284 if self.verbose {
285 for proc in processes {
287 let status_str = format!("{:?}", proc.status);
288 let status_colored = colorize_status(&proc.status, &status_str);
289
290 writeln!(
291 out,
292 "{} {} {} {:.1}% CPU {} {}",
293 proc.pid.to_string().cyan().bold(),
294 proc.name.white().bold(),
295 format!("[{}]", status_colored).bright_black(),
296 proc.cpu_percent,
297 format_memory(proc.memory_mb),
298 proc.user.as_deref().unwrap_or("-").bright_black()
299 )?;
300
301 if let Some(ref cmd) = proc.command {
302 writeln!(out, " {} {}", "cmd:".bright_black(), cmd)?;
303 }
304 if let Some(ref path) = proc.exe_path {
305 writeln!(out, " {} {}", "exe:".bright_black(), path.bright_black())?;
306 }
307 if let Some(ref cwd) = proc.cwd {
308 writeln!(out, " {} {}", "cwd:".bright_black(), cwd.bright_black())?;
309 }
310 if let Some(ppid) = proc.parent_pid {
311 writeln!(
312 out,
313 " {} {}",
314 "parent:".bright_black(),
315 ppid.to_string().bright_black()
316 )?;
317 }
318 if let Some(uptime) = proc.uptime_secs {
319 writeln!(
320 out,
321 " {} {}",
322 "uptime:".bright_black(),
323 format_duration(uptime).bright_black()
324 )?;
325 }
326 writeln!(out)?;
327 }
328 } else {
329 let budget = process_table_budget(width);
330 let show_uptime = budget.show_uptime;
331 let (dir_content, name_content, args_content) = (budget.dir, budget.name, budget.args);
332
333 let mut table = Table::new();
334 table
339 .load_preset(NOTHING)
340 .set_content_arrangement(ContentArrangement::Disabled);
341
342 let mut header = vec![
344 Cell::new("PID")
345 .fg(Color::Blue)
346 .add_attribute(Attribute::Bold),
347 Cell::new("DIR")
348 .fg(Color::Blue)
349 .add_attribute(Attribute::Bold),
350 Cell::new("NAME")
351 .fg(Color::Blue)
352 .add_attribute(Attribute::Bold),
353 Cell::new("ARGS")
354 .fg(Color::Blue)
355 .add_attribute(Attribute::Bold),
356 Cell::new("CPU%")
357 .fg(Color::Blue)
358 .add_attribute(Attribute::Bold)
359 .set_alignment(CellAlignment::Right),
360 Cell::new("MEM")
361 .fg(Color::Blue)
362 .add_attribute(Attribute::Bold)
363 .set_alignment(CellAlignment::Right),
364 ];
365 if show_uptime {
366 header.push(
367 Cell::new("UPTIME")
368 .fg(Color::Blue)
369 .add_attribute(Attribute::Bold)
370 .set_alignment(CellAlignment::Right),
371 );
372 }
373 header.push(
374 Cell::new("STATUS")
375 .fg(Color::Blue)
376 .add_attribute(Attribute::Bold)
377 .set_alignment(CellAlignment::Right),
378 );
379 table.set_header(header);
380
381 if !self.wide {
384 use comfy_table::ColumnConstraint::*;
385 use comfy_table::Width::*;
386 table
387 .column_mut(0)
388 .expect("PID column")
389 .set_constraint(Absolute(Fixed(budget.pid as u16))); table
391 .column_mut(4)
392 .expect("CPU% column")
393 .set_constraint(Absolute(Fixed(budget.cpu as u16))); table
395 .column_mut(5)
396 .expect("MEM column")
397 .set_constraint(Absolute(Fixed(budget.mem as u16))); let status_column = if show_uptime {
399 table
400 .column_mut(6)
401 .expect("UPTIME column")
402 .set_constraint(Absolute(Fixed(budget.uptime as u16))); 7
404 } else {
405 6
406 };
407 table
408 .column_mut(status_column)
409 .expect("STATUS column")
410 .set_constraint(Absolute(Fixed(budget.status as u16))); }
412
413 for proc in processes {
414 let status_str = format!("{:?}", proc.status);
415
416 let cwd = proc.cwd.as_deref().unwrap_or("-");
420 let path_display = if self.wide {
421 cwd.to_string()
422 } else {
423 truncate_path(cwd, dir_content)
424 };
425 let name_display = if self.wide {
426 proc.name.clone()
427 } else {
428 truncate_string(&proc.name, name_content)
429 };
430
431 let cmd_display = proc
433 .command
434 .as_ref()
435 .map(|c| {
436 let parts: Vec<&str> = c.split_whitespace().collect();
437 if parts.len() > 1 {
438 let args: Vec<String> = parts[1..]
439 .iter()
440 .map(|arg| {
441 if arg.contains('/') && !arg.starts_with('-') {
442 std::path::Path::new(arg)
443 .file_name()
444 .map(|f| f.to_string_lossy().to_string())
445 .unwrap_or_else(|| arg.to_string())
446 } else {
447 arg.to_string()
448 }
449 })
450 .collect();
451 let result = args.join(" ");
452 if self.wide {
453 result
454 } else {
455 truncate_string(&result, args_content)
456 }
457 } else {
458 "-".to_string()
460 }
461 })
462 .unwrap_or_else(|| "-".to_string());
463
464 let mem_display = format_memory(proc.memory_mb);
465
466 let status_color = match proc.status {
467 crate::core::ProcessStatus::Running => Color::Green,
468 crate::core::ProcessStatus::Sleeping => Color::Blue,
469 crate::core::ProcessStatus::Stopped => Color::Yellow,
470 crate::core::ProcessStatus::Zombie => Color::Red,
471 _ => Color::White,
472 };
473
474 let mut row = vec![
475 Cell::new(proc.pid).fg(Color::Cyan),
476 Cell::new(&path_display).fg(Color::DarkGrey),
477 Cell::new(&name_display).fg(Color::White),
478 Cell::new(&cmd_display).fg(Color::DarkGrey),
479 Cell::new(format!("{:.1}", proc.cpu_percent))
480 .set_alignment(CellAlignment::Right),
481 Cell::new(&mem_display).set_alignment(CellAlignment::Right),
482 ];
483 if show_uptime {
484 let uptime_display = proc
485 .uptime_secs
486 .map(format_duration)
487 .unwrap_or_else(|| "-".to_string());
488 row.push(Cell::new(uptime_display).set_alignment(CellAlignment::Right));
489 }
490 row.push(
491 Cell::new(&status_str)
492 .fg(status_color)
493 .set_alignment(CellAlignment::Right),
494 );
495 table.add_row(row);
496 }
497
498 writeln!(out, "{table}")?;
499 }
500 writeln!(out)
501 }
502
503 pub fn print_json<T: Serialize>(&self, data: &T) {
505 to_stdout(|out| self.write_json(out, &mut out::stderr(), data));
506 }
507
508 fn write_json<T: Serialize>(
510 &self,
511 out: &mut impl Write,
512 err: &mut impl Write,
513 data: &T,
514 ) -> std::io::Result<()> {
515 match serde_json::to_string_pretty(data) {
516 Ok(json) => writeln!(out, "{}", json),
517 Err(e) => writeln!(err, "Failed to serialize JSON: {}", e),
518 }
519 }
520
521 pub fn print_action_result(
529 &self,
530 action: &str,
531 succeeded: &[Process],
532 failed: &[(Process, String)],
533 ) -> Result<()> {
534 self.print_action_result_as(action, action, succeeded, failed)
535 }
536
537 pub fn print_action_result_as(
540 &self,
541 action: &str,
542 verb: &str,
543 succeeded: &[Process],
544 failed: &[(Process, String)],
545 ) -> Result<()> {
546 self.write_action_result_as(&mut out::stdout(), action, verb, succeeded, failed)
547 }
548
549 fn write_action_result_as(
550 &self,
551 out: &mut impl Write,
552 action: &str,
553 verb: &str,
554 succeeded: &[Process],
555 failed: &[(Process, String)],
556 ) -> Result<()> {
557 let past_tense = match verb {
559 "kill" => "Killed".to_string(),
560 "stop" => "Stopped".to_string(),
561 "freeze" => "Frozen".to_string(),
562 "thaw" => "Thawed".to_string(),
563 _ => format!("{}{}ed", verb[..1].to_uppercase(), &verb[1..]),
564 };
565
566 let printed = match self.format {
567 OutputFormat::Human => {
568 Self::write_action_lines(out, &past_tense, verb, succeeded, failed)
569 }
570 OutputFormat::Json => self.write_json(
571 out,
572 &mut out::stderr(),
573 &ActionOutput {
574 action,
575 success: failed.is_empty(),
576 succeeded_count: succeeded.len(),
577 failed_count: failed.len(),
578 not_found: self.not_found.as_deref(),
579 succeeded,
580 failed: &failed
581 .iter()
582 .map(|(p, e)| FailedAction {
583 process: p,
584 error: e,
585 })
586 .collect::<Vec<_>>(),
587 },
588 ),
589 };
590 out::recorded(printed);
593
594 if failed.is_empty() {
595 Ok(())
596 } else {
597 Err(ProcError::reported(
598 ExitCode::GeneralError,
599 format!(
600 "Failed to {} {} process{}",
601 verb,
602 failed.len(),
603 plural(failed.len())
604 ),
605 ))
606 }
607 }
608
609 fn write_action_lines(
611 out: &mut impl Write,
612 past_tense: &str,
613 verb: &str,
614 succeeded: &[Process],
615 failed: &[(Process, String)],
616 ) -> std::io::Result<()> {
617 if !succeeded.is_empty() {
618 writeln!(
619 out,
620 "{} {} {} process{}",
621 "✓".green().bold(),
622 past_tense,
623 succeeded.len().to_string().cyan().bold(),
624 plural(succeeded.len())
625 )?;
626 for proc in succeeded {
627 writeln!(
628 out,
629 " {} {} [PID {}]",
630 "→".bright_black(),
631 proc.name.white(),
632 proc.pid.to_string().cyan()
633 )?;
634 }
635 }
636 if !failed.is_empty() {
637 writeln!(
638 out,
639 "{} Failed to {} {} process{}",
640 "✗".red().bold(),
641 verb,
642 failed.len(),
643 plural(failed.len())
644 )?;
645 for (proc, err) in failed {
646 writeln!(
647 out,
648 " {} {} [PID {}]: {}",
649 "→".bright_black(),
650 proc.name.white(),
651 proc.pid.to_string().cyan(),
652 err.red()
653 )?;
654 }
655 }
656 Ok(())
657 }
658
659 pub fn print_dry_run(&self, action: &str, processes: &[Process]) {
664 self.print_dry_run_with(
665 action,
666 processes,
667 &format!(
668 "Dry run: would {} {} process{}",
669 action,
670 processes.len(),
671 plural(processes.len())
672 ),
673 );
674 }
675
676 pub fn print_dry_run_with(&self, action: &str, processes: &[Process], message: &str) {
681 match self.format {
682 OutputFormat::Human => {
683 self.print_processes_human(processes, None);
684 self.warning(message);
685 }
686 OutputFormat::Json => self.print_json(&DryRunOutput {
687 action,
688 success: true,
689 dry_run: true,
690 count: processes.len(),
691 not_found: self.not_found.as_deref(),
692 processes,
693 }),
694 }
695 }
696
697 pub fn ask_confirm(&self, action: &str, processes: &[Process], yes: bool) -> Result<bool> {
704 let json = matches!(self.format, OutputFormat::Json);
705 if !must_confirm(action, yes, json, processes, self.not_found.as_deref())? {
706 return Ok(true);
707 }
708 let question = format!(
710 "{}{} {} process{}?",
711 action[..1].to_uppercase(),
712 &action[1..],
713 processes.len(),
714 plural(processes.len())
715 );
716 let list = || self.print_confirmation(action, processes);
717 let not_found = self.not_found.as_deref();
718 let confirmed = prompt::ask(action, processes, not_found, &question, list)?;
719 if !confirmed {
720 self.warning("Cancelled");
721 }
722 Ok(confirmed)
723 }
724
725 pub fn print_confirmation(&self, action: &str, processes: &[Process]) {
727 out_println!(
728 "\n{} Found {} process{} to {}:\n",
729 "⚠".yellow().bold(),
730 processes.len().to_string().cyan().bold(),
731 if processes.len() == 1 { "" } else { "es" },
732 action
733 );
734
735 for proc in processes {
736 out_println!(
737 " {} {} [PID {}] - CPU: {:.1}%, MEM: {}",
738 "→".bright_black(),
739 proc.name.white().bold(),
740 proc.pid.to_string().cyan(),
741 proc.cpu_percent,
742 format_memory(proc.memory_mb)
743 );
744 }
745 out_println!();
746 }
747}
748
749#[derive(Serialize)]
751struct EmptyResult<'a> {
752 action: &'a str,
753 success: bool,
754 count: usize,
755 message: &'a str,
756 #[serde(skip_serializing_if = "Option::is_none")]
757 not_found: Option<&'a [String]>,
758}
759
760#[derive(Serialize)]
761struct ProcessListOutput<'a> {
762 action: &'a str,
763 success: bool,
764 count: usize,
765 processes: &'a [Process],
766}
767
768#[derive(Serialize)]
769struct DryRunOutput<'a> {
770 action: &'a str,
771 success: bool,
772 dry_run: bool,
773 count: usize,
774 #[serde(skip_serializing_if = "Option::is_none")]
775 not_found: Option<&'a [String]>,
776 processes: &'a [Process],
777}
778
779#[derive(Serialize)]
780struct ActionOutput<'a> {
781 action: &'a str,
782 success: bool,
783 succeeded_count: usize,
784 failed_count: usize,
785 #[serde(skip_serializing_if = "Option::is_none")]
786 not_found: Option<&'a [String]>,
787 succeeded: &'a [Process],
788 failed: &'a [FailedAction<'a>],
789}
790
791#[derive(Serialize)]
792struct FailedAction<'a> {
793 process: &'a Process,
794 error: &'a str,
795}
796
797impl Default for Printer {
798 fn default() -> Self {
799 Self::new(OutputFormat::Human, false)
800 }
801}
802
803#[cfg(test)]
804mod tests {
805 use super::*;
806 use crate::test_support::assert_stops_at_a_failed_write;
807
808 #[test]
809 fn a_note_is_plain_unless_stderr_is_a_terminal_and_no_color_is_unset() {
810 let note = |colour| {
811 let mut out = Vec::new();
812 write_note(&mut out, "pass them apart", colour).unwrap();
813 String::from_utf8(out).unwrap()
814 };
815 assert_eq!(note(false), "note: pass them apart\n");
816 assert_eq!(note(true), "\x1b[1;36mnote:\x1b[0m pass them apart\n");
817
818 let set = |v: &str| Some(std::ffi::OsString::from(v));
819 assert!(note_in_colour(true, None));
820 assert!(note_in_colour(true, set("")));
821 assert!(!note_in_colour(true, set("1")));
822 assert!(!note_in_colour(false, None));
823 }
824
825 #[test]
826 fn json_never_stands_in_for_yes() {
827 assert!(!must_confirm("kill", true, false, &[], None).unwrap());
829 assert!(!must_confirm("kill", true, true, &[], None).unwrap());
830 let refused = must_confirm("kill", false, true, &[], None).unwrap_err();
832 assert!(
833 matches!(refused, ProcError::ConfirmationRequired { .. }),
834 "{refused}"
835 );
836 assert!(refused.to_string().contains("--yes"));
837 }
838
839 #[test]
840 fn the_refusal_carries_the_targets_that_were_not_found() {
841 let missing = ["99999999".to_string()];
842 let refused = must_confirm("kill", false, true, &[], Some(&missing)).unwrap_err();
843 let shown = format!("{refused:?}");
844 assert!(shown.contains(r#"not_found: Some(["99999999"])"#));
845 let refused = must_confirm("stuck", false, true, &[], None).unwrap_err();
847 assert!(format!("{refused:?}").contains("not_found: None"));
848 }
849
850 use crate::core::ProcessStatus;
851
852 fn written(print: impl FnOnce(&mut Vec<u8>) -> std::io::Result<()>) -> String {
854 colored::control::set_override(false);
855 let mut out = Vec::new();
856 print(&mut out).unwrap();
857 plain(&String::from_utf8(out).unwrap())
858 }
859
860 fn plain(text: &str) -> String {
862 regex::Regex::new("\x1b\\[[0-9;]*m")
863 .unwrap()
864 .replace_all(text, "")
865 .into_owned()
866 }
867
868 fn process(pid: u32, name: &str) -> Process {
869 Process {
870 pid,
871 name: name.to_string(),
872 exe_path: None,
873 cwd: None,
874 command: None,
875 argv0: None,
876 raw: None,
877 cpu_percent: 0.0,
878 memory_mb: 0.0,
879 status: ProcessStatus::Running,
880 user: None,
881 parent_pid: None,
882 start_time: None,
883 uptime_secs: None,
884 }
885 }
886
887 fn rows(text: &str) -> Vec<String> {
890 text.lines()
891 .map(|l| l.split_whitespace().collect::<Vec<_>>().join(" "))
892 .filter(|l| !l.is_empty())
893 .collect()
894 }
895
896 fn human() -> Printer {
897 Printer::new(OutputFormat::Human, false)
898 }
899
900 fn json() -> Printer {
901 Printer::new(OutputFormat::Json, false)
902 }
903
904 #[test]
905 fn success_and_warning_print_a_line_for_people_and_nothing_for_json() {
906 assert_eq!(
907 written(|w| human().write_success(w, "all done")),
908 "✓ all done\n"
909 );
910 assert_eq!(
911 written(|w| human().write_warning(w, "careful")),
912 "⚠ careful\n"
913 );
914 assert_eq!(written(|w| json().write_success(w, "all done")), "");
915 assert_eq!(written(|w| json().write_warning(w, "careful")), "");
916 }
917
918 #[test]
919 fn no_processes_says_so_with_or_without_a_context() {
920 let out = written(|w| human().write_processes_human(w, 120, &[], None));
921 assert_eq!(out, "⚠ No processes found\n");
922 let out = written(|w| human().write_processes_human(w, 120, &[], Some("in /srv")));
923 assert_eq!(out, "⚠ No processes found in /srv\n");
924 }
925
926 #[test]
927 fn the_headline_counts_processes_and_names_the_context() {
928 let one = [process(42, "node")];
929 let out = written(|w| human().write_processes_human(w, 120, &one, Some("in /a")));
930 assert_eq!(out.lines().next(), Some("✓ Found 1 process in /a"));
931 assert_eq!(out.lines().nth(1), Some(""));
932
933 let two = [process(42, "node"), process(43, "deno")];
934 let out = written(|w| human().write_processes_human(w, 120, &two, None));
935 assert_eq!(out.lines().next(), Some("✓ Found 2 processes"));
936 }
937
938 #[test]
939 fn the_table_lists_a_process_per_row_and_ends_with_a_blank_line() {
940 let mut p = process(42, "node");
941 p.cwd = Some("/a/b".into());
942 p.command = Some("node /x/y/server.js --port 3000".into());
943 p.cpu_percent = 1.25;
944 p.memory_mb = 12.5;
945 p.uptime_secs = Some(65);
946 let out = written(|w| human().write_process_rows(w, 120, &[p]));
947 assert_eq!(
948 rows(&out),
949 [
950 "PID DIR NAME ARGS CPU% MEM UPTIME STATUS",
951 "42 /a/b node server.js --port 3000 1.2 12.5MB 1m 5s Running"
952 ]
953 );
954 assert!(out.ends_with("\n\n"), "{out:?}");
955 }
956
957 #[test]
958 fn a_narrow_terminal_drops_the_uptime_column_and_cuts_the_args() {
959 let mut p = process(42, "node");
960 p.cwd = Some("/a/b".into());
961 p.command = Some("node /x/y/server.js --port 3000".into());
962 p.uptime_secs = Some(65);
963 let out = written(|w| human().write_process_rows(w, 80, &[p.clone()]));
964 assert_eq!(
965 rows(&out),
966 [
967 "PID DIR NAME ARGS CPU% MEM STATUS",
968 "42 /a/b node server.js -… 0.0 0KB Running"
969 ]
970 );
971 let out = written(|w| human().write_process_rows(w, 99, &[p.clone()]));
973 assert!(!out.contains("UPTIME"), "{out}");
974 let out = written(|w| human().write_process_rows(w, 100, &[p]));
975 assert!(out.contains("UPTIME"), "{out}");
976 }
977
978 #[test]
979 fn wide_prints_every_value_in_full() {
980 let mut p = process(42, "a-process-with-a-very-long-name");
981 p.cwd = Some("/srv/some/deeply/nested/working/directory/of/the/app".into());
982 p.command = Some("a-process /x/y/server.js --listen-on-all-interfaces-please 3000".into());
983
984 let cut = written(|w| human().write_process_rows(w, 80, &[p.clone()]));
985 assert!(cut.matches('…').count() >= 2, "{cut}");
986
987 let wide = written(|w| human().wide(true).write_process_rows(w, 80, &[p]));
988 assert_eq!(
989 rows(&wide)[1],
990 "42 /srv/some/deeply/nested/working/directory/of/the/app \
991 a-process-with-a-very-long-name \
992 server.js --listen-on-all-interfaces-please 3000 0.0 0KB Running"
993 );
994 }
995
996 #[test]
997 fn args_show_file_names_for_paths_and_dashes_when_there_are_none() {
998 let args_of = |command: Option<&str>| {
999 let mut p = process(42, "node");
1000 p.command = command.map(str::to_string);
1001 let out = written(|w| human().wide(true).write_process_rows(w, 120, &[p]));
1002 rows(&out)[1]
1004 .split(' ')
1005 .skip(3)
1006 .take_while(|c| *c != "0.0")
1007 .collect::<Vec<_>>()
1008 .join(" ")
1009 };
1010 assert_eq!(
1012 args_of(Some(
1013 "/usr/bin/node /opt/app/server.js -c /etc/x.conf -p/q start"
1014 )),
1015 "server.js -c x.conf -p/q start"
1016 );
1017 assert_eq!(args_of(Some("node / a/..")), "/ a/..");
1019 assert_eq!(args_of(Some("/usr/bin/node")), "-");
1021 assert_eq!(args_of(None), "-");
1022 }
1023
1024 #[test]
1025 fn a_missing_directory_or_uptime_shows_a_dash() {
1026 let p = process(42, "node");
1027 let out = written(|w| human().write_process_rows(w, 120, &[p]));
1028 assert_eq!(rows(&out)[1], "42 - node - 0.0 0KB - Running");
1029 }
1030
1031 #[test]
1032 fn the_table_names_every_status() {
1033 let statuses = [
1034 ProcessStatus::Running,
1035 ProcessStatus::Sleeping,
1036 ProcessStatus::Stopped,
1037 ProcessStatus::Zombie,
1038 ProcessStatus::Dead,
1039 ProcessStatus::Unknown,
1040 ];
1041 let list: Vec<Process> = statuses
1042 .iter()
1043 .enumerate()
1044 .map(|(i, status)| {
1045 let mut p = process(100 + i as u32, "node");
1046 p.status = *status;
1047 p
1048 })
1049 .collect();
1050 let table = written(|w| human().write_process_rows(w, 120, &list));
1051 let verbose =
1052 written(|w| Printer::new(OutputFormat::Human, true).write_process_rows(w, 120, &list));
1053 for (i, status) in statuses.iter().enumerate() {
1054 let name = format!("{status:?}");
1055 assert!(rows(&table)[i + 1].ends_with(&name), "{table}");
1056 assert!(verbose.contains(&format!("[{name}]")), "{verbose}");
1057 }
1058 }
1059
1060 #[test]
1061 fn verbose_lists_every_known_detail_of_each_process() {
1062 let mut p = process(42, "node");
1063 p.cpu_percent = 2.5;
1064 p.memory_mb = 1500.0;
1065 p.user = Some("zee".into());
1066 p.command = Some("node server.js".into());
1067 p.exe_path = Some("/usr/bin/node".into());
1068 p.cwd = Some("/srv/app".into());
1069 p.parent_pid = Some(7);
1070 p.uptime_secs = Some(3700);
1071 let bare = process(43, "deno");
1072 let verbose = Printer::new(OutputFormat::Human, true);
1073 let out = written(|w| verbose.write_process_rows(w, 120, &[p, bare]));
1074 assert_eq!(
1075 out,
1076 "42 node [Running] 2.5% CPU 1.5GB zee\n\
1077 \x20 cmd: node server.js\n\
1078 \x20 exe: /usr/bin/node\n\
1079 \x20 cwd: /srv/app\n\
1080 \x20 parent: 7\n\
1081 \x20 uptime: 1h 1m\n\
1082 \n\
1083 43 deno [Running] 0.0% CPU 0KB -\n\
1084 \n\
1085 \n"
1086 );
1087 }
1088
1089 #[test]
1090 fn an_action_result_lists_what_worked_and_what_failed() {
1091 let ok = [process(1, "a"), process(2, "b")];
1092 let out = written(|w| {
1093 human()
1094 .write_action_result_as(w, "kill", "kill", &ok, &[])
1095 .map_err(std::io::Error::other)
1096 });
1097 assert_eq!(out, "✓ Killed 2 processes\n → a [PID 1]\n → b [PID 2]\n");
1098
1099 let one = [process(1, "a")];
1100 let out = written(|w| {
1101 human()
1102 .write_action_result_as(w, "stop", "stop", &one, &[])
1103 .map_err(std::io::Error::other)
1104 });
1105 assert_eq!(out, "✓ Stopped 1 process\n → a [PID 1]\n");
1106
1107 let failed = [(process(3, "c"), "Permission denied".to_string())];
1109 let mut buf = Vec::new();
1110 let err = human()
1111 .write_action_result_as(&mut buf, "freeze", "freeze", &one, &failed)
1112 .unwrap_err();
1113 assert_eq!(
1114 plain(&String::from_utf8(buf).unwrap()),
1115 "✓ Frozen 1 process\n → a [PID 1]\n\
1116 ✗ Failed to freeze 1 process\n → c [PID 3]: Permission denied\n"
1117 );
1118 assert_eq!(err.to_string(), "Failed to freeze 1 process");
1119 assert_eq!(ExitCode::from(&err) as i32, 1);
1120 }
1121
1122 #[test]
1123 fn only_failures_print_no_success_block_and_pluralize() {
1124 let failed = [
1125 (process(3, "c"), "gone".to_string()),
1126 (process(4, "d"), "busy".to_string()),
1127 ];
1128 let mut buf = Vec::new();
1129 let err = human()
1130 .write_action_result_as(&mut buf, "thaw", "thaw", &[], &failed)
1131 .unwrap_err();
1132 assert_eq!(
1133 plain(&String::from_utf8(buf).unwrap()),
1134 "✗ Failed to thaw 2 processes\n → c [PID 3]: gone\n → d [PID 4]: busy\n"
1135 );
1136 assert_eq!(err.to_string(), "Failed to thaw 2 processes");
1137 }
1138
1139 #[test]
1140 fn a_verb_without_its_own_past_tense_gets_ed() {
1141 let one = [process(1, "a")];
1142 let out = written(|w| {
1143 human()
1144 .write_action_result_as(w, "restart", "restart", &one, &[])
1145 .map_err(std::io::Error::other)
1146 });
1147 assert!(out.starts_with("✓ Restarted 1 process\n"), "{out}");
1148 }
1149
1150 #[test]
1151 fn json_action_results_carry_the_failures() {
1152 let ok = [process(1, "a")];
1153 let failed = [(process(3, "c"), "Permission denied".to_string())];
1154 let mut buf = Vec::new();
1155 let err = json()
1156 .write_action_result_as(&mut buf, "orphans", "kill", &ok, &failed)
1157 .unwrap_err();
1158 let doc: serde_json::Value = serde_json::from_slice(&buf).unwrap();
1159 assert_eq!(doc["action"], "orphans");
1160 assert_eq!(doc["success"], false);
1161 assert_eq!(doc["succeeded_count"], 1);
1162 assert_eq!(doc["failed_count"], 1);
1163 assert_eq!(doc["succeeded"][0]["pid"], 1);
1164 assert_eq!(doc["failed"][0]["process"]["pid"], 3);
1165 assert_eq!(doc["failed"][0]["error"], "Permission denied");
1166 assert_eq!(err.to_string(), "Failed to kill 1 process");
1167 }
1168
1169 #[test]
1170 fn a_value_that_cannot_be_serialized_is_reported_on_stderr() {
1171 struct Broken;
1172 impl Serialize for Broken {
1173 fn serialize<S: serde::Serializer>(
1174 &self,
1175 _: S,
1176 ) -> std::result::Result<S::Ok, S::Error> {
1177 Err(serde::ser::Error::custom("no can do"))
1178 }
1179 }
1180 let (mut out, mut err) = (Vec::new(), Vec::new());
1181 json().write_json(&mut out, &mut err, &Broken).unwrap();
1182 assert!(out.is_empty());
1183 assert_eq!(
1184 String::from_utf8(err).unwrap(),
1185 "Failed to serialize JSON: no can do\n"
1186 );
1187
1188 let (mut out, mut err) = (Vec::new(), Vec::new());
1189 json()
1190 .write_json(&mut out, &mut err, &serde_json::json!({"a": 1}))
1191 .unwrap();
1192 assert!(err.is_empty());
1193 assert_eq!(String::from_utf8(out).unwrap(), "{\n \"a\": 1\n}\n");
1194 }
1195
1196 #[test]
1197 fn the_default_printer_is_plain_human_output() {
1198 let printer = Printer::default();
1199 assert!(matches!(printer.format, OutputFormat::Human));
1200 assert!(!printer.verbose);
1201 assert!(!printer.wide);
1202 }
1203
1204 #[test]
1205 fn a_failed_write_ends_the_table() {
1206 let mut p = process(42, "node");
1207 p.cwd = Some("/a/b".into());
1208 p.command = Some("node server.js".into());
1209 assert_stops_at_a_failed_write(|w| {
1210 human().write_processes_human(w, 120, &[p.clone()], Some("in /a"))
1211 });
1212 assert_stops_at_a_failed_write(|w| human().write_processes_human(w, 120, &[], None));
1213 }
1214
1215 #[test]
1216 fn a_failed_write_ends_the_verbose_list() {
1217 let mut p = process(42, "node");
1218 p.command = Some("node server.js".into());
1219 p.exe_path = Some("/usr/bin/node".into());
1220 p.cwd = Some("/srv/app".into());
1221 p.parent_pid = Some(7);
1222 p.uptime_secs = Some(3700);
1223 let verbose = Printer::new(OutputFormat::Human, true);
1224 assert_stops_at_a_failed_write(|w| verbose.write_process_rows(w, 120, &[p.clone()]));
1225 }
1226
1227 #[test]
1228 fn a_failed_write_ends_the_action_result() {
1229 let ok = [process(1, "a")];
1230 let failed = [(process(3, "c"), "Permission denied".to_string())];
1231 assert_stops_at_a_failed_write(|w| {
1232 Printer::write_action_lines(w, "Killed", "kill", &ok, &failed)
1233 });
1234 assert_stops_at_a_failed_write(|w| human().write_success(w, "done"));
1235 assert_stops_at_a_failed_write(|w| human().write_warning(w, "careful"));
1236 }
1237}