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    /// Detected motherboard name and manufacturer.
78    pub motherboard: Option<String>,
79    /// Detected BIOS details.
80    pub bios: Option<String>,
81    /// List of connected display resolutions and refresh rates.
82    pub displays: Vec<String>,
83}
84
85impl SystemInfo {
86    /// Collects system information using sysinfo and environment probes.
87    ///
88    /// This method aggregates data from the operating system, hardware,
89    /// and current user environment into a `SystemInfo` struct.
90    pub fn collect(_cli: &Cli, _config: &Config) -> anyhow::Result<Self> {
91        let mut sys = System::new_all();
92        sys.refresh_all();
93
94        let os = System::long_os_version()
95            .or_else(System::name)
96            .unwrap_or_else(|| "Unknown".to_string());
97
98        let kernel = System::kernel_version();
99        let hostname = System::host_name();
100
101        let cpu = sys
102            .cpus()
103            .first()
104            .map(|c| c.brand().to_string())
105            .unwrap_or_else(|| "Unknown CPU".to_string());
106
107        let cpu_cores = sys.cpus().len();
108
109        let total_mem = sys.total_memory() as f64 / 1024.0 / 1024.0;
110        let used_mem = sys.used_memory() as f64 / 1024.0 / 1024.0;
111        let memory = format!("{:.1} / {:.1} GB", used_mem, total_mem);
112
113        let total_swap = sys.total_swap() as f64 / 1024.0 / 1024.0;
114        let used_swap = sys.used_swap() as f64 / 1024.0 / 1024.0;
115        let swap = if total_swap > 0.0 {
116            format!("{:.1} / {:.1} GB", used_swap, total_swap)
117        } else {
118            "No swap".to_string()
119        };
120
121        let uptime = format!("{}s", System::uptime());
122
123        let disks_list = Disks::new_with_refreshed_list();
124        let disks: Vec<String> = if !_cli.long {
125            let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/"));
126            let mut best_match: Option<&sysinfo::Disk> = None;
127            for disk in disks_list.iter() {
128                if home.starts_with(disk.mount_point()) {
129                    if let Some(best) = best_match {
130                        if disk.mount_point().components().count()
131                            > best.mount_point().components().count()
132                        {
133                            best_match = Some(disk);
134                        }
135                    } else {
136                        best_match = Some(disk);
137                    }
138                }
139            }
140            let selected_disks = if let Some(best) = best_match {
141                vec![best]
142            } else {
143                disks_list.iter().collect::<Vec<_>>()
144            };
145
146            selected_disks
147                .iter()
148                .map(|d| {
149                    let total = d.total_space() as f64 / 1024.0 / 1024.0 / 1024.0;
150                    let avail = d.available_space() as f64 / 1024.0 / 1024.0 / 1024.0;
151                    let fs = d.file_system().to_string_lossy();
152                    format!(
153                        "{} ({}): {:.1} GB free / {:.1} GB",
154                        d.mount_point().display(),
155                        fs,
156                        avail,
157                        total
158                    )
159                })
160                .collect()
161        } else {
162            disks_list
163                .iter()
164                .map(|d| {
165                    let total = d.total_space() as f64 / 1024.0 / 1024.0 / 1024.0;
166                    let avail = d.available_space() as f64 / 1024.0 / 1024.0 / 1024.0;
167                    let fs = d.file_system().to_string_lossy();
168                    format!(
169                        "{} ({}): {:.1} GB free / {:.1} GB",
170                        d.mount_point().display(),
171                        fs,
172                        avail,
173                        total
174                    )
175                })
176                .collect()
177        };
178
179        let battery = battery::Manager::new()
180            .ok()
181            .and_then(|manager| manager.batteries().ok())
182            .and_then(|mut batteries| batteries.next())
183            .and_then(|result| result.ok())
184            .map(|bat| {
185                let pct = bat.state_of_charge().value * 100.0;
186                let health = bat.state_of_health().value * 100.0;
187                let state = match bat.state() {
188                    battery::State::Charging => "charging",
189                    battery::State::Discharging => "discharging",
190                    battery::State::Full => "full",
191                    _ => "not charging",
192                };
193                let vendor = bat.vendor().map(|s| s.to_string());
194                let model = bat.model().map(|s| s.to_string());
195
196                // Format time remaining as "Xh Ym" or "Xd Yh"
197                let time_str = match bat.state() {
198                    battery::State::Charging => bat.time_to_full().map(|d| {
199                        let total_mins = (d.value / 60.0) as u32;
200                        let hours = total_mins / 60;
201                        let mins = total_mins % 60;
202                        if hours >= 24 {
203                            let days = hours / 24;
204                            let rem_hours = hours % 24;
205                            format!("{}d {}h until full", days, rem_hours)
206                        } else if hours > 0 {
207                            format!("{}h {}m until full", hours, mins)
208                        } else {
209                            format!("{}m until full", mins)
210                        }
211                    }),
212                    battery::State::Discharging => bat.time_to_empty().map(|d| {
213                        let total_mins = (d.value / 60.0) as u32;
214                        let hours = total_mins / 60;
215                        let mins = total_mins % 60;
216                        if hours >= 24 {
217                            let days = hours / 24;
218                            let rem_hours = hours % 24;
219                            format!("{}d {}h remaining", days, rem_hours)
220                        } else if hours > 0 {
221                            format!("{}h {}m remaining", hours, mins)
222                        } else {
223                            format!("{}m remaining", mins)
224                        }
225                    }),
226                    _ => None,
227                };
228
229                let mut parts = vec![state.to_string()];
230                if let Some(t) = time_str {
231                    parts.insert(0, t);
232                }
233                if health < 99.0 {
234                    parts.push(format!("{:.0}% health", health));
235                }
236
237                let base = format!("{:.0}% ({})", pct, parts.join(", "));
238
239                match (vendor, model) {
240                    (Some(v), Some(m)) => format!("{} [{} {}]", base, v, m),
241                    (Some(v), None) => format!("{} [{}]", base, v),
242                    _ => base,
243                }
244            });
245
246        let arch = System::cpu_arch();
247
248        let processes = sys.processes().len();
249
250        let load_avg = {
251            let avg = System::load_average();
252            if avg.one > 0.0 || avg.five > 0.0 {
253                Some(format!(
254                    "{:.2}, {:.2}, {:.2}",
255                    avg.one, avg.five, avg.fifteen
256                ))
257            } else {
258                None
259            }
260        };
261
262        // Compute slow system queries concurrently in parallel threads
263        let (gpu, packages, public_ip, (local_ip, active_interface), motherboard, bios, displays) =
264            std::thread::scope(|s| {
265                let gpu_handle = s.spawn(|| {
266                    gpu::detect_gpus()
267                        .into_iter()
268                        .map(|g| g.format())
269                        .collect::<Vec<String>>()
270                });
271                let packages_handle = s.spawn(detect_packages);
272                let public_ip_handle = s.spawn(|| {
273                    std::process::Command::new("curl")
274                        .args(["-s", "--max-time", "2", "https://api.ipify.org"])
275                        .output()
276                        .ok()
277                        .and_then(|o| String::from_utf8(o.stdout).ok())
278                        .map(|s| s.trim().to_string())
279                        .filter(|s| !s.is_empty())
280                });
281                let network_ips_handle = s.spawn(|| {
282                let local_ip = std::net::UdpSocket::bind("0.0.0.0:0")
283                    .ok()
284                    .and_then(|socket| {
285                        socket.connect("8.8.8.8:53").ok()?;
286                        socket.local_addr().ok().map(|addr| addr.ip().to_string())
287                    });
288
289                let active_interface = {
290                    #[cfg(target_os = "linux")]
291                    {
292                        std::process::Command::new("ip")
293                            .args(["route", "show", "default"])
294                            .output()
295                            .ok()
296                            .and_then(|o| String::from_utf8(o.stdout).ok())
297                            .and_then(|s| {
298                                s.split_whitespace()
299                                    .position(|w| w == "dev")
300                                    .and_then(|i| s.split_whitespace().nth(i + 1))
301                                    .map(|s| s.to_string())
302                            })
303                    }
304                    #[cfg(target_os = "macos")]
305                    {
306                        std::process::Command::new("route")
307                            .args(["-n", "get", "default"])
308                            .output()
309                            .ok()
310                            .and_then(|o| String::from_utf8(o.stdout).ok())
311                            .and_then(|s| {
312                                s.lines()
313                                    .find(|l| l.contains("interface:"))
314                                    .and_then(|l| l.split_whitespace().last())
315                                    .map(|s| s.to_string())
316                            })
317                    }
318                    #[cfg(target_os = "windows")]
319                    {
320                        std::process::Command::new("powershell")
321                            .args(["-Command", "Get-NetRoute -DestinationPrefix 0.0.0.0/0 | Select-Object -First 1 -ExpandProperty InterfaceAlias"])
322                            .output()
323                            .ok()
324                            .and_then(|o| String::from_utf8(o.stdout).ok())
325                            .map(|s| s.trim().to_string())
326                            .filter(|s| !s.is_empty())
327                    }
328                    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
329                    {
330                        None
331                    }
332                };
333
334                (local_ip, active_interface)
335            });
336                let motherboard_handle = s.spawn(detect_motherboard);
337                let bios_handle = s.spawn(detect_bios);
338                let displays_handle = s.spawn(detect_displays);
339
340                (
341                    gpu_handle.join().unwrap_or_default(),
342                    packages_handle.join().ok().flatten(),
343                    public_ip_handle.join().ok().flatten(),
344                    network_ips_handle.join().unwrap_or((None, None)),
345                    motherboard_handle.join().ok().flatten(),
346                    bios_handle.join().ok().flatten(),
347                    displays_handle.join().unwrap_or_default(),
348                )
349            });
350
351        let mut temps: Vec<String> = Components::new_with_refreshed_list()
352            .iter()
353            .filter_map(|c| {
354                c.temperature().and_then(|t| {
355                    if t > 0.0 {
356                        Some(format!("{}: {:.0}°C", c.label(), t))
357                    } else {
358                        None
359                    }
360                })
361            })
362            .collect();
363
364        // Sort so CPU temperatures appear first
365        temps.sort_by(|a, b| {
366            let a_cpu = a.to_lowercase().contains("cpu") || a.to_lowercase().contains("core");
367            let b_cpu = b.to_lowercase().contains("cpu") || b.to_lowercase().contains("core");
368            b_cpu.cmp(&a_cpu)
369        });
370
371        let networks = Networks::new_with_refreshed_list()
372            .iter()
373            .map(|(name, data)| {
374                let rx = format_bytes(data.total_received());
375                let tx = format_bytes(data.total_transmitted());
376                let is_up = data.operational_state() == sysinfo::InterfaceOperationalState::Up
377                    || data.total_received() > 0
378                    || data.total_transmitted() > 0;
379                let status = if is_up {
380                    "Up".green().to_string()
381                } else {
382                    "Down".red().to_string()
383                };
384
385                let mut ipv4_addresses = Vec::new();
386                let mut ipv6_addresses = Vec::new();
387
388                if is_up {
389                    for ip_net in data.ip_networks() {
390                        let ip = ip_net.addr;
391                        let name_lower = name.to_lowercase();
392                        let is_loopback_iface =
393                            name_lower.starts_with("lo") || name_lower.contains("loopback");
394                        if ip.is_loopback() && !is_loopback_iface {
395                            continue;
396                        }
397                        match ip {
398                            std::net::IpAddr::V4(v4) => {
399                                ipv4_addresses.push(v4.to_string());
400                            }
401                            std::net::IpAddr::V6(v6) => {
402                                if !v6.is_unicast_link_local() {
403                                    ipv6_addresses.push(v6.to_string());
404                                }
405                            }
406                        }
407                    }
408
409                    // Fallback to active interface UDP-resolved local IP if no IPs detected by sysinfo
410                    if ipv4_addresses.is_empty() && ipv6_addresses.is_empty() {
411                        if let (Some(ref active), Some(ref ip)) = (&active_interface, &local_ip) {
412                            if name == active {
413                                ipv4_addresses.push(ip.clone());
414                            }
415                        }
416                    }
417                }
418
419                let ip_str = if !ipv4_addresses.is_empty() || !ipv6_addresses.is_empty() {
420                    let mut combined = Vec::new();
421                    if !ipv4_addresses.is_empty() {
422                        combined.push(ipv4_addresses.join(", "));
423                    }
424                    if !ipv6_addresses.is_empty() {
425                        combined.push(ipv6_addresses.join(", "));
426                    }
427                    format!(" ({})", combined.join(", "))
428                } else {
429                    String::new()
430                };
431
432                format!("{}{} [{}] RX: {} TX: {}", name, ip_str, status, rx, tx)
433            })
434            .collect();
435
436        let boot_timestamp = System::boot_time();
437        let boot_dt = chrono::Local
438            .timestamp_opt(boot_timestamp as i64, 0)
439            .single()
440            .map(|dt| dt.format("%Y-%m-%dT%H:%M:%S%:z").to_string())
441            .unwrap_or_else(|| boot_timestamp.to_string());
442        let boot_time = boot_dt;
443
444        // Environment-based info
445        let shell = std::env::var("SHELL").ok();
446        let terminal = std::env::var("TERM").ok();
447        let desktop = std::env::var("XDG_CURRENT_DESKTOP")
448            .or_else(|_| std::env::var("DESKTOP_SESSION"))
449            .ok();
450
451        // CPU frequency (first CPU)
452        let cpu_freq = sys
453            .cpus()
454            .first()
455            .map(|c| format!("{:.2} GHz", c.frequency() as f64 / 1000.0));
456
457        // Current logged in user
458        let current_user = std::env::var("USER").ok();
459
460        // Number of interactive users (UID >= 1000, excluding system accounts)
461        let users = Users::new_with_refreshed_list()
462            .iter()
463            .filter(|user| {
464                // UID is exposed via Display
465                let uid_str = user.id().to_string();
466                if let Ok(uid) = uid_str.parse::<u32>() {
467                    uid >= 1000
468                } else {
469                    false
470                }
471            })
472            .count();
473
474        Ok(Self {
475            os,
476            kernel,
477            hostname,
478            arch,
479            cpu,
480            cpu_cores,
481            memory,
482            swap,
483            uptime,
484            processes,
485            load_avg,
486            disks,
487            temps,
488            networks,
489            boot_time,
490            battery,
491            shell,
492            terminal,
493            desktop,
494            cpu_freq,
495            users,
496            gpu,
497            packages,
498            current_user,
499            local_ip,
500            public_ip,
501            active_interface,
502            motherboard,
503            bios,
504            displays,
505        })
506    }
507}
508
509/// Count installed packages by inspecting package manager databases directly.
510///
511/// Supports Pacman (Arch), Dpkg (Debian), XBPS (Void), RPM (Fedora/RHEL) on Linux,
512/// and Homebrew (Formulae and Casks) and MacPorts on macOS.
513fn detect_packages() -> Option<usize> {
514    #[cfg(target_os = "macos")]
515    {
516        let mut count = 0;
517
518        // Homebrew Cellar (Formulae)
519        for cellar_path in &["/opt/homebrew/Cellar", "/usr/local/Cellar"] {
520            if let Ok(entries) = std::fs::read_dir(cellar_path) {
521                count += entries.filter_map(|e| e.ok()).count();
522            }
523        }
524
525        // Homebrew Caskroom (Casks)
526        for cask_path in &["/opt/homebrew/Caskroom", "/usr/local/Caskroom"] {
527            if let Ok(entries) = std::fs::read_dir(cask_path) {
528                count += entries.filter_map(|e| e.ok()).count();
529            }
530        }
531
532        // MacPorts
533        if let Ok(entries) = std::fs::read_dir("/opt/local/var/macports/software") {
534            count += entries.filter_map(|e| e.ok()).count();
535        }
536
537        if count > 0 {
538            Some(count)
539        } else {
540            None
541        }
542    }
543
544    #[cfg(target_os = "windows")]
545    {
546        let mut count = 0;
547
548        // Scoop
549        if let Some(home) = dirs::home_dir() {
550            let scoop_dir = std::env::var("SCOOP")
551                .map(std::path::PathBuf::from)
552                .unwrap_or_else(|_| home.join("scoop"));
553            let scoop_apps = scoop_dir.join("apps");
554            if let Ok(entries) = std::fs::read_dir(scoop_apps) {
555                count += entries.filter_map(|e| e.ok()).count();
556            }
557        }
558
559        // Chocolatey
560        let choco_install = std::env::var("ChocolateyInstall")
561            .unwrap_or_else(|_| "C:\\ProgramData\\chocolatey".to_string());
562        let choco_lib = std::path::Path::new(&choco_install).join("lib");
563        if let Ok(entries) = std::fs::read_dir(choco_lib) {
564            count += entries.filter_map(|e| e.ok()).count();
565        }
566
567        if count > 0 {
568            Some(count)
569        } else {
570            None
571        }
572    }
573
574    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
575    {
576        // Arch / Manjaro
577        if let Ok(entries) = std::fs::read_dir("/var/lib/pacman/local") {
578            let count = entries.filter_map(|e| e.ok()).count();
579            if count > 0 {
580                return Some(count);
581            }
582        }
583
584        // Debian / Ubuntu
585        if let Ok(entries) = std::fs::read_dir("/var/lib/dpkg/info") {
586            let count = entries
587                .filter_map(|e| e.ok())
588                .filter(|e| e.path().extension().is_some_and(|ext| ext == "list"))
589                .count();
590            if count > 0 {
591                return Some(count);
592            }
593        }
594
595        // Gentoo
596        if let Ok(entries) = std::fs::read_dir("/var/db/pkg") {
597            let count: usize = entries
598                .filter_map(|e| e.ok())
599                .map(|e| {
600                    std::fs::read_dir(e.path())
601                        .map(|d| d.filter(|_| true).count())
602                        .unwrap_or(0)
603                })
604                .sum();
605            if count > 0 {
606                return Some(count);
607            }
608        }
609
610        // Void Linux
611        if let Ok(entries) = std::fs::read_dir("/var/db/xbps") {
612            let count = entries
613                .filter_map(|e| e.ok())
614                .filter(|e| e.path().extension().is_some_and(|ext| ext == "plist"))
615                .count();
616            if count > 0 {
617                return Some(count);
618            }
619        }
620
621        // Fedora / RHEL / openSUSE - read from RPM SQLite database
622        let rpm_db = "/var/lib/rpm/rpmdb.sqlite";
623        if std::path::Path::new(rpm_db).exists() {
624            match rusqlite::Connection::open(rpm_db) {
625                Ok(conn) => {
626                    if let Ok(count) = conn.query_row("SELECT COUNT(*) FROM Packages", [], |row| {
627                        row.get::<_, i64>(0)
628                    }) {
629                        if count > 0 {
630                            return Some(count as usize);
631                        }
632                    }
633                }
634                Err(e) => {
635                    eprintln!("warning: failed to open RPM database at {}: {}", rpm_db, e);
636                }
637            }
638        }
639
640        None
641    }
642}
643
644fn detect_motherboard() -> Option<String> {
645    #[cfg(target_os = "macos")]
646    {
647        if let Ok(output) = std::process::Command::new("sysctl")
648            .args(["-n", "hw.model"])
649            .output()
650        {
651            if let Ok(model) = String::from_utf8(output.stdout) {
652                let trimmed = model.trim();
653                if !trimmed.is_empty() {
654                    return Some(trimmed.to_string());
655                }
656            }
657        }
658        None
659    }
660
661    #[cfg(target_os = "windows")]
662    {
663        if let Ok(output) = std::process::Command::new("wmic")
664            .args(["baseboard", "get", "manufacturer,product", "/value"])
665            .output()
666        {
667            if let Ok(stdout) = String::from_utf8(output.stdout) {
668                let mut manufacturer = String::new();
669                let mut product = String::new();
670                for line in stdout.lines() {
671                    let line = line.trim();
672                    if line.starts_with("Manufacturer=") {
673                        manufacturer = line
674                            .strip_prefix("Manufacturer=")
675                            .unwrap_or("")
676                            .trim()
677                            .to_string();
678                    } else if line.starts_with("Product=") {
679                        product = line
680                            .strip_prefix("Product=")
681                            .unwrap_or("")
682                            .trim()
683                            .to_string();
684                    }
685                }
686                if !manufacturer.is_empty() && !product.is_empty() {
687                    return Some(format!("{} {}", manufacturer, product));
688                } else if !product.is_empty() {
689                    return Some(product);
690                } else if !manufacturer.is_empty() {
691                    return Some(manufacturer);
692                }
693            }
694        }
695        None
696    }
697
698    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
699    {
700        let vendor = std::fs::read_to_string("/sys/class/dmi/id/board_vendor")
701            .map(|s| s.trim().to_string())
702            .ok();
703        let name = std::fs::read_to_string("/sys/class/dmi/id/board_name")
704            .map(|s| s.trim().to_string())
705            .ok();
706
707        match (vendor, name) {
708            (Some(v), Some(n)) if !v.is_empty() && !n.is_empty() => {
709                if n.starts_with(&v) {
710                    Some(n)
711                } else {
712                    Some(format!("{} {}", v, n))
713                }
714            }
715            (Some(v), _) if !v.is_empty() => Some(v),
716            (_, Some(n)) if !n.is_empty() => Some(n),
717            _ => None,
718        }
719    }
720}
721
722fn detect_bios() -> Option<String> {
723    #[cfg(target_os = "macos")]
724    {
725        if let Ok(output) = std::process::Command::new("system_profiler")
726            .arg("SPHardwareDataType")
727            .output()
728        {
729            if let Ok(stdout) = String::from_utf8(output.stdout) {
730                for line in stdout.lines() {
731                    let line = line.trim();
732                    if line.starts_with("System Firmware Version:")
733                        || line.starts_with("Boot ROM Version:")
734                    {
735                        if let Some(val) = line.split(':').nth(1) {
736                            return Some(val.trim().to_string());
737                        }
738                    }
739                }
740            }
741        }
742        None
743    }
744
745    #[cfg(target_os = "windows")]
746    {
747        if let Ok(output) = std::process::Command::new("wmic")
748            .args([
749                "bios",
750                "get",
751                "manufacturer,smbiosbiosversion,releasedate",
752                "/value",
753            ])
754            .output()
755        {
756            if let Ok(stdout) = String::from_utf8(output.stdout) {
757                let mut manufacturer = String::new();
758                let mut version = String::new();
759                let mut date = String::new();
760                for line in stdout.lines() {
761                    let line = line.trim();
762                    if line.starts_with("Manufacturer=") {
763                        manufacturer = line
764                            .strip_prefix("Manufacturer=")
765                            .unwrap_or("")
766                            .trim()
767                            .to_string();
768                    } else if line.starts_with("SMBIOSBIOSVersion=") {
769                        version = line
770                            .strip_prefix("SMBIOSBIOSVersion=")
771                            .unwrap_or("")
772                            .trim()
773                            .to_string();
774                    } else if line.starts_with("ReleaseDate=") {
775                        let raw_date = line.strip_prefix("ReleaseDate=").unwrap_or("").trim();
776                        if raw_date.len() >= 8 {
777                            let year = &raw_date[0..4];
778                            let month = &raw_date[4..6];
779                            let day = &raw_date[6..8];
780                            date = format!("{}/{}/{}", month, day, year);
781                        } else {
782                            date = raw_date.to_string();
783                        }
784                    }
785                }
786
787                let mut parts = Vec::new();
788                if !manufacturer.is_empty() {
789                    parts.push(manufacturer);
790                }
791                if !version.is_empty() {
792                    parts.push(version);
793                }
794                let mut res = parts.join(" ");
795                if !date.is_empty() {
796                    if res.is_empty() {
797                        res = date;
798                    } else {
799                        res = format!("{} ({})", res, date);
800                    }
801                }
802                if !res.is_empty() {
803                    return Some(res);
804                }
805            }
806        }
807        None
808    }
809
810    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
811    {
812        let vendor = std::fs::read_to_string("/sys/class/dmi/id/bios_vendor")
813            .map(|s| s.trim().to_string())
814            .ok();
815        let version = std::fs::read_to_string("/sys/class/dmi/id/bios_version")
816            .map(|s| s.trim().to_string())
817            .ok();
818        let date = std::fs::read_to_string("/sys/class/dmi/id/bios_date")
819            .map(|s| s.trim().to_string())
820            .ok();
821
822        let mut parts = Vec::new();
823        if let Some(v) = vendor {
824            if !v.is_empty() {
825                parts.push(v);
826            }
827        }
828        if let Some(ver) = version {
829            let mut ver_cleaned = ver;
830            while ver_cleaned.contains(" )") {
831                ver_cleaned = ver_cleaned.replace(" )", ")");
832            }
833            let ver_cleaned = ver_cleaned.trim().to_string();
834            if !ver_cleaned.is_empty() {
835                parts.push(ver_cleaned);
836            }
837        }
838        let mut res = parts.join(" ");
839        if let Some(d) = date {
840            if !d.is_empty() {
841                if res.is_empty() {
842                    res = d;
843                } else {
844                    res = format!("{} ({})", res, d);
845                }
846            }
847        }
848        if !res.is_empty() {
849            Some(res)
850        } else {
851            None
852        }
853    }
854}
855
856#[allow(dead_code)]
857fn parse_monitor_name_from_edid(edid: &[u8]) -> Option<String> {
858    if edid.len() < 128 {
859        return None;
860    }
861    let offsets = [54, 72, 90, 108];
862    for &offset in &offsets {
863        if offset + 18 <= edid.len() {
864            let block = &edid[offset..offset + 18];
865            if block[0] == 0x00 && block[1] == 0x00 && block[2] == 0x00 && block[3] == 0xFC {
866                let name_bytes = &block[4..17];
867                let name = String::from_utf8_lossy(name_bytes);
868                let cleaned = name.trim().replace('\0', "").to_string();
869                if !cleaned.is_empty() {
870                    return Some(cleaned);
871                }
872            }
873        }
874    }
875    None
876}
877
878#[allow(dead_code)]
879fn parse_refresh_rate_from_edid(edid: &[u8]) -> Option<f64> {
880    if edid.len() < 72 {
881        return None;
882    }
883    let block = &edid[54..72];
884    let pixel_clock = ((block[1] as u32) << 8) | (block[0] as u32);
885    if pixel_clock == 0 {
886        return None;
887    }
888    let pixel_clock_hz = pixel_clock * 10_000;
889    let h_active = (block[2] as u32) | (((block[4] as u32) & 0xF0) << 4);
890    let h_blanking = (block[3] as u32) | (((block[4] as u32) & 0x0F) << 8);
891    let v_active = (block[5] as u32) | (((block[7] as u32) & 0xF0) << 4);
892    let v_blanking = (block[6] as u32) | (((block[7] as u32) & 0x0F) << 8);
893
894    let h_total = h_active + h_blanking;
895    let v_total = v_active + v_blanking;
896    if h_total == 0 || v_total == 0 {
897        return None;
898    }
899
900    let refresh = (pixel_clock_hz as f64) / ((h_total * v_total) as f64);
901    Some((refresh * 100.0).round() / 100.0)
902}
903
904#[allow(dead_code)]
905fn format_refresh_rate(refresh: f64) -> String {
906    if (refresh - refresh.round()).abs() < 0.01 {
907        format!("{:.0}", refresh)
908    } else {
909        format!("{:.2}", refresh)
910    }
911}
912
913#[allow(dead_code)]
914fn parse_serial_number_from_edid(edid: &[u8]) -> Option<String> {
915    if edid.len() < 128 {
916        return None;
917    }
918    // 1. Try finding ASCII Serial Number descriptor block (tag 0xFF)
919    let offsets = [54, 72, 90, 108];
920    for &offset in &offsets {
921        if offset + 18 <= edid.len() {
922            let block = &edid[offset..offset + 18];
923            if block[0] == 0x00 && block[1] == 0x00 && block[2] == 0x00 && block[3] == 0xFF {
924                let serial_bytes = &block[4..17];
925                let serial = String::from_utf8_lossy(serial_bytes);
926                let cleaned = serial.trim().replace('\0', "").to_string();
927                if !cleaned.is_empty() {
928                    return Some(cleaned);
929                }
930            }
931        }
932    }
933
934    // 2. Fallback to the 32-bit numeric serial number at offset 12-15
935    let serial_num = ((edid[15] as u32) << 24)
936        | ((edid[14] as u32) << 16)
937        | ((edid[13] as u32) << 8)
938        | (edid[12] as u32);
939    if serial_num != 0 && serial_num != 0xFFFFFFFF {
940        return Some(serial_num.to_string());
941    }
942
943    None
944}
945
946#[allow(dead_code)]
947fn get_monitor_name_for_port(port: &str) -> Option<String> {
948    if let Ok(entries) = std::fs::read_dir("/sys/class/drm") {
949        for entry in entries.filter_map(|e| e.ok()) {
950            let name = entry.file_name().to_string_lossy().to_string();
951            if name.ends_with(port) {
952                let edid_path = entry.path().join("edid");
953                if edid_path.exists() {
954                    if let Ok(edid_bytes) = std::fs::read(&edid_path) {
955                        if let Some(monitor_name) = parse_monitor_name_from_edid(&edid_bytes) {
956                            return Some(monitor_name);
957                        }
958                    }
959                }
960            }
961        }
962    }
963    None
964}
965
966fn detect_displays() -> Vec<String> {
967    #[cfg(target_os = "macos")]
968    {
969        if let Ok(output) = std::process::Command::new("system_profiler")
970            .arg("SPDisplaysDataType")
971            .output()
972        {
973            if let Ok(stdout) = String::from_utf8(output.stdout) {
974                return parse_macos_displays(&stdout);
975            }
976        }
977        Vec::new()
978    }
979
980    #[cfg(target_os = "windows")]
981    {
982        let mut displays = Vec::new();
983        if let Ok(output) = std::process::Command::new("wmic")
984            .args([
985                "path",
986                "Win32_VideoController",
987                "get",
988                "Name,CurrentHorizontalResolution,CurrentVerticalResolution,CurrentRefreshRate",
989                "/value",
990            ])
991            .output()
992        {
993            if let Ok(stdout) = String::from_utf8(output.stdout) {
994                let mut name = String::new();
995                let mut width = String::new();
996                let mut height = String::new();
997                let mut refresh = String::new();
998                for line in stdout.lines() {
999                    let line = line.trim();
1000                    if line.starts_with("Name=") {
1001                        name = line.strip_prefix("Name=").unwrap_or("").trim().to_string();
1002                    } else if line.starts_with("CurrentHorizontalResolution=") {
1003                        width = line
1004                            .strip_prefix("CurrentHorizontalResolution=")
1005                            .unwrap_or("")
1006                            .trim()
1007                            .to_string();
1008                    } else if line.starts_with("CurrentVerticalResolution=") {
1009                        height = line
1010                            .strip_prefix("CurrentVerticalResolution=")
1011                            .unwrap_or("")
1012                            .trim()
1013                            .to_string();
1014                    } else if line.starts_with("CurrentRefreshRate=") {
1015                        refresh = line
1016                            .strip_prefix("CurrentRefreshRate=")
1017                            .unwrap_or("")
1018                            .trim()
1019                            .to_string();
1020                    }
1021
1022                    if !name.is_empty()
1023                        && !width.is_empty()
1024                        && !height.is_empty()
1025                        && !refresh.is_empty()
1026                    {
1027                        displays.push(format!("{} ({}x{} @ {}Hz)", name, width, height, refresh));
1028                        name.clear();
1029                        width.clear();
1030                        height.clear();
1031                        refresh.clear();
1032                    }
1033                }
1034                if !name.is_empty() && !width.is_empty() && !height.is_empty() {
1035                    displays.push(format!("{} ({}x{})", name, width, height));
1036                }
1037            }
1038        }
1039        displays
1040    }
1041
1042    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
1043    {
1044        let mut displays = Vec::new();
1045
1046        if let Ok(output) = std::process::Command::new("xrandr")
1047            .arg("--current")
1048            .output()
1049        {
1050            if let Ok(stdout) = String::from_utf8(output.stdout) {
1051                displays = parse_xrandr_displays(&stdout);
1052            }
1053        }
1054
1055        if displays.is_empty() {
1056            if let Ok(entries) = std::fs::read_dir("/sys/class/drm") {
1057                for entry in entries.filter_map(|e| e.ok()) {
1058                    let path = entry.path();
1059                    let status_path = path.join("status");
1060                    let modes_path = path.join("modes");
1061                    let edid_path = path.join("edid");
1062                    if status_path.exists() && modes_path.exists() {
1063                        if let Ok(status) = std::fs::read_to_string(&status_path) {
1064                            if status.trim() == "connected" {
1065                                if let Ok(modes) = std::fs::read_to_string(&modes_path) {
1066                                    if let Some(first_mode) = modes.lines().next() {
1067                                        let res = first_mode.trim().to_string();
1068                                        let port = entry.file_name().to_string_lossy().to_string();
1069                                        let clean_port = if let Some(idx) = port.find('-') {
1070                                            port[idx + 1..].to_string()
1071                                        } else {
1072                                            port
1073                                        };
1074
1075                                        let edid_bytes = if edid_path.exists() {
1076                                            std::fs::read(&edid_path).ok()
1077                                        } else {
1078                                            None
1079                                        };
1080
1081                                        let name = edid_bytes
1082                                            .as_ref()
1083                                            .and_then(|bytes| parse_monitor_name_from_edid(bytes))
1084                                            .unwrap_or(clean_port);
1085
1086                                        let refresh = edid_bytes
1087                                            .as_ref()
1088                                            .and_then(|bytes| parse_refresh_rate_from_edid(bytes));
1089
1090                                        let serial = edid_bytes
1091                                            .as_ref()
1092                                            .and_then(|bytes| parse_serial_number_from_edid(bytes));
1093
1094                                        let display_name = if let Some(ref s) = serial {
1095                                            format!("{} #{}", name, s)
1096                                        } else {
1097                                            name
1098                                        };
1099
1100                                        if let Some(r) = refresh {
1101                                            displays.push(format!(
1102                                                "{} ({} @ {}Hz)",
1103                                                display_name,
1104                                                res,
1105                                                format_refresh_rate(r)
1106                                            ));
1107                                        } else {
1108                                            displays.push(format!("{} ({})", display_name, res));
1109                                        }
1110                                    }
1111                                }
1112                            }
1113                        }
1114                    }
1115                }
1116            }
1117        }
1118
1119        displays
1120    }
1121}
1122
1123#[cfg(target_os = "macos")]
1124fn parse_macos_displays(stdout: &str) -> Vec<String> {
1125    let mut displays = Vec::new();
1126    let mut current_name = None;
1127    let mut current_res = None;
1128    let mut in_displays = false;
1129
1130    for line in stdout.lines() {
1131        let trimmed = line.trim();
1132        let indent = line.len() - line.trim_start().len();
1133
1134        if trimmed.starts_with("Displays:") {
1135            in_displays = true;
1136            continue;
1137        }
1138
1139        if in_displays {
1140            if indent < 8 && !trimmed.is_empty() && !trimmed.starts_with("Displays:") {
1141                in_displays = false;
1142                continue;
1143            }
1144
1145            if trimmed.ends_with(':') && !trimmed.starts_with("UI Looks like:") {
1146                let name = trimmed.trim_end_matches(':').trim().to_string();
1147                current_name = Some(name);
1148            } else if trimmed.starts_with("Resolution:") {
1149                let res = trimmed.strip_prefix("Resolution:").unwrap_or("").trim();
1150                let cleaned = res.replace(" ", "");
1151                current_res = Some(cleaned);
1152            } else if trimmed.starts_with("UI Looks like:") {
1153                if let Some(res) = current_res.take() {
1154                    let name_str = current_name.take().unwrap_or_else(|| "Display".to_string());
1155                    if let Some(idx) = trimmed.find('@') {
1156                        let freq = trimmed[idx..].trim();
1157                        let freq_clean = freq.replace(" ", "").replace(".00", "");
1158                        displays.push(format!(
1159                            "{} ({} @ {})",
1160                            name_str,
1161                            res,
1162                            freq_clean.trim_start_matches('@')
1163                        ));
1164                    } else {
1165                        displays.push(format!("{} ({})", name_str, res));
1166                    }
1167                }
1168            }
1169        }
1170    }
1171    if let Some(res) = current_res {
1172        let name_str = current_name.unwrap_or_else(|| "Display".to_string());
1173        displays.push(format!("{} ({})", name_str, res));
1174    }
1175    displays
1176}
1177
1178#[cfg(not(any(target_os = "macos", target_os = "windows")))]
1179fn parse_xrandr_displays(stdout: &str) -> Vec<String> {
1180    let mut displays = Vec::new();
1181    let mut current_display = None;
1182    let mut current_port = None;
1183    for line in stdout.lines() {
1184        let line = line.trim();
1185        if line.contains(" connected ") {
1186            let parts: Vec<&str> = line.split_whitespace().collect();
1187            if let Some(&port) = parts.first() {
1188                current_port = Some(port.to_string());
1189            }
1190            for part in parts {
1191                if part.contains('x') && part.contains('+') {
1192                    if let Some(res) = part.split('+').next() {
1193                        current_display = Some(res.to_string());
1194                    }
1195                }
1196            }
1197        } else if line.contains('*') {
1198            if let Some(res) = current_display.take() {
1199                let port = current_port.take().unwrap_or_default();
1200                let name = get_monitor_name_for_port(&port).unwrap_or_else(|| port.clone());
1201                let parts: Vec<&str> = line.split_whitespace().collect();
1202                let mut added = false;
1203                for part in parts {
1204                    if part.contains('*') {
1205                        let freq = part.trim_end_matches(['*', '+']);
1206                        displays.push(format!("{} ({} @ {}Hz)", name, res, freq));
1207                        added = true;
1208                        break;
1209                    }
1210                }
1211                if !added {
1212                    displays.push(format!("{} ({})", name, res));
1213                }
1214            }
1215        }
1216    }
1217    displays
1218}
1219
1220/// Format bytes into human-readable form (KB, MB, GB, etc.)
1221fn format_bytes(bytes: u64) -> String {
1222    const KB: u64 = 1024;
1223    const MB: u64 = KB * 1024;
1224    const GB: u64 = MB * 1024;
1225
1226    if bytes >= GB {
1227        format!("{:.1} GB", bytes as f64 / GB as f64)
1228    } else if bytes >= MB {
1229        format!("{:.1} MB", bytes as f64 / MB as f64)
1230    } else if bytes >= KB {
1231        format!("{:.1} KB", bytes as f64 / KB as f64)
1232    } else {
1233        format!("{} B", bytes)
1234    }
1235}
1236
1237#[cfg(test)]
1238mod tests {
1239    use super::*;
1240
1241    #[test]
1242    fn test_format_bytes() {
1243        assert_eq!(format_bytes(500), "500 B");
1244        assert_eq!(format_bytes(1024), "1.0 KB");
1245        assert_eq!(format_bytes(1024 * 1024), "1.0 MB");
1246        assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB");
1247        assert_eq!(format_bytes(1536), "1.5 KB");
1248    }
1249
1250    #[test]
1251    fn test_system_info_collect() {
1252        use clap::Parser;
1253        let cli = Cli::try_parse_from(["retch"]).unwrap();
1254        let config = Config::default();
1255        let res = SystemInfo::collect(&cli, &config);
1256        assert!(res.is_ok());
1257        let info = res.unwrap();
1258        assert!(!info.os.is_empty());
1259        assert!(info.cpu_cores > 0);
1260    }
1261
1262    #[cfg(target_os = "macos")]
1263    #[test]
1264    fn test_parse_macos_displays() {
1265        let sample = "Graphics/Displays:\n\n    Apple M2:\n\n      Chipset Model: Apple M2\n      Displays:\n        Color LCD:\n          Resolution: 3024 x 1964\n          UI Looks like: 1512 x 982 @ 60.00Hz\n";
1266        let parsed = parse_macos_displays(sample);
1267        assert_eq!(parsed, vec!["Color LCD (3024x1964 @ 60Hz)".to_string()]);
1268    }
1269
1270    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
1271    #[test]
1272    fn test_parse_xrandr_displays() {
1273        let sample = "Screen 0: minimum 320 x 200, current 2560 x 1440\nDP-1 connected primary 2560x1440+0+0\n   2560x1440     143.97*+\n   1920x1080     60.00\n";
1274        let parsed = parse_xrandr_displays(sample);
1275        assert_eq!(parsed, vec!["DP-1 (2560x1440 @ 143.97Hz)".to_string()]);
1276    }
1277
1278    #[test]
1279    fn test_parse_refresh_rate_from_edid() {
1280        // Construct a mock EDID block with a DTD at byte 54
1281        let mut edid = vec![0u8; 128];
1282        // Pixel clock = 14850 -> 0x3A02 (in 10 kHz units -> 148.5 MHz)
1283        edid[54] = 0x02;
1284        edid[55] = 0x3A;
1285        // H Active = 1920 (0x780), H Blanking = 280 (0x118)
1286        edid[56] = 0x80; // Low 8 bits of H Active
1287        edid[57] = 0x18; // Low 8 bits of H Blanking
1288        edid[58] = 0x71; // High 4 bits: H Active (7), H Blanking (1)
1289                         // V Active = 1080 (0x438), V Blanking = 45 (0x2D)
1290        edid[59] = 0x38; // Low 8 bits of V Active
1291        edid[60] = 0x2D; // Low 8 bits of V Blanking
1292        edid[61] = 0x40; // High 4 bits: V Active (4), V Blanking (0)
1293
1294        let refresh = parse_refresh_rate_from_edid(&edid);
1295        assert!(refresh.is_some());
1296        // 148,500,000 / ((1920 + 280) * (1080 + 45)) = 60 Hz
1297        assert_eq!(refresh.unwrap(), 60.0);
1298    }
1299
1300    #[test]
1301    fn test_format_refresh_rate() {
1302        assert_eq!(format_refresh_rate(60.0), "60");
1303        assert_eq!(format_refresh_rate(59.94), "59.94");
1304        assert_eq!(format_refresh_rate(143.971), "143.97");
1305    }
1306
1307    #[test]
1308    fn test_parse_serial_number_from_edid() {
1309        let mut edid = vec![0u8; 128];
1310        // 1. Test fallback 32-bit numeric serial
1311        edid[12] = 0x78;
1312        edid[13] = 0x56;
1313        edid[14] = 0x34;
1314        edid[15] = 0x12; // 0x12345678 = 305419896
1315        assert_eq!(
1316            parse_serial_number_from_edid(&edid),
1317            Some("305419896".to_string())
1318        );
1319
1320        // 2. Test ASCII Monitor Serial Number descriptor block (tag 0xFF) at offset 72
1321        edid[72] = 0x00;
1322        edid[73] = 0x00;
1323        edid[74] = 0x00;
1324        edid[75] = 0xFF; // ASCII Serial Number descriptor tag
1325        let serial_str = b"CN0123456789\n";
1326        for i in 0..serial_str.len() {
1327            edid[76 + i] = serial_str[i];
1328        }
1329        assert_eq!(
1330            parse_serial_number_from_edid(&edid),
1331            Some("CN0123456789".to_string())
1332        );
1333    }
1334}