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