Skip to main content

proc_cli/ui/
output.rs

1//! Output formatting for proc CLI
2//!
3//! Provides colored terminal output and JSON formatting.
4
5use 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
18/// Run a writer-based print against stdout. A failed write panics, as
19/// `println!` does.
20fn 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
24/// Whether a command that changes processes should ask before acting.
25///
26/// The convention is the same in every output format: changing a process
27/// needs a yes. `--yes` gives it up front (`Ok(false)`: go ahead without
28/// asking); otherwise a person is asked (`Ok(true)`). `--json` only changes
29/// how results are printed, so it never stands in for `--yes`, and without a
30/// terminal there's nobody to ask: both are refused with exit 4 before
31/// anything is touched. `--dry-run` never gets here; it previews without one.
32///
33/// Both refusals carry `processes`, the ones the command would have changed,
34/// so a caller reading the JSON error can see what a retry with `--yes` hits.
35/// A command that takes targets also passes `not_found`, the ones that matched
36/// nothing, which the refusal carries too.
37pub 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
71/// Whether a note on stderr is coloured: only on a terminal, and never when
72/// `NO_COLOR` is set to something (https://no-color.org).
73fn 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
85/// Print a `note:` line on stderr, for something the user should change that
86/// does not stop the command. It is for human output only: stdout and `--json`
87/// stay as they are. Coloured only when stderr is a terminal and `NO_COLOR` is
88/// not set.
89pub 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    // A closed stderr has nobody to tell, and the command goes on
96    let _ = write_note(&mut std::io::stderr(), message, colour);
97}
98
99/// Output format selection
100#[derive(Debug, Clone, Copy, Default)]
101pub enum OutputFormat {
102    /// Colored, human-readable terminal output
103    #[default]
104    Human,
105    /// Machine-readable JSON output for scripting
106    Json,
107}
108
109/// Main printer for CLI output
110pub struct Printer {
111    format: OutputFormat,
112    verbose: bool,
113    /// Print full values instead of truncating them to the terminal width.
114    wide: bool,
115    /// The targets that matched nothing, for a command that takes targets. When
116    /// set, every JSON result the printer writes has them as `not_found`.
117    not_found: Option<Vec<String>>,
118}
119
120/// Detect terminal width, falling back to 120 when stdout is not a TTY.
121fn terminal_width() -> u16 {
122    crossterm::terminal::size().map(|(w, _)| w).unwrap_or(120)
123}
124
125impl Printer {
126    /// Creates a new printer with the specified format and verbosity.
127    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    /// Say which targets matched nothing: the JSON results (dry run, action,
137    /// the empty result and the refusal that asks for `--yes`) then list them
138    /// as `not_found`, an empty array when every target was found.
139    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    /// Opt into untruncated output (`--wide`). Values print in full and rows
145    /// may exceed the terminal width rather than being elided.
146    pub fn wide(mut self, wide: bool) -> Self {
147        self.wide = wide;
148        self
149    }
150
151    /// Create a printer from common CLI flags.
152    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    /// Print a success message (human only — use `print_empty_result` for JSON-safe success)
164    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            // JSON output handled separately
172            OutputFormat::Json => Ok(()),
173        }
174    }
175
176    /// Print a "nothing found" success result with JSON support.
177    ///
178    /// In human mode, prints a green success message. In JSON mode, outputs
179    /// structured JSON so scripts/LLMs always get parseable output.
180    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    /// Print a warning message
194    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            // JSON output handled separately
202            OutputFormat::Json => Ok(()),
203        }
204    }
205
206    /// Print a list of processes with action name and optional context.
207    ///
208    /// The `action` parameter sets the JSON `action` field (e.g. "list", "by", "stuck").
209    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    /// Print a list of processes with optional context (e.g., "in /path/to/dir").
222    /// Uses "list" as the JSON action name.
223    pub fn print_processes_with_context(&self, processes: &[Process], context: Option<&str>) {
224        self.print_processes_as("list", processes, context)
225    }
226
227    /// Print a list of processes. Uses "list" as the JSON action name.
228    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    /// `width` is the terminal width the table is fitted to.
237    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    /// Like `print_processes_as`, for a caller that has printed its own headline
267    /// (`⚠ Found 2 orphaned processes`): the generic `✓ Found 2 processes` line
268    /// would repeat it, so only the table is printed.
269    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    /// The process table (or the verbose list), without a headline, fitted to
280    /// a terminal `width` columns wide
281    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            // Verbose: full details, nothing truncated
289            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            // Disabled never reflows a cell, so one process is always one line.
338            // Fitting the terminal is handled by truncating content above, not
339            // by letting comfy-table wrap — a wrapped cell breaks the column
340            // alignment that makes the table readable in the first place.
341            table
342                .load_preset(NOTHING)
343                .set_content_arrangement(ContentArrangement::Disabled);
344
345            // Header
346            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            // Constraints mirror the budget above. In --wide mode no constraint
385            // is set at all, so every column sizes to its full content.
386            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))); // 6 content — fits "999999"
393                table
394                    .column_mut(4)
395                    .expect("CPU% column")
396                    .set_constraint(Absolute(Fixed(budget.cpu as u16))); // 6 content — fits "100.0"
397                table
398                    .column_mut(5)
399                    .expect("MEM column")
400                    .set_constraint(Absolute(Fixed(budget.mem as u16))); // 9 content — fits "9999.9MB"
401                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))); // 8 content
406                    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))); // 10 content
414            }
415
416            for proc in processes {
417                let status_str = format!("{:?}", proc.status);
418
419                // Show working directory (where the process was started from).
420                // Middle elision keeps the root and the tail that differs
421                // between rows; truncating either end alone loses one of them.
422                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                // Show command args (skip executable, simplify paths to filenames)
435                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                            // No args beyond the executable itself
462                            "-".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    /// Print JSON output for any serializable type
507    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    /// The JSON goes to `out`; a value that can't be serialized is reported on `err`.
512    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    /// Print action result (generalized for kill/stop/freeze/thaw).
525    ///
526    /// The `action` parameter is the command name (e.g. "kill", "stop", "freeze").
527    /// It is used as-is in JSON output and as the verb for human display.
528    ///
529    /// Returns `Err(ProcError::Reported)` when any process failed, so the command
530    /// exits non-zero without printing a second result.
531    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    /// Print action result where the JSON `action` differs from the verb shown
541    /// to humans (e.g. `proc orphans --kill` reports action "orphans", verb "kill").
542    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        // Past tense for human display: "kill" → "Killed", "stop" → "Stopped"
561        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        // A failed write panics, as `println!` does
570        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    /// The human lines of an action result: what worked, then what failed
612    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    /// Print dry-run summary for a destructive command.
662    ///
663    /// The `action` is the command name (e.g. "kill", "stop", "freeze", "thaw"),
664    /// used both as the JSON `action` and as the verb in the human summary.
665    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    /// Print dry-run summary with a custom human message.
679    ///
680    /// JSON output is `{"action", "success", "dry_run": true, "count", "not_found" (when the
681    /// printer was given targets), "processes"}`.
682    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    /// Show confirmation prompt and return whether the user confirmed.
700    ///
701    /// Returns `Ok(true)` if confirmed or skipped (--yes), `Ok(false)` if the
702    /// user declined (prints "Cancelled"), and an error when there's nobody to
703    /// ask (see [`must_confirm`]).
704    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        // A person is being asked: `must_confirm` never gets here with --json
710        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    /// Print a confirmation prompt showing processes about to be acted on
729    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// JSON output structures
753#[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        // --yes: go ahead without asking, in either output format
831        assert!(!must_confirm("kill", true, false, &[], None).unwrap());
832        assert!(!must_confirm("kill", true, true, &[], None).unwrap());
833        // --json without --yes is refused (exit 4), never treated as a yes
834        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        // a command without targets has nothing to say about them
849        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    /// What a writer-based print writes, with colours off
856    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    /// `text` without the escape sequences a table adds when run in a terminal
864    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    /// Output with every run of spaces collapsed, one entry per non-empty line,
891    /// so a table row can be compared without its column padding
892    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        // 100 columns is the first width with room for UPTIME
975        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            // PID DIR NAME ARGS ...: the fourth cell of the row
1006            rows(&out)[1]
1007                .split(' ')
1008                .skip(3)
1009                .take_while(|c| *c != "0.0")
1010                .collect::<Vec<_>>()
1011                .join(" ")
1012        };
1013        // Paths shrink to their file name, flags and plain words stay
1014        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        // Paths with no file name stay as they are
1021        assert_eq!(args_of(Some("node / a/..")), "/ a/..");
1022        // The executable alone has no args; a process with no command line neither
1023        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        // Mixed: both blocks, then the error a command exits with
1111        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}