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    resolve_in_dir, sort_processes, with_ports, NameMatch, PortInfo, Process, SortKey, StatusFilter,
12};
13use crate::error::{ProcError, Result};
14use crate::out_println;
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 results = with_ports(processes)?;
121
122        // 10. Output
123        if self.json {
124            self.print_json(&results)?;
125        } else {
126            self.print_human(&results);
127        }
128
129        Ok(())
130    }
131
132    fn resolve_path(&self, path: &str) -> Result<PathBuf> {
133        // Tilde expansion
134        let expanded = if let Some(stripped) = path.strip_prefix("~/") {
135            if let Ok(home) = std::env::var("HOME") {
136                PathBuf::from(home).join(stripped)
137            } else {
138                PathBuf::from(path)
139            }
140        } else if path == "~" {
141            PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| ".".to_string()))
142        } else {
143            PathBuf::from(path)
144        };
145
146        // Make absolute if relative
147        let absolute = if expanded.is_relative() {
148            crate::core::filters::current_dir_for(&expanded)?.join(expanded)
149        } else {
150            expanded
151        };
152
153        // Canonicalize (resolve symlinks, normalize)
154        let canonical = absolute
155            .canonicalize()
156            .map_err(|_| ProcError::InvalidInput(format!("File not found: {}", path)))?;
157
158        // Windows returns `\\?\D:\...`, but process exe paths are `D:\...`
159        #[cfg(windows)]
160        let canonical = PathBuf::from(crate::core::filters::strip_verbatim_prefix(
161            &canonical.to_string_lossy(),
162        ));
163
164        Ok(canonical)
165    }
166
167    fn apply_filters(&self, processes: &mut Vec<Process>) -> Result<()> {
168        let in_dir_filter = resolve_in_dir(&self.in_dir)?;
169        let in_dir_has_any = in_dir_filter.as_ref().is_some_and(|dir_path| {
170            processes
171                .iter()
172                .any(|p| crate::core::filters::matches_in_filter(p, dir_path))
173        });
174        crate::core::require_in_dir(&self.in_dir, &in_dir_filter, in_dir_has_any)?;
175
176        processes.retain(|p| {
177            // Directory filter (--in)
178            if let Some(ref dir_path) = in_dir_filter {
179                if !crate::core::filters::matches_in_filter(p, dir_path) {
180                    return false;
181                }
182            }
183
184            // Name filter (--by)
185            if let Some(ref name) = self.by_name {
186                if !crate::core::matches_by_filter(p, name, NameMatch::from_flag(self.cmd)) {
187                    return false;
188                }
189            }
190
191            // CPU filter
192            if let Some(min_cpu) = self.min_cpu {
193                if p.cpu_percent < min_cpu {
194                    return false;
195                }
196            }
197
198            // Memory filter
199            if let Some(min_mem) = self.min_mem {
200                if p.memory_mb < min_mem {
201                    return false;
202                }
203            }
204
205            // Status filter
206            if let Some(status) = self.status {
207                if !status.matches(p.status) {
208                    return false;
209                }
210            }
211
212            // Uptime filter
213            if let Some(min_uptime) = self.min_uptime {
214                if let Some(start_time) = p.start_time {
215                    let now = std::time::SystemTime::now()
216                        .duration_since(std::time::UNIX_EPOCH)
217                        .map(|d| d.as_secs())
218                        .unwrap_or(0);
219                    if now.saturating_sub(start_time) < min_uptime {
220                        return false;
221                    }
222                } else {
223                    return false;
224                }
225            }
226
227            true
228        });
229        Ok(())
230    }
231
232    fn print_human(&self, results: &[(Process, Vec<PortInfo>)]) {
233        let count = results.len();
234        let file_display = &self.file;
235
236        if count == 1 {
237            // Single process - detailed view (like `on` command)
238            let (proc, ports) = &results[0];
239
240            out_println!(
241                "{} Found 1 process for {}",
242                "✓".green().bold(),
243                file_display.cyan().bold()
244            );
245            out_println!();
246
247            // Same labels and order as `proc on :port`
248            out_println!(
249                "  {} {} (PID {})",
250                "Process:".bright_black(),
251                proc.name.white().bold(),
252                proc.pid.to_string().cyan()
253            );
254            if let Some(ref cwd) = proc.cwd {
255                out_println!("  {} {}", "Directory:".bright_black(), cwd);
256            }
257            if let Some(ref path) = proc.exe_path {
258                out_println!("  {} {}", "Path:".bright_black(), path.bright_black());
259            }
260            for port_info in ports {
261                let addr = port_info.address.as_deref().unwrap_or("*");
262                out_println!(
263                    "  {} :{} ({} on {})",
264                    "Listening:".bright_black(),
265                    port_info.port.to_string().cyan(),
266                    format!("{:?}", port_info.protocol).to_uppercase(),
267                    addr
268                );
269            }
270            out_println!(
271                "  {} {:.1}% CPU, {}",
272                "Resources:".bright_black(),
273                proc.cpu_percent,
274                format_memory(proc.memory_mb)
275            );
276            if let Some(uptime) = proc.uptime_secs {
277                out_println!("  {} {}", "Uptime:".bright_black(), format_duration(uptime));
278            }
279            if self.verbose {
280                if let Some(ref cmd) = proc.command {
281                    out_println!("  {} {}", "Command:".bright_black(), cmd.bright_black());
282                }
283            }
284            out_println!();
285        } else {
286            // Multiple processes: the standard process table, then who listens where
287            let processes: Vec<Process> = results.iter().map(|(p, _)| p.clone()).collect();
288            Printer::from_flags(false, self.verbose)
289                .wide(self.wide)
290                .print_processes_as("for", &processes, Some(&format!("for {}", file_display)));
291
292            let listening: Vec<_> = results
293                .iter()
294                .flat_map(|(proc, ports)| ports.iter().map(move |p| (proc, p)))
295                .collect();
296            if !listening.is_empty() {
297                out_println!("  {}", "Listening:".bright_black());
298                for (proc, port_info) in listening {
299                    let addr = port_info.address.as_deref().unwrap_or("*");
300                    out_println!(
301                        "    {} :{} ({} on {})  {} [PID {}]",
302                        "→".bright_black(),
303                        port_info.port.to_string().cyan(),
304                        format!("{:?}", port_info.protocol).to_uppercase(),
305                        addr,
306                        proc.name.white(),
307                        proc.pid.to_string().cyan()
308                    );
309                }
310                out_println!();
311            }
312        }
313    }
314
315    fn print_json(&self, results: &[(Process, Vec<PortInfo>)]) -> Result<()> {
316        let printer = Printer::from_flags(true, self.verbose);
317        let items: Vec<ProcessForJson> = results
318            .iter()
319            .map(|(proc, ports)| ProcessForJson {
320                process: proc,
321                ports,
322            })
323            .collect();
324
325        printer.print_json(&ForOutput {
326            action: "for",
327            success: true,
328            count: items.len(),
329            results: &items,
330        });
331        Ok(())
332    }
333}
334
335#[derive(Serialize)]
336struct ProcessForJson<'a> {
337    process: &'a Process,
338    ports: &'a [PortInfo],
339}
340
341#[derive(Serialize)]
342struct ForOutput<'a> {
343    action: &'static str,
344    success: bool,
345    count: usize,
346    results: &'a [ProcessForJson<'a>],
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use crate::core::ProcessStatus;
353
354    fn command() -> ForCommand {
355        ForCommand {
356            file: "x".to_string(),
357            in_dir: None,
358            by_name: None,
359            cmd: false,
360            min_cpu: None,
361            min_mem: None,
362            status: None,
363            min_uptime: None,
364            json: false,
365            verbose: false,
366            wide: false,
367            sort: SortKey::Cpu,
368            limit: None,
369        }
370    }
371
372    /// A process record with no working directory and no start time, which
373    /// some processes really are (a zombie has neither on Linux)
374    fn bare_process() -> Process {
375        Process {
376            pid: 1,
377            name: "x".to_string(),
378            exe_path: None,
379            cwd: None,
380            command: None,
381            argv0: None,
382            raw: None,
383            cpu_percent: 0.0,
384            memory_mb: 0.0,
385            status: ProcessStatus::Sleeping,
386            user: None,
387            parent_pid: None,
388            start_time: None,
389            uptime_secs: None,
390        }
391    }
392
393    #[test]
394    fn a_process_with_no_working_directory_fails_in() {
395        let cmd = ForCommand {
396            in_dir: Some("/".to_string()),
397            ..command()
398        };
399        let mut processes = vec![bare_process()];
400        cmd.apply_filters(&mut processes).unwrap();
401        assert!(processes.is_empty());
402
403        let mut processes = vec![bare_process()];
404        command().apply_filters(&mut processes).unwrap();
405        assert_eq!(processes.len(), 1);
406    }
407
408    #[test]
409    fn a_process_with_no_start_time_fails_min_uptime() {
410        let cmd = ForCommand {
411            min_uptime: Some(0),
412            ..command()
413        };
414        let mut processes = vec![bare_process()];
415        cmd.apply_filters(&mut processes).unwrap();
416        assert!(processes.is_empty());
417    }
418}