1use crate::cmd::run_cmd;
22use serde::{Deserialize, Serialize};
23use std::sync::Mutex;
24
25static PROCESS_CACHE: Mutex<Option<(ProcessSnapshot, std::time::Instant)>> = Mutex::new(None);
26const CACHE_TTL_SECS: u64 = 30;
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
32#[allow(clippy::exhaustive_structs)]
33pub struct ProcessSnapshot {
34 pub timestamp: u64,
36 pub processes: Vec<ProcessInfo>,
38 pub summary: ProcessSummary,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
46#[allow(clippy::exhaustive_structs)]
47pub struct ProcessInfo {
48 pub pid: u32,
50 pub ppid: u32,
52 pub user: String,
54 pub cpu_percent: f32,
56 pub mem_percent: f32,
58 pub vsz: u64,
60 pub rss: u64,
62 pub stat: String,
64 pub start_time: String,
66 pub elapsed: String,
68 pub command: String,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74#[allow(clippy::exhaustive_structs)]
75pub struct ProcessSummary {
76 pub total_processes: usize,
78 pub total_cpu_percent: f32,
80 pub total_mem_percent: f32,
82 pub top_cpu_consumer: Option<String>,
84 pub top_mem_consumer: Option<String>,
86 pub zombie_count: usize,
88}
89
90impl ProcessSnapshot {
91 pub fn capture() -> Self {
96 let now = std::time::Instant::now();
97 {
98 let cache = PROCESS_CACHE.lock().unwrap_or_else(|e| e.into_inner());
100 if let Some((cached, instant)) = cache.as_ref() {
101 if now.duration_since(*instant).as_secs() < CACHE_TTL_SECS {
102 return cached.clone();
103 }
104 }
105 }
106
107 let timestamp = std::time::SystemTime::now()
108 .duration_since(std::time::UNIX_EPOCH)
109 .map_or(0, |d| d.as_secs());
110
111 let mut processes = Vec::new();
112 let ps_output =
115 run_cmd("ps -eo pid,ppid,user,%cpu,%mem,vsz,rss,stat,start,time,comm --no-headers")
116 .unwrap_or_default();
117
118 for line in ps_output.lines() {
119 if let Some(proc) = parse_ps_line(line) {
120 processes.push(proc);
121 }
122 }
123
124 let summary = ProcessSummary::compute(&processes);
125
126 let snapshot = Self {
127 timestamp,
128 processes,
129 summary,
130 };
131
132 let mut cache = PROCESS_CACHE.lock().unwrap_or_else(|e| e.into_inner());
134 *cache = Some((snapshot.clone(), now));
135 snapshot
136 }
137
138 #[must_use]
143 pub fn zombies(&self) -> Vec<&ProcessInfo> {
144 self.processes
145 .iter()
146 .filter(|p| p.stat.starts_with('Z'))
147 .collect()
148 }
149
150 pub fn clear_cache() {
154 let mut cache = PROCESS_CACHE.lock().unwrap_or_else(|e| e.into_inner());
155 *cache = None;
156 }
157
158 #[must_use]
160 pub fn top_by_cpu(&self, n: usize) -> Vec<&ProcessInfo> {
161 let mut procs: Vec<_> = self.processes.iter().collect();
162 procs.sort_by(|a, b| {
163 b.cpu_percent
164 .partial_cmp(&a.cpu_percent)
165 .unwrap_or(std::cmp::Ordering::Equal)
166 });
167 procs.into_iter().take(n).collect()
168 }
169
170 #[must_use]
172 pub fn top_by_mem(&self, n: usize) -> Vec<&ProcessInfo> {
173 let mut procs: Vec<_> = self.processes.iter().collect();
174 procs.sort_by(|a, b| {
175 b.mem_percent
176 .partial_cmp(&a.mem_percent)
177 .unwrap_or(std::cmp::Ordering::Equal)
178 });
179 procs.into_iter().take(n).collect()
180 }
181
182 #[allow(clippy::arithmetic_side_effects)] pub fn print_report(&self) {
185 println!("\n{}", "=".repeat(80));
186 println!(" PROCESS SNAPSHOT [{}]", self.timestamp);
187 println!("{}", "=".repeat(80));
188
189 println!("\n--- SUMMARY ---");
190 println!(" Total Processes: {}", self.summary.total_processes);
191 println!(" Total CPU: {:.1}%", self.summary.total_cpu_percent);
192 println!(" Total Memory: {:.1}%", self.summary.total_mem_percent);
193 println!(" Zombies: {}", self.summary.zombie_count);
194
195 if let Some(ref top_cpu) = self.summary.top_cpu_consumer {
196 println!(
197 " Top CPU: {} ({:.1}%)",
198 top_cpu,
199 self.processes
200 .iter()
201 .find(|p| p.command == *top_cpu)
202 .map_or(0.0, |p| p.cpu_percent)
203 );
204 }
205
206 if let Some(ref top_mem) = self.summary.top_mem_consumer {
207 println!(
208 " Top Memory: {} ({:.1}%)",
209 top_mem,
210 self.processes
211 .iter()
212 .find(|p| p.command == *top_mem)
213 .map_or(0.0, |p| p.mem_percent)
214 );
215 }
216
217 println!("\n--- TOP 10 BY CPU ---");
218 for (i, proc) in self.top_by_cpu(10).iter().enumerate() {
219 println!(
220 "{:2}. {:6} {:6} {:5.1} {:5.1} {:8} {:8} {:?} {}",
221 i + 1,
222 proc.pid,
223 proc.user,
224 proc.cpu_percent,
225 proc.mem_percent,
226 format_size(proc.vsz),
227 format_size(proc.rss),
228 proc.stat,
229 truncate(&proc.command, 50)
230 );
231 }
232
233 println!("\n--- TOP 10 BY MEMORY ---");
234 for (i, proc) in self.top_by_mem(10).iter().enumerate() {
235 println!(
236 "{:2}. {:6} {:6} {:5.1} {:5.1} {:8} {:8} {:?} {}",
237 i + 1,
238 proc.pid,
239 proc.user,
240 proc.cpu_percent,
241 proc.mem_percent,
242 format_size(proc.vsz),
243 format_size(proc.rss),
244 proc.stat,
245 truncate(&proc.command, 50)
246 );
247 }
248
249 println!("\n{}", "=".repeat(80));
250 }
251}
252
253#[allow(clippy::indexing_slicing, clippy::arithmetic_side_effects)]
258fn parse_ps_line(line: &str) -> Option<ProcessInfo> {
259 let parts: Vec<&str> = line.split_whitespace().collect();
260 if parts.len() < 10 {
261 return None;
262 }
263
264 let pid = parts[0].parse().ok()?;
265 let ppid = parts[1].parse().ok()?;
266 let user = parts[2].to_string();
267 let cpu_percent = parts[3].parse().unwrap_or(0.0);
268 let mem_percent = parts[4].parse().unwrap_or(0.0);
269 let vsz: u64 = parts[5].parse().unwrap_or(0);
270 let rss: u64 = parts[6].parse().unwrap_or(0);
271 let stat = parts[7].to_string();
272 let start_time = parts[8].to_string();
273 let elapsed = parts[9].to_string();
274 let command = parts.get(10..).map(|s| s.join(" ")).unwrap_or_default();
275
276 Some(ProcessInfo {
277 pid,
278 ppid,
279 user,
280 cpu_percent,
281 mem_percent,
282 vsz,
283 rss,
284 stat,
285 start_time,
286 elapsed,
287 command,
288 })
289}
290
291impl ProcessSummary {
292 fn compute(processes: &[ProcessInfo]) -> Self {
293 let total_processes = processes.len();
294 let total_cpu_percent: f32 = processes.iter().map(|p| p.cpu_percent).sum();
295 let total_mem_percent: f32 = processes.iter().map(|p| p.mem_percent).sum();
296
297 let top_cpu_consumer = processes
298 .iter()
299 .max_by(|a, b| {
300 a.cpu_percent
301 .partial_cmp(&b.cpu_percent)
302 .unwrap_or(std::cmp::Ordering::Equal)
303 })
304 .map(|p| p.command.clone());
305
306 let top_mem_consumer = processes
307 .iter()
308 .max_by(|a, b| {
309 a.mem_percent
310 .partial_cmp(&b.mem_percent)
311 .unwrap_or(std::cmp::Ordering::Equal)
312 })
313 .map(|p| p.command.clone());
314
315 let zombie_count = processes.iter().filter(|p| p.stat.starts_with('Z')).count();
316
317 Self {
318 total_processes,
319 total_cpu_percent,
320 total_mem_percent,
321 top_cpu_consumer,
322 top_mem_consumer,
323 zombie_count,
324 }
325 }
326}
327
328#[allow(clippy::cast_precision_loss)]
334fn format_size(kb: u64) -> String {
335 if kb >= 1024 * 1024 {
336 format!("{:.1}G", kb as f64 / (1024.0 * 1024.0))
337 } else if kb >= 1024 {
338 format!("{:.1}M", kb as f64 / 1024.0)
339 } else {
340 format!("{}K", kb)
341 }
342}
343
344fn truncate(s: &str, max_len: usize) -> String {
349 if s.chars().count() > max_len {
350 let end = max_len.saturating_sub(3);
351 let byte_end = s.char_indices().nth(end).map_or(s.len(), |(i, _)| i);
352 format!("{}...", &s[..byte_end])
353 } else {
354 s.to_string()
355 }
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361 #[test]
362 fn test_process_snapshot() {
363 let snapshot = ProcessSnapshot::capture();
364 assert!(!snapshot.processes.is_empty());
365 assert!(snapshot.summary.total_processes > 0);
366 }
367
368 #[test]
369 fn test_truncate_ascii() {
370 assert_eq!(truncate("hello world", 8), "hello...");
371 assert_eq!(truncate("short", 10), "short");
372 }
373
374 #[test]
375 fn test_truncate_multibyte_utf8() {
376 let cjk = "你好世界这是一个很长的命令行参数"; let result = truncate(cjk, 8);
379 assert!(result.ends_with("..."));
380 assert!(result.is_char_boundary(result.len()));
382 }
383
384 #[test]
385 fn test_format_size() {
386 assert_eq!(format_size(0), "0K");
388 assert_eq!(format_size(512), "512K");
389 assert_eq!(format_size(1024), "1.0M");
390 assert_eq!(format_size(1024 * 1024), "1.0G");
391 assert_eq!(format_size(1024 * 512), "512.0M");
392 assert_eq!(format_size(1024 * 1024 * 2), "2.0G");
393 }
394
395 #[test]
396 fn test_process_vsz_rss_in_kb() {
397 let snap = ProcessSnapshot::capture();
398 for p in &snap.processes {
401 assert!(
404 p.vsz < 1_000_000_000,
405 "vsz={}KB is unreasonably large for {}",
406 p.vsz,
407 p.command
408 );
409 assert!(
411 p.rss < 100_000_000,
412 "rss={}KB is unreasonably large for {}",
413 p.rss,
414 p.command
415 );
416 }
417 }
418}