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