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