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