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