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