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