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("PATH")
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("PATH 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 directory of executable (let comfy-table handle truncation)
224                let path_display = proc
225                    .exe_path
226                    .as_ref()
227                    .map(|p| {
228                        std::path::Path::new(p)
229                            .parent()
230                            .map(|parent| parent.to_string_lossy().to_string())
231                            .unwrap_or_else(|| "-".to_string())
232                    })
233                    .unwrap_or_else(|| "-".to_string());
234
235                // Show command args (skip executable, simplify paths to filenames)
236                let cmd_display = proc
237                    .command
238                    .as_ref()
239                    .map(|c| {
240                        let parts: Vec<&str> = c.split_whitespace().collect();
241                        if parts.len() > 1 {
242                            let args: Vec<String> = parts[1..]
243                                .iter()
244                                .map(|arg| {
245                                    if arg.contains('/') && !arg.starts_with('-') {
246                                        std::path::Path::new(arg)
247                                            .file_name()
248                                            .map(|f| f.to_string_lossy().to_string())
249                                            .unwrap_or_else(|| arg.to_string())
250                                    } else {
251                                        arg.to_string()
252                                    }
253                                })
254                                .collect();
255                            let result = args.join(" ");
256                            if result.is_empty() {
257                                "-".to_string()
258                            } else {
259                                truncate_string(&result, (args_max as usize).saturating_sub(2))
260                            }
261                        } else {
262                            // No args beyond the executable itself
263                            "-".to_string()
264                        }
265                    })
266                    .unwrap_or_else(|| "-".to_string());
267
268                let mem_display = format_memory(proc.memory_mb);
269
270                let status_color = match proc.status {
271                    crate::core::ProcessStatus::Running => Color::Green,
272                    crate::core::ProcessStatus::Sleeping => Color::Blue,
273                    crate::core::ProcessStatus::Stopped => Color::Yellow,
274                    crate::core::ProcessStatus::Zombie => Color::Red,
275                    _ => Color::White,
276                };
277
278                table.add_row(vec![
279                    Cell::new(proc.pid).fg(Color::Cyan),
280                    Cell::new(&path_display).fg(Color::DarkGrey),
281                    Cell::new(&proc.name).fg(Color::White),
282                    Cell::new(&cmd_display).fg(Color::DarkGrey),
283                    Cell::new(format!("{:.1}", proc.cpu_percent))
284                        .set_alignment(CellAlignment::Right),
285                    Cell::new(&mem_display).set_alignment(CellAlignment::Right),
286                    Cell::new(&status_str)
287                        .fg(status_color)
288                        .set_alignment(CellAlignment::Right),
289                ]);
290            }
291
292            println!("{table}");
293        }
294        println!();
295    }
296
297    /// Print port information
298    pub fn print_ports(&self, ports: &[PortInfo]) {
299        match self.format {
300            OutputFormat::Human => self.print_ports_human(ports),
301            OutputFormat::Json => self.print_json(&PortListOutput {
302                action: "ports",
303                success: true,
304                count: ports.len(),
305                ports,
306            }),
307        }
308    }
309
310    fn print_ports_human(&self, ports: &[PortInfo]) {
311        if ports.is_empty() {
312            self.warning("No listening ports found");
313            return;
314        }
315
316        println!(
317            "{} Found {} listening port{}",
318            "✓".green().bold(),
319            ports.len().to_string().cyan().bold(),
320            if ports.len() == 1 { "" } else { "s" }
321        );
322        println!();
323
324        let width = terminal_width();
325
326        let mut table = Table::new();
327        table
328            .load_preset(NOTHING)
329            .set_content_arrangement(ContentArrangement::Dynamic)
330            .set_width(width);
331
332        table.set_header(vec![
333            Cell::new("PORT")
334                .fg(Color::Blue)
335                .add_attribute(Attribute::Bold),
336            Cell::new("PROTO")
337                .fg(Color::Blue)
338                .add_attribute(Attribute::Bold),
339            Cell::new("PID")
340                .fg(Color::Blue)
341                .add_attribute(Attribute::Bold),
342            Cell::new("PROCESS")
343                .fg(Color::Blue)
344                .add_attribute(Attribute::Bold),
345            Cell::new("ADDRESS")
346                .fg(Color::Blue)
347                .add_attribute(Attribute::Bold),
348        ]);
349
350        use comfy_table::ColumnConstraint::*;
351        use comfy_table::Width::*;
352        table
353            .column_mut(0)
354            .expect("PORT column")
355            .set_constraint(Absolute(Fixed(8)));
356        table
357            .column_mut(1)
358            .expect("PROTO column")
359            .set_constraint(Absolute(Fixed(6)));
360        table
361            .column_mut(2)
362            .expect("PID column")
363            .set_constraint(Absolute(Fixed(8)));
364        table
365            .column_mut(3)
366            .expect("PROCESS column")
367            .set_constraint(LowerBoundary(Fixed(12)));
368        table
369            .column_mut(4)
370            .expect("ADDRESS column")
371            .set_constraint(LowerBoundary(Fixed(10)));
372
373        for port in ports {
374            let addr = port.address.as_deref().unwrap_or("*");
375            let proto = format!("{:?}", port.protocol).to_uppercase();
376
377            table.add_row(vec![
378                Cell::new(port.port).fg(Color::Cyan),
379                Cell::new(&proto).fg(Color::White),
380                Cell::new(port.pid).fg(Color::Cyan),
381                Cell::new(truncate_string(&port.process_name, 19)).fg(Color::White),
382                Cell::new(addr).fg(Color::DarkGrey),
383            ]);
384        }
385
386        println!("{table}");
387        println!();
388    }
389
390    /// Print a single port info (for `proc on :port`)
391    pub fn print_port_info(&self, port_info: &PortInfo) {
392        match self.format {
393            OutputFormat::Human => {
394                println!(
395                    "{} Process on port {}:",
396                    "✓".green().bold(),
397                    port_info.port.to_string().cyan().bold()
398                );
399                println!();
400                println!(
401                    "  {} {}",
402                    "Name:".bright_black(),
403                    port_info.process_name.white().bold()
404                );
405                println!(
406                    "  {} {}",
407                    "PID:".bright_black(),
408                    port_info.pid.to_string().cyan()
409                );
410                println!("  {} {:?}", "Protocol:".bright_black(), port_info.protocol);
411                if let Some(ref addr) = port_info.address {
412                    println!("  {} {}", "Address:".bright_black(), addr);
413                }
414                println!();
415            }
416            OutputFormat::Json => self.print_json(&SinglePortOutput {
417                action: "on",
418                success: true,
419                port: port_info,
420            }),
421        }
422    }
423
424    /// Print JSON output for any serializable type
425    pub fn print_json<T: Serialize>(&self, data: &T) {
426        match serde_json::to_string_pretty(data) {
427            Ok(json) => println!("{}", json),
428            Err(e) => eprintln!("Failed to serialize JSON: {}", e),
429        }
430    }
431
432    /// Print action result (generalized for kill/stop/unstick)
433    pub fn print_action_result(
434        &self,
435        action: &str,
436        succeeded: &[Process],
437        failed: &[(Process, String)],
438    ) {
439        match self.format {
440            OutputFormat::Human => {
441                if !succeeded.is_empty() {
442                    println!(
443                        "{} {} {} process{}",
444                        "✓".green().bold(),
445                        action,
446                        succeeded.len().to_string().cyan().bold(),
447                        if succeeded.len() == 1 { "" } else { "es" }
448                    );
449                    for proc in succeeded {
450                        println!(
451                            "  {} {} [PID {}]",
452                            "→".bright_black(),
453                            proc.name.white(),
454                            proc.pid.to_string().cyan()
455                        );
456                    }
457                }
458                if !failed.is_empty() {
459                    println!(
460                        "{} Failed to {} {} process{}",
461                        "✗".red().bold(),
462                        action.to_lowercase(),
463                        failed.len(),
464                        if failed.len() == 1 { "" } else { "es" }
465                    );
466                    for (proc, err) in failed {
467                        println!(
468                            "  {} {} [PID {}]: {}",
469                            "→".bright_black(),
470                            proc.name.white(),
471                            proc.pid.to_string().cyan(),
472                            err.red()
473                        );
474                    }
475                }
476            }
477            OutputFormat::Json => {
478                self.print_json(&ActionOutput {
479                    action,
480                    success: failed.is_empty(),
481                    succeeded_count: succeeded.len(),
482                    failed_count: failed.len(),
483                    succeeded,
484                    failed: &failed
485                        .iter()
486                        .map(|(p, e)| FailedAction {
487                            process: p,
488                            error: e,
489                        })
490                        .collect::<Vec<_>>(),
491                });
492            }
493        }
494    }
495
496    /// Print kill result (delegates to print_action_result for backwards compatibility)
497    pub fn print_kill_result(&self, killed: &[Process], failed: &[(Process, String)]) {
498        self.print_action_result("Killed", killed, failed);
499    }
500
501    /// Print a confirmation prompt showing processes about to be acted on
502    pub fn print_confirmation(&self, action: &str, processes: &[Process]) {
503        println!(
504            "\n{} Found {} process{} to {}:\n",
505            "⚠".yellow().bold(),
506            processes.len().to_string().cyan().bold(),
507            if processes.len() == 1 { "" } else { "es" },
508            action
509        );
510
511        for proc in processes {
512            println!(
513                "  {} {} [PID {}] - CPU: {:.1}%, MEM: {}",
514                "→".bright_black(),
515                proc.name.white().bold(),
516                proc.pid.to_string().cyan(),
517                proc.cpu_percent,
518                format_memory(proc.memory_mb)
519            );
520        }
521        println!();
522    }
523}
524
525// JSON output structures
526#[derive(Serialize)]
527struct ProcessListOutput<'a> {
528    action: &'static str,
529    success: bool,
530    count: usize,
531    processes: &'a [Process],
532}
533
534#[derive(Serialize)]
535struct PortListOutput<'a> {
536    action: &'static str,
537    success: bool,
538    count: usize,
539    ports: &'a [PortInfo],
540}
541
542#[derive(Serialize)]
543struct SinglePortOutput<'a> {
544    action: &'static str,
545    success: bool,
546    port: &'a PortInfo,
547}
548
549#[derive(Serialize)]
550struct ActionOutput<'a> {
551    action: &'a str,
552    success: bool,
553    succeeded_count: usize,
554    failed_count: usize,
555    succeeded: &'a [Process],
556    failed: &'a [FailedAction<'a>],
557}
558
559#[derive(Serialize)]
560struct FailedAction<'a> {
561    process: &'a Process,
562    error: &'a str,
563}
564
565impl Default for Printer {
566    fn default() -> Self {
567        Self::new(OutputFormat::Human, false)
568    }
569}