Skip to main content

proc_cli/commands/
for_file.rs

1//! `proc for` - Find processes by file path
2//!
3//! Examples:
4//!   proc for ./server            # Processes running this program (or holding it open)
5//!   proc for ./app               # Relative path
6//!   proc for /var/log/app.log    # What has this file open? (not searched on Windows)
7//!   proc for ~/bin/myapp         # Tilde expansion
8//!   proc for app.log --by node   # Only the process named node (--cmd: node anywhere in the command line)
9
10use crate::core::{
11    find_ports_for_pid, resolve_in_dir, sort_processes, NameMatch, PortInfo, Process, SortKey,
12    StatusFilter,
13};
14use crate::error::{ProcError, Result};
15use crate::ui::{format_duration, format_memory, Printer};
16use clap::Args;
17use colored::*;
18use serde::Serialize;
19use std::collections::HashSet;
20use std::path::PathBuf;
21
22/// Find processes by file path
23#[derive(Args, Debug)]
24pub struct ForCommand {
25    /// File path (relative, absolute, or with ~)
26    pub file: String,
27
28    /// Filter by working directory; takes the next word as the directory unless it starts with '-'
29    /// (bare --in is the current directory, --in=DIR is explicit)
30    #[arg(long = "in", short = 'i', num_args = 0..=1, default_missing_value = ".")]
31    pub in_dir: Option<String>,
32
33    /// Filter by process name
34    #[arg(long = "by", short = 'b')]
35    pub by_name: Option<String>,
36
37    /// Match --by as a substring of the process name or full command line (like pkill -f)
38    #[arg(long)]
39    pub cmd: bool,
40
41    /// Only show processes using more than this CPU %
42    #[arg(long)]
43    pub min_cpu: Option<f32>,
44
45    /// Only show processes using more than this memory (MB)
46    #[arg(long)]
47    pub min_mem: Option<f64>,
48
49    /// Filter by status: running, sleeping, stopped, zombie
50    #[arg(long, value_enum, ignore_case = true)]
51    pub status: Option<StatusFilter>,
52
53    /// Only show processes running longer than this (seconds)
54    #[arg(long)]
55    pub min_uptime: Option<u64>,
56
57    /// Output as JSON
58    #[arg(long, short = 'j')]
59    pub json: bool,
60
61    /// Show verbose output
62    #[arg(long, short = 'v')]
63    pub verbose: bool,
64
65    /// Print full directories and arguments instead of fitting the terminal width
66    #[arg(long, short = 'w')]
67    pub wide: bool,
68
69    /// Sort by: cpu, mem (biggest first); pid, name (ascending). Ties come by PID
70    #[arg(long, short = 's', value_enum, default_value_t = SortKey::Cpu)]
71    pub sort: SortKey,
72
73    /// Limit the number of results
74    #[arg(long, short = 'n')]
75    pub limit: Option<usize>,
76}
77
78impl ForCommand {
79    /// Executes the for command, finding processes by file path.
80    pub fn execute(&self) -> Result<()> {
81        // 1. Resolve file path (relative, tilde, canonicalize)
82        let file_path = self.resolve_path(&self.file)?;
83
84        // 2. Find processes by executable path
85        let exe_processes = Process::find_by_exe_path(&file_path)?;
86
87        // 3. Find processes with file open (lsof)
88        let open_file_procs = Process::find_by_open_file(&file_path)?;
89
90        // 4. Merge and deduplicate by PID
91        let mut seen_pids = HashSet::new();
92        let mut processes: Vec<Process> = Vec::new();
93
94        // One loop for both lists: Windows has no open-file lookup, so its second
95        // list is always empty and a loop of its own would never run there
96        for proc in exe_processes.into_iter().chain(open_file_procs) {
97            if seen_pids.insert(proc.pid) {
98                processes.push(proc);
99            }
100        }
101
102        // 5. Apply filters, with real CPU readings for --min-cpu, the sort and the view
103        Process::measure_cpu(&mut processes);
104        self.apply_filters(&mut processes);
105
106        // 6. Sort processes
107        sort_processes(&mut processes, self.sort);
108
109        // 7. Apply limit if specified
110        if let Some(limit) = self.limit {
111            processes.truncate(limit);
112        }
113
114        // 8. Handle no results
115        if processes.is_empty() {
116            return Err(ProcError::ProcessNotFound(self.file.clone()));
117        }
118
119        // 9. For each process, get ports
120        let mut results: Vec<(Process, Vec<PortInfo>)> = Vec::new();
121        for proc in processes {
122            let ports = find_ports_for_pid(proc.pid)?;
123            results.push((proc, ports));
124        }
125
126        // 10. Output
127        if self.json {
128            self.print_json(&results)?;
129        } else {
130            self.print_human(&results);
131        }
132
133        Ok(())
134    }
135
136    fn resolve_path(&self, path: &str) -> Result<PathBuf> {
137        // Tilde expansion
138        let expanded = if let Some(stripped) = path.strip_prefix("~/") {
139            if let Ok(home) = std::env::var("HOME") {
140                PathBuf::from(home).join(stripped)
141            } else {
142                PathBuf::from(path)
143            }
144        } else if path == "~" {
145            PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| ".".to_string()))
146        } else {
147            PathBuf::from(path)
148        };
149
150        // Make absolute if relative
151        let absolute = if expanded.is_relative() {
152            std::env::current_dir()?.join(expanded)
153        } else {
154            expanded
155        };
156
157        // Canonicalize (resolve symlinks, normalize)
158        let canonical = absolute
159            .canonicalize()
160            .map_err(|_| ProcError::InvalidInput(format!("File not found: {}", path)))?;
161
162        // Windows returns `\\?\D:\...`, but process exe paths are `D:\...`
163        #[cfg(windows)]
164        let canonical = PathBuf::from(crate::core::filters::strip_verbatim_prefix(
165            &canonical.to_string_lossy(),
166        ));
167
168        Ok(canonical)
169    }
170
171    fn apply_filters(&self, processes: &mut Vec<Process>) {
172        let in_dir_filter = resolve_in_dir(&self.in_dir);
173
174        processes.retain(|p| {
175            // Directory filter (--in)
176            if let Some(ref dir_path) = in_dir_filter {
177                if let Some(ref proc_cwd) = p.cwd {
178                    if !crate::core::filters::cwd_within(proc_cwd, dir_path) {
179                        return false;
180                    }
181                } else {
182                    return false;
183                }
184            }
185
186            // Name filter (--by)
187            if let Some(ref name) = self.by_name {
188                if !crate::core::matches_by_filter(p, name, NameMatch::from_flag(self.cmd)) {
189                    return false;
190                }
191            }
192
193            // CPU filter
194            if let Some(min_cpu) = self.min_cpu {
195                if p.cpu_percent < min_cpu {
196                    return false;
197                }
198            }
199
200            // Memory filter
201            if let Some(min_mem) = self.min_mem {
202                if p.memory_mb < min_mem {
203                    return false;
204                }
205            }
206
207            // Status filter
208            if let Some(status) = self.status {
209                if !status.matches(p.status) {
210                    return false;
211                }
212            }
213
214            // Uptime filter
215            if let Some(min_uptime) = self.min_uptime {
216                if let Some(start_time) = p.start_time {
217                    let now = std::time::SystemTime::now()
218                        .duration_since(std::time::UNIX_EPOCH)
219                        .map(|d| d.as_secs())
220                        .unwrap_or(0);
221                    if now.saturating_sub(start_time) < min_uptime {
222                        return false;
223                    }
224                } else {
225                    return false;
226                }
227            }
228
229            true
230        });
231    }
232
233    fn print_human(&self, results: &[(Process, Vec<PortInfo>)]) {
234        let count = results.len();
235        let file_display = &self.file;
236
237        if count == 1 {
238            // Single process - detailed view (like `on` command)
239            let (proc, ports) = &results[0];
240
241            println!(
242                "{} Found 1 process for {}",
243                "✓".green().bold(),
244                file_display.cyan().bold()
245            );
246            println!();
247
248            // Same labels and order as `proc on :port`
249            println!(
250                "  {} {} (PID {})",
251                "Process:".bright_black(),
252                proc.name.white().bold(),
253                proc.pid.to_string().cyan()
254            );
255            if let Some(ref cwd) = proc.cwd {
256                println!("  {} {}", "Directory:".bright_black(), cwd);
257            }
258            if let Some(ref path) = proc.exe_path {
259                println!("  {} {}", "Path:".bright_black(), path.bright_black());
260            }
261            for port_info in ports {
262                let addr = port_info.address.as_deref().unwrap_or("*");
263                println!(
264                    "  {} :{} ({} on {})",
265                    "Listening:".bright_black(),
266                    port_info.port.to_string().cyan(),
267                    format!("{:?}", port_info.protocol).to_uppercase(),
268                    addr
269                );
270            }
271            println!(
272                "  {} {:.1}% CPU, {}",
273                "Resources:".bright_black(),
274                proc.cpu_percent,
275                format_memory(proc.memory_mb)
276            );
277            if let Some(uptime) = proc.uptime_secs {
278                println!("  {} {}", "Uptime:".bright_black(), format_duration(uptime));
279            }
280            if self.verbose {
281                if let Some(ref cmd) = proc.command {
282                    println!("  {} {}", "Command:".bright_black(), cmd.bright_black());
283                }
284            }
285            println!();
286        } else {
287            // Multiple processes: the standard process table, then who listens where
288            let processes: Vec<Process> = results.iter().map(|(p, _)| p.clone()).collect();
289            Printer::from_flags(false, self.verbose)
290                .wide(self.wide)
291                .print_processes_as("for", &processes, Some(&format!("for {}", file_display)));
292
293            let listening: Vec<_> = results
294                .iter()
295                .flat_map(|(proc, ports)| ports.iter().map(move |p| (proc, p)))
296                .collect();
297            if !listening.is_empty() {
298                println!("  {}", "Listening:".bright_black());
299                for (proc, port_info) in listening {
300                    let addr = port_info.address.as_deref().unwrap_or("*");
301                    println!(
302                        "    {} :{} ({} on {})  {} [PID {}]",
303                        "→".bright_black(),
304                        port_info.port.to_string().cyan(),
305                        format!("{:?}", port_info.protocol).to_uppercase(),
306                        addr,
307                        proc.name.white(),
308                        proc.pid.to_string().cyan()
309                    );
310                }
311                println!();
312            }
313        }
314    }
315
316    fn print_json(&self, results: &[(Process, Vec<PortInfo>)]) -> Result<()> {
317        let printer = Printer::from_flags(true, self.verbose);
318        let items: Vec<ProcessForJson> = results
319            .iter()
320            .map(|(proc, ports)| ProcessForJson {
321                process: proc,
322                ports,
323            })
324            .collect();
325
326        printer.print_json(&ForOutput {
327            action: "for",
328            success: true,
329            count: items.len(),
330            results: &items,
331        });
332        Ok(())
333    }
334}
335
336#[derive(Serialize)]
337struct ProcessForJson<'a> {
338    process: &'a Process,
339    ports: &'a [PortInfo],
340}
341
342#[derive(Serialize)]
343struct ForOutput<'a> {
344    action: &'static str,
345    success: bool,
346    count: usize,
347    results: &'a [ProcessForJson<'a>],
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use crate::core::ProcessStatus;
354
355    fn command() -> ForCommand {
356        ForCommand {
357            file: "x".to_string(),
358            in_dir: None,
359            by_name: None,
360            cmd: false,
361            min_cpu: None,
362            min_mem: None,
363            status: None,
364            min_uptime: None,
365            json: false,
366            verbose: false,
367            wide: false,
368            sort: SortKey::Cpu,
369            limit: None,
370        }
371    }
372
373    /// A process record with no working directory and no start time, which
374    /// some processes really are (a zombie has neither on Linux)
375    fn bare_process() -> Process {
376        Process {
377            pid: 1,
378            name: "x".to_string(),
379            exe_path: None,
380            cwd: None,
381            command: None,
382            argv0: None,
383            raw: None,
384            cpu_percent: 0.0,
385            memory_mb: 0.0,
386            status: ProcessStatus::Sleeping,
387            user: None,
388            parent_pid: None,
389            start_time: None,
390            uptime_secs: None,
391        }
392    }
393
394    #[test]
395    fn a_process_with_no_working_directory_fails_in() {
396        let cmd = ForCommand {
397            in_dir: Some("/".to_string()),
398            ..command()
399        };
400        let mut processes = vec![bare_process()];
401        cmd.apply_filters(&mut processes);
402        assert!(processes.is_empty());
403
404        let mut processes = vec![bare_process()];
405        command().apply_filters(&mut processes);
406        assert_eq!(processes.len(), 1);
407    }
408
409    #[test]
410    fn a_process_with_no_start_time_fails_min_uptime() {
411        let cmd = ForCommand {
412            min_uptime: Some(0),
413            ..command()
414        };
415        let mut processes = vec![bare_process()];
416        cmd.apply_filters(&mut processes);
417        assert!(processes.is_empty());
418    }
419}