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    /// Check if the process still exists
301    pub fn exists(&self) -> bool {
302        let mut sys = System::new();
303        sys.refresh_processes(
304            sysinfo::ProcessesToUpdate::Some(&[Pid::from_u32(self.pid)]),
305            true,
306        );
307        sys.process(Pid::from_u32(self.pid)).is_some()
308    }
309
310    /// Check if the process is still running (alias for exists for compatibility)
311    pub fn is_running(&self) -> bool {
312        self.exists()
313    }
314
315    /// Wait for the process to terminate
316    /// Returns the exit status if available
317    pub fn wait(&self) -> Option<std::process::ExitStatus> {
318        let mut sys = System::new();
319        sys.refresh_processes(
320            sysinfo::ProcessesToUpdate::Some(&[Pid::from_u32(self.pid)]),
321            true,
322        );
323
324        sys.process(Pid::from_u32(self.pid))
325            .and_then(|proc| proc.wait())
326    }
327
328    /// Convert from sysinfo Process
329    pub(crate) fn from_sysinfo(pid: Pid, proc: &sysinfo::Process) -> Self {
330        let cmd_vec = proc.cmd();
331        let command = if cmd_vec.is_empty() {
332            None
333        } else {
334            Some(
335                cmd_vec
336                    .iter()
337                    .map(|s| s.to_string_lossy())
338                    .collect::<Vec<_>>()
339                    .join(" "),
340            )
341        };
342
343        let exe_path = proc.exe().map(|p| p.to_string_lossy().to_string());
344        let cwd = proc.cwd().map(|p| p.to_string_lossy().to_string());
345
346        Process {
347            pid: pid.as_u32(),
348            name: proc.name().to_string_lossy().to_string(),
349            exe_path,
350            cwd,
351            command,
352            cpu_percent: proc.cpu_usage(),
353            memory_mb: proc.memory() as f64 / 1024.0 / 1024.0,
354            status: ProcessStatus::from(proc.status()),
355            user: proc.user_id().map(|u| u.to_string()),
356            parent_pid: proc.parent().map(|p| p.as_u32()),
357            start_time: Some(proc.start_time()),
358        }
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    #[test]
367    fn test_find_all_processes() {
368        let processes = Process::find_all().unwrap();
369        assert!(!processes.is_empty(), "Should find at least one process");
370    }
371
372    #[test]
373    fn test_find_by_pid_self() {
374        let pid = std::process::id();
375        let process = Process::find_by_pid(pid).unwrap();
376        assert!(process.is_some(), "Should find own process");
377    }
378
379    #[test]
380    fn test_find_nonexistent_process() {
381        let result = Process::find_by_name("nonexistent_process_12345");
382        assert!(result.is_err());
383    }
384}