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