prt_core/core/
process_detail.rs1use std::path::PathBuf;
11
12#[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
22pub 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#[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#[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 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#[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#[allow(dead_code)]
108fn parse_proc_stat_status(pid: u32) -> (Option<f32>, Option<u64>) {
109 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 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#[allow(dead_code)]
137fn fetch_macos(pid: u32) -> Option<ProcessDetail> {
138 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 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
181fn 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
197fn 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 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
213fn 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
222pub 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}