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