Skip to main content

proc_cli/core/
process.rs

1//! Cross-platform process abstraction
2//!
3//! Provides a unified interface for discovering and managing processes
4//! across macOS, Linux, and Windows.
5
6use crate::error::{ProcError, Result};
7use serde::{Deserialize, Serialize};
8use std::time::Duration;
9use sysinfo::{Pid, ProcessStatus as SysProcessStatus, System};
10
11/// Process status
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "lowercase")]
14pub enum ProcessStatus {
15    /// Process is actively executing on CPU
16    Running,
17    /// Process is waiting for an event or resource
18    Sleeping,
19    /// Process has been stopped (e.g., by SIGSTOP)
20    Stopped,
21    /// Process has terminated but not yet been reaped by parent
22    Zombie,
23    /// Process is being terminated
24    Dead,
25    /// Process status could not be determined
26    Unknown,
27}
28
29impl From<SysProcessStatus> for ProcessStatus {
30    fn from(status: SysProcessStatus) -> Self {
31        match status {
32            SysProcessStatus::Run => ProcessStatus::Running,
33            SysProcessStatus::Sleep => ProcessStatus::Sleeping,
34            SysProcessStatus::Stop => ProcessStatus::Stopped,
35            SysProcessStatus::Zombie => ProcessStatus::Zombie,
36            SysProcessStatus::Dead => ProcessStatus::Dead,
37            _ => ProcessStatus::Unknown,
38        }
39    }
40}
41
42/// Represents a system process with relevant information
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct Process {
45    /// Process ID
46    pub pid: u32,
47    /// Process name (executable name)
48    pub name: String,
49    /// Path to the executable
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub exe_path: Option<String>,
52    /// Current working directory
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub cwd: Option<String>,
55    /// Full command line (if available)
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub command: Option<String>,
58    /// CPU usage percentage (0.0 - 100.0+)
59    pub cpu_percent: f32,
60    /// Memory usage in megabytes
61    pub memory_mb: f64,
62    /// Process status
63    pub status: ProcessStatus,
64    /// User who owns the process
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub user: Option<String>,
67    /// Parent process ID
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub parent_pid: Option<u32>,
70    /// Process start time (Unix timestamp)
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub start_time: Option<u64>,
73}
74
75impl Process {
76    /// Find all processes matching a name pattern (case-insensitive)
77    pub fn find_by_name(pattern: &str) -> Result<Vec<Process>> {
78        let mut sys = System::new_all();
79        sys.refresh_all();
80
81        let pattern_lower = pattern.to_lowercase();
82        let self_pid = sysinfo::Pid::from_u32(std::process::id());
83        // Exclude the entire ancestor chain — their command lines contain proc's
84        // arguments (e.g. "zsh -c proc by node"), causing false positives.
85        // Walk up to 10 levels (covers: proc → shell → shell wrapper → IDE → ...)
86        let ancestor_pids = {
87            let mut pids = std::collections::HashSet::new();
88            pids.insert(self_pid);
89            let mut current = self_pid;
90            for _ in 0..10 {
91                if let Some(parent) = sys.process(current).and_then(|p| p.parent()) {
92                    pids.insert(parent);
93                    current = parent;
94                } else {
95                    break;
96                }
97            }
98            pids
99        };
100        let processes: Vec<Process> = sys
101            .processes()
102            .iter()
103            .filter_map(|(pid, proc)| {
104                // Exclude self and ancestor shells
105                if ancestor_pids.contains(pid) {
106                    return None;
107                }
108
109                let name = proc.name().to_string_lossy().to_string();
110                let cmd: String = proc
111                    .cmd()
112                    .iter()
113                    .map(|s| s.to_string_lossy())
114                    .collect::<Vec<_>>()
115                    .join(" ");
116
117                // Match against name or command
118                if name.to_lowercase().contains(&pattern_lower)
119                    || cmd.to_lowercase().contains(&pattern_lower)
120                {
121                    Some(Process::from_sysinfo(*pid, proc))
122                } else {
123                    None
124                }
125            })
126            .collect();
127
128        if processes.is_empty() {
129            return Err(ProcError::ProcessNotFound(pattern.to_string()));
130        }
131
132        Ok(processes)
133    }
134
135    /// Find a specific process by PID
136    pub fn find_by_pid(pid: u32) -> Result<Option<Process>> {
137        let mut sys = System::new_all();
138        sys.refresh_all();
139
140        let sysinfo_pid = Pid::from_u32(pid);
141
142        Ok(sys
143            .processes()
144            .get(&sysinfo_pid)
145            .map(|proc| Process::from_sysinfo(sysinfo_pid, proc)))
146    }
147
148    /// Get all running processes (excludes proc's own process)
149    pub fn find_all() -> Result<Vec<Process>> {
150        let mut sys = System::new_all();
151        sys.refresh_all();
152
153        let self_pid = sysinfo::Pid::from_u32(std::process::id());
154        let processes: Vec<Process> = sys
155            .processes()
156            .iter()
157            .filter(|(pid, _)| **pid != self_pid)
158            .map(|(pid, proc)| Process::from_sysinfo(*pid, proc))
159            .collect();
160
161        Ok(processes)
162    }
163
164    /// Find processes running a specific executable path
165    pub fn find_by_exe_path(path: &std::path::Path) -> Result<Vec<Process>> {
166        let all = Self::find_all()?;
167        let path_str = path.to_string_lossy();
168
169        Ok(all
170            .into_iter()
171            .filter(|p| {
172                if let Some(ref exe) = p.exe_path {
173                    // Exact match (compare as strings to avoid PathBuf allocation)
174                    exe == &*path_str || std::path::Path::new(exe) == path
175                } else {
176                    false
177                }
178            })
179            .collect())
180    }
181
182    /// Find processes that have a file open (Unix only via lsof)
183    #[cfg(unix)]
184    pub fn find_by_open_file(path: &std::path::Path) -> Result<Vec<Process>> {
185        use std::process::Command;
186
187        let output = Command::new("lsof")
188            .args(["-t", &path.to_string_lossy()]) // -t = terse (PIDs only)
189            .output();
190
191        let output = match output {
192            Ok(o) => o,
193            Err(_) => return Ok(vec![]), // lsof not available
194        };
195
196        if !output.status.success() {
197            return Ok(vec![]); // No processes have file open
198        }
199
200        let pids: Vec<u32> = String::from_utf8_lossy(&output.stdout)
201            .lines()
202            .filter_map(|line| line.trim().parse().ok())
203            .collect();
204
205        let mut processes = Vec::new();
206        for pid in pids {
207            if let Ok(Some(proc)) = Self::find_by_pid(pid) {
208                processes.push(proc);
209            }
210        }
211
212        Ok(processes)
213    }
214
215    /// Find processes that have a file open (Windows stub)
216    #[cfg(not(unix))]
217    pub fn find_by_open_file(_path: &std::path::Path) -> Result<Vec<Process>> {
218        // Windows: Could use handle.exe from Sysinternals, but skip for now
219        Ok(vec![])
220    }
221
222    /// Find processes that appear to be stuck (high CPU, no progress)
223    /// This is a heuristic-based detection
224    pub fn find_stuck(timeout: Duration) -> Result<Vec<Process>> {
225        let mut sys = System::new_all();
226        sys.refresh_all();
227
228        // Wait a bit and refresh to compare
229        std::thread::sleep(Duration::from_millis(500));
230        sys.refresh_all();
231
232        let timeout_secs = timeout.as_secs();
233        let processes: Vec<Process> = sys
234            .processes()
235            .iter()
236            .filter_map(|(pid, proc)| {
237                let cpu = proc.cpu_usage();
238                let run_time = proc.run_time();
239
240                // Heuristic: Process using significant CPU for longer than timeout
241                // and in a potentially stuck state
242                if run_time > timeout_secs && cpu > 50.0 {
243                    Some(Process::from_sysinfo(*pid, proc))
244                } else {
245                    None
246                }
247            })
248            .collect();
249
250        Ok(processes)
251    }
252
253    /// Force kill the process (SIGKILL on Unix, taskkill /F on Windows)
254    pub fn kill(&self) -> Result<()> {
255        let mut sys = System::new();
256        sys.refresh_processes(
257            sysinfo::ProcessesToUpdate::Some(&[Pid::from_u32(self.pid)]),
258            true,
259        );
260
261        if let Some(proc) = sys.process(Pid::from_u32(self.pid)) {
262            if proc.kill() {
263                Ok(())
264            } else {
265                Err(ProcError::SignalError(format!(
266                    "Failed to kill process {}",
267                    self.pid
268                )))
269            }
270        } else {
271            Err(ProcError::ProcessNotFound(self.pid.to_string()))
272        }
273    }
274
275    /// Force kill and wait for process to terminate
276    /// Returns the exit status if available
277    pub fn kill_and_wait(&self) -> Result<Option<std::process::ExitStatus>> {
278        let mut sys = System::new();
279        sys.refresh_processes(
280            sysinfo::ProcessesToUpdate::Some(&[Pid::from_u32(self.pid)]),
281            true,
282        );
283
284        if let Some(proc) = sys.process(Pid::from_u32(self.pid)) {
285            proc.kill_and_wait().map_err(|e| {
286                ProcError::SignalError(format!("Failed to kill process {}: {:?}", self.pid, e))
287            })
288        } else {
289            Err(ProcError::ProcessNotFound(self.pid.to_string()))
290        }
291    }
292
293    /// Send SIGTERM for graceful termination (Unix) or taskkill (Windows)
294    #[cfg(unix)]
295    pub fn terminate(&self) -> Result<()> {
296        use nix::sys::signal::{kill, Signal};
297        use nix::unistd::Pid as NixPid;
298
299        kill(NixPid::from_raw(self.pid as i32), Signal::SIGTERM)
300            .map_err(|e| ProcError::SignalError(e.to_string()))
301    }
302
303    /// Graceful termination (Windows)
304    #[cfg(windows)]
305    pub fn terminate(&self) -> Result<()> {
306        use std::process::Command;
307
308        Command::new("taskkill")
309            .args(["/PID", &self.pid.to_string()])
310            .output()
311            .map_err(|e| ProcError::SystemError(e.to_string()))?;
312
313        Ok(())
314    }
315
316    /// Send an arbitrary signal to the process (Unix only)
317    #[cfg(unix)]
318    pub fn send_signal(&self, signal: nix::sys::signal::Signal) -> Result<()> {
319        use nix::sys::signal::kill;
320        use nix::unistd::Pid as NixPid;
321        kill(NixPid::from_raw(self.pid as i32), signal)
322            .map_err(|e| ProcError::SignalError(format!("{}: {}", signal, e)))
323    }
324
325    // Note: On Windows, send_signal is not available. Commands that use it
326    // (freeze, thaw) have their own #[cfg(not(unix))] stubs that return NotSupported.
327
328    /// Find orphaned processes (parent is PID 1 / init / launchd, excluding system daemons)
329    pub fn find_orphans() -> Result<Vec<Process>> {
330        let all = Self::find_all()?;
331
332        Ok(all
333            .into_iter()
334            .filter(|p| {
335                if let Some(ppid) = p.parent_pid {
336                    ppid == 1 && p.pid != 1 && !Self::is_system_process(p)
337                } else {
338                    false
339                }
340            })
341            .collect())
342    }
343
344    /// Check if a process is a system daemon (naturally has PPID 1)
345    ///
346    /// Filters out processes that legitimately have PPID 1 — system daemons,
347    /// kernel threads, and services managed by init/systemd/launchd.
348    /// Heuristic: no cwd or cwd is "/" with exe in system paths.
349    fn is_system_process(p: &Process) -> bool {
350        if p.cwd.is_none() || p.cwd.as_deref() == Some("/") {
351            if let Some(ref exe) = p.exe_path {
352                // macOS system paths
353                if exe.starts_with("/System/") || exe.starts_with("/usr/libexec/") {
354                    return true;
355                }
356                // Shared Unix system paths (macOS + Linux)
357                if exe.starts_with("/usr/sbin/")
358                    || exe.starts_with("/sbin/")
359                    || exe.starts_with("/usr/bin/")
360                    || exe.starts_with("/usr/lib/")
361                    || exe.starts_with("/usr/lib64/")
362                    || exe.starts_with("/lib/")
363                    || exe.starts_with("/lib64/")
364                    || exe.starts_with("/opt/")
365                    || exe.starts_with("/snap/")
366                {
367                    return true;
368                }
369            }
370            return true; // No exe path = likely kernel thread
371        }
372        false
373    }
374
375    /// Check if the process still exists
376    pub fn exists(&self) -> bool {
377        let mut sys = System::new();
378        sys.refresh_processes(
379            sysinfo::ProcessesToUpdate::Some(&[Pid::from_u32(self.pid)]),
380            true,
381        );
382        sys.process(Pid::from_u32(self.pid)).is_some()
383    }
384
385    /// Check if the process is still running (alias for exists for compatibility)
386    pub fn is_running(&self) -> bool {
387        self.exists()
388    }
389
390    /// Wait for the process to terminate
391    /// Returns the exit status if available
392    pub fn wait(&self) -> Option<std::process::ExitStatus> {
393        let mut sys = System::new();
394        sys.refresh_processes(
395            sysinfo::ProcessesToUpdate::Some(&[Pid::from_u32(self.pid)]),
396            true,
397        );
398
399        sys.process(Pid::from_u32(self.pid))
400            .and_then(|proc| proc.wait())
401    }
402
403    /// Convert from sysinfo Process
404    pub(crate) fn from_sysinfo(pid: Pid, proc: &sysinfo::Process) -> Self {
405        let cmd_vec = proc.cmd();
406        let command = if cmd_vec.is_empty() {
407            None
408        } else {
409            Some(
410                cmd_vec
411                    .iter()
412                    .map(|s| s.to_string_lossy())
413                    .collect::<Vec<_>>()
414                    .join(" "),
415            )
416        };
417
418        let exe_path = proc.exe().map(|p| p.to_string_lossy().to_string());
419        let cwd = proc.cwd().map(|p| p.to_string_lossy().to_string());
420
421        Process {
422            pid: pid.as_u32(),
423            name: proc.name().to_string_lossy().to_string(),
424            exe_path,
425            cwd,
426            command,
427            cpu_percent: proc.cpu_usage(),
428            memory_mb: proc.memory() as f64 / 1024.0 / 1024.0,
429            status: ProcessStatus::from(proc.status()),
430            user: proc.user_id().map(|u| u.to_string()),
431            parent_pid: proc.parent().map(|p| p.as_u32()),
432            start_time: Some(proc.start_time()),
433        }
434    }
435}
436
437/// Parse a signal name to a nix Signal (Unix only)
438///
439/// Accepts: "HUP", "SIGHUP", "hup" (signal names only, not numbers —
440/// numeric signal values differ between macOS and Linux)
441#[cfg(unix)]
442pub fn parse_signal_name(name: &str) -> Result<nix::sys::signal::Signal> {
443    use nix::sys::signal::Signal;
444
445    let upper = name.to_uppercase();
446    let upper = upper.trim_start_matches("SIG");
447    match upper {
448        "HUP" => Ok(Signal::SIGHUP),
449        "INT" => Ok(Signal::SIGINT),
450        "QUIT" => Ok(Signal::SIGQUIT),
451        "ABRT" => Ok(Signal::SIGABRT),
452        "KILL" => Ok(Signal::SIGKILL),
453        "TERM" => Ok(Signal::SIGTERM),
454        "STOP" => Ok(Signal::SIGSTOP),
455        "CONT" => Ok(Signal::SIGCONT),
456        "USR1" => Ok(Signal::SIGUSR1),
457        "USR2" => Ok(Signal::SIGUSR2),
458        _ => Err(ProcError::InvalidInput(format!(
459            "Unknown signal: '{}'. Valid signals: HUP, INT, QUIT, ABRT, KILL, TERM, STOP, CONT, USR1, USR2",
460            name
461        ))),
462    }
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468
469    #[test]
470    fn test_find_all_processes() {
471        let processes = Process::find_all().unwrap();
472        assert!(!processes.is_empty(), "Should find at least one process");
473    }
474
475    #[test]
476    fn test_find_by_pid_self() {
477        let pid = std::process::id();
478        let process = Process::find_by_pid(pid).unwrap();
479        assert!(process.is_some(), "Should find own process");
480    }
481
482    #[test]
483    fn test_find_nonexistent_process() {
484        let result = Process::find_by_name("nonexistent_process_12345");
485        assert!(result.is_err());
486    }
487
488    #[test]
489    fn test_find_orphans_returns_ok() {
490        // Should not error — may or may not find orphans depending on system state
491        let result = Process::find_orphans();
492        assert!(result.is_ok());
493    }
494
495    #[test]
496    fn test_find_orphans_excludes_system_processes() {
497        let orphans = Process::find_orphans().unwrap();
498        for orphan in &orphans {
499            // No orphan should have a system exe path with cwd "/"
500            if orphan.cwd.as_deref() == Some("/") {
501                if let Some(ref exe) = orphan.exe_path {
502                    assert!(
503                        !exe.starts_with("/usr/sbin/")
504                            && !exe.starts_with("/sbin/")
505                            && !exe.starts_with("/System/")
506                            && !exe.starts_with("/usr/libexec/"),
507                        "System process should have been filtered: {} ({})",
508                        orphan.name,
509                        exe
510                    );
511                }
512            }
513        }
514    }
515
516    #[test]
517    fn test_is_system_process_system_paths() {
518        let make_proc = |exe: Option<&str>, cwd: Option<&str>| Process {
519            pid: 100,
520            name: "test".to_string(),
521            exe_path: exe.map(String::from),
522            cwd: cwd.map(String::from),
523            command: None,
524            cpu_percent: 0.0,
525            memory_mb: 0.0,
526            status: ProcessStatus::Running,
527            user: None,
528            parent_pid: Some(1),
529            start_time: None,
530        };
531
532        // System daemons with cwd "/" and system exe paths
533        assert!(Process::is_system_process(&make_proc(
534            Some("/usr/sbin/sshd"),
535            Some("/")
536        )));
537        assert!(Process::is_system_process(&make_proc(
538            Some("/System/Library/foo"),
539            Some("/")
540        )));
541        assert!(Process::is_system_process(&make_proc(
542            Some("/usr/bin/systemd"),
543            Some("/")
544        )));
545        assert!(Process::is_system_process(&make_proc(
546            Some("/usr/lib/snapd/snapd"),
547            Some("/")
548        )));
549
550        // No exe path with cwd "/" = kernel thread
551        assert!(Process::is_system_process(&make_proc(None, Some("/"))));
552
553        // No cwd at all = likely kernel thread
554        assert!(Process::is_system_process(&make_proc(
555            Some("/usr/bin/foo"),
556            None
557        )));
558
559        // User process with user cwd = NOT system
560        assert!(!Process::is_system_process(&make_proc(
561            Some("/usr/bin/node"),
562            Some("/home/user/project")
563        )));
564        assert!(!Process::is_system_process(&make_proc(
565            Some("/home/user/.local/bin/app"),
566            Some("/home/user")
567        )));
568    }
569
570    #[cfg(unix)]
571    #[test]
572    fn test_parse_signal_name_valid() {
573        use nix::sys::signal::Signal;
574
575        assert_eq!(parse_signal_name("HUP").unwrap(), Signal::SIGHUP);
576        assert_eq!(parse_signal_name("hup").unwrap(), Signal::SIGHUP);
577        assert_eq!(parse_signal_name("SIGHUP").unwrap(), Signal::SIGHUP);
578        assert_eq!(parse_signal_name("sighup").unwrap(), Signal::SIGHUP);
579        assert_eq!(parse_signal_name("INT").unwrap(), Signal::SIGINT);
580        assert_eq!(parse_signal_name("QUIT").unwrap(), Signal::SIGQUIT);
581        assert_eq!(parse_signal_name("ABRT").unwrap(), Signal::SIGABRT);
582        assert_eq!(parse_signal_name("KILL").unwrap(), Signal::SIGKILL);
583        assert_eq!(parse_signal_name("TERM").unwrap(), Signal::SIGTERM);
584        assert_eq!(parse_signal_name("STOP").unwrap(), Signal::SIGSTOP);
585        assert_eq!(parse_signal_name("CONT").unwrap(), Signal::SIGCONT);
586        assert_eq!(parse_signal_name("USR1").unwrap(), Signal::SIGUSR1);
587        assert_eq!(parse_signal_name("USR2").unwrap(), Signal::SIGUSR2);
588    }
589
590    #[cfg(unix)]
591    #[test]
592    fn test_parse_signal_name_invalid() {
593        assert!(parse_signal_name("INVALID").is_err());
594        assert!(parse_signal_name("FOO").is_err());
595        assert!(parse_signal_name("").is_err());
596    }
597
598    #[cfg(unix)]
599    #[test]
600    fn test_parse_signal_name_case_insensitive() {
601        use nix::sys::signal::Signal;
602
603        assert_eq!(parse_signal_name("term").unwrap(), Signal::SIGTERM);
604        assert_eq!(parse_signal_name("Term").unwrap(), Signal::SIGTERM);
605        assert_eq!(parse_signal_name("TERM").unwrap(), Signal::SIGTERM);
606        assert_eq!(parse_signal_name("sigterm").unwrap(), Signal::SIGTERM);
607        assert_eq!(parse_signal_name("SigTerm").unwrap(), Signal::SIGTERM);
608    }
609
610    #[cfg(unix)]
611    #[test]
612    fn test_send_signal_nonexistent_process() {
613        use nix::sys::signal::Signal;
614
615        // PID 99999999 almost certainly doesn't exist
616        let proc = Process {
617            pid: 99999999,
618            name: "ghost".to_string(),
619            exe_path: None,
620            cwd: None,
621            command: None,
622            cpu_percent: 0.0,
623            memory_mb: 0.0,
624            status: ProcessStatus::Running,
625            user: None,
626            parent_pid: None,
627            start_time: None,
628        };
629
630        let result = proc.send_signal(Signal::SIGCONT);
631        assert!(result.is_err());
632    }
633}