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::{PortInfo, Process};
6use crate::error::{ExitCode, ProcError, Result};
7use crate::ui::format::{colorize_status, format_duration, format_memory, plural, truncate_string};
8use colored::*;
9use comfy_table::presets::NOTHING;
10use comfy_table::{Attribute, Cell, CellAlignment, Color, ContentArrangement, Table};
11use dialoguer::Confirm;
12use serde::Serialize;
13
14/// Output format selection
15#[derive(Debug, Clone, Copy, Default)]
16pub enum OutputFormat {
17    /// Colored, human-readable terminal output
18    #[default]
19    Human,
20    /// Machine-readable JSON output for scripting
21    Json,
22}
23
24/// Main printer for CLI output
25pub struct Printer {
26    format: OutputFormat,
27    verbose: bool,
28}
29
30/// Detect terminal width, falling back to 120 when stdout is not a TTY.
31fn terminal_width() -> u16 {
32    crossterm::terminal::size().map(|(w, _)| w).unwrap_or(120)
33}
34
35impl Printer {
36    /// Creates a new printer with the specified format and verbosity.
37    pub fn new(format: OutputFormat, verbose: bool) -> Self {
38        Self { format, verbose }
39    }
40
41    /// Create a printer from common CLI flags.
42    pub fn from_flags(json: bool, verbose: bool) -> Self {
43        Self::new(
44            if json {
45                OutputFormat::Json
46            } else {
47                OutputFormat::Human
48            },
49            verbose,
50        )
51    }
52
53    /// Print a success message (human only — use `print_empty_result` for JSON-safe success)
54    pub fn success(&self, message: &str) {
55        match self.format {
56            OutputFormat::Human => {
57                println!("{} {}", "✓".green().bold(), message.green());
58            }
59            OutputFormat::Json => {
60                // JSON output handled separately
61            }
62        }
63    }
64
65    /// Print a "nothing found" success result with JSON support.
66    ///
67    /// In human mode, prints a green success message. In JSON mode, outputs
68    /// structured JSON so scripts/LLMs always get parseable output.
69    pub fn print_empty_result(&self, action: &str, message: &str) {
70        match self.format {
71            OutputFormat::Human => self.success(message),
72            OutputFormat::Json => self.print_json(&EmptyResult {
73                action,
74                success: true,
75                count: 0,
76                message,
77            }),
78        }
79    }
80
81    /// Print an error message
82    pub fn error(&self, message: &str) {
83        match self.format {
84            OutputFormat::Human => {
85                eprintln!("{} {}", "✗".red().bold(), message.red());
86            }
87            OutputFormat::Json => {
88                // JSON output handled separately
89            }
90        }
91    }
92
93    /// Print a warning message
94    pub fn warning(&self, message: &str) {
95        match self.format {
96            OutputFormat::Human => {
97                println!("{} {}", "⚠".yellow().bold(), message.yellow());
98            }
99            OutputFormat::Json => {
100                // JSON output handled separately
101            }
102        }
103    }
104
105    /// Print a list of processes with action name and optional context.
106    ///
107    /// The `action` parameter sets the JSON `action` field (e.g. "list", "by", "stuck").
108    pub fn print_processes_as(&self, action: &str, processes: &[Process], context: Option<&str>) {
109        match self.format {
110            OutputFormat::Human => self.print_processes_human(processes, context),
111            OutputFormat::Json => self.print_json(&ProcessListOutput {
112                action,
113                success: true,
114                count: processes.len(),
115                processes,
116            }),
117        }
118    }
119
120    /// Print a list of processes with optional context (e.g., "in /path/to/dir").
121    /// Uses "list" as the JSON action name.
122    pub fn print_processes_with_context(&self, processes: &[Process], context: Option<&str>) {
123        self.print_processes_as("list", processes, context)
124    }
125
126    /// Print a list of processes. Uses "list" as the JSON action name.
127    pub fn print_processes(&self, processes: &[Process]) {
128        self.print_processes_with_context(processes, None)
129    }
130
131    fn print_processes_human(&self, processes: &[Process], context: Option<&str>) {
132        if processes.is_empty() {
133            let msg = match context {
134                Some(ctx) => format!("No processes found {}", ctx),
135                None => "No processes found".to_string(),
136            };
137            self.warning(&msg);
138            return;
139        }
140
141        let context_str = context.map(|c| format!(" {}", c)).unwrap_or_default();
142        println!(
143            "{} Found {} process{}{}",
144            "✓".green().bold(),
145            processes.len().to_string().cyan().bold(),
146            if processes.len() == 1 { "" } else { "es" },
147            context_str.bright_black()
148        );
149        println!();
150
151        self.print_process_rows(processes);
152    }
153
154    /// Like `print_processes_as`, for a caller that has printed its own headline
155    /// (`⚠ Found 2 orphaned processes`): the generic `✓ Found 2 processes` line
156    /// would repeat it, so only the table is printed.
157    pub fn print_processes_under_headline(&self, action: &str, processes: &[Process]) {
158        match self.format {
159            OutputFormat::Human => {
160                println!();
161                self.print_process_rows(processes);
162            }
163            OutputFormat::Json => self.print_processes_as(action, processes, None),
164        }
165    }
166
167    /// The process table (or the verbose list), without a headline
168    fn print_process_rows(&self, processes: &[Process]) {
169        if self.verbose {
170            // Verbose: full details, nothing truncated
171            for proc in processes {
172                let status_str = format!("{:?}", proc.status);
173                let status_colored = colorize_status(&proc.status, &status_str);
174
175                println!(
176                    "{} {} {}  {:.1}% CPU  {}  {}",
177                    proc.pid.to_string().cyan().bold(),
178                    proc.name.white().bold(),
179                    format!("[{}]", status_colored).bright_black(),
180                    proc.cpu_percent,
181                    format_memory(proc.memory_mb),
182                    proc.user.as_deref().unwrap_or("-").bright_black()
183                );
184
185                if let Some(ref cmd) = proc.command {
186                    println!("    {} {}", "cmd:".bright_black(), cmd);
187                }
188                if let Some(ref path) = proc.exe_path {
189                    println!("    {} {}", "exe:".bright_black(), path.bright_black());
190                }
191                if let Some(ref cwd) = proc.cwd {
192                    println!("    {} {}", "cwd:".bright_black(), cwd.bright_black());
193                }
194                if let Some(ppid) = proc.parent_pid {
195                    println!(
196                        "    {} {}",
197                        "parent:".bright_black(),
198                        ppid.to_string().bright_black()
199                    );
200                }
201                if let Some(uptime) = proc.uptime_secs {
202                    println!(
203                        "    {} {}",
204                        "uptime:".bright_black(),
205                        format_duration(uptime).bright_black()
206                    );
207                }
208                println!();
209            }
210        } else {
211            let width = terminal_width();
212            // UPTIME only fits next to a readable ARGS column on wider terminals
213            let show_uptime = width >= 100;
214
215            let mut table = Table::new();
216            table
217                .load_preset(NOTHING)
218                .set_content_arrangement(ContentArrangement::Dynamic)
219                .set_width(width);
220
221            // Header
222            let mut header = vec![
223                Cell::new("PID")
224                    .fg(Color::Blue)
225                    .add_attribute(Attribute::Bold),
226                Cell::new("DIR")
227                    .fg(Color::Blue)
228                    .add_attribute(Attribute::Bold),
229                Cell::new("NAME")
230                    .fg(Color::Blue)
231                    .add_attribute(Attribute::Bold),
232                Cell::new("ARGS")
233                    .fg(Color::Blue)
234                    .add_attribute(Attribute::Bold),
235                Cell::new("CPU%")
236                    .fg(Color::Blue)
237                    .add_attribute(Attribute::Bold)
238                    .set_alignment(CellAlignment::Right),
239                Cell::new("MEM")
240                    .fg(Color::Blue)
241                    .add_attribute(Attribute::Bold)
242                    .set_alignment(CellAlignment::Right),
243            ];
244            if show_uptime {
245                header.push(
246                    Cell::new("UPTIME")
247                        .fg(Color::Blue)
248                        .add_attribute(Attribute::Bold)
249                        .set_alignment(CellAlignment::Right),
250                );
251            }
252            header.push(
253                Cell::new("STATUS")
254                    .fg(Color::Blue)
255                    .add_attribute(Attribute::Bold)
256                    .set_alignment(CellAlignment::Right),
257            );
258            table.set_header(header);
259
260            // Set fixed-width columns and flexible ones
261            use comfy_table::ColumnConstraint::*;
262            use comfy_table::Width::*;
263            // Fixed widths must account for comfy-table's per-cell padding (1 left + 1 right = 2)
264            // Content width = Fixed(N) - 2
265            table
266                .column_mut(0)
267                .expect("PID column")
268                .set_constraint(Absolute(Fixed(8))); // 6 content — fits "999999"
269            table
270                .column_mut(1)
271                .expect("DIR column")
272                .set_constraint(LowerBoundary(Fixed(20)));
273            table
274                .column_mut(2)
275                .expect("NAME column")
276                .set_constraint(LowerBoundary(Fixed(10)));
277            // ARGS: flexible but capped so it doesn't squeeze other columns
278            let args_max = (width / 2).max(30);
279            table
280                .column_mut(3)
281                .expect("ARGS column")
282                .set_constraint(UpperBoundary(Fixed(args_max)));
283            table
284                .column_mut(4)
285                .expect("CPU% column")
286                .set_constraint(Absolute(Fixed(8))); // 6 content — fits "100.0"
287            table
288                .column_mut(5)
289                .expect("MEM column")
290                .set_constraint(Absolute(Fixed(11))); // 9 content — fits "9999.9MB"
291            let status_column = if show_uptime {
292                table
293                    .column_mut(6)
294                    .expect("UPTIME column")
295                    .set_constraint(Absolute(Fixed(10))); // 8 content — fits "999d 23h"
296                7
297            } else {
298                6
299            };
300            table
301                .column_mut(status_column)
302                .expect("STATUS column")
303                .set_constraint(Absolute(Fixed(12))); // 10 content — fits "Sleeping"
304
305            for proc in processes {
306                let status_str = format!("{:?}", proc.status);
307
308                // Show working directory (where the process was started from)
309                let path_display = proc.cwd.as_deref().unwrap_or("-").to_string();
310
311                // Show command args (skip executable, simplify paths to filenames)
312                let cmd_display = proc
313                    .command
314                    .as_ref()
315                    .map(|c| {
316                        let parts: Vec<&str> = c.split_whitespace().collect();
317                        if parts.len() > 1 {
318                            let args: Vec<String> = parts[1..]
319                                .iter()
320                                .map(|arg| {
321                                    if arg.contains('/') && !arg.starts_with('-') {
322                                        std::path::Path::new(arg)
323                                            .file_name()
324                                            .map(|f| f.to_string_lossy().to_string())
325                                            .unwrap_or_else(|| arg.to_string())
326                                    } else {
327                                        arg.to_string()
328                                    }
329                                })
330                                .collect();
331                            let result = args.join(" ");
332                            if result.is_empty() {
333                                "-".to_string()
334                            } else {
335                                truncate_string(&result, (args_max as usize).saturating_sub(2))
336                            }
337                        } else {
338                            // No args beyond the executable itself
339                            "-".to_string()
340                        }
341                    })
342                    .unwrap_or_else(|| "-".to_string());
343
344                let mem_display = format_memory(proc.memory_mb);
345
346                let status_color = match proc.status {
347                    crate::core::ProcessStatus::Running => Color::Green,
348                    crate::core::ProcessStatus::Sleeping => Color::Blue,
349                    crate::core::ProcessStatus::Stopped => Color::Yellow,
350                    crate::core::ProcessStatus::Zombie => Color::Red,
351                    _ => Color::White,
352                };
353
354                let mut row = vec![
355                    Cell::new(proc.pid).fg(Color::Cyan),
356                    Cell::new(&path_display).fg(Color::DarkGrey),
357                    Cell::new(&proc.name).fg(Color::White),
358                    Cell::new(&cmd_display).fg(Color::DarkGrey),
359                    Cell::new(format!("{:.1}", proc.cpu_percent))
360                        .set_alignment(CellAlignment::Right),
361                    Cell::new(&mem_display).set_alignment(CellAlignment::Right),
362                ];
363                if show_uptime {
364                    let uptime_display = proc
365                        .uptime_secs
366                        .map(format_duration)
367                        .unwrap_or_else(|| "-".to_string());
368                    row.push(Cell::new(uptime_display).set_alignment(CellAlignment::Right));
369                }
370                row.push(
371                    Cell::new(&status_str)
372                        .fg(status_color)
373                        .set_alignment(CellAlignment::Right),
374                );
375                table.add_row(row);
376            }
377
378            println!("{table}");
379        }
380        println!();
381    }
382
383    /// Print port information
384    pub fn print_ports(&self, ports: &[PortInfo]) {
385        match self.format {
386            OutputFormat::Human => self.print_ports_human(ports),
387            OutputFormat::Json => self.print_json(&PortListOutput {
388                action: "ports",
389                success: true,
390                count: ports.len(),
391                ports,
392            }),
393        }
394    }
395
396    fn print_ports_human(&self, ports: &[PortInfo]) {
397        if ports.is_empty() {
398            self.warning("No listening ports found");
399            return;
400        }
401
402        println!(
403            "{} Found {} listening port{}",
404            "✓".green().bold(),
405            ports.len().to_string().cyan().bold(),
406            if ports.len() == 1 { "" } else { "s" }
407        );
408        println!();
409
410        let width = terminal_width();
411
412        let mut table = Table::new();
413        table
414            .load_preset(NOTHING)
415            .set_content_arrangement(ContentArrangement::Dynamic)
416            .set_width(width);
417
418        table.set_header(vec![
419            Cell::new("PORT")
420                .fg(Color::Blue)
421                .add_attribute(Attribute::Bold),
422            Cell::new("PROTO")
423                .fg(Color::Blue)
424                .add_attribute(Attribute::Bold),
425            Cell::new("PID")
426                .fg(Color::Blue)
427                .add_attribute(Attribute::Bold),
428            Cell::new("PROCESS")
429                .fg(Color::Blue)
430                .add_attribute(Attribute::Bold),
431            Cell::new("ADDRESS")
432                .fg(Color::Blue)
433                .add_attribute(Attribute::Bold),
434        ]);
435
436        use comfy_table::ColumnConstraint::*;
437        use comfy_table::Width::*;
438        table
439            .column_mut(0)
440            .expect("PORT column")
441            .set_constraint(Absolute(Fixed(8)));
442        table
443            .column_mut(1)
444            .expect("PROTO column")
445            .set_constraint(Absolute(Fixed(6)));
446        table
447            .column_mut(2)
448            .expect("PID column")
449            .set_constraint(Absolute(Fixed(8)));
450        table
451            .column_mut(3)
452            .expect("PROCESS column")
453            .set_constraint(LowerBoundary(Fixed(12)));
454        table
455            .column_mut(4)
456            .expect("ADDRESS column")
457            .set_constraint(LowerBoundary(Fixed(10)));
458
459        for port in ports {
460            let addr = port.address.as_deref().unwrap_or("*");
461            let proto = format!("{:?}", port.protocol).to_uppercase();
462
463            table.add_row(vec![
464                Cell::new(port.port).fg(Color::Cyan),
465                Cell::new(&proto).fg(Color::White),
466                Cell::new(port.pid).fg(Color::Cyan),
467                Cell::new(truncate_string(&port.process_name, 19)).fg(Color::White),
468                Cell::new(addr).fg(Color::DarkGrey),
469            ]);
470        }
471
472        println!("{table}");
473        println!();
474    }
475
476    /// Print a single port info (for `proc on :port`)
477    pub fn print_port_info(&self, port_info: &PortInfo) {
478        match self.format {
479            OutputFormat::Human => {
480                println!(
481                    "{} Process on port {}:",
482                    "✓".green().bold(),
483                    port_info.port.to_string().cyan().bold()
484                );
485                println!();
486                println!(
487                    "  {} {}",
488                    "Name:".bright_black(),
489                    port_info.process_name.white().bold()
490                );
491                println!(
492                    "  {} {}",
493                    "PID:".bright_black(),
494                    port_info.pid.to_string().cyan()
495                );
496                println!("  {} {:?}", "Protocol:".bright_black(), port_info.protocol);
497                if let Some(ref addr) = port_info.address {
498                    println!("  {} {}", "Address:".bright_black(), addr);
499                }
500                println!();
501            }
502            OutputFormat::Json => self.print_json(&SinglePortOutput {
503                action: "on",
504                success: true,
505                port: port_info,
506            }),
507        }
508    }
509
510    /// Print JSON output for any serializable type
511    pub fn print_json<T: Serialize>(&self, data: &T) {
512        match serde_json::to_string_pretty(data) {
513            Ok(json) => println!("{}", json),
514            Err(e) => eprintln!("Failed to serialize JSON: {}", e),
515        }
516    }
517
518    /// Print action result (generalized for kill/stop/freeze/thaw).
519    ///
520    /// The `action` parameter is the command name (e.g. "kill", "stop", "freeze").
521    /// It is used as-is in JSON output and as the verb for human display.
522    ///
523    /// Returns `Err(ProcError::Reported)` when any process failed, so the command
524    /// exits non-zero without printing a second result.
525    pub fn print_action_result(
526        &self,
527        action: &str,
528        succeeded: &[Process],
529        failed: &[(Process, String)],
530    ) -> Result<()> {
531        self.print_action_result_as(action, action, succeeded, failed)
532    }
533
534    /// Print action result where the JSON `action` differs from the verb shown
535    /// to humans (e.g. `proc orphans --kill` reports action "orphans", verb "kill").
536    pub fn print_action_result_as(
537        &self,
538        action: &str,
539        verb: &str,
540        succeeded: &[Process],
541        failed: &[(Process, String)],
542    ) -> Result<()> {
543        // Past tense for human display: "kill" → "Killed", "stop" → "Stopped"
544        let past_tense = match verb {
545            "kill" => "Killed".to_string(),
546            "stop" => "Stopped".to_string(),
547            "freeze" => "Frozen".to_string(),
548            "thaw" => "Thawed".to_string(),
549            _ => format!("{}{}ed", verb[..1].to_uppercase(), &verb[1..]),
550        };
551
552        match self.format {
553            OutputFormat::Human => {
554                if !succeeded.is_empty() {
555                    println!(
556                        "{} {} {} process{}",
557                        "✓".green().bold(),
558                        past_tense,
559                        succeeded.len().to_string().cyan().bold(),
560                        plural(succeeded.len())
561                    );
562                    for proc in succeeded {
563                        println!(
564                            "  {} {} [PID {}]",
565                            "→".bright_black(),
566                            proc.name.white(),
567                            proc.pid.to_string().cyan()
568                        );
569                    }
570                }
571                if !failed.is_empty() {
572                    println!(
573                        "{} Failed to {} {} process{}",
574                        "✗".red().bold(),
575                        verb,
576                        failed.len(),
577                        plural(failed.len())
578                    );
579                    for (proc, err) in failed {
580                        println!(
581                            "  {} {} [PID {}]: {}",
582                            "→".bright_black(),
583                            proc.name.white(),
584                            proc.pid.to_string().cyan(),
585                            err.red()
586                        );
587                    }
588                }
589            }
590            OutputFormat::Json => {
591                self.print_json(&ActionOutput {
592                    action,
593                    success: failed.is_empty(),
594                    succeeded_count: succeeded.len(),
595                    failed_count: failed.len(),
596                    succeeded,
597                    failed: &failed
598                        .iter()
599                        .map(|(p, e)| FailedAction {
600                            process: p,
601                            error: e,
602                        })
603                        .collect::<Vec<_>>(),
604                });
605            }
606        }
607
608        if failed.is_empty() {
609            Ok(())
610        } else {
611            Err(ProcError::reported(
612                ExitCode::GeneralError,
613                format!(
614                    "Failed to {} {} process{}",
615                    verb,
616                    failed.len(),
617                    plural(failed.len())
618                ),
619            ))
620        }
621    }
622
623    /// Print kill result (delegates to print_action_result)
624    pub fn print_kill_result(
625        &self,
626        killed: &[Process],
627        failed: &[(Process, String)],
628    ) -> Result<()> {
629        self.print_action_result("kill", killed, failed)
630    }
631
632    /// Print dry-run summary for a destructive command.
633    ///
634    /// The `action` is the command name (e.g. "kill", "stop", "freeze", "thaw"),
635    /// used both as the JSON `action` and as the verb in the human summary.
636    pub fn print_dry_run(&self, action: &str, processes: &[Process]) {
637        self.print_dry_run_with(
638            action,
639            processes,
640            &format!(
641                "Dry run: would {} {} process{}",
642                action,
643                processes.len(),
644                plural(processes.len())
645            ),
646        );
647    }
648
649    /// Print dry-run summary with a custom human message.
650    ///
651    /// JSON output is always `{"action", "success", "dry_run": true, "count", "processes"}`.
652    pub fn print_dry_run_with(&self, action: &str, processes: &[Process], message: &str) {
653        match self.format {
654            OutputFormat::Human => {
655                self.print_processes_human(processes, None);
656                self.warning(message);
657            }
658            OutputFormat::Json => self.print_json(&DryRunOutput {
659                action,
660                success: true,
661                dry_run: true,
662                count: processes.len(),
663                processes,
664            }),
665        }
666    }
667
668    /// Show confirmation prompt and return whether the user confirmed.
669    ///
670    /// Returns `Ok(true)` if confirmed or skipped (--yes / --json).
671    /// Returns `Ok(false)` if the user declined (prints "Cancelled").
672    pub fn ask_confirm(&self, action: &str, processes: &[Process], yes: bool) -> Result<bool> {
673        if yes {
674            return Ok(true);
675        }
676        match self.format {
677            OutputFormat::Json => Ok(true),
678            OutputFormat::Human => {
679                self.print_confirmation(action, processes);
680                let prompt = format!(
681                    "{}{} {} process{}?",
682                    action[..1].to_uppercase(),
683                    &action[1..],
684                    processes.len(),
685                    plural(processes.len())
686                );
687                let confirmed = Confirm::new()
688                    .with_prompt(prompt)
689                    .default(false)
690                    .interact()?;
691                if !confirmed {
692                    self.warning("Cancelled");
693                }
694                Ok(confirmed)
695            }
696        }
697    }
698
699    /// Print a confirmation prompt showing processes about to be acted on
700    pub fn print_confirmation(&self, action: &str, processes: &[Process]) {
701        println!(
702            "\n{} Found {} process{} to {}:\n",
703            "⚠".yellow().bold(),
704            processes.len().to_string().cyan().bold(),
705            if processes.len() == 1 { "" } else { "es" },
706            action
707        );
708
709        for proc in processes {
710            println!(
711                "  {} {} [PID {}] - CPU: {:.1}%, MEM: {}",
712                "→".bright_black(),
713                proc.name.white().bold(),
714                proc.pid.to_string().cyan(),
715                proc.cpu_percent,
716                format_memory(proc.memory_mb)
717            );
718        }
719        println!();
720    }
721}
722
723// JSON output structures
724#[derive(Serialize)]
725struct EmptyResult<'a> {
726    action: &'a str,
727    success: bool,
728    count: usize,
729    message: &'a str,
730}
731
732#[derive(Serialize)]
733struct ProcessListOutput<'a> {
734    action: &'a str,
735    success: bool,
736    count: usize,
737    processes: &'a [Process],
738}
739
740#[derive(Serialize)]
741struct DryRunOutput<'a> {
742    action: &'a str,
743    success: bool,
744    dry_run: bool,
745    count: usize,
746    processes: &'a [Process],
747}
748
749#[derive(Serialize)]
750struct PortListOutput<'a> {
751    action: &'static str,
752    success: bool,
753    count: usize,
754    ports: &'a [PortInfo],
755}
756
757#[derive(Serialize)]
758struct SinglePortOutput<'a> {
759    action: &'static str,
760    success: bool,
761    port: &'a PortInfo,
762}
763
764#[derive(Serialize)]
765struct ActionOutput<'a> {
766    action: &'a str,
767    success: bool,
768    succeeded_count: usize,
769    failed_count: usize,
770    succeeded: &'a [Process],
771    failed: &'a [FailedAction<'a>],
772}
773
774#[derive(Serialize)]
775struct FailedAction<'a> {
776    process: &'a Process,
777    error: &'a str,
778}
779
780impl Default for Printer {
781    fn default() -> Self {
782        Self::new(OutputFormat::Human, false)
783    }
784}