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::ui::format::{colorize_status, format_memory, truncate_string};
7use colored::*;
8use comfy_table::presets::NOTHING;
9use comfy_table::{Attribute, Cell, CellAlignment, Color, ContentArrangement, Table};
10use serde::Serialize;
11
12/// Output format selection
13#[derive(Debug, Clone, Copy, Default)]
14pub enum OutputFormat {
15    /// Colored, human-readable terminal output
16    #[default]
17    Human,
18    /// Machine-readable JSON output for scripting
19    Json,
20}
21
22/// Main printer for CLI output
23pub struct Printer {
24    format: OutputFormat,
25    verbose: bool,
26}
27
28/// Detect terminal width, falling back to 120 when stdout is not a TTY.
29fn terminal_width() -> u16 {
30    crossterm::terminal::size().map(|(w, _)| w).unwrap_or(120)
31}
32
33impl Printer {
34    /// Creates a new printer with the specified format and verbosity.
35    pub fn new(format: OutputFormat, verbose: bool) -> Self {
36        Self { format, verbose }
37    }
38
39    /// Print a success message
40    pub fn success(&self, message: &str) {
41        match self.format {
42            OutputFormat::Human => {
43                println!("{} {}", "✓".green().bold(), message.green());
44            }
45            OutputFormat::Json => {
46                // JSON output handled separately
47            }
48        }
49    }
50
51    /// Print an error message
52    pub fn error(&self, message: &str) {
53        match self.format {
54            OutputFormat::Human => {
55                eprintln!("{} {}", "✗".red().bold(), message.red());
56            }
57            OutputFormat::Json => {
58                // JSON output handled separately
59            }
60        }
61    }
62
63    /// Print a warning message
64    pub fn warning(&self, message: &str) {
65        match self.format {
66            OutputFormat::Human => {
67                println!("{} {}", "⚠".yellow().bold(), message.yellow());
68            }
69            OutputFormat::Json => {
70                // JSON output handled separately
71            }
72        }
73    }
74
75    /// Print a list of processes with optional context (e.g., "in /path/to/dir")
76    pub fn print_processes_with_context(&self, processes: &[Process], context: Option<&str>) {
77        match self.format {
78            OutputFormat::Human => self.print_processes_human(processes, context),
79            OutputFormat::Json => self.print_json(&ProcessListOutput {
80                action: "list",
81                success: true,
82                count: processes.len(),
83                processes,
84            }),
85        }
86    }
87
88    /// Print a list of processes
89    pub fn print_processes(&self, processes: &[Process]) {
90        self.print_processes_with_context(processes, None)
91    }
92
93    fn print_processes_human(&self, processes: &[Process], context: Option<&str>) {
94        if processes.is_empty() {
95            let msg = match context {
96                Some(ctx) => format!("No processes found {}", ctx),
97                None => "No processes found".to_string(),
98            };
99            self.warning(&msg);
100            return;
101        }
102
103        let context_str = context.map(|c| format!(" {}", c)).unwrap_or_default();
104        println!(
105            "{} Found {} process{}{}",
106            "✓".green().bold(),
107            processes.len().to_string().cyan().bold(),
108            if processes.len() == 1 { "" } else { "es" },
109            context_str.bright_black()
110        );
111        println!();
112
113        if self.verbose {
114            // Verbose: full details, nothing truncated
115            for proc in processes {
116                let status_str = format!("{:?}", proc.status);
117                let status_colored = colorize_status(&proc.status, &status_str);
118
119                println!(
120                    "{} {} {}  {:.1}% CPU  {}  {}",
121                    proc.pid.to_string().cyan().bold(),
122                    proc.name.white().bold(),
123                    format!("[{}]", status_colored).bright_black(),
124                    proc.cpu_percent,
125                    format_memory(proc.memory_mb),
126                    proc.user.as_deref().unwrap_or("-").bright_black()
127                );
128
129                if let Some(ref cmd) = proc.command {
130                    println!("    {} {}", "cmd:".bright_black(), cmd);
131                }
132                if let Some(ref path) = proc.exe_path {
133                    println!("    {} {}", "exe:".bright_black(), path.bright_black());
134                }
135                if let Some(ref cwd) = proc.cwd {
136                    println!("    {} {}", "cwd:".bright_black(), cwd.bright_black());
137                }
138                if let Some(ppid) = proc.parent_pid {
139                    println!(
140                        "    {} {}",
141                        "parent:".bright_black(),
142                        ppid.to_string().bright_black()
143                    );
144                }
145                println!();
146            }
147        } else {
148            let width = terminal_width();
149
150            let mut table = Table::new();
151            table
152                .load_preset(NOTHING)
153                .set_content_arrangement(ContentArrangement::Dynamic)
154                .set_width(width);
155
156            // Header
157            table.set_header(vec![
158                Cell::new("PID")
159                    .fg(Color::Blue)
160                    .add_attribute(Attribute::Bold),
161                Cell::new("DIR")
162                    .fg(Color::Blue)
163                    .add_attribute(Attribute::Bold),
164                Cell::new("NAME")
165                    .fg(Color::Blue)
166                    .add_attribute(Attribute::Bold),
167                Cell::new("ARGS")
168                    .fg(Color::Blue)
169                    .add_attribute(Attribute::Bold),
170                Cell::new("CPU%")
171                    .fg(Color::Blue)
172                    .add_attribute(Attribute::Bold)
173                    .set_alignment(CellAlignment::Right),
174                Cell::new("MEM")
175                    .fg(Color::Blue)
176                    .add_attribute(Attribute::Bold)
177                    .set_alignment(CellAlignment::Right),
178                Cell::new("STATUS")
179                    .fg(Color::Blue)
180                    .add_attribute(Attribute::Bold)
181                    .set_alignment(CellAlignment::Right),
182            ]);
183
184            // Set fixed-width columns and flexible ones
185            use comfy_table::ColumnConstraint::*;
186            use comfy_table::Width::*;
187            // Fixed widths must account for comfy-table's per-cell padding (1 left + 1 right = 2)
188            // Content width = Fixed(N) - 2
189            table
190                .column_mut(0)
191                .expect("PID column")
192                .set_constraint(Absolute(Fixed(8))); // 6 content — fits "999999"
193            table
194                .column_mut(1)
195                .expect("DIR column")
196                .set_constraint(LowerBoundary(Fixed(20)));
197            table
198                .column_mut(2)
199                .expect("NAME column")
200                .set_constraint(LowerBoundary(Fixed(10)));
201            // ARGS: flexible but capped so it doesn't squeeze other columns
202            let args_max = (width / 2).max(30);
203            table
204                .column_mut(3)
205                .expect("ARGS column")
206                .set_constraint(UpperBoundary(Fixed(args_max)));
207            table
208                .column_mut(4)
209                .expect("CPU% column")
210                .set_constraint(Absolute(Fixed(8))); // 6 content — fits "100.0"
211            table
212                .column_mut(5)
213                .expect("MEM column")
214                .set_constraint(Absolute(Fixed(11))); // 9 content — fits "9999.9MB"
215            table
216                .column_mut(6)
217                .expect("STATUS column")
218                .set_constraint(Absolute(Fixed(12))); // 10 content — fits "Sleeping"
219
220            for proc in processes {
221                let status_str = format!("{:?}", proc.status);
222
223                // Show working directory (where the process was started from)
224                let path_display = proc.cwd.as_deref().unwrap_or("-").to_string();
225
226                // Show command args (skip executable, simplify paths to filenames)
227                let cmd_display = proc
228                    .command
229                    .as_ref()
230                    .map(|c| {
231                        let parts: Vec<&str> = c.split_whitespace().collect();
232                        if parts.len() > 1 {
233                            let args: Vec<String> = parts[1..]
234                                .iter()
235                                .map(|arg| {
236                                    if arg.contains('/') && !arg.starts_with('-') {
237                                        std::path::Path::new(arg)
238                                            .file_name()
239                                            .map(|f| f.to_string_lossy().to_string())
240                                            .unwrap_or_else(|| arg.to_string())
241                                    } else {
242                                        arg.to_string()
243                                    }
244                                })
245                                .collect();
246                            let result = args.join(" ");
247                            if result.is_empty() {
248                                "-".to_string()
249                            } else {
250                                truncate_string(&result, (args_max as usize).saturating_sub(2))
251                            }
252                        } else {
253                            // No args beyond the executable itself
254                            "-".to_string()
255                        }
256                    })
257                    .unwrap_or_else(|| "-".to_string());
258
259                let mem_display = format_memory(proc.memory_mb);
260
261                let status_color = match proc.status {
262                    crate::core::ProcessStatus::Running => Color::Green,
263                    crate::core::ProcessStatus::Sleeping => Color::Blue,
264                    crate::core::ProcessStatus::Stopped => Color::Yellow,
265                    crate::core::ProcessStatus::Zombie => Color::Red,
266                    _ => Color::White,
267                };
268
269                table.add_row(vec![
270                    Cell::new(proc.pid).fg(Color::Cyan),
271                    Cell::new(&path_display).fg(Color::DarkGrey),
272                    Cell::new(&proc.name).fg(Color::White),
273                    Cell::new(&cmd_display).fg(Color::DarkGrey),
274                    Cell::new(format!("{:.1}", proc.cpu_percent))
275                        .set_alignment(CellAlignment::Right),
276                    Cell::new(&mem_display).set_alignment(CellAlignment::Right),
277                    Cell::new(&status_str)
278                        .fg(status_color)
279                        .set_alignment(CellAlignment::Right),
280                ]);
281            }
282
283            println!("{table}");
284        }
285        println!();
286    }
287
288    /// Print port information
289    pub fn print_ports(&self, ports: &[PortInfo]) {
290        match self.format {
291            OutputFormat::Human => self.print_ports_human(ports),
292            OutputFormat::Json => self.print_json(&PortListOutput {
293                action: "ports",
294                success: true,
295                count: ports.len(),
296                ports,
297            }),
298        }
299    }
300
301    fn print_ports_human(&self, ports: &[PortInfo]) {
302        if ports.is_empty() {
303            self.warning("No listening ports found");
304            return;
305        }
306
307        println!(
308            "{} Found {} listening port{}",
309            "✓".green().bold(),
310            ports.len().to_string().cyan().bold(),
311            if ports.len() == 1 { "" } else { "s" }
312        );
313        println!();
314
315        let width = terminal_width();
316
317        let mut table = Table::new();
318        table
319            .load_preset(NOTHING)
320            .set_content_arrangement(ContentArrangement::Dynamic)
321            .set_width(width);
322
323        table.set_header(vec![
324            Cell::new("PORT")
325                .fg(Color::Blue)
326                .add_attribute(Attribute::Bold),
327            Cell::new("PROTO")
328                .fg(Color::Blue)
329                .add_attribute(Attribute::Bold),
330            Cell::new("PID")
331                .fg(Color::Blue)
332                .add_attribute(Attribute::Bold),
333            Cell::new("PROCESS")
334                .fg(Color::Blue)
335                .add_attribute(Attribute::Bold),
336            Cell::new("ADDRESS")
337                .fg(Color::Blue)
338                .add_attribute(Attribute::Bold),
339        ]);
340
341        use comfy_table::ColumnConstraint::*;
342        use comfy_table::Width::*;
343        table
344            .column_mut(0)
345            .expect("PORT column")
346            .set_constraint(Absolute(Fixed(8)));
347        table
348            .column_mut(1)
349            .expect("PROTO column")
350            .set_constraint(Absolute(Fixed(6)));
351        table
352            .column_mut(2)
353            .expect("PID column")
354            .set_constraint(Absolute(Fixed(8)));
355        table
356            .column_mut(3)
357            .expect("PROCESS column")
358            .set_constraint(LowerBoundary(Fixed(12)));
359        table
360            .column_mut(4)
361            .expect("ADDRESS column")
362            .set_constraint(LowerBoundary(Fixed(10)));
363
364        for port in ports {
365            let addr = port.address.as_deref().unwrap_or("*");
366            let proto = format!("{:?}", port.protocol).to_uppercase();
367
368            table.add_row(vec![
369                Cell::new(port.port).fg(Color::Cyan),
370                Cell::new(&proto).fg(Color::White),
371                Cell::new(port.pid).fg(Color::Cyan),
372                Cell::new(truncate_string(&port.process_name, 19)).fg(Color::White),
373                Cell::new(addr).fg(Color::DarkGrey),
374            ]);
375        }
376
377        println!("{table}");
378        println!();
379    }
380
381    /// Print a single port info (for `proc on :port`)
382    pub fn print_port_info(&self, port_info: &PortInfo) {
383        match self.format {
384            OutputFormat::Human => {
385                println!(
386                    "{} Process on port {}:",
387                    "✓".green().bold(),
388                    port_info.port.to_string().cyan().bold()
389                );
390                println!();
391                println!(
392                    "  {} {}",
393                    "Name:".bright_black(),
394                    port_info.process_name.white().bold()
395                );
396                println!(
397                    "  {} {}",
398                    "PID:".bright_black(),
399                    port_info.pid.to_string().cyan()
400                );
401                println!("  {} {:?}", "Protocol:".bright_black(), port_info.protocol);
402                if let Some(ref addr) = port_info.address {
403                    println!("  {} {}", "Address:".bright_black(), addr);
404                }
405                println!();
406            }
407            OutputFormat::Json => self.print_json(&SinglePortOutput {
408                action: "on",
409                success: true,
410                port: port_info,
411            }),
412        }
413    }
414
415    /// Print JSON output for any serializable type
416    pub fn print_json<T: Serialize>(&self, data: &T) {
417        match serde_json::to_string_pretty(data) {
418            Ok(json) => println!("{}", json),
419            Err(e) => eprintln!("Failed to serialize JSON: {}", e),
420        }
421    }
422
423    /// Print action result (generalized for kill/stop/unstick)
424    pub fn print_action_result(
425        &self,
426        action: &str,
427        succeeded: &[Process],
428        failed: &[(Process, String)],
429    ) {
430        match self.format {
431            OutputFormat::Human => {
432                if !succeeded.is_empty() {
433                    println!(
434                        "{} {} {} process{}",
435                        "✓".green().bold(),
436                        action,
437                        succeeded.len().to_string().cyan().bold(),
438                        if succeeded.len() == 1 { "" } else { "es" }
439                    );
440                    for proc in succeeded {
441                        println!(
442                            "  {} {} [PID {}]",
443                            "→".bright_black(),
444                            proc.name.white(),
445                            proc.pid.to_string().cyan()
446                        );
447                    }
448                }
449                if !failed.is_empty() {
450                    println!(
451                        "{} Failed to {} {} process{}",
452                        "✗".red().bold(),
453                        action.to_lowercase(),
454                        failed.len(),
455                        if failed.len() == 1 { "" } else { "es" }
456                    );
457                    for (proc, err) in failed {
458                        println!(
459                            "  {} {} [PID {}]: {}",
460                            "→".bright_black(),
461                            proc.name.white(),
462                            proc.pid.to_string().cyan(),
463                            err.red()
464                        );
465                    }
466                }
467            }
468            OutputFormat::Json => {
469                self.print_json(&ActionOutput {
470                    action,
471                    success: failed.is_empty(),
472                    succeeded_count: succeeded.len(),
473                    failed_count: failed.len(),
474                    succeeded,
475                    failed: &failed
476                        .iter()
477                        .map(|(p, e)| FailedAction {
478                            process: p,
479                            error: e,
480                        })
481                        .collect::<Vec<_>>(),
482                });
483            }
484        }
485    }
486
487    /// Print kill result (delegates to print_action_result for backwards compatibility)
488    pub fn print_kill_result(&self, killed: &[Process], failed: &[(Process, String)]) {
489        self.print_action_result("Killed", killed, failed);
490    }
491
492    /// Print a confirmation prompt showing processes about to be acted on
493    pub fn print_confirmation(&self, action: &str, processes: &[Process]) {
494        println!(
495            "\n{} Found {} process{} to {}:\n",
496            "⚠".yellow().bold(),
497            processes.len().to_string().cyan().bold(),
498            if processes.len() == 1 { "" } else { "es" },
499            action
500        );
501
502        for proc in processes {
503            println!(
504                "  {} {} [PID {}] - CPU: {:.1}%, MEM: {}",
505                "→".bright_black(),
506                proc.name.white().bold(),
507                proc.pid.to_string().cyan(),
508                proc.cpu_percent,
509                format_memory(proc.memory_mb)
510            );
511        }
512        println!();
513    }
514}
515
516// JSON output structures
517#[derive(Serialize)]
518struct ProcessListOutput<'a> {
519    action: &'static str,
520    success: bool,
521    count: usize,
522    processes: &'a [Process],
523}
524
525#[derive(Serialize)]
526struct PortListOutput<'a> {
527    action: &'static str,
528    success: bool,
529    count: usize,
530    ports: &'a [PortInfo],
531}
532
533#[derive(Serialize)]
534struct SinglePortOutput<'a> {
535    action: &'static str,
536    success: bool,
537    port: &'a PortInfo,
538}
539
540#[derive(Serialize)]
541struct ActionOutput<'a> {
542    action: &'a str,
543    success: bool,
544    succeeded_count: usize,
545    failed_count: usize,
546    succeeded: &'a [Process],
547    failed: &'a [FailedAction<'a>],
548}
549
550#[derive(Serialize)]
551struct FailedAction<'a> {
552    process: &'a Process,
553    error: &'a str,
554}
555
556impl Default for Printer {
557    fn default() -> Self {
558        Self::new(OutputFormat::Human, false)
559    }
560}