Skip to main content

retch_cli/
fetch.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright (C) 2026 l1a
3
4//! System information gathering.
5//!
6//! Uses the `sysinfo` crate and other heuristics to collect details
7//! about the OS, hardware, and environment.
8
9use crate::cli::Cli;
10use crate::config::Config;
11use crate::gpu;
12use chrono::TimeZone;
13use owo_colors::OwoColorize;
14
15use sysinfo::{Components, Disks, Networks, System, Users};
16
17/// Comprehensive system information data structure.
18///
19/// This struct holds all the metrics collected from the system,
20/// ranging from OS details to hardware specs and network status.
21#[derive(Debug)]
22pub struct SystemInfo {
23    /// Operating system name and version.
24    pub os: String,
25    /// Kernel version.
26    pub kernel: Option<String>,
27    /// System hostname.
28    pub hostname: Option<String>,
29    /// CPU architecture (e.g., x86_64).
30    pub arch: String,
31    /// CPU model brand string.
32    pub cpu: String,
33    /// Total number of logical CPU cores.
34    pub cpu_cores: usize,
35    /// Formatted memory usage (Used / Total).
36    pub memory: String,
37    /// Formatted swap usage (Used / Total).
38    pub swap: String,
39    /// System uptime formatted as a duration.
40    pub uptime: String,
41    /// Number of currently running processes.
42    pub processes: usize,
43    /// Load average (1, 5, 15 minutes).
44    pub load_avg: Option<String>,
45    /// List of mounted disks with usage information.
46    pub disks: Vec<String>,
47    /// Hardware component temperatures.
48    pub temps: Vec<String>,
49    /// Network interface statistics and status.
50    pub networks: Vec<String>,
51    /// System boot time in ISO 8601 format.
52    pub boot_time: String,
53    /// Battery status (currently placeholder for future feature).
54    pub battery: Option<String>,
55    /// Path to the current user's shell.
56    pub shell: Option<String>,
57    /// Name of the terminal emulator in use.
58    pub terminal: Option<String>,
59    /// Detected desktop environment or window manager.
60    pub desktop: Option<String>,
61    /// Current CPU frequency (formatted).
62    pub cpu_freq: Option<String>,
63    /// Number of interactive users (UID >= 1000).
64    pub users: usize,
65    /// List of detected GPUs with model names.
66    pub gpu: Vec<String>,
67    /// Total count of installed packages across supported managers.
68    pub packages: Option<usize>,
69    /// Name of the user running the process.
70    pub current_user: Option<String>,
71    /// Primary local IP address.
72    pub local_ip: Option<String>,
73    /// Public IP address (best effort).
74    pub public_ip: Option<String>,
75    /// Name of the active/default network interface.
76    pub active_interface: Option<String>,
77}
78
79impl SystemInfo {
80    /// Collects system information using sysinfo and environment probes.
81    ///
82    /// This method aggregates data from the operating system, hardware,
83    /// and current user environment into a `SystemInfo` struct.
84    pub fn collect(_cli: &Cli, _config: &Config) -> anyhow::Result<Self> {
85        let mut sys = System::new_all();
86        sys.refresh_all();
87
88        let os = System::long_os_version()
89            .or_else(System::name)
90            .unwrap_or_else(|| "Unknown".to_string());
91
92        let kernel = System::kernel_version();
93        let hostname = System::host_name();
94
95        let cpu = sys
96            .cpus()
97            .first()
98            .map(|c| c.brand().to_string())
99            .unwrap_or_else(|| "Unknown CPU".to_string());
100
101        let cpu_cores = sys.cpus().len();
102
103        let total_mem = sys.total_memory() as f64 / 1024.0 / 1024.0;
104        let used_mem = sys.used_memory() as f64 / 1024.0 / 1024.0;
105        let memory = format!("{:.1} / {:.1} GB", used_mem, total_mem);
106
107        let total_swap = sys.total_swap() as f64 / 1024.0 / 1024.0;
108        let used_swap = sys.used_swap() as f64 / 1024.0 / 1024.0;
109        let swap = if total_swap > 0.0 {
110            format!("{:.1} / {:.1} GB", used_swap, total_swap)
111        } else {
112            "No swap".to_string()
113        };
114
115        let uptime = format!("{}s", System::uptime());
116
117        let disks = Disks::new_with_refreshed_list()
118            .iter()
119            .map(|d| {
120                let total = d.total_space() as f64 / 1024.0 / 1024.0 / 1024.0;
121                let avail = d.available_space() as f64 / 1024.0 / 1024.0 / 1024.0;
122                let fs = d.file_system().to_string_lossy();
123                format!(
124                    "{} ({}): {:.1} GB free / {:.1} GB",
125                    d.mount_point().display(),
126                    fs,
127                    avail,
128                    total
129                )
130            })
131            .collect();
132
133        let battery = battery::Manager::new()
134            .ok()
135            .and_then(|manager| manager.batteries().ok())
136            .and_then(|mut batteries| batteries.next())
137            .and_then(|result| result.ok())
138            .map(|bat| {
139                let pct = bat.state_of_charge().value * 100.0;
140                let health = bat.state_of_health().value * 100.0;
141                let state = match bat.state() {
142                    battery::State::Charging => "charging",
143                    battery::State::Discharging => "discharging",
144                    battery::State::Full => "full",
145                    _ => "not charging",
146                };
147                let vendor = bat.vendor().map(|s| s.to_string());
148                let model = bat.model().map(|s| s.to_string());
149
150                // Format time remaining as "Xh Ym" or "Xd Yh"
151                let time_str = match bat.state() {
152                    battery::State::Charging => bat.time_to_full().map(|d| {
153                        let total_mins = (d.value / 60.0) as u32;
154                        let hours = total_mins / 60;
155                        let mins = total_mins % 60;
156                        if hours >= 24 {
157                            let days = hours / 24;
158                            let rem_hours = hours % 24;
159                            format!("{}d {}h until full", days, rem_hours)
160                        } else if hours > 0 {
161                            format!("{}h {}m until full", hours, mins)
162                        } else {
163                            format!("{}m until full", mins)
164                        }
165                    }),
166                    battery::State::Discharging => bat.time_to_empty().map(|d| {
167                        let total_mins = (d.value / 60.0) as u32;
168                        let hours = total_mins / 60;
169                        let mins = total_mins % 60;
170                        if hours >= 24 {
171                            let days = hours / 24;
172                            let rem_hours = hours % 24;
173                            format!("{}d {}h remaining", days, rem_hours)
174                        } else if hours > 0 {
175                            format!("{}h {}m remaining", hours, mins)
176                        } else {
177                            format!("{}m remaining", mins)
178                        }
179                    }),
180                    _ => None,
181                };
182
183                let mut parts = vec![state.to_string()];
184                if let Some(t) = time_str {
185                    parts.insert(0, t);
186                }
187                if health < 99.0 {
188                    parts.push(format!("{:.0}% health", health));
189                }
190
191                let base = format!("{:.0}% ({})", pct, parts.join(", "));
192
193                match (vendor, model) {
194                    (Some(v), Some(m)) => format!("{} [{} {}]", base, v, m),
195                    (Some(v), None) => format!("{} [{}]", base, v),
196                    _ => base,
197                }
198            });
199
200        let arch = System::cpu_arch();
201
202        let processes = sys.processes().len();
203
204        let load_avg = {
205            let avg = System::load_average();
206            if avg.one > 0.0 || avg.five > 0.0 {
207                Some(format!(
208                    "{:.2}, {:.2}, {:.2}",
209                    avg.one, avg.five, avg.fifteen
210                ))
211            } else {
212                None
213            }
214        };
215
216        // Compute IP-related values early so they are available for network formatting
217        let local_ip: Option<String> =
218            std::net::UdpSocket::bind("0.0.0.0:0")
219                .ok()
220                .and_then(|socket| {
221                    socket.connect("8.8.8.8:53").ok()?;
222                    socket.local_addr().ok().map(|addr| addr.ip().to_string())
223                });
224
225        let public_ip: Option<String> = std::process::Command::new("curl")
226            .args(["-s", "--max-time", "2", "https://api.ipify.org"])
227            .output()
228            .ok()
229            .and_then(|o| String::from_utf8(o.stdout).ok())
230            .map(|s| s.trim().to_string())
231            .filter(|s| !s.is_empty());
232
233        let active_interface: Option<String> = {
234            #[cfg(target_os = "linux")]
235            {
236                std::process::Command::new("ip")
237                    .args(["route", "show", "default"])
238                    .output()
239                    .ok()
240                    .and_then(|o| String::from_utf8(o.stdout).ok())
241                    .and_then(|s| {
242                        s.split_whitespace()
243                            .position(|w| w == "dev")
244                            .and_then(|i| s.split_whitespace().nth(i + 1))
245                            .map(|s| s.to_string())
246                    })
247            }
248            #[cfg(target_os = "macos")]
249            {
250                std::process::Command::new("route")
251                    .args(["-n", "get", "default"])
252                    .output()
253                    .ok()
254                    .and_then(|o| String::from_utf8(o.stdout).ok())
255                    .and_then(|s| {
256                        s.lines()
257                            .find(|l| l.contains("interface:"))
258                            .and_then(|l| l.split_whitespace().last())
259                            .map(|s| s.to_string())
260                    })
261            }
262            #[cfg(target_os = "windows")]
263            {
264                std::process::Command::new("powershell")
265                    .args(["-Command", "Get-NetRoute -DestinationPrefix 0.0.0.0/0 | Select-Object -First 1 -ExpandProperty InterfaceAlias"])
266                    .output()
267                    .ok()
268                    .and_then(|o| String::from_utf8(o.stdout).ok())
269                    .map(|s| s.trim().to_string())
270                    .filter(|s| !s.is_empty())
271            }
272            #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
273            {
274                None
275            }
276        };
277
278        let mut temps: Vec<String> = Components::new_with_refreshed_list()
279            .iter()
280            .filter_map(|c| {
281                c.temperature().and_then(|t| {
282                    if t > 0.0 {
283                        Some(format!("{}: {:.0}°C", c.label(), t))
284                    } else {
285                        None
286                    }
287                })
288            })
289            .collect();
290
291        // Sort so CPU temperatures appear first
292        temps.sort_by(|a, b| {
293            let a_cpu = a.to_lowercase().contains("cpu") || a.to_lowercase().contains("core");
294            let b_cpu = b.to_lowercase().contains("cpu") || b.to_lowercase().contains("core");
295            b_cpu.cmp(&a_cpu)
296        });
297
298        let networks = Networks::new_with_refreshed_list()
299            .iter()
300            .map(|(name, data)| {
301                let rx = format_bytes(data.total_received());
302                let tx = format_bytes(data.total_transmitted());
303                let is_up = data.operational_state() == sysinfo::InterfaceOperationalState::Up
304                    || data.total_received() > 0
305                    || data.total_transmitted() > 0;
306                let status = if is_up {
307                    "Up".green().to_string()
308                } else {
309                    "Down".red().to_string()
310                };
311
312                let mut ipv4_addresses = Vec::new();
313                let mut ipv6_addresses = Vec::new();
314
315                if is_up {
316                    for ip_net in data.ip_networks() {
317                        let ip = ip_net.addr;
318                        let name_lower = name.to_lowercase();
319                        let is_loopback_iface =
320                            name_lower.starts_with("lo") || name_lower.contains("loopback");
321                        if ip.is_loopback() && !is_loopback_iface {
322                            continue;
323                        }
324                        match ip {
325                            std::net::IpAddr::V4(v4) => {
326                                ipv4_addresses.push(v4.to_string());
327                            }
328                            std::net::IpAddr::V6(v6) => {
329                                if !v6.is_unicast_link_local() {
330                                    ipv6_addresses.push(v6.to_string());
331                                }
332                            }
333                        }
334                    }
335
336                    // Fallback to active interface UDP-resolved local IP if no IPs detected by sysinfo
337                    if ipv4_addresses.is_empty() && ipv6_addresses.is_empty() {
338                        if let (Some(ref active), Some(ref ip)) = (&active_interface, &local_ip) {
339                            if name == active {
340                                ipv4_addresses.push(ip.clone());
341                            }
342                        }
343                    }
344                }
345
346                let ip_str = if !ipv4_addresses.is_empty() || !ipv6_addresses.is_empty() {
347                    let mut combined = Vec::new();
348                    if !ipv4_addresses.is_empty() {
349                        combined.push(ipv4_addresses.join(", "));
350                    }
351                    if !ipv6_addresses.is_empty() {
352                        combined.push(ipv6_addresses.join(", "));
353                    }
354                    format!(" ({})", combined.join(", "))
355                } else {
356                    String::new()
357                };
358
359                format!("{}{} [{}] RX: {} TX: {}", name, ip_str, status, rx, tx)
360            })
361            .collect();
362
363        let boot_timestamp = System::boot_time();
364        let boot_dt = chrono::Local
365            .timestamp_opt(boot_timestamp as i64, 0)
366            .single()
367            .map(|dt| dt.format("%Y-%m-%dT%H:%M:%S%:z").to_string())
368            .unwrap_or_else(|| boot_timestamp.to_string());
369        let boot_time = boot_dt;
370
371        // Environment-based info
372        let shell = std::env::var("SHELL").ok();
373        let terminal = std::env::var("TERM").ok();
374        let desktop = std::env::var("XDG_CURRENT_DESKTOP")
375            .or_else(|_| std::env::var("DESKTOP_SESSION"))
376            .ok();
377
378        // CPU frequency (first CPU)
379        let cpu_freq = sys
380            .cpus()
381            .first()
382            .map(|c| format!("{:.2} GHz", c.frequency() as f64 / 1000.0));
383
384        // Current logged in user
385        let current_user = std::env::var("USER").ok();
386
387        // Number of interactive users (UID >= 1000, excluding system accounts)
388        let users = Users::new_with_refreshed_list()
389            .iter()
390            .filter(|user| {
391                // UID is exposed via Display
392                let uid_str = user.id().to_string();
393                if let Ok(uid) = uid_str.parse::<u32>() {
394                    uid >= 1000
395                } else {
396                    false
397                }
398            })
399            .count();
400
401        Ok(Self {
402            os,
403            kernel,
404            hostname,
405            arch,
406            cpu,
407            cpu_cores,
408            memory,
409            swap,
410            uptime,
411            processes,
412            load_avg,
413            disks,
414            temps,
415            networks,
416            boot_time,
417            battery,
418            shell,
419            terminal,
420            desktop,
421            cpu_freq,
422            users,
423            gpu: gpu::detect_gpus().into_iter().map(|g| g.format()).collect(),
424            packages: detect_packages(),
425            current_user,
426            local_ip,
427            public_ip,
428            active_interface,
429        })
430    }
431}
432
433/// Count installed packages by inspecting package manager databases directly.
434///
435/// Supports Pacman (Arch), Dpkg (Debian), XBPS (Void), RPM (Fedora/RHEL) on Linux,
436/// and Homebrew (Formulae and Casks) and MacPorts on macOS.
437fn detect_packages() -> Option<usize> {
438    #[cfg(target_os = "macos")]
439    {
440        let mut count = 0;
441
442        // Homebrew Cellar (Formulae)
443        for cellar_path in &["/opt/homebrew/Cellar", "/usr/local/Cellar"] {
444            if let Ok(entries) = std::fs::read_dir(cellar_path) {
445                count += entries.filter_map(|e| e.ok()).count();
446            }
447        }
448
449        // Homebrew Caskroom (Casks)
450        for cask_path in &["/opt/homebrew/Caskroom", "/usr/local/Caskroom"] {
451            if let Ok(entries) = std::fs::read_dir(cask_path) {
452                count += entries.filter_map(|e| e.ok()).count();
453            }
454        }
455
456        // MacPorts
457        if let Ok(entries) = std::fs::read_dir("/opt/local/var/macports/software") {
458            count += entries.filter_map(|e| e.ok()).count();
459        }
460
461        if count > 0 {
462            Some(count)
463        } else {
464            None
465        }
466    }
467
468    #[cfg(target_os = "windows")]
469    {
470        let mut count = 0;
471
472        // Scoop
473        if let Some(home) = dirs::home_dir() {
474            let scoop_dir = std::env::var("SCOOP")
475                .map(std::path::PathBuf::from)
476                .unwrap_or_else(|_| home.join("scoop"));
477            let scoop_apps = scoop_dir.join("apps");
478            if let Ok(entries) = std::fs::read_dir(scoop_apps) {
479                count += entries.filter_map(|e| e.ok()).count();
480            }
481        }
482
483        // Chocolatey
484        let choco_install = std::env::var("ChocolateyInstall")
485            .unwrap_or_else(|_| "C:\\ProgramData\\chocolatey".to_string());
486        let choco_lib = std::path::Path::new(&choco_install).join("lib");
487        if let Ok(entries) = std::fs::read_dir(choco_lib) {
488            count += entries.filter_map(|e| e.ok()).count();
489        }
490
491        if count > 0 {
492            Some(count)
493        } else {
494            None
495        }
496    }
497
498    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
499    {
500        // Arch / Manjaro
501        if let Ok(entries) = std::fs::read_dir("/var/lib/pacman/local") {
502            let count = entries.filter_map(|e| e.ok()).count();
503            if count > 0 {
504                return Some(count);
505            }
506        }
507
508        // Debian / Ubuntu
509        if let Ok(entries) = std::fs::read_dir("/var/lib/dpkg/info") {
510            let count = entries
511                .filter_map(|e| e.ok())
512                .filter(|e| e.path().extension().is_some_and(|ext| ext == "list"))
513                .count();
514            if count > 0 {
515                return Some(count);
516            }
517        }
518
519        // Gentoo
520        if let Ok(entries) = std::fs::read_dir("/var/db/pkg") {
521            let count: usize = entries
522                .filter_map(|e| e.ok())
523                .map(|e| {
524                    std::fs::read_dir(e.path())
525                        .map(|d| d.filter(|_| true).count())
526                        .unwrap_or(0)
527                })
528                .sum();
529            if count > 0 {
530                return Some(count);
531            }
532        }
533
534        // Void Linux
535        if let Ok(entries) = std::fs::read_dir("/var/db/xbps") {
536            let count = entries
537                .filter_map(|e| e.ok())
538                .filter(|e| e.path().extension().is_some_and(|ext| ext == "plist"))
539                .count();
540            if count > 0 {
541                return Some(count);
542            }
543        }
544
545        // Fedora / RHEL / openSUSE - read from RPM SQLite database
546        let rpm_db = "/var/lib/rpm/rpmdb.sqlite";
547        if std::path::Path::new(rpm_db).exists() {
548            match rusqlite::Connection::open(rpm_db) {
549                Ok(conn) => {
550                    if let Ok(count) = conn.query_row("SELECT COUNT(*) FROM Packages", [], |row| {
551                        row.get::<_, i64>(0)
552                    }) {
553                        if count > 0 {
554                            return Some(count as usize);
555                        }
556                    }
557                }
558                Err(e) => {
559                    eprintln!("warning: failed to open RPM database at {}: {}", rpm_db, e);
560                }
561            }
562        }
563
564        None
565    }
566}
567
568/// Format bytes into human-readable form (KB, MB, GB, etc.)
569fn format_bytes(bytes: u64) -> String {
570    const KB: u64 = 1024;
571    const MB: u64 = KB * 1024;
572    const GB: u64 = MB * 1024;
573
574    if bytes >= GB {
575        format!("{:.1} GB", bytes as f64 / GB as f64)
576    } else if bytes >= MB {
577        format!("{:.1} MB", bytes as f64 / MB as f64)
578    } else if bytes >= KB {
579        format!("{:.1} KB", bytes as f64 / KB as f64)
580    } else {
581        format!("{} B", bytes)
582    }
583}
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588
589    #[test]
590    fn test_format_bytes() {
591        assert_eq!(format_bytes(500), "500 B");
592        assert_eq!(format_bytes(1024), "1.0 KB");
593        assert_eq!(format_bytes(1024 * 1024), "1.0 MB");
594        assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB");
595        assert_eq!(format_bytes(1536), "1.5 KB");
596    }
597}