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