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 processes: Vec<Process> = sys
83            .processes()
84            .iter()
85            .filter_map(|(pid, proc)| {
86                let name = proc.name().to_string_lossy().to_string();
87                let cmd: String = proc
88                    .cmd()
89                    .iter()
90                    .map(|s| s.to_string_lossy())
91                    .collect::<Vec<_>>()
92                    .join(" ");
93
94                // Match against name or command
95                if name.to_lowercase().contains(&pattern_lower)
96                    || cmd.to_lowercase().contains(&pattern_lower)
97                {
98                    Some(Process::from_sysinfo(*pid, proc))
99                } else {
100                    None
101                }
102            })
103            .collect();
104
105        if processes.is_empty() {
106            return Err(ProcError::ProcessNotFound(pattern.to_string()));
107        }
108
109        Ok(processes)
110    }
111
112    /// Find a specific process by PID
113    pub fn find_by_pid(pid: u32) -> Result<Option<Process>> {
114        let mut sys = System::new_all();
115        sys.refresh_all();
116
117        let sysinfo_pid = Pid::from_u32(pid);
118
119        Ok(sys
120            .processes()
121            .get(&sysinfo_pid)
122            .map(|proc| Process::from_sysinfo(sysinfo_pid, proc)))
123    }
124
125    /// Get all running processes
126    pub fn find_all() -> Result<Vec<Process>> {
127        let mut sys = System::new_all();
128        sys.refresh_all();
129
130        let processes: Vec<Process> = sys
131            .processes()
132            .iter()
133            .map(|(pid, proc)| Process::from_sysinfo(*pid, proc))
134            .collect();
135
136        Ok(processes)
137    }
138
139    /// Find processes running a specific executable path
140    pub fn find_by_exe_path(path: &std::path::Path) -> Result<Vec<Process>> {
141        let all = Self::find_all()?;
142        let path_str = path.to_string_lossy();
143
144        Ok(all
145            .into_iter()
146            .filter(|p| {
147                if let Some(ref exe) = p.exe_path {
148                    // Exact match (compare as strings to avoid PathBuf allocation)
149                    exe == &*path_str || std::path::Path::new(exe) == path
150                } else {
151                    false
152                }
153            })
154            .collect())
155    }
156
157    /// Find processes that have a file open (Unix only via lsof)
158    #[cfg(unix)]
159    pub fn find_by_open_file(path: &std::path::Path) -> Result<Vec<Process>> {
160        use std::process::Command;
161
162        let output = Command::new("lsof")
163            .args(["-t", &path.to_string_lossy()]) // -t = terse (PIDs only)
164            .output();
165
166        let output = match output {
167            Ok(o) => o,
168            Err(_) => return Ok(vec![]), // lsof not available
169        };
170
171        if !output.status.success() {
172            return Ok(vec![]); // No processes have file open
173        }
174
175        let pids: Vec<u32> = String::from_utf8_lossy(&output.stdout)
176            .lines()
177            .filter_map(|line| line.trim().parse().ok())
178            .collect();
179
180        let mut processes = Vec::new();
181        for pid in pids {
182            if let Ok(Some(proc)) = Self::find_by_pid(pid) {
183                processes.push(proc);
184            }
185        }
186
187        Ok(processes)
188    }
189
190    /// Find processes that have a file open (Windows stub)
191    #[cfg(not(unix))]
192    pub fn find_by_open_file(_path: &std::path::Path) -> Result<Vec<Process>> {
193        // Windows: Could use handle.exe from Sysinternals, but skip for now
194        Ok(vec![])
195    }
196
197    /// Find processes that appear to be stuck (high CPU, no progress)
198    /// This is a heuristic-based detection
199    pub fn find_stuck(timeout: Duration) -> Result<Vec<Process>> {
200        let mut sys = System::new_all();
201        sys.refresh_all();
202
203        // Wait a bit and refresh to compare
204        std::thread::sleep(Duration::from_millis(500));
205        sys.refresh_all();
206
207        let timeout_secs = timeout.as_secs();
208        let processes: Vec<Process> = sys
209            .processes()
210            .iter()
211            .filter_map(|(pid, proc)| {
212                let cpu = proc.cpu_usage();
213                let run_time = proc.run_time();
214
215                // Heuristic: Process using significant CPU for longer than timeout
216                // and in a potentially stuck state
217                if run_time > timeout_secs && cpu > 50.0 {
218                    Some(Process::from_sysinfo(*pid, proc))
219                } else {
220                    None
221                }
222            })
223            .collect();
224
225        Ok(processes)
226    }
227
228    /// Force kill the process (SIGKILL on Unix, taskkill /F on Windows)
229    pub fn kill(&self) -> Result<()> {
230        let mut sys = System::new();
231        sys.refresh_processes(
232            sysinfo::ProcessesToUpdate::Some(&[Pid::from_u32(self.pid)]),
233            true,
234        );
235
236        if let Some(proc) = sys.process(Pid::from_u32(self.pid)) {
237            if proc.kill() {
238                Ok(())
239            } else {
240                Err(ProcError::SignalError(format!(
241                    "Failed to kill process {}",
242                    self.pid
243                )))
244            }
245        } else {
246            Err(ProcError::ProcessNotFound(self.pid.to_string()))
247        }
248    }
249
250    /// Force kill and wait for process to terminate
251    /// Returns the exit status if available
252    pub fn kill_and_wait(&self) -> Result<Option<std::process::ExitStatus>> {
253        let mut sys = System::new();
254        sys.refresh_processes(
255            sysinfo::ProcessesToUpdate::Some(&[Pid::from_u32(self.pid)]),
256            true,
257        );
258
259        if let Some(proc) = sys.process(Pid::from_u32(self.pid)) {
260            proc.kill_and_wait().map_err(|e| {
261                ProcError::SignalError(format!("Failed to kill process {}: {:?}", self.pid, e))
262            })
263        } else {
264            Err(ProcError::ProcessNotFound(self.pid.to_string()))
265        }
266    }
267
268    /// Send SIGTERM for graceful termination (Unix) or taskkill (Windows)
269    #[cfg(unix)]
270    pub fn terminate(&self) -> Result<()> {
271        use nix::sys::signal::{kill, Signal};
272        use nix::unistd::Pid as NixPid;
273
274        kill(NixPid::from_raw(self.pid as i32), Signal::SIGTERM)
275            .map_err(|e| ProcError::SignalError(e.to_string()))
276    }
277
278    /// Graceful termination (Windows)
279    #[cfg(windows)]
280    pub fn terminate(&self) -> Result<()> {
281        use std::process::Command;
282
283        Command::new("taskkill")
284            .args(["/PID", &self.pid.to_string()])
285            .output()
286            .map_err(|e| ProcError::SystemError(e.to_string()))?;
287
288        Ok(())
289    }
290
291    /// Check if the process still exists
292    pub fn exists(&self) -> bool {
293        let mut sys = System::new();
294        sys.refresh_processes(
295            sysinfo::ProcessesToUpdate::Some(&[Pid::from_u32(self.pid)]),
296            true,
297        );
298        sys.process(Pid::from_u32(self.pid)).is_some()
299    }
300
301    /// Check if the process is still running (alias for exists for compatibility)
302    pub fn is_running(&self) -> bool {
303        self.exists()
304    }
305
306    /// Wait for the process to terminate
307    /// Returns the exit status if available
308    pub fn wait(&self) -> Option<std::process::ExitStatus> {
309        let mut sys = System::new();
310        sys.refresh_processes(
311            sysinfo::ProcessesToUpdate::Some(&[Pid::from_u32(self.pid)]),
312            true,
313        );
314
315        sys.process(Pid::from_u32(self.pid))
316            .and_then(|proc| proc.wait())
317    }
318
319    /// Convert from sysinfo Process
320    fn from_sysinfo(pid: Pid, proc: &sysinfo::Process) -> Self {
321        let cmd_vec = proc.cmd();
322        let command = if cmd_vec.is_empty() {
323            None
324        } else {
325            Some(
326                cmd_vec
327                    .iter()
328                    .map(|s| s.to_string_lossy())
329                    .collect::<Vec<_>>()
330                    .join(" "),
331            )
332        };
333
334        let exe_path = proc.exe().map(|p| p.to_string_lossy().to_string());
335        let cwd = proc.cwd().map(|p| p.to_string_lossy().to_string());
336
337        Process {
338            pid: pid.as_u32(),
339            name: proc.name().to_string_lossy().to_string(),
340            exe_path,
341            cwd,
342            command,
343            cpu_percent: proc.cpu_usage(),
344            memory_mb: proc.memory() as f64 / 1024.0 / 1024.0,
345            status: ProcessStatus::from(proc.status()),
346            user: proc.user_id().map(|u| u.to_string()),
347            parent_pid: proc.parent().map(|p| p.as_u32()),
348            start_time: Some(proc.start_time()),
349        }
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    #[test]
358    fn test_find_all_processes() {
359        let processes = Process::find_all().unwrap();
360        assert!(!processes.is_empty(), "Should find at least one process");
361    }
362
363    #[test]
364    fn test_find_by_pid_self() {
365        let pid = std::process::id();
366        let process = Process::find_by_pid(pid).unwrap();
367        assert!(process.is_some(), "Should find own process");
368    }
369
370    #[test]
371    fn test_find_nonexistent_process() {
372        let result = Process::find_by_name("nonexistent_process_12345");
373        assert!(result.is_err());
374    }
375}