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