Skip to main content

retch_cli/
fetch.rs

1use crate::cli::Cli;
2use crate::config::Config;
3use chrono::TimeZone;
4use owo_colors::OwoColorize;
5use std::collections::HashSet;
6use std::fs;
7use sysinfo::{Components, Disks, Networks, System, Users};
8
9/// Comprehensive system information data structure.
10///
11/// This struct holds all the metrics collected from the system,
12/// ranging from OS details to hardware specs and network status.
13#[derive(Debug)]
14pub struct SystemInfo {
15    /// Operating system name and version.
16    pub os: String,
17    /// Kernel version.
18    pub kernel: Option<String>,
19    /// System hostname.
20    pub hostname: Option<String>,
21    /// CPU architecture (e.g., x86_64).
22    pub arch: String,
23    /// CPU model brand string.
24    pub cpu: String,
25    /// Total number of logical CPU cores.
26    pub cpu_cores: usize,
27    /// Formatted memory usage (Used / Total).
28    pub memory: String,
29    /// Formatted swap usage (Used / Total).
30    pub swap: String,
31    /// System uptime formatted as a duration.
32    pub uptime: String,
33    /// Number of currently running processes.
34    pub processes: usize,
35    /// Load average (1, 5, 15 minutes).
36    pub load_avg: Option<String>,
37    /// List of mounted disks with usage information.
38    pub disks: Vec<String>,
39    /// Hardware component temperatures.
40    pub temps: Vec<String>,
41    /// Network interface statistics and status.
42    pub networks: Vec<String>,
43    /// System boot time in ISO 8601 format.
44    pub boot_time: String,
45    /// Battery status (currently placeholder for future feature).
46    pub battery: Option<String>,
47    /// Path to the current user's shell.
48    pub shell: Option<String>,
49    /// Name of the terminal emulator in use.
50    pub terminal: Option<String>,
51    /// Detected desktop environment or window manager.
52    pub desktop: Option<String>,
53    /// Current CPU frequency (formatted).
54    pub cpu_freq: Option<String>,
55    /// Number of interactive users (UID >= 1000).
56    pub users: usize,
57    /// List of detected GPUs with model names.
58    pub gpu: Vec<String>,
59    /// Total count of installed packages across supported managers.
60    pub packages: Option<usize>,
61    /// Name of the user running the process.
62    pub current_user: Option<String>,
63}
64
65/// Helper to lookup PCI device name in standard system pci.ids files.
66///
67/// Searches `/usr/share/hwdata/pci.ids` or `/usr/share/misc/pci.ids`.
68fn lookup_pci_device(vendor_id: &str, device_id: &str) -> Option<String> {
69    let vendor_id = vendor_id.trim_start_matches("0x").to_lowercase();
70    let device_id = device_id.trim_start_matches("0x").to_lowercase();
71
72    let paths = ["/usr/share/hwdata/pci.ids", "/usr/share/misc/pci.ids"];
73
74    for path in &paths {
75        if let Ok(content) = fs::read_to_string(path) {
76            let mut in_vendor = false;
77            for line in content.lines() {
78                if line.starts_with('#') || line.is_empty() {
79                    continue;
80                }
81
82                if !line.starts_with('\t') {
83                    // Vendor line: "vendor_id  Vendor Name"
84                    in_vendor = line.starts_with(&vendor_id);
85                } else if in_vendor && line.starts_with('\t') && !line.starts_with("\t\t") {
86                    // Device line: "\tdevice_id  Device Name"
87                    let trimmed = line.trim_start();
88                    if trimmed.starts_with(&device_id) {
89                        let name = trimmed[device_id.len()..].trim();
90                        return Some(name.to_string());
91                    }
92                }
93            }
94        }
95    }
96    None
97}
98
99impl SystemInfo {
100    /// Collects system information using sysinfo and environment probes.
101    ///
102    /// This method aggregates data from the operating system, hardware,
103    /// and current user environment into a `SystemInfo` struct.
104    pub fn collect(_cli: &Cli, _config: &Config) -> anyhow::Result<Self> {
105        let mut sys = System::new_all();
106        sys.refresh_all();
107
108        let os = System::long_os_version()
109            .or_else(System::name)
110            .unwrap_or_else(|| "Unknown".to_string());
111
112        let kernel = System::kernel_version();
113        let hostname = System::host_name();
114
115        let cpu = sys
116            .cpus()
117            .first()
118            .map(|c| c.brand().to_string())
119            .unwrap_or_else(|| "Unknown CPU".to_string());
120
121        let cpu_cores = sys.cpus().len();
122
123        let total_mem = sys.total_memory() as f64 / 1024.0 / 1024.0;
124        let used_mem = sys.used_memory() as f64 / 1024.0 / 1024.0;
125        let memory = format!("{:.1} / {:.1} GB", used_mem, total_mem);
126
127        let total_swap = sys.total_swap() as f64 / 1024.0 / 1024.0;
128        let used_swap = sys.used_swap() as f64 / 1024.0 / 1024.0;
129        let swap = if total_swap > 0.0 {
130            format!("{:.1} / {:.1} GB", used_swap, total_swap)
131        } else {
132            "No swap".to_string()
133        };
134
135        let uptime = format!("{}s", System::uptime());
136
137        let disks = Disks::new_with_refreshed_list()
138            .iter()
139            .map(|d| {
140                let total = d.total_space() as f64 / 1024.0 / 1024.0 / 1024.0;
141                let avail = d.available_space() as f64 / 1024.0 / 1024.0 / 1024.0;
142                let fs = d.file_system().to_string_lossy();
143                format!(
144                    "{} ({}): {:.1} GB free / {:.1} GB",
145                    d.mount_point().display(),
146                    fs,
147                    avail,
148                    total
149                )
150            })
151            .collect();
152
153        let battery: Option<String> = None; // Requires sysinfo "battery" feature
154
155        let arch = System::cpu_arch();
156
157        let processes = sys.processes().len();
158
159        let load_avg = {
160            let avg = System::load_average();
161            if avg.one > 0.0 || avg.five > 0.0 {
162                Some(format!(
163                    "{:.2}, {:.2}, {:.2}",
164                    avg.one, avg.five, avg.fifteen
165                ))
166            } else {
167                None
168            }
169        };
170
171        let temps = Components::new_with_refreshed_list()
172            .iter()
173            .filter_map(|c| {
174                c.temperature().and_then(|t| {
175                    if t > 0.0 {
176                        Some(format!("{}: {:.1}°C", c.label(), t))
177                    } else {
178                        None
179                    }
180                })
181            })
182            .collect();
183
184        let networks = Networks::new_with_refreshed_list()
185            .iter()
186            .map(|(name, data)| {
187                let rx = format_bytes(data.total_received());
188                let tx = format_bytes(data.total_transmitted());
189                let status = if data.total_received() > 0 || data.total_transmitted() > 0 {
190                    "Up".green().to_string()
191                } else {
192                    "Down".red().to_string()
193                };
194                format!("{} [{}] RX: {} TX: {}", name, status, rx, tx)
195            })
196            .collect();
197
198        let boot_timestamp = System::boot_time();
199        let boot_dt = chrono::Local
200            .timestamp_opt(boot_timestamp as i64, 0)
201            .single()
202            .map(|dt| dt.format("%Y-%m-%dT%H:%M:%S%:z").to_string())
203            .unwrap_or_else(|| boot_timestamp.to_string());
204        let boot_time = boot_dt;
205
206        // Environment-based info
207        let shell = std::env::var("SHELL").ok();
208        let terminal = std::env::var("TERM").ok();
209        let desktop = std::env::var("XDG_CURRENT_DESKTOP")
210            .or_else(|_| std::env::var("DESKTOP_SESSION"))
211            .ok();
212
213        // CPU frequency (first CPU)
214        let cpu_freq = sys
215            .cpus()
216            .first()
217            .map(|c| format!("{:.2} GHz", c.frequency() as f64 / 1000.0));
218
219        // Current logged in user
220        let current_user = std::env::var("USER").ok();
221
222        // Number of interactive users (UID >= 1000, excluding system accounts)
223        let users = Users::new_with_refreshed_list()
224            .iter()
225            .filter(|user| {
226                // UID is exposed via Display
227                let uid_str = user.id().to_string();
228                if let Ok(uid) = uid_str.parse::<u32>() {
229                    uid >= 1000
230                } else {
231                    false
232                }
233            })
234            .count();
235
236        Ok(Self {
237            os,
238            kernel,
239            hostname,
240            arch,
241            cpu,
242            cpu_cores,
243            memory,
244            swap,
245            uptime,
246            processes,
247            load_avg,
248            disks,
249            temps,
250            networks,
251            boot_time,
252            battery,
253            shell,
254            terminal,
255            desktop,
256            cpu_freq,
257            users,
258            gpu: detect_gpu(),
259            packages: detect_packages(),
260            current_user,
261        })
262    }
263}
264
265/// Detects GPUs using sysfs and PCI database lookups.
266///
267/// Scans `/sys/class/drm` for graphics devices and attempts to extract
268/// model names and VRAM info (where supported).
269fn detect_gpu() -> Vec<String> {
270    let mut gpus = Vec::new();
271    let mut seen_devices = HashSet::new();
272
273    // Scan /sys/class/drm for all card* and renderD* entries
274    if let Ok(entries) = std::fs::read_dir("/sys/class/drm") {
275        for entry in entries.flatten() {
276            let name = entry.file_name().into_string().unwrap_or_default();
277            if !name.starts_with("card") && !name.starts_with("renderD") {
278                continue;
279            }
280
281            let device_path = entry.path().join("device");
282            if let Ok(real_path) = std::fs::canonicalize(&device_path) {
283                if !seen_devices.insert(real_path) {
284                    continue;
285                }
286
287                // Try to identify vendor and model
288                let vendor_id = fs::read_to_string(device_path.join("vendor"))
289                    .unwrap_or_default()
290                    .trim()
291                    .to_string();
292                let device_id = fs::read_to_string(device_path.join("device"))
293                    .unwrap_or_default()
294                    .trim()
295                    .to_string();
296
297                if vendor_id.is_empty() || device_id.is_empty() {
298                    continue;
299                }
300
301                let mut gpu_name = lookup_pci_device(&vendor_id, &device_id).unwrap_or_else(|| {
302                    if vendor_id.contains("10de") {
303                        "NVIDIA GPU".to_string()
304                    } else if vendor_id.contains("1002") {
305                        "AMD GPU".to_string()
306                    } else if vendor_id.contains("8086") {
307                        "Intel GPU".to_string()
308                    } else {
309                        "Unknown GPU".to_string()
310                    }
311                });
312
313                // NVIDIA special case: try /proc for even better name
314                if vendor_id.contains("10de") {
315                    if let Ok(pci_slot_path) = fs::read_link(&device_path) {
316                        if let Some(slot_name) = pci_slot_path.file_name() {
317                            let proc_info_path = format!(
318                                "/proc/driver/nvidia/gpus/{}/information",
319                                slot_name.to_string_lossy()
320                            );
321                            if let Ok(info) = fs::read_to_string(proc_info_path) {
322                                for line in info.lines() {
323                                    if line.starts_with("Model:") {
324                                        gpu_name = line.replace("Model:", "").trim().to_string();
325                                        break;
326                                    }
327                                }
328                            }
329                        }
330                    }
331                }
332
333                // Try to get VRAM from common sysfs locations (mainly AMD)
334                let vram_path = device_path.join("mem_info_vram_total");
335                if let Ok(vram_str) = fs::read_to_string(vram_path) {
336                    if let Ok(vram_bytes) = vram_str.trim().parse::<u64>() {
337                        let vram_gb = vram_bytes as f64 / 1024.0 / 1024.0 / 1024.0;
338                        if vram_gb >= 1.0 {
339                            gpu_name = format!("{} ({:.0} GB)", gpu_name, vram_gb);
340                        } else {
341                            let vram_mb = vram_bytes / 1024 / 1024;
342                            gpu_name = format!("{} ({} MB)", gpu_name, vram_mb);
343                        }
344                    }
345                }
346
347                gpus.push(gpu_name);
348            }
349        }
350    }
351
352    if gpus.is_empty() {
353        // Fallback for non-standard setups or if /sys/class/drm is empty
354        if let Ok(model) = fs::read_to_string("/sys/class/drm/card0/device/model") {
355            let model = model.trim();
356            if !model.is_empty() {
357                gpus.push(model.to_string());
358            }
359        }
360    }
361
362    gpus
363}
364
365/// Count installed packages by inspecting package manager databases directly.
366///
367/// Supports Pacman (Arch), Dpkg (Debian), XBPS (Void), and RPM (Fedora/RHEL).
368fn detect_packages() -> Option<usize> {
369    // Arch / Manjaro
370    if let Ok(entries) = std::fs::read_dir("/var/lib/pacman/local") {
371        let count = entries.filter_map(|e| e.ok()).count();
372        if count > 0 {
373            return Some(count);
374        }
375    }
376
377    // Debian / Ubuntu
378    if let Ok(entries) = std::fs::read_dir("/var/lib/dpkg/info") {
379        let count = entries
380            .filter_map(|e| e.ok())
381            .filter(|e| e.path().extension().is_some_and(|ext| ext == "list"))
382            .count();
383        if count > 0 {
384            return Some(count);
385        }
386    }
387
388    // Gentoo
389    if let Ok(entries) = std::fs::read_dir("/var/db/pkg") {
390        let count: usize = entries
391            .filter_map(|e| e.ok())
392            .map(|e| {
393                std::fs::read_dir(e.path())
394                    .map(|d| d.filter(|_| true).count())
395                    .unwrap_or(0)
396            })
397            .sum();
398        if count > 0 {
399            return Some(count);
400        }
401    }
402
403    // Void Linux
404    if let Ok(entries) = std::fs::read_dir("/var/db/xbps") {
405        let count = entries
406            .filter_map(|e| e.ok())
407            .filter(|e| e.path().extension().is_some_and(|ext| ext == "plist"))
408            .count();
409        if count > 0 {
410            return Some(count);
411        }
412    }
413
414    // Fedora / RHEL / openSUSE - read from RPM SQLite database
415    let rpm_db = "/var/lib/rpm/rpmdb.sqlite";
416    if std::path::Path::new(rpm_db).exists() {
417        if let Ok(conn) = rusqlite::Connection::open(rpm_db) {
418            if let Ok(count) = conn.query_row("SELECT COUNT(*) FROM Packages", [], |row| {
419                row.get::<_, i64>(0)
420            }) {
421                if count > 0 {
422                    return Some(count as usize);
423                }
424            }
425        }
426    }
427
428    None
429}
430
431/// Format bytes into human-readable form (KB, MB, GB, etc.)
432fn format_bytes(bytes: u64) -> String {
433    const KB: u64 = 1024;
434    const MB: u64 = KB * 1024;
435    const GB: u64 = MB * 1024;
436
437    if bytes >= GB {
438        format!("{:.1} GB", bytes as f64 / GB as f64)
439    } else if bytes >= MB {
440        format!("{:.1} MB", bytes as f64 / MB as f64)
441    } else if bytes >= KB {
442        format!("{:.1} KB", bytes as f64 / KB as f64)
443    } else {
444        format!("{} B", bytes)
445    }
446}