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::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
19/// Run a writer-based print against stdout. A failed write is recorded by
20/// [`out`], which decides what it means for the exit code; it never panics.
21fn to_stdout(write: impl FnOnce(&mut out::Out) -> std::io::Result<()>) {
22    out::recorded(write(&mut out::stdout()));
23}
24
25/// Whether a command that changes processes should ask before acting.
26///
27/// The convention is the same in every output format: changing a process
28/// needs a yes. `--yes` gives it up front (`Ok(false)`: go ahead without
29/// asking); otherwise a person is asked (`Ok(true)`). `--json` only changes
30/// how results are printed, so it never stands in for `--yes`, and without a
31/// terminal there's nobody to ask: both are refused with exit 4 before
32/// anything is touched. `--dry-run` never gets here; it previews without one.
33///
34/// Both refusals carry `processes`, the ones the command would have changed,
35/// so a caller reading the JSON error can see what a retry with `--yes` hits.
36/// A command that takes targets also passes `not_found`, the ones that matched
37/// nothing, which the refusal carries too.
38pub 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
72/// Whether a note on stderr is coloured: only on a terminal, and never when
73/// `NO_COLOR` is set to something (https://no-color.org).
74fn 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
86/// Print a `note:` line on stderr, for something the user should change that
87/// does not stop the command. It is for human output only: stdout and `--json`
88/// stay as they are. Coloured only when stderr is a terminal and `NO_COLOR` is
89/// not set.
90pub fn print_note(message: &str) {
91    let colour = note_in_colour(out::stderr_is_terminal(), std::env::var_os("NO_COLOR"));
92    // A closed stderr has nobody to tell, and the command goes on
93    out::recorded(write_note(&mut out::stderr(), message, colour));
94}
95
96/// Output format selection
97#[derive(Debug, Clone, Copy, Default)]
98pub enum OutputFormat {
99    /// Colored, human-readable terminal output
100    #[default]
101    Human,
102    /// Machine-readable JSON output for scripting
103    Json,
104}
105
106/// Main printer for CLI output
107pub struct Printer {
108    format: OutputFormat,
109    verbose: bool,
110    /// Print full values instead of truncating them to the terminal width.
111    wide: bool,
112    /// The targets that matched nothing, for a command that takes targets. When
113    /// set, every JSON result the printer writes has them as `not_found`.
114    not_found: Option<Vec<String>>,
115}
116
117/// Detect terminal width, falling back to 120 when stdout is not a TTY.
118fn terminal_width() -> u16 {
119    crossterm::terminal::size().map(|(w, _)| w).unwrap_or(120)
120}
121
122impl Printer {
123    /// Creates a new printer with the specified format and verbosity.
124    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    /// Say which targets matched nothing: the JSON results (dry run, action,
134    /// the empty result and the refusal that asks for `--yes`) then list them
135    /// as `not_found`, an empty array when every target was found.
136    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    /// Opt into untruncated output (`--wide`). Values print in full and rows
142    /// may exceed the terminal width rather than being elided.
143    pub fn wide(mut self, wide: bool) -> Self {
144        self.wide = wide;
145        self
146    }
147
148    /// Create a printer from common CLI flags.
149    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    /// Print a success message (human only — use `print_empty_result` for JSON-safe success)
161    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            // JSON output handled separately
169            OutputFormat::Json => Ok(()),
170        }
171    }
172
173    /// Print a "nothing found" success result with JSON support.
174    ///
175    /// In human mode, prints a green success message. In JSON mode, outputs
176    /// structured JSON so scripts/LLMs always get parseable output.
177    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    /// Print a warning message
191    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            // JSON output handled separately
199            OutputFormat::Json => Ok(()),
200        }
201    }
202
203    /// Print a list of processes with action name and optional context.
204    ///
205    /// The `action` parameter sets the JSON `action` field (e.g. "list", "by", "stuck").
206    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    /// Print a list of processes with optional context (e.g., "in /path/to/dir").
219    /// Uses "list" as the JSON action name.
220    pub fn print_processes_with_context(&self, processes: &[Process], context: Option<&str>) {
221        self.print_processes_as("list", processes, context)
222    }
223
224    /// Print a list of processes. Uses "list" as the JSON action name.
225    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    /// `width` is the terminal width the table is fitted to.
234    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    /// Like `print_processes_as`, for a caller that has printed its own headline
264    /// (`⚠ Found 2 orphaned processes`): the generic `✓ Found 2 processes` line
265    /// would repeat it, so only the table is printed.
266    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    /// The process table (or the verbose list), without a headline, fitted to
277    /// a terminal `width` columns wide
278    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            // Verbose: full details, nothing truncated
286            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            // Disabled never reflows a cell, so one process is always one line.
335            // Fitting the terminal is handled by truncating content above, not
336            // by letting comfy-table wrap — a wrapped cell breaks the column
337            // alignment that makes the table readable in the first place.
338            table
339                .load_preset(NOTHING)
340                .set_content_arrangement(ContentArrangement::Disabled);
341
342            // Header
343            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            // Constraints mirror the budget above. In --wide mode no constraint
382            // is set at all, so every column sizes to its full content.
383            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))); // 6 content — fits "999999"
390                table
391                    .column_mut(4)
392                    .expect("CPU% column")
393                    .set_constraint(Absolute(Fixed(budget.cpu as u16))); // 6 content — fits "100.0"
394                table
395                    .column_mut(5)
396                    .expect("MEM column")
397                    .set_constraint(Absolute(Fixed(budget.mem as u16))); // 9 content — fits "9999.9MB"
398                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))); // 8 content
403                    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))); // 10 content
411            }
412
413            for proc in processes {
414                let status_str = format!("{:?}", proc.status);
415
416                // Show working directory (where the process was started from).
417                // Middle elision keeps the root and the tail that differs
418                // between rows; truncating either end alone loses one of them.
419                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                // Show command args (skip executable, simplify paths to filenames)
432                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                            // No args beyond the executable itself
459                            "-".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    /// Print JSON output for any serializable type
504    pub fn print_json<T: Serialize>(&self, data: &T) {
505        to_stdout(|out| self.write_json(out, &mut out::stderr(), data));
506    }
507
508    /// The JSON goes to `out`; a value that can't be serialized is reported on `err`.
509    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    /// Print action result (generalized for kill/stop/freeze/thaw).
522    ///
523    /// The `action` parameter is the command name (e.g. "kill", "stop", "freeze").
524    /// It is used as-is in JSON output and as the verb for human display.
525    ///
526    /// Returns `Err(ProcError::Reported)` when any process failed, so the command
527    /// exits non-zero without printing a second result.
528    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    /// Print action result where the JSON `action` differs from the verb shown
538    /// to humans (e.g. `proc orphans --kill` reports action "orphans", verb "kill").
539    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        // Past tense for human display: "kill" → "Killed", "stop" → "Stopped"
558        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        // The action has been done; a result nobody can read (a closed pipe, a
591        // full disk) is recorded by `out` and does not change what it was
592        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    /// The human lines of an action result: what worked, then what failed
610    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    /// Print dry-run summary for a destructive command.
660    ///
661    /// The `action` is the command name (e.g. "kill", "stop", "freeze", "thaw"),
662    /// used both as the JSON `action` and as the verb in the human summary.
663    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    /// Print dry-run summary with a custom human message.
677    ///
678    /// JSON output is `{"action", "success", "dry_run": true, "count", "not_found" (when the
679    /// printer was given targets), "processes"}`.
680    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    /// Show confirmation prompt and return whether the user confirmed.
698    ///
699    /// Returns `Ok(true)` if confirmed or skipped (--yes), `Ok(false)` if the
700    /// user declined (prints "Cancelled"), and an error when there's nobody to
701    /// ask (see [`must_confirm`]) or the list of what would change can't be
702    /// shown to them (see [`prompt`]).
703    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        // A person is being asked: `must_confirm` never gets here with --json
709        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    /// Print a confirmation prompt showing processes about to be acted on
726    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// JSON output structures
750#[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        // --yes: go ahead without asking, in either output format
828        assert!(!must_confirm("kill", true, false, &[], None).unwrap());
829        assert!(!must_confirm("kill", true, true, &[], None).unwrap());
830        // --json without --yes is refused (exit 4), never treated as a yes
831        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        // a command without targets has nothing to say about them
846        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    /// What a writer-based print writes, with colours off
853    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    /// `text` without the escape sequences a table adds when run in a terminal
861    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    /// Output with every run of spaces collapsed, one entry per non-empty line,
888    /// so a table row can be compared without its column padding
889    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        // 100 columns is the first width with room for UPTIME
972        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            // PID DIR NAME ARGS ...: the fourth cell of the row
1003            rows(&out)[1]
1004                .split(' ')
1005                .skip(3)
1006                .take_while(|c| *c != "0.0")
1007                .collect::<Vec<_>>()
1008                .join(" ")
1009        };
1010        // Paths shrink to their file name, flags and plain words stay
1011        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        // Paths with no file name stay as they are
1018        assert_eq!(args_of(Some("node / a/..")), "/ a/..");
1019        // The executable alone has no args; a process with no command line neither
1020        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        // Mixed: both blocks, then the error a command exits with
1108        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}