Skip to main content

prt_core/core/
process_detail.rs

1//! Enhanced process detail: cwd, environment, open files, CPU/RAM.
2//!
3//! Used by the Connection tab (or a dedicated Info tab) to show
4//! deeper info about the selected process. Data is fetched lazily —
5//! only when the detail panel is visible and the selected PID changes.
6//!
7//! - **Linux:** reads `/proc/{pid}/cwd`, `/proc/{pid}/environ`, `/proc/{pid}/fd/*`, `/proc/{pid}/stat`
8//! - **macOS:** uses `lsof -p {pid}` and `ps -o %cpu,rss -p {pid}`
9
10use std::path::PathBuf;
11
12/// Detailed information about a single process, fetched on demand.
13#[derive(Debug, Clone)]
14pub struct ProcessDetail {
15    pub cwd: Option<PathBuf>,
16    pub env_vars: Vec<(String, String)>,
17    pub open_files: Vec<String>,
18    pub cpu_percent: Option<f32>,
19    pub rss_kb: Option<u64>,
20}
21
22/// Fetch detailed process information for the given PID.
23/// Returns `None` if the process no longer exists.
24/// Individual fields gracefully degrade to `None`/empty on permission errors.
25pub fn fetch(pid: u32) -> Option<ProcessDetail> {
26    if cfg!(target_os = "linux") {
27        fetch_linux(pid)
28    } else if cfg!(target_os = "macos") {
29        fetch_macos(pid)
30    } else {
31        Some(ProcessDetail {
32            cwd: None,
33            env_vars: Vec::new(),
34            open_files: Vec::new(),
35            cpu_percent: None,
36            rss_kb: None,
37        })
38    }
39}
40
41/// Linux: read directly from /proc/{pid}/
42#[allow(dead_code)]
43fn fetch_linux(pid: u32) -> Option<ProcessDetail> {
44    let proc_dir = PathBuf::from(format!("/proc/{pid}"));
45    if !proc_dir.exists() {
46        return None;
47    }
48
49    let cwd = std::fs::read_link(proc_dir.join("cwd")).ok();
50
51    let env_vars = std::fs::read(proc_dir.join("environ"))
52        .ok()
53        .map(|data| parse_environ(&data))
54        .unwrap_or_default();
55
56    let open_files = read_fd_links(pid);
57
58    let (cpu_percent, rss_kb) = parse_proc_stat_status(pid);
59
60    Some(ProcessDetail {
61        cwd,
62        env_vars,
63        open_files,
64        cpu_percent,
65        rss_kb,
66    })
67}
68
69/// Read /proc/{pid}/fd/* symlinks to list open files.
70#[allow(dead_code)]
71fn read_fd_links(pid: u32) -> Vec<String> {
72    let fd_dir = format!("/proc/{pid}/fd");
73    let entries = match std::fs::read_dir(&fd_dir) {
74        Ok(e) => e,
75        Err(_) => return Vec::new(),
76    };
77
78    let mut files = Vec::new();
79    for entry in entries.flatten() {
80        if let Ok(target) = std::fs::read_link(entry.path()) {
81            let s = target.to_string_lossy().to_string();
82            // Skip anonymous pipes, sockets without paths
83            if !s.starts_with("pipe:") && !s.starts_with("anon_inode:") {
84                files.push(s);
85            }
86        }
87    }
88    files.sort();
89    files.dedup();
90    files
91}
92
93/// Parse /proc/{pid}/environ (NUL-separated KEY=VALUE pairs).
94#[allow(dead_code)]
95fn parse_environ(data: &[u8]) -> Vec<(String, String)> {
96    data.split(|&b| b == 0)
97        .filter(|s| !s.is_empty())
98        .filter_map(|s| {
99            let s = String::from_utf8_lossy(s);
100            let eq = s.find('=')?;
101            Some((s[..eq].to_string(), s[eq + 1..].to_string()))
102        })
103        .collect()
104}
105
106/// Parse CPU% from /proc/{pid}/stat and RSS from /proc/{pid}/status.
107#[allow(dead_code)]
108fn parse_proc_stat_status(pid: u32) -> (Option<f32>, Option<u64>) {
109    // RSS from /proc/{pid}/status (VmRSS line, in kB)
110    let rss_kb = std::fs::read_to_string(format!("/proc/{pid}/status"))
111        .ok()
112        .and_then(|content| {
113            for line in content.lines() {
114                if let Some(rest) = line.strip_prefix("VmRSS:") {
115                    let num_str = rest.trim().trim_end_matches(" kB").trim();
116                    return num_str.parse::<u64>().ok();
117                }
118            }
119            None
120        });
121
122    // CPU% — we'd need two samples to compute; use ps as a simpler approach
123    let cpu_percent = std::process::Command::new("ps")
124        .args(["-o", "%cpu=", "-p", &pid.to_string()])
125        .output()
126        .ok()
127        .and_then(|o| {
128            let s = String::from_utf8_lossy(&o.stdout);
129            s.trim().parse::<f32>().ok()
130        });
131
132    (cpu_percent, rss_kb)
133}
134
135/// macOS: use lsof and ps commands.
136#[allow(dead_code)]
137fn fetch_macos(pid: u32) -> Option<ProcessDetail> {
138    // Check process exists
139    let exists = std::process::Command::new("ps")
140        .args(["-p", &pid.to_string()])
141        .output()
142        .map(|o| o.status.success())
143        .unwrap_or(false);
144
145    if !exists {
146        return None;
147    }
148
149    let cwd = std::process::Command::new("lsof")
150        .args(["-a", "-p", &pid.to_string(), "-d", "cwd", "-Fn"])
151        .output()
152        .ok()
153        .and_then(|o| parse_lsof_cwd(&String::from_utf8_lossy(&o.stdout)));
154
155    let open_files = std::process::Command::new("lsof")
156        .args(["-p", &pid.to_string(), "-Fn"])
157        .output()
158        .ok()
159        .map(|o| parse_lsof_files(&String::from_utf8_lossy(&o.stdout)))
160        .unwrap_or_default();
161
162    let (cpu_percent, rss_kb) = std::process::Command::new("ps")
163        .args(["-o", "%cpu=,rss=", "-p", &pid.to_string()])
164        .output()
165        .ok()
166        .map(|o| parse_ps_cpu_rss(&String::from_utf8_lossy(&o.stdout)))
167        .unwrap_or((None, None));
168
169    // environ not easily accessible on macOS without SIP issues
170    let env_vars = Vec::new();
171
172    Some(ProcessDetail {
173        cwd,
174        env_vars,
175        open_files,
176        cpu_percent,
177        rss_kb,
178    })
179}
180
181/// Parse `lsof -d cwd -Fn` output for the cwd path.
182/// Lines: `p<pid>`, `fcwd`, `n<path>`
183fn parse_lsof_cwd(output: &str) -> Option<PathBuf> {
184    let mut in_cwd = false;
185    for line in output.lines() {
186        if line == "fcwd" {
187            in_cwd = true;
188        } else if in_cwd && line.starts_with('n') {
189            return Some(PathBuf::from(&line[1..]));
190        } else if line.starts_with('f') {
191            in_cwd = false;
192        }
193    }
194    None
195}
196
197/// Parse `lsof -Fn` output for file names.
198fn parse_lsof_files(output: &str) -> Vec<String> {
199    let mut files = Vec::new();
200    for line in output.lines() {
201        if let Some(path) = line.strip_prefix('n') {
202            // Skip internal pseudo-entries
203            if !path.is_empty() && !path.starts_with("->") && path != "pipe" {
204                files.push(path.to_string());
205            }
206        }
207    }
208    files.sort();
209    files.dedup();
210    files
211}
212
213/// Parse `ps -o %cpu=,rss=` output.
214fn parse_ps_cpu_rss(output: &str) -> (Option<f32>, Option<u64>) {
215    let trimmed = output.trim();
216    let parts: Vec<&str> = trimmed.split_whitespace().collect();
217    let cpu = parts.first().and_then(|s| s.parse::<f32>().ok());
218    let rss = parts.get(1).and_then(|s| s.parse::<u64>().ok());
219    (cpu, rss)
220}
221
222/// Format RSS in human-readable form.
223pub fn format_rss(kb: u64) -> String {
224    if kb >= 1_048_576 {
225        format!("{:.1} GB", kb as f64 / 1_048_576.0)
226    } else if kb >= 1024 {
227        format!("{:.1} MB", kb as f64 / 1024.0)
228    } else {
229        format!("{kb} KB")
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    #[test]
238    fn parse_environ_basic() {
239        let data = b"HOME=/home/user\0PATH=/usr/bin\0LANG=en_US\0";
240        let vars = parse_environ(data);
241        assert_eq!(vars.len(), 3);
242        assert_eq!(vars[0], ("HOME".to_string(), "/home/user".to_string()));
243        assert_eq!(vars[1], ("PATH".to_string(), "/usr/bin".to_string()));
244    }
245
246    #[test]
247    fn parse_environ_empty() {
248        let vars = parse_environ(b"");
249        assert!(vars.is_empty());
250    }
251
252    #[test]
253    fn parse_environ_value_with_equals() {
254        let data = b"FOO=bar=baz\0";
255        let vars = parse_environ(data);
256        assert_eq!(vars.len(), 1);
257        assert_eq!(vars[0], ("FOO".to_string(), "bar=baz".to_string()));
258    }
259
260    #[test]
261    fn parse_lsof_cwd_valid() {
262        let output = "p1234\nfcwd\nn/home/user/project\nf0\nn/dev/null\n";
263        let cwd = parse_lsof_cwd(output);
264        assert_eq!(cwd, Some(PathBuf::from("/home/user/project")));
265    }
266
267    #[test]
268    fn parse_lsof_cwd_missing() {
269        let output = "p1234\nf0\nn/dev/null\n";
270        assert_eq!(parse_lsof_cwd(output), None);
271    }
272
273    #[test]
274    fn parse_lsof_files_basic() {
275        let output = "p1234\nf0\nn/dev/null\nf1\nn/home/user/file.txt\nf2\nn/tmp/log\n";
276        let files = parse_lsof_files(output);
277        assert!(files.contains(&"/dev/null".to_string()));
278        assert!(files.contains(&"/home/user/file.txt".to_string()));
279    }
280
281    #[test]
282    fn parse_ps_cpu_rss_valid() {
283        let (cpu, rss) = parse_ps_cpu_rss("  3.2 12345\n");
284        assert!((cpu.unwrap() - 3.2).abs() < 0.01);
285        assert_eq!(rss, Some(12345));
286    }
287
288    #[test]
289    fn parse_ps_cpu_rss_empty() {
290        let (cpu, rss) = parse_ps_cpu_rss("");
291        assert!(cpu.is_none());
292        assert!(rss.is_none());
293    }
294
295    #[test]
296    fn format_rss_kilobytes() {
297        assert_eq!(format_rss(512), "512 KB");
298    }
299
300    #[test]
301    fn format_rss_megabytes() {
302        assert_eq!(format_rss(2048), "2.0 MB");
303    }
304
305    #[test]
306    fn format_rss_gigabytes() {
307        assert_eq!(format_rss(2_097_152), "2.0 GB");
308    }
309}