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::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/unstick).
462    ///
463    /// The `action` parameter should be a lowercase verb (e.g. "kill", "stop", "freeze").
464    /// It is used as-is in JSON output and capitalized for human display.
465    pub fn print_action_result(
466        &self,
467        action: &str,
468        succeeded: &[Process],
469        failed: &[(Process, String)],
470    ) {
471        // Capitalize for human display: "kill" → "Killed", "freeze" → "Frozen"
472        let past_tense = match action {
473            "kill" => "Killed".to_string(),
474            "freeze" => "Frozen".to_string(),
475            "resume" => "Resumed".to_string(),
476            _ => format!("{}{}ed", action[..1].to_uppercase(), &action[1..]),
477        };
478
479        match self.format {
480            OutputFormat::Human => {
481                if !succeeded.is_empty() {
482                    println!(
483                        "{} {} {} process{}",
484                        "✓".green().bold(),
485                        past_tense,
486                        succeeded.len().to_string().cyan().bold(),
487                        plural(succeeded.len())
488                    );
489                    for proc in succeeded {
490                        println!(
491                            "  {} {} [PID {}]",
492                            "→".bright_black(),
493                            proc.name.white(),
494                            proc.pid.to_string().cyan()
495                        );
496                    }
497                }
498                if !failed.is_empty() {
499                    println!(
500                        "{} Failed to {} {} process{}",
501                        "✗".red().bold(),
502                        action,
503                        failed.len(),
504                        plural(failed.len())
505                    );
506                    for (proc, err) in failed {
507                        println!(
508                            "  {} {} [PID {}]: {}",
509                            "→".bright_black(),
510                            proc.name.white(),
511                            proc.pid.to_string().cyan(),
512                            err.red()
513                        );
514                    }
515                }
516            }
517            OutputFormat::Json => {
518                self.print_json(&ActionOutput {
519                    action,
520                    success: failed.is_empty(),
521                    succeeded_count: succeeded.len(),
522                    failed_count: failed.len(),
523                    succeeded,
524                    failed: &failed
525                        .iter()
526                        .map(|(p, e)| FailedAction {
527                            process: p,
528                            error: e,
529                        })
530                        .collect::<Vec<_>>(),
531                });
532            }
533        }
534    }
535
536    /// Print kill result (delegates to print_action_result)
537    pub fn print_kill_result(&self, killed: &[Process], failed: &[(Process, String)]) {
538        self.print_action_result("kill", killed, failed);
539    }
540
541    /// Print dry-run summary and return early.
542    ///
543    /// Used by destructive commands (kill, stop, freeze, thaw) for consistent dry-run output.
544    pub fn print_dry_run(&self, verb: &str, processes: &[Process]) {
545        self.print_processes(processes);
546        self.warning(&format!(
547            "Dry run: would {} {} process{}",
548            verb,
549            processes.len(),
550            plural(processes.len())
551        ));
552    }
553
554    /// Show confirmation prompt and return whether the user confirmed.
555    ///
556    /// Returns `Ok(true)` if confirmed or skipped (--yes / --json).
557    /// Returns `Ok(false)` if the user declined (prints "Cancelled").
558    pub fn ask_confirm(&self, action: &str, processes: &[Process], yes: bool) -> Result<bool> {
559        if yes {
560            return Ok(true);
561        }
562        match self.format {
563            OutputFormat::Json => Ok(true),
564            OutputFormat::Human => {
565                self.print_confirmation(action, processes);
566                let prompt = format!(
567                    "{}{} {} process{}?",
568                    action[..1].to_uppercase(),
569                    &action[1..],
570                    processes.len(),
571                    plural(processes.len())
572                );
573                let confirmed = Confirm::new()
574                    .with_prompt(prompt)
575                    .default(false)
576                    .interact()?;
577                if !confirmed {
578                    self.warning("Cancelled");
579                }
580                Ok(confirmed)
581            }
582        }
583    }
584
585    /// Print a confirmation prompt showing processes about to be acted on
586    pub fn print_confirmation(&self, action: &str, processes: &[Process]) {
587        println!(
588            "\n{} Found {} process{} to {}:\n",
589            "⚠".yellow().bold(),
590            processes.len().to_string().cyan().bold(),
591            if processes.len() == 1 { "" } else { "es" },
592            action
593        );
594
595        for proc in processes {
596            println!(
597                "  {} {} [PID {}] - CPU: {:.1}%, MEM: {}",
598                "→".bright_black(),
599                proc.name.white().bold(),
600                proc.pid.to_string().cyan(),
601                proc.cpu_percent,
602                format_memory(proc.memory_mb)
603            );
604        }
605        println!();
606    }
607}
608
609// JSON output structures
610#[derive(Serialize)]
611struct EmptyResult<'a> {
612    action: &'a str,
613    success: bool,
614    count: usize,
615    message: &'a str,
616}
617
618#[derive(Serialize)]
619struct ProcessListOutput<'a> {
620    action: &'a str,
621    success: bool,
622    count: usize,
623    processes: &'a [Process],
624}
625
626#[derive(Serialize)]
627struct PortListOutput<'a> {
628    action: &'static str,
629    success: bool,
630    count: usize,
631    ports: &'a [PortInfo],
632}
633
634#[derive(Serialize)]
635struct SinglePortOutput<'a> {
636    action: &'static str,
637    success: bool,
638    port: &'a PortInfo,
639}
640
641#[derive(Serialize)]
642struct ActionOutput<'a> {
643    action: &'a str,
644    success: bool,
645    succeeded_count: usize,
646    failed_count: usize,
647    succeeded: &'a [Process],
648    failed: &'a [FailedAction<'a>],
649}
650
651#[derive(Serialize)]
652struct FailedAction<'a> {
653    process: &'a Process,
654    error: &'a str,
655}
656
657impl Default for Printer {
658    fn default() -> Self {
659        Self::new(OutputFormat::Human, false)
660    }
661}