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