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 colored::*;
7use serde::Serialize;
8
9/// Output format selection
10#[derive(Debug, Clone, Copy, Default)]
11pub enum OutputFormat {
12    /// Colored, human-readable terminal output
13    #[default]
14    Human,
15    /// Machine-readable JSON output for scripting
16    Json,
17}
18
19/// Main printer for CLI output
20pub struct Printer {
21    format: OutputFormat,
22    verbose: bool,
23}
24
25impl Printer {
26    /// Creates a new printer with the specified format and verbosity.
27    pub fn new(format: OutputFormat, verbose: bool) -> Self {
28        Self { format, verbose }
29    }
30
31    /// Print a success message
32    pub fn success(&self, message: &str) {
33        match self.format {
34            OutputFormat::Human => {
35                println!("{} {}", "✓".green().bold(), message.green());
36            }
37            OutputFormat::Json => {
38                // JSON output handled separately
39            }
40        }
41    }
42
43    /// Print an error message
44    pub fn error(&self, message: &str) {
45        match self.format {
46            OutputFormat::Human => {
47                eprintln!("{} {}", "✗".red().bold(), message.red());
48            }
49            OutputFormat::Json => {
50                // JSON output handled separately
51            }
52        }
53    }
54
55    /// Print a warning message
56    pub fn warning(&self, message: &str) {
57        match self.format {
58            OutputFormat::Human => {
59                println!("{} {}", "⚠".yellow().bold(), message.yellow());
60            }
61            OutputFormat::Json => {
62                // JSON output handled separately
63            }
64        }
65    }
66
67    /// Print a list of processes with optional context (e.g., "in /path/to/dir")
68    pub fn print_processes_with_context(&self, processes: &[Process], context: Option<&str>) {
69        match self.format {
70            OutputFormat::Human => self.print_processes_human(processes, context),
71            OutputFormat::Json => self.print_json(&ProcessListOutput {
72                action: "list",
73                success: true,
74                count: processes.len(),
75                processes,
76            }),
77        }
78    }
79
80    /// Print a list of processes
81    pub fn print_processes(&self, processes: &[Process]) {
82        self.print_processes_with_context(processes, None)
83    }
84
85    fn print_processes_human(&self, processes: &[Process], context: Option<&str>) {
86        if processes.is_empty() {
87            let msg = match context {
88                Some(ctx) => format!("No processes found {}", ctx),
89                None => "No processes found".to_string(),
90            };
91            self.warning(&msg);
92            return;
93        }
94
95        let context_str = context.map(|c| format!(" {}", c)).unwrap_or_default();
96        println!(
97            "{} Found {} process{}{}",
98            "✓".green().bold(),
99            processes.len().to_string().cyan().bold(),
100            if processes.len() == 1 { "" } else { "es" },
101            context_str.bright_black()
102        );
103        println!();
104
105        if self.verbose {
106            // Verbose: full details, nothing truncated
107            for proc in processes {
108                let status_str = format!("{:?}", proc.status);
109                let status_colored = colorize_status(&proc.status, &status_str);
110
111                println!(
112                    "{} {} {}  {:.1}% CPU  {:.1} MB  {}",
113                    proc.pid.to_string().cyan().bold(),
114                    proc.name.white().bold(),
115                    format!("[{}]", status_colored).bright_black(),
116                    proc.cpu_percent,
117                    proc.memory_mb,
118                    proc.user.as_deref().unwrap_or("-").bright_black()
119                );
120
121                if let Some(ref cmd) = proc.command {
122                    println!("    {} {}", "cmd:".bright_black(), cmd);
123                }
124                if let Some(ref path) = proc.exe_path {
125                    println!("    {} {}", "exe:".bright_black(), path.bright_black());
126                }
127                if let Some(ref cwd) = proc.cwd {
128                    println!("    {} {}", "cwd:".bright_black(), cwd.bright_black());
129                }
130                if let Some(ppid) = proc.parent_pid {
131                    println!(
132                        "    {} {}",
133                        "parent:".bright_black(),
134                        ppid.to_string().bright_black()
135                    );
136                }
137                println!();
138            }
139        } else {
140            // Normal: compact table with all key columns
141            println!(
142                "{:<7} {:<20} {:<12} {:<35} {:>5} {:>8} {:>8}",
143                "PID".bright_blue().bold(),
144                "PATH".bright_blue().bold(),
145                "NAME".bright_blue().bold(),
146                "ARGS".bright_blue().bold(),
147                "CPU%".bright_blue().bold(),
148                "MEM".bright_blue().bold(),
149                "STATUS".bright_blue().bold(),
150            );
151            println!("{}", "─".repeat(100).bright_black());
152
153            for proc in processes {
154                let name = truncate_string(&proc.name, 11);
155                let status_str = format!("{:?}", proc.status);
156                let status_colored = colorize_status(&proc.status, &status_str);
157
158                // Show directory of executable
159                let path_display = proc
160                    .exe_path
161                    .as_ref()
162                    .map(|p| {
163                        std::path::Path::new(p)
164                            .parent()
165                            .map(|parent| truncate_path(&parent.to_string_lossy(), 19))
166                            .unwrap_or_else(|| "-".to_string())
167                    })
168                    .unwrap_or_else(|| "-".to_string());
169
170                // Show command args (skip executable, simplify paths to filenames)
171                let cmd_display = proc
172                    .command
173                    .as_ref()
174                    .map(|c| {
175                        // Skip the first element (executable) to show just the args
176                        let parts: Vec<&str> = c.split_whitespace().collect();
177                        if parts.len() > 1 {
178                            // Simplify paths to just filenames where possible
179                            let args: Vec<String> = parts[1..]
180                                .iter()
181                                .map(|arg| {
182                                    if arg.contains('/') && !arg.starts_with('-') {
183                                        // It's a path - extract filename
184                                        std::path::Path::new(arg)
185                                            .file_name()
186                                            .map(|f| f.to_string_lossy().to_string())
187                                            .unwrap_or_else(|| arg.to_string())
188                                    } else {
189                                        arg.to_string()
190                                    }
191                                })
192                                .collect();
193                            truncate_string(&args.join(" "), 34)
194                        } else {
195                            truncate_string(c, 34)
196                        }
197                    })
198                    .unwrap_or_else(|| "-".to_string());
199
200                println!(
201                    "{:<7} {:<20} {:<12} {:<35} {:>5.1} {:>6.1}MB {:>8}",
202                    proc.pid.to_string().cyan(),
203                    path_display.bright_black(),
204                    name.white(),
205                    cmd_display.bright_black(),
206                    proc.cpu_percent,
207                    proc.memory_mb,
208                    status_colored,
209                );
210            }
211        }
212        println!();
213    }
214
215    /// Print port information
216    pub fn print_ports(&self, ports: &[PortInfo]) {
217        match self.format {
218            OutputFormat::Human => self.print_ports_human(ports),
219            OutputFormat::Json => self.print_json(&PortListOutput {
220                action: "ports",
221                success: true,
222                count: ports.len(),
223                ports,
224            }),
225        }
226    }
227
228    fn print_ports_human(&self, ports: &[PortInfo]) {
229        if ports.is_empty() {
230            self.warning("No listening ports found");
231            return;
232        }
233
234        println!(
235            "{} Found {} listening port{}",
236            "✓".green().bold(),
237            ports.len().to_string().cyan().bold(),
238            if ports.len() == 1 { "" } else { "s" }
239        );
240        println!();
241
242        // Header
243        println!(
244            "{:<8} {:<10} {:<8} {:<20} {:<15}",
245            "PORT".bright_blue().bold(),
246            "PROTO".bright_blue().bold(),
247            "PID".bright_blue().bold(),
248            "PROCESS".bright_blue().bold(),
249            "ADDRESS".bright_blue().bold()
250        );
251        println!("{}", "─".repeat(65).bright_black());
252
253        for port in ports {
254            let addr = port.address.as_deref().unwrap_or("*");
255            let proto = format!("{:?}", port.protocol).to_uppercase();
256
257            println!(
258                "{:<8} {:<10} {:<8} {:<20} {:<15}",
259                port.port.to_string().cyan().bold(),
260                proto.white(),
261                port.pid.to_string().cyan(),
262                truncate_string(&port.process_name, 19).white(),
263                addr.bright_black()
264            );
265        }
266        println!();
267    }
268
269    /// Print a single port info (for `proc on :port`)
270    pub fn print_port_info(&self, port_info: &PortInfo) {
271        match self.format {
272            OutputFormat::Human => {
273                println!(
274                    "{} Process on port {}:",
275                    "✓".green().bold(),
276                    port_info.port.to_string().cyan().bold()
277                );
278                println!();
279                println!(
280                    "  {} {}",
281                    "Name:".bright_black(),
282                    port_info.process_name.white().bold()
283                );
284                println!(
285                    "  {} {}",
286                    "PID:".bright_black(),
287                    port_info.pid.to_string().cyan()
288                );
289                println!("  {} {:?}", "Protocol:".bright_black(), port_info.protocol);
290                if let Some(ref addr) = port_info.address {
291                    println!("  {} {}", "Address:".bright_black(), addr);
292                }
293                println!();
294            }
295            OutputFormat::Json => self.print_json(&SinglePortOutput {
296                action: "on",
297                success: true,
298                port: port_info,
299            }),
300        }
301    }
302
303    /// Print JSON output for any serializable type
304    pub fn print_json<T: Serialize>(&self, data: &T) {
305        match serde_json::to_string_pretty(data) {
306            Ok(json) => println!("{}", json),
307            Err(e) => eprintln!("Failed to serialize JSON: {}", e),
308        }
309    }
310
311    /// Print kill confirmation
312    pub fn print_kill_result(&self, killed: &[Process], failed: &[(Process, String)]) {
313        match self.format {
314            OutputFormat::Human => {
315                if !killed.is_empty() {
316                    println!(
317                        "{} Killed {} process{}",
318                        "✓".green().bold(),
319                        killed.len().to_string().cyan().bold(),
320                        if killed.len() == 1 { "" } else { "es" }
321                    );
322                    for proc in killed {
323                        println!(
324                            "  {} {} [PID {}]",
325                            "→".bright_black(),
326                            proc.name.white(),
327                            proc.pid.to_string().cyan()
328                        );
329                    }
330                }
331                if !failed.is_empty() {
332                    println!(
333                        "{} Failed to kill {} process{}",
334                        "✗".red().bold(),
335                        failed.len(),
336                        if failed.len() == 1 { "" } else { "es" }
337                    );
338                    for (proc, err) in failed {
339                        println!(
340                            "  {} {} [PID {}]: {}",
341                            "→".bright_black(),
342                            proc.name.white(),
343                            proc.pid.to_string().cyan(),
344                            err.red()
345                        );
346                    }
347                }
348            }
349            OutputFormat::Json => {
350                self.print_json(&KillOutput {
351                    action: "kill",
352                    success: failed.is_empty(),
353                    killed_count: killed.len(),
354                    failed_count: failed.len(),
355                    killed,
356                    failed: &failed
357                        .iter()
358                        .map(|(p, e)| FailedKill {
359                            process: p,
360                            error: e,
361                        })
362                        .collect::<Vec<_>>(),
363                });
364            }
365        }
366    }
367}
368
369/// Truncate a string to a maximum length
370fn truncate_string(s: &str, max_len: usize) -> String {
371    if s.len() <= max_len {
372        s.to_string()
373    } else {
374        format!("{}...", &s[..max_len.saturating_sub(3)])
375    }
376}
377
378/// Truncate a path intelligently - show the end (most relevant part)
379fn truncate_path(path: &str, max_len: usize) -> String {
380    if path.len() <= max_len {
381        path.to_string()
382    } else {
383        // Show ...ending of path
384        let start = path.len().saturating_sub(max_len.saturating_sub(3));
385        format!("...{}", &path[start..])
386    }
387}
388
389/// Colorize process status
390fn colorize_status(
391    status: &crate::core::ProcessStatus,
392    status_str: &str,
393) -> colored::ColoredString {
394    use colored::*;
395    match status {
396        crate::core::ProcessStatus::Running => status_str.green(),
397        crate::core::ProcessStatus::Sleeping => status_str.blue(),
398        crate::core::ProcessStatus::Stopped => status_str.yellow(),
399        crate::core::ProcessStatus::Zombie => status_str.red(),
400        _ => status_str.white(),
401    }
402}
403
404// JSON output structures
405#[derive(Serialize)]
406struct ProcessListOutput<'a> {
407    action: &'static str,
408    success: bool,
409    count: usize,
410    processes: &'a [Process],
411}
412
413#[derive(Serialize)]
414struct PortListOutput<'a> {
415    action: &'static str,
416    success: bool,
417    count: usize,
418    ports: &'a [PortInfo],
419}
420
421#[derive(Serialize)]
422struct SinglePortOutput<'a> {
423    action: &'static str,
424    success: bool,
425    port: &'a PortInfo,
426}
427
428#[derive(Serialize)]
429struct KillOutput<'a> {
430    action: &'static str,
431    success: bool,
432    killed_count: usize,
433    failed_count: usize,
434    killed: &'a [Process],
435    failed: &'a [FailedKill<'a>],
436}
437
438#[derive(Serialize)]
439struct FailedKill<'a> {
440    process: &'a Process,
441    error: &'a str,
442}
443
444impl Default for Printer {
445    fn default() -> Self {
446        Self::new(OutputFormat::Human, false)
447    }
448}