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