Skip to main content

retch_sysinfo/
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::gpu;
10use chrono::TimeZone;
11use owo_colors::OwoColorize;
12use sysinfo::{Components, Disks, Networks, System, Users};
13
14/// Options for controlling what system information is gathered.
15///
16/// This decouples the collection logic from the CLI argument parser,
17/// allowing `retch-sysinfo` to be used as a standalone library.
18#[derive(Debug, Default, Clone)]
19pub struct CollectOptions {
20    /// Whether to collect all fields (long mode) or only the primary/default ones.
21    pub long: bool,
22}
23
24/// Comprehensive system information data structure.
25///
26/// This struct holds all the metrics collected from the system,
27/// ranging from OS details to hardware specs and network status.
28#[derive(Debug)]
29pub struct SystemInfo {
30    /// Operating system name and version.
31    pub os: String,
32    /// Kernel version.
33    pub kernel: Option<String>,
34    /// System hostname.
35    pub hostname: Option<String>,
36    /// CPU architecture (e.g., x86_64).
37    pub arch: String,
38    /// CPU model brand string.
39    pub cpu: String,
40    /// Total number of logical CPU cores.
41    pub cpu_cores: usize,
42    /// Formatted memory usage (Used / Total).
43    pub memory: String,
44    /// Formatted swap usage (Used / Total).
45    pub swap: String,
46    /// System uptime formatted as a duration.
47    pub uptime: String,
48    /// Number of currently running processes.
49    pub processes: usize,
50    /// Load average (1, 5, 15 minutes).
51    pub load_avg: Option<String>,
52    /// List of mounted disks with usage information.
53    pub disks: Vec<String>,
54    /// Hardware component temperatures.
55    pub temps: Vec<String>,
56    /// Network interface statistics and status.
57    pub networks: Vec<String>,
58    /// System boot time in ISO 8601 format.
59    pub boot_time: String,
60    /// Battery status (currently placeholder for future feature).
61    pub battery: Option<String>,
62    /// Path to the current user's shell.
63    pub shell: Option<String>,
64    /// Name of the terminal emulator in use.
65    pub terminal: Option<String>,
66    /// Detected desktop environment or window manager.
67    pub desktop: Option<String>,
68    /// Current CPU frequency (formatted).
69    pub cpu_freq: Option<String>,
70    /// Number of interactive users (UID >= 1000).
71    pub users: usize,
72    /// List of detected GPUs with model names.
73    pub gpu: Vec<String>,
74    /// Total count of installed packages across supported managers.
75    pub packages: Option<usize>,
76    /// Name of the user running the process.
77    pub current_user: Option<String>,
78    /// Primary local IP address.
79    pub local_ip: Option<String>,
80    /// Public IP address (best effort).
81    pub public_ip: Option<String>,
82    /// Name of the active/default network interface.
83    pub active_interface: Option<String>,
84    /// Detected motherboard name and manufacturer.
85    pub motherboard: Option<String>,
86    /// Detected BIOS details.
87    pub bios: Option<String>,
88    /// List of connected display resolutions and refresh rates.
89    pub displays: Vec<String>,
90    /// Detected active audio driver/server and devices.
91    pub audio: Option<String>,
92    /// Connected Wi-Fi SSID and speed.
93    pub wifi: Option<String>,
94    /// Bluetooth power status.
95    pub bluetooth: Option<String>,
96    /// UI Theme (GTK, Qt, macOS, Windows).
97    pub ui_theme: Option<String>,
98    /// Icon theme (GTK/Qt).
99    pub icons: Option<String>,
100    /// Cursor theme (GTK/Qt).
101    pub cursor: Option<String>,
102    /// System Font.
103    pub font: Option<String>,
104    /// Terminal Font (configured in terminal emulator).
105    pub terminal_font: Option<String>,
106    /// Connected camera/webcam names.
107    pub camera: Vec<String>,
108    /// Connected gamepad/controller names.
109    pub gamepad: Vec<String>,
110}
111
112impl SystemInfo {
113    /// Collects system information using sysinfo and environment probes.
114    ///
115    /// This method aggregates data from the operating system, hardware,
116    /// and current user environment into a `SystemInfo` struct.
117    pub fn collect(opts: CollectOptions) -> anyhow::Result<Self> {
118        let mut sys = System::new_all();
119        sys.refresh_all();
120
121        let os = System::long_os_version()
122            .or_else(System::name)
123            .unwrap_or_else(|| "Unknown".to_string());
124
125        let kernel = System::kernel_version();
126        let hostname = System::host_name();
127
128        let cpu = sys
129            .cpus()
130            .first()
131            .map(|c| c.brand().to_string())
132            .unwrap_or_else(|| "Unknown CPU".to_string());
133
134        let cpu_cores = sys.cpus().len();
135
136        let total_mem = sys.total_memory() as f64 / 1024.0 / 1024.0 / 1024.0;
137        let used_mem = sys.used_memory() as f64 / 1024.0 / 1024.0 / 1024.0;
138        let memory = format!("{:.1} / {:.1} GB", used_mem, total_mem);
139
140        let total_swap = sys.total_swap() as f64 / 1024.0 / 1024.0 / 1024.0;
141        let used_swap = sys.used_swap() as f64 / 1024.0 / 1024.0 / 1024.0;
142        let swap = if total_swap > 0.0 {
143            format!("{:.1} / {:.1} GB", used_swap, total_swap)
144        } else {
145            "No swap".to_string()
146        };
147
148        let uptime = format!("{}s", System::uptime());
149
150        let disks_list = Disks::new_with_refreshed_list();
151        let disks: Vec<String> = if !opts.long {
152            let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/"));
153            let mut best_match: Option<&sysinfo::Disk> = None;
154            for disk in disks_list.iter() {
155                if home.starts_with(disk.mount_point()) {
156                    if let Some(best) = best_match {
157                        if disk.mount_point().components().count()
158                            > best.mount_point().components().count()
159                        {
160                            best_match = Some(disk);
161                        }
162                    } else {
163                        best_match = Some(disk);
164                    }
165                }
166            }
167            let selected_disks = if let Some(best) = best_match {
168                vec![best]
169            } else {
170                disks_list.iter().collect::<Vec<_>>()
171            };
172
173            selected_disks
174                .iter()
175                .map(|d| {
176                    let total = d.total_space() as f64 / 1024.0 / 1024.0 / 1024.0;
177                    let avail = d.available_space() as f64 / 1024.0 / 1024.0 / 1024.0;
178                    let fs = d.file_system().to_string_lossy();
179                    format!(
180                        "{} ({}): {:.1} GB free / {:.1} GB",
181                        d.mount_point().display(),
182                        fs,
183                        avail,
184                        total
185                    )
186                })
187                .collect()
188        } else {
189            disks_list
190                .iter()
191                .map(|d| {
192                    let total = d.total_space() as f64 / 1024.0 / 1024.0 / 1024.0;
193                    let avail = d.available_space() as f64 / 1024.0 / 1024.0 / 1024.0;
194                    let fs = d.file_system().to_string_lossy();
195                    format!(
196                        "{} ({}): {:.1} GB free / {:.1} GB",
197                        d.mount_point().display(),
198                        fs,
199                        avail,
200                        total
201                    )
202                })
203                .collect()
204        };
205
206        let battery = crate::battery::get_battery_info().map(|bat| {
207            let pct = bat.percentage;
208            let state = match bat.state {
209                crate::battery::BatteryState::Charging => "charging",
210                crate::battery::BatteryState::Discharging => "discharging",
211                crate::battery::BatteryState::Full => "full",
212                _ => "not charging",
213            };
214            let vendor = bat.vendor;
215            let model = bat.model;
216
217            // Format time remaining as "Xh Ym" or "Xd Yh"
218            let time_str = match bat.state {
219                crate::battery::BatteryState::Charging => bat.time_remaining.map(|d| {
220                    let total_mins = d.as_secs() / 60;
221                    let hours = total_mins / 60;
222                    let mins = total_mins % 60;
223                    if hours >= 24 {
224                        let days = hours / 24;
225                        let rem_hours = hours % 24;
226                        format!("{}d {}h until full", days, rem_hours)
227                    } else if hours > 0 {
228                        format!("{}h {}m until full", hours, mins)
229                    } else {
230                        format!("{}m until full", mins)
231                    }
232                }),
233                crate::battery::BatteryState::Discharging => bat.time_remaining.map(|d| {
234                    let total_mins = d.as_secs() / 60;
235                    let hours = total_mins / 60;
236                    let mins = total_mins % 60;
237                    if hours >= 24 {
238                        let days = hours / 24;
239                        let rem_hours = hours % 24;
240                        format!("{}d {}h remaining", days, rem_hours)
241                    } else if hours > 0 {
242                        format!("{}h {}m remaining", hours, mins)
243                    } else {
244                        format!("{}m remaining", mins)
245                    }
246                }),
247                _ => None,
248            };
249
250            let mut parts = vec![state.to_string()];
251            if let Some(t) = time_str {
252                parts.insert(0, t);
253            }
254            if let Some(health) = bat.health {
255                if health < 99.0 {
256                    parts.push(format!("{:.0}% health", health));
257                }
258            }
259
260            let base = format!("{:.0}% ({})", pct, parts.join(", "));
261
262            match (vendor, model) {
263                (Some(v), Some(m)) => format!("{} [{} {}]", base, v, m),
264                (Some(v), None) => format!("{} [{}]", base, v),
265                _ => base,
266            }
267        });
268
269        let arch = System::cpu_arch();
270
271        let processes = sys.processes().len();
272
273        let load_avg = {
274            let avg = System::load_average();
275            if avg.one > 0.0 || avg.five > 0.0 {
276                Some(format!(
277                    "{:.2}, {:.2}, {:.2}",
278                    avg.one, avg.five, avg.fifteen
279                ))
280            } else {
281                None
282            }
283        };
284
285        // Compute slow system queries concurrently in parallel threads
286        let (
287            gpu,
288            packages,
289            public_ip,
290            (local_ip, active_interface),
291            motherboard,
292            bios,
293            displays,
294            audio,
295            wifi,
296            bluetooth,
297            (ui_theme, icons, cursor, font),
298            camera,
299            gamepad,
300        ) = std::thread::scope(|s| {
301            let gpu_handle = s.spawn(|| {
302                gpu::detect_gpus()
303                    .into_iter()
304                    .map(|g| g.format())
305                    .collect::<Vec<String>>()
306            });
307            let packages_handle = s.spawn(detect_packages);
308            let public_ip_handle = s.spawn(|| {
309                std::process::Command::new("curl")
310                    .args(["-s", "--max-time", "2", "https://api.ipify.org"])
311                    .output()
312                    .ok()
313                    .and_then(|o| String::from_utf8(o.stdout).ok())
314                    .map(|s| s.trim().to_string())
315                    .filter(|s| !s.is_empty())
316            });
317            let network_ips_handle = s.spawn(|| {
318                let local_ip = std::net::UdpSocket::bind("0.0.0.0:0")
319                    .ok()
320                    .and_then(|socket| {
321                        socket.connect("8.8.8.8:53").ok()?;
322                        socket.local_addr().ok().map(|addr| addr.ip().to_string())
323                    });
324
325                let active_interface = {
326                    #[cfg(target_os = "linux")]
327                    {
328                        let native_iface = std::fs::read_to_string("/proc/net/route")
329                            .ok()
330                            .and_then(|content| parse_proc_net_route(&content));
331
332                        native_iface.or_else(|| {
333                            std::process::Command::new("ip")
334                                .args(["route", "show", "default"])
335                                .output()
336                                .ok()
337                                .and_then(|o| String::from_utf8(o.stdout).ok())
338                                .and_then(|s| {
339                                    s.split_whitespace()
340                                        .position(|w| w == "dev")
341                                        .and_then(|i| s.split_whitespace().nth(i + 1))
342                                        .map(|s| s.to_string())
343                                })
344                        })
345                    }
346                    #[cfg(target_os = "macos")]
347                    {
348                        std::process::Command::new("route")
349                            .args(["-n", "get", "default"])
350                            .output()
351                            .ok()
352                            .and_then(|o| String::from_utf8(o.stdout).ok())
353                            .and_then(|s| {
354                                s.lines()
355                                    .find(|l| l.contains("interface:"))
356                                    .and_then(|l| l.split_whitespace().last())
357                                    .map(|s| s.to_string())
358                            })
359                    }
360                    #[cfg(target_os = "windows")]
361                    {
362                        std::process::Command::new("powershell")
363                            .args(["-Command", "Get-NetRoute -DestinationPrefix 0.0.0.0/0 | Select-Object -First 1 -ExpandProperty InterfaceAlias"])
364                            .output()
365                            .ok()
366                            .and_then(|o| String::from_utf8(o.stdout).ok())
367                            .map(|s| s.trim().to_string())
368                            .filter(|s| !s.is_empty())
369                    }
370                    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
371                    {
372                        None
373                    }
374                };
375
376                (local_ip, active_interface)
377            });
378            let motherboard_handle = s.spawn(detect_motherboard);
379            let bios_handle = s.spawn(detect_bios);
380            let displays_handle = s.spawn(detect_displays);
381            let audio_handle = s.spawn(|| detect_audio(&sys));
382            let wifi_handle = s.spawn(detect_wifi);
383            let bluetooth_handle = s.spawn(detect_bluetooth);
384            let ui_theme_and_fonts_handle = s.spawn(detect_ui_theme_and_fonts);
385            let camera_handle = s.spawn(detect_camera);
386            let gamepad_handle = s.spawn(detect_gamepad);
387
388            (
389                gpu_handle.join().unwrap_or_default(),
390                packages_handle.join().ok().flatten(),
391                public_ip_handle.join().ok().flatten(),
392                network_ips_handle.join().unwrap_or((None, None)),
393                motherboard_handle.join().ok().flatten(),
394                bios_handle.join().ok().flatten(),
395                displays_handle.join().unwrap_or_default(),
396                audio_handle.join().ok().flatten(),
397                wifi_handle.join().ok().flatten(),
398                bluetooth_handle.join().ok().flatten(),
399                ui_theme_and_fonts_handle
400                    .join()
401                    .unwrap_or((None, None, None, None)),
402                camera_handle.join().unwrap_or_default(),
403                gamepad_handle.join().unwrap_or_default(),
404            )
405        });
406
407        let mut temps: Vec<String> = Components::new_with_refreshed_list()
408            .iter()
409            .filter_map(|c| {
410                c.temperature().and_then(|t| {
411                    if t > 0.0 {
412                        Some(format!("{}: {:.0}°C", c.label(), t))
413                    } else {
414                        None
415                    }
416                })
417            })
418            .collect();
419
420        // Sort so CPU temperatures appear first
421        temps.sort_by(|a, b| {
422            let a_cpu = a.to_lowercase().contains("cpu") || a.to_lowercase().contains("core");
423            let b_cpu = b.to_lowercase().contains("cpu") || b.to_lowercase().contains("core");
424            b_cpu.cmp(&a_cpu)
425        });
426
427        let networks = Networks::new_with_refreshed_list()
428            .iter()
429            .map(|(name, data)| {
430                let rx = format_bytes(data.total_received());
431                let tx = format_bytes(data.total_transmitted());
432                let is_up = data.operational_state() == sysinfo::InterfaceOperationalState::Up
433                    || data.total_received() > 0
434                    || data.total_transmitted() > 0;
435                let status = if is_up {
436                    "Up".green().to_string()
437                } else {
438                    "Down".red().to_string()
439                };
440
441                let mut ipv4_addresses = Vec::new();
442                let mut ipv6_addresses = Vec::new();
443
444                if is_up {
445                    for ip_net in data.ip_networks() {
446                        let ip = ip_net.addr;
447                        let name_lower = name.to_lowercase();
448                        let is_loopback_iface =
449                            name_lower.starts_with("lo") || name_lower.contains("loopback");
450                        if ip.is_loopback() && !is_loopback_iface {
451                            continue;
452                        }
453                        match ip {
454                            std::net::IpAddr::V4(v4) => {
455                                ipv4_addresses.push(v4.to_string());
456                            }
457                            std::net::IpAddr::V6(v6) => {
458                                if !v6.is_unicast_link_local() {
459                                    ipv6_addresses.push(v6.to_string());
460                                }
461                            }
462                        }
463                    }
464
465                    // Fallback to active interface UDP-resolved local IP if no IPs detected by sysinfo
466                    if ipv4_addresses.is_empty() && ipv6_addresses.is_empty() {
467                        if let (Some(ref active), Some(ref ip)) = (&active_interface, &local_ip) {
468                            if name == active {
469                                ipv4_addresses.push(ip.clone());
470                            }
471                        }
472                    }
473                }
474
475                let ip_str = if !ipv4_addresses.is_empty() || !ipv6_addresses.is_empty() {
476                    let mut combined = Vec::new();
477                    if !ipv4_addresses.is_empty() {
478                        combined.push(ipv4_addresses.join(", "));
479                    }
480                    if !ipv6_addresses.is_empty() {
481                        combined.push(ipv6_addresses.join(", "));
482                    }
483                    format!(" ({})", combined.join(", "))
484                } else {
485                    String::new()
486                };
487
488                format!("{}{} [{}] RX: {} TX: {}", name, ip_str, status, rx, tx)
489            })
490            .collect();
491
492        let boot_timestamp = System::boot_time();
493        let boot_dt = chrono::Local
494            .timestamp_opt(boot_timestamp as i64, 0)
495            .single()
496            .map(|dt| dt.format("%Y-%m-%dT%H:%M:%S%:z").to_string())
497            .unwrap_or_else(|| boot_timestamp.to_string());
498        let boot_time = boot_dt;
499
500        // Environment-based info
501        let shell = detect_shell(&sys);
502        let terminal = detect_terminal(&sys);
503        let terminal_font = detect_terminal_font(terminal.as_deref());
504        let desktop = std::env::var("XDG_CURRENT_DESKTOP")
505            .or_else(|_| std::env::var("DESKTOP_SESSION"))
506            .ok();
507
508        // CPU frequency (first CPU)
509        let cpu_freq = sys
510            .cpus()
511            .first()
512            .map(|c| format!("{:.2} GHz", c.frequency() as f64 / 1000.0));
513
514        // Current logged in user
515        let current_user = std::env::var("USER").ok();
516
517        // Number of interactive users (UID >= 1000, excluding system accounts)
518        let users = Users::new_with_refreshed_list()
519            .iter()
520            .filter(|user| {
521                // UID is exposed via Display
522                let uid_str = user.id().to_string();
523                if let Ok(uid) = uid_str.parse::<u32>() {
524                    uid >= 1000
525                } else {
526                    false
527                }
528            })
529            .count();
530
531        Ok(Self {
532            os,
533            kernel,
534            hostname,
535            arch,
536            cpu,
537            cpu_cores,
538            memory,
539            swap,
540            uptime,
541            processes,
542            load_avg,
543            disks,
544            temps,
545            networks,
546            boot_time,
547            battery,
548            shell,
549            terminal,
550            desktop,
551            cpu_freq,
552            users,
553            gpu,
554            packages,
555            current_user,
556            local_ip,
557            public_ip,
558            active_interface,
559            motherboard,
560            bios,
561            displays,
562            audio,
563            wifi,
564            bluetooth,
565            ui_theme,
566            icons,
567            cursor,
568            font,
569            terminal_font,
570            camera,
571            gamepad,
572        })
573    }
574}
575
576#[cfg(any(target_os = "linux", test))]
577fn parse_proc_net_route(content: &str) -> Option<String> {
578    for line in content.lines().skip(1) {
579        let parts: Vec<&str> = line.split_whitespace().collect();
580        if parts.len() >= 8 {
581            let dest = parts[1];
582            let mask = parts[7];
583            if dest == "00000000" && mask == "00000000" {
584                return Some(parts[0].to_string());
585            }
586        }
587    }
588    None
589}
590
591/// Count installed packages by inspecting package manager databases directly.
592///
593/// Supports Pacman (Arch), Dpkg (Debian), XBPS (Void), RPM (Fedora/RHEL) on Linux,
594/// and Homebrew (Formulae and Casks) and MacPorts on macOS.
595fn detect_packages() -> Option<usize> {
596    #[cfg(target_os = "macos")]
597    {
598        let mut count = 0;
599
600        // Homebrew Cellar (Formulae)
601        for cellar_path in &["/opt/homebrew/Cellar", "/usr/local/Cellar"] {
602            if let Ok(entries) = std::fs::read_dir(cellar_path) {
603                count += entries.filter_map(|e| e.ok()).count();
604            }
605        }
606
607        // Homebrew Caskroom (Casks)
608        for cask_path in &["/opt/homebrew/Caskroom", "/usr/local/Caskroom"] {
609            if let Ok(entries) = std::fs::read_dir(cask_path) {
610                count += entries.filter_map(|e| e.ok()).count();
611            }
612        }
613
614        // MacPorts
615        if let Ok(entries) = std::fs::read_dir("/opt/local/var/macports/software") {
616            count += entries.filter_map(|e| e.ok()).count();
617        }
618
619        if count > 0 {
620            Some(count)
621        } else {
622            None
623        }
624    }
625
626    #[cfg(target_os = "windows")]
627    {
628        let mut count = 0;
629
630        // Scoop
631        if let Some(home) = dirs::home_dir() {
632            let scoop_dir = std::env::var("SCOOP")
633                .map(std::path::PathBuf::from)
634                .unwrap_or_else(|_| home.join("scoop"));
635            let scoop_apps = scoop_dir.join("apps");
636            if let Ok(entries) = std::fs::read_dir(scoop_apps) {
637                count += entries.filter_map(|e| e.ok()).count();
638            }
639        }
640
641        // Chocolatey
642        let choco_install = std::env::var("ChocolateyInstall")
643            .unwrap_or_else(|_| "C:\\ProgramData\\chocolatey".to_string());
644        let choco_lib = std::path::Path::new(&choco_install).join("lib");
645        if let Ok(entries) = std::fs::read_dir(choco_lib) {
646            count += entries.filter_map(|e| e.ok()).count();
647        }
648
649        if count > 0 {
650            Some(count)
651        } else {
652            None
653        }
654    }
655
656    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
657    {
658        // Arch / Manjaro
659        if let Ok(entries) = std::fs::read_dir("/var/lib/pacman/local") {
660            let count = entries.filter_map(|e| e.ok()).count();
661            if count > 0 {
662                return Some(count);
663            }
664        }
665
666        // Debian / Ubuntu
667        if let Ok(entries) = std::fs::read_dir("/var/lib/dpkg/info") {
668            let count = entries
669                .filter_map(|e| e.ok())
670                .filter(|e| e.path().extension().is_some_and(|ext| ext == "list"))
671                .count();
672            if count > 0 {
673                return Some(count);
674            }
675        }
676
677        // Gentoo
678        if let Ok(entries) = std::fs::read_dir("/var/db/pkg") {
679            let count: usize = entries
680                .filter_map(|e| e.ok())
681                .map(|e| {
682                    std::fs::read_dir(e.path())
683                        .map(|d| d.filter(|_| true).count())
684                        .unwrap_or(0)
685                })
686                .sum();
687            if count > 0 {
688                return Some(count);
689            }
690        }
691
692        // Void Linux
693        if let Ok(entries) = std::fs::read_dir("/var/db/xbps") {
694            let count = entries
695                .filter_map(|e| e.ok())
696                .filter(|e| e.path().extension().is_some_and(|ext| ext == "plist"))
697                .count();
698            if count > 0 {
699                return Some(count);
700            }
701        }
702
703        // Fedora / RHEL / openSUSE - read from RPM SQLite database
704        let rpm_db = "/var/lib/rpm/rpmdb.sqlite";
705        if std::path::Path::new(rpm_db).exists() {
706            match rusqlite::Connection::open(rpm_db) {
707                Ok(conn) => {
708                    if let Ok(count) = conn.query_row("SELECT COUNT(*) FROM Packages", [], |row| {
709                        row.get::<_, i64>(0)
710                    }) {
711                        if count > 0 {
712                            return Some(count as usize);
713                        }
714                    }
715                }
716                Err(e) => {
717                    eprintln!("warning: failed to open RPM database at {}: {}", rpm_db, e);
718                }
719            }
720        }
721
722        None
723    }
724}
725
726fn detect_wifi() -> Option<String> {
727    #[cfg(target_os = "linux")]
728    {
729        let mut wifi_interface = None;
730        if let Ok(entries) = std::fs::read_dir("/sys/class/net") {
731            for entry in entries.filter_map(|e| e.ok()) {
732                let path = entry.path();
733                if path.join("wireless").exists() || path.join("phy80211").exists() {
734                    wifi_interface = Some(entry.file_name().to_string_lossy().to_string());
735                    break;
736                }
737            }
738        }
739
740        if let Some(ref iface) = wifi_interface {
741            if let Ok(output) = std::process::Command::new("iw")
742                .args(["dev", iface, "link"])
743                .output()
744            {
745                if let Ok(stdout) = String::from_utf8(output.stdout) {
746                    let (ssid, links) = parse_iw_link_output(&stdout);
747                    if let Some(s) = ssid {
748                        let card_model = get_wifi_card_model(iface);
749                        let prefix = if let Some(m) = card_model {
750                            format!("{} [{}] - ", m, iface)
751                        } else {
752                            format!("[{}] - ", iface)
753                        };
754
755                        if !links.is_empty() {
756                            let mut link_strs = Vec::new();
757                            for link in links {
758                                let freq_str = link.freq.map(|f| {
759                                    let ghz_mhz = if f >= 1000.0 {
760                                        format!("{:.1} GHz", f / 1000.0)
761                                    } else {
762                                        format!("{} MHz", f)
763                                    };
764                                    if let Some(ch) = freq_to_channel(f) {
765                                        format!("{} ch{}", ghz_mhz, ch)
766                                    } else {
767                                        ghz_mhz
768                                    }
769                                });
770
771                                let mut rx_tx = Vec::new();
772                                if let Some(rx) = link.rx_rate {
773                                    if rx != "0"
774                                        && !rx.starts_with("0 ")
775                                        && rx != "0 Mbps"
776                                        && rx != "0 MBit/s"
777                                    {
778                                        rx_tx.push(format!("↓{}", clean_rate(&rx)));
779                                    }
780                                }
781                                if let Some(tx) = link.tx_rate {
782                                    if tx != "0"
783                                        && !tx.starts_with("0 ")
784                                        && tx != "0 Mbps"
785                                        && tx != "0 MBit/s"
786                                    {
787                                        rx_tx.push(format!("↑{}", clean_rate(&tx)));
788                                    }
789                                }
790
791                                match (freq_str, rx_tx.is_empty()) {
792                                    (Some(f), false) => {
793                                        link_strs.push(format!("{} [{}]", f, rx_tx.join(" ")))
794                                    }
795                                    (Some(f), true) => link_strs.push(f),
796                                    (None, false) => link_strs.push(rx_tx.join(" ")),
797                                    _ => {}
798                                }
799                            }
800                            if !link_strs.is_empty() {
801                                return Some(format!(
802                                    "{}{}{} ({})",
803                                    prefix,
804                                    s,
805                                    "",
806                                    link_strs.join(", ")
807                                ));
808                            } else {
809                                return Some(format!("{}{}", prefix, s));
810                            }
811                        }
812                        return Some(format!("{}{}", prefix, s));
813                    }
814                }
815            }
816        }
817
818        // Fallback to nmcli
819        if let Ok(output) = std::process::Command::new("nmcli")
820            .args(["-t", "-f", "active,ssid,rate", "dev", "wifi"])
821            .output()
822        {
823            if let Ok(stdout) = String::from_utf8(output.stdout) {
824                for line in stdout.lines() {
825                    let line = line.trim();
826                    if let Some(rest) = line.strip_prefix("yes:") {
827                        if let Some(colon_idx) = rest.rfind(':') {
828                            let ssid = &rest[..colon_idx];
829                            let rate = rest[colon_idx + 1..].trim();
830                            if !ssid.is_empty() {
831                                if !rate.is_empty()
832                                    && rate != "0"
833                                    && !rate.starts_with("0 ")
834                                    && rate != "0 Mbit/s"
835                                    && rate != "0 Mbps"
836                                {
837                                    return Some(format!("{} ({})", ssid, clean_rate(rate)));
838                                } else {
839                                    return Some(ssid.to_string());
840                                }
841                            }
842                        } else if !rest.is_empty() {
843                            return Some(rest.to_string());
844                        }
845                    }
846                }
847            }
848        }
849
850        // Fallback to iwgetid
851        if let Ok(output) = std::process::Command::new("iwgetid").arg("-r").output() {
852            if let Ok(stdout) = String::from_utf8(output.stdout) {
853                let ssid = stdout.trim();
854                if !ssid.is_empty() {
855                    return Some(ssid.to_string());
856                }
857            }
858        }
859        None
860    }
861
862    #[cfg(target_os = "macos")]
863    {
864        if let Ok(output) = std::process::Command::new("/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport")
865            .arg("-I")
866            .output()
867        {
868            if let Ok(stdout) = String::from_utf8(output.stdout) {
869                return parse_airport_output(&stdout);
870            }
871        }
872        None
873    }
874
875    #[cfg(target_os = "windows")]
876    {
877        if let Ok(output) = std::process::Command::new("netsh")
878            .args(["wlan", "show", "interfaces"])
879            .output()
880        {
881            if let Ok(stdout) = String::from_utf8(output.stdout) {
882                return parse_netsh_output(&stdout);
883            }
884        }
885        None
886    }
887
888    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
889    {
890        None
891    }
892}
893
894#[allow(
895    clippy::manual_is_multiple_of,
896    clippy::manual_range_contains,
897    dead_code
898)]
899fn freq_to_channel(freq_mhz: f64) -> Option<u32> {
900    let freq = freq_mhz.round() as u32;
901    if freq >= 2412 && freq <= 2472 {
902        Some((freq - 2407) / 5)
903    } else if freq == 2484 {
904        Some(14)
905    } else if freq >= 5160 && freq <= 5885 {
906        if (freq - 5000) % 5 == 0 {
907            Some((freq - 5000) / 5)
908        } else {
909            None
910        }
911    } else if freq >= 5955 && freq <= 7115 {
912        if (freq - 5950) % 5 == 0 {
913            Some((freq - 5950) / 5)
914        } else {
915            None
916        }
917    } else {
918        None
919    }
920}
921
922#[allow(dead_code)]
923fn lookup_pci_vendor(vendor_id: &str) -> Option<String> {
924    let vendor_id = vendor_id.trim_start_matches("0x").to_lowercase();
925    let paths = ["/usr/share/hwdata/pci.ids", "/usr/share/misc/pci.ids"];
926    for path in &paths {
927        if let Ok(content) = std::fs::read_to_string(path) {
928            for line in content.lines() {
929                if line.starts_with('#') || line.is_empty() {
930                    continue;
931                }
932                if !line.starts_with('\t') {
933                    let parts: Vec<&str> = line.split_whitespace().collect();
934                    if parts.len() >= 2 && parts[0].to_lowercase() == vendor_id {
935                        let name = line.strip_prefix(parts[0]).unwrap().trim();
936                        return Some(name.to_string());
937                    }
938                }
939            }
940        }
941    }
942    None
943}
944
945#[allow(dead_code)]
946fn get_wifi_card_model(iface: &str) -> Option<String> {
947    let vendor = std::fs::read_to_string(format!("/sys/class/net/{}/device/vendor", iface)).ok()?;
948    let device = std::fs::read_to_string(format!("/sys/class/net/{}/device/device", iface)).ok()?;
949    let vendor_clean = vendor.trim().trim_start_matches("0x").to_lowercase();
950    let device_clean = device.trim().trim_start_matches("0x").to_lowercase();
951
952    let vendor_name = lookup_pci_vendor(&vendor_clean);
953    let model_name = crate::gpu::lookup_pci_device(&vendor_clean, &device_clean);
954
955    match (vendor_name, model_name) {
956        (Some(v), Some(m)) => {
957            let v_clean = v.replace(", Inc.", "").replace(" Corporation", "");
958            if m.to_lowercase().contains(&v_clean.to_lowercase())
959                || m.to_lowercase().contains(
960                    &v_clean
961                        .split_whitespace()
962                        .next()
963                        .unwrap_or("")
964                        .to_lowercase(),
965                )
966            {
967                Some(m)
968            } else {
969                Some(format!("{} {}", v_clean, m))
970            }
971        }
972        (None, Some(m)) => Some(m),
973        _ => None,
974    }
975}
976
977#[allow(dead_code)]
978fn clean_rate(rate: &str) -> String {
979    rate.replace("MBit/s", "Mbps")
980        .replace("GBit/s", "Gbps")
981        .replace("Bit/s", "bps")
982}
983
984#[derive(Debug, Clone)]
985struct WifiLink {
986    freq: Option<f64>,
987    rx_rate: Option<String>,
988    tx_rate: Option<String>,
989}
990
991#[allow(dead_code)]
992fn parse_iw_link_output(stdout: &str) -> (Option<String>, Vec<WifiLink>) {
993    let mut ssid = None;
994    let mut links = Vec::new();
995    let mut current_link = None;
996
997    for line in stdout.lines() {
998        let trimmed = line.trim();
999        if trimmed.starts_with("Connected to") || trimmed.starts_with("link") {
1000            if let Some(link) = current_link.take() {
1001                links.push(link);
1002            }
1003            current_link = Some(WifiLink {
1004                freq: None,
1005                rx_rate: None,
1006                tx_rate: None,
1007            });
1008        } else if trimmed.starts_with("SSID:") {
1009            ssid = Some(trimmed.strip_prefix("SSID:").unwrap().trim().to_string());
1010        } else if trimmed.starts_with("freq:") {
1011            if let Some(ref mut link) = current_link {
1012                let freq_str = trimmed.strip_prefix("freq:").unwrap().trim();
1013                link.freq = freq_str.parse::<f64>().ok();
1014            }
1015        } else if trimmed.starts_with("rx bitrate:") {
1016            if let Some(ref mut link) = current_link {
1017                let rx_str = trimmed.strip_prefix("rx bitrate:").unwrap().trim();
1018                let rate = rx_str
1019                    .split_whitespace()
1020                    .take(2)
1021                    .collect::<Vec<&str>>()
1022                    .join(" ");
1023                link.rx_rate = Some(rate);
1024            }
1025        } else if trimmed.starts_with("tx bitrate:") {
1026            if let Some(ref mut link) = current_link {
1027                let tx_str = trimmed.strip_prefix("tx bitrate:").unwrap().trim();
1028                let rate = tx_str
1029                    .split_whitespace()
1030                    .take(2)
1031                    .collect::<Vec<&str>>()
1032                    .join(" ");
1033                link.tx_rate = Some(rate);
1034            }
1035        }
1036    }
1037    if let Some(link) = current_link {
1038        links.push(link);
1039    }
1040    (ssid, links)
1041}
1042
1043#[allow(dead_code)]
1044fn parse_airport_output(stdout: &str) -> Option<String> {
1045    let mut ssid = None;
1046    let mut rate = None;
1047    for line in stdout.lines() {
1048        let trimmed = line.trim();
1049        if trimmed.starts_with("SSID:") {
1050            let val = trimmed.strip_prefix("SSID:").unwrap().trim().to_string();
1051            if !val.is_empty() {
1052                ssid = Some(val);
1053            }
1054        } else if trimmed.starts_with("lastTxRate:") {
1055            let val = trimmed
1056                .strip_prefix("lastTxRate:")
1057                .unwrap()
1058                .trim()
1059                .to_string();
1060            if !val.is_empty() {
1061                rate = Some(val);
1062            }
1063        }
1064    }
1065    match (ssid, rate) {
1066        (Some(s), Some(r)) => {
1067            if r != "0" && !r.starts_with("0 ") && r != "0 Mbps" && r != "0 Mbit/s" {
1068                Some(format!("{} (↑{} Mbps)", s, r))
1069            } else {
1070                Some(s)
1071            }
1072        }
1073        (Some(s), None) => Some(s),
1074        _ => None,
1075    }
1076}
1077
1078#[allow(dead_code)]
1079fn parse_netsh_output(stdout: &str) -> Option<String> {
1080    let mut ssid = None;
1081    let mut rx = None;
1082    let mut tx = None;
1083    let mut band = None;
1084    for line in stdout.lines() {
1085        let trimmed = line.trim();
1086        if trimmed.starts_with("SSID") {
1087            if let Some(idx) = trimmed.find(':') {
1088                let val = trimmed[idx + 1..].trim().to_string();
1089                if !val.is_empty() {
1090                    ssid = Some(val);
1091                }
1092            }
1093        } else if trimmed.starts_with("Receive rate (Mbps)") {
1094            if let Some(idx) = trimmed.find(':') {
1095                let val = trimmed[idx + 1..].trim().to_string();
1096                if !val.is_empty() {
1097                    rx = Some(val);
1098                }
1099            }
1100        } else if trimmed.starts_with("Transmit rate (Mbps)") {
1101            if let Some(idx) = trimmed.find(':') {
1102                let val = trimmed[idx + 1..].trim().to_string();
1103                if !val.is_empty() {
1104                    tx = Some(val);
1105                }
1106            }
1107        } else if trimmed.starts_with("Band") {
1108            if let Some(idx) = trimmed.find(':') {
1109                let val = trimmed[idx + 1..].trim().to_string();
1110                if !val.is_empty() {
1111                    band = Some(val);
1112                }
1113            }
1114        }
1115    }
1116    if let Some(s) = ssid {
1117        let mut rate_strs = Vec::new();
1118        if let Some(rx_val) = rx {
1119            if rx_val != "0" {
1120                rate_strs.push(format!("↓{} Mbps", rx_val));
1121            }
1122        }
1123        if let Some(tx_val) = tx {
1124            if tx_val != "0" {
1125                rate_strs.push(format!("↑{} Mbps", tx_val));
1126            }
1127        }
1128        let info = match (band, rate_strs.is_empty()) {
1129            (Some(b), false) => format!("{} [{}]", b, rate_strs.join(" ")),
1130            (Some(b), true) => b,
1131            (None, false) => rate_strs.join(" "),
1132            _ => String::new(),
1133        };
1134        if !info.is_empty() {
1135            Some(format!("{} ({})", s, info))
1136        } else {
1137            Some(s)
1138        }
1139    } else {
1140        None
1141    }
1142}
1143
1144#[allow(dead_code)]
1145fn lookup_usb_vendor(vendor_id: &str) -> Option<String> {
1146    let vendor_id = vendor_id.trim_start_matches("0x").to_lowercase();
1147    let paths = ["/usr/share/hwdata/usb.ids", "/usr/share/misc/usb.ids"];
1148    for path in &paths {
1149        if let Ok(content) = std::fs::read_to_string(path) {
1150            for line in content.lines() {
1151                if line.starts_with('#') || line.is_empty() {
1152                    continue;
1153                }
1154                if !line.starts_with('\t') {
1155                    let parts: Vec<&str> = line.split_whitespace().collect();
1156                    if parts.len() >= 2 && parts[0].to_lowercase() == vendor_id {
1157                        let name = line.strip_prefix(parts[0]).unwrap().trim();
1158                        return Some(name.to_string());
1159                    }
1160                }
1161            }
1162        }
1163    }
1164    None
1165}
1166
1167#[allow(dead_code)]
1168fn lookup_usb_device(vendor_id: &str, product_id: &str) -> Option<String> {
1169    let vendor_id = vendor_id.trim_start_matches("0x").to_lowercase();
1170    let product_id = product_id.trim_start_matches("0x").to_lowercase();
1171    let paths = ["/usr/share/hwdata/usb.ids", "/usr/share/misc/usb.ids"];
1172    for path in &paths {
1173        if let Ok(content) = std::fs::read_to_string(path) {
1174            let mut in_vendor = false;
1175            for line in content.lines() {
1176                if line.starts_with('#') || line.is_empty() {
1177                    continue;
1178                }
1179                if !line.starts_with('\t') {
1180                    let parts: Vec<&str> = line.split_whitespace().collect();
1181                    in_vendor = parts.len() >= 2 && parts[0].to_lowercase() == vendor_id;
1182                } else if in_vendor && line.starts_with('\t') && !line.starts_with("\t\t") {
1183                    let trimmed = line.trim_start();
1184                    if let Some(stripped) = trimmed.strip_prefix(&product_id) {
1185                        let name = stripped.trim();
1186                        return Some(name.to_string());
1187                    }
1188                }
1189            }
1190        }
1191    }
1192    None
1193}
1194
1195#[allow(dead_code)]
1196fn parse_macos_bluetooth(stdout: &str) -> Option<String> {
1197    let mut state = "Off";
1198    let mut connected_names = Vec::new();
1199    let mut chipset = None;
1200    let mut current_device = None;
1201
1202    for line in stdout.lines() {
1203        let trimmed = line.trim();
1204        if trimmed.starts_with("Bluetooth Power:") || trimmed.starts_with("State:") {
1205            if trimmed.contains("On") {
1206                state = "On";
1207            }
1208        } else if trimmed.starts_with("Chipset:") {
1209            chipset = Some(trimmed.strip_prefix("Chipset:").unwrap().trim().to_string());
1210        } else if line.starts_with("          ") && !trimmed.is_empty() && trimmed.ends_with(':') {
1211            current_device = Some(trimmed.trim_end_matches(':').trim().to_string());
1212        } else if (trimmed.starts_with("Connected:") || trimmed.starts_with("Connection:"))
1213            && trimmed.contains("Yes")
1214        {
1215            if let Some(ref dev) = current_device {
1216                connected_names.push(dev.clone());
1217            }
1218        }
1219    }
1220
1221    let mut info_str = state.to_string();
1222    if let Some(ch) = chipset {
1223        info_str.push_str(&format!(" (Apple {})", ch));
1224    } else {
1225        info_str.push_str(" (Apple Bluetooth)");
1226    }
1227
1228    if state == "On" {
1229        info_str.push_str(&format!(" - {} connected", connected_names.len()));
1230        if !connected_names.is_empty() {
1231            info_str.push_str(&format!(" ({})", connected_names.join(", ")));
1232        }
1233    }
1234    Some(info_str)
1235}
1236
1237#[allow(dead_code)]
1238fn parse_windows_bluetooth_output(stdout: &str) -> Option<String> {
1239    let parts: Vec<&str> = stdout.trim().split('|').collect();
1240    if parts.len() < 3 {
1241        return None;
1242    }
1243    let status_str = parts[0].trim();
1244    let adapter_str = parts[1].trim();
1245    let devices_str = parts[2]
1246        .trim()
1247        .trim_start_matches('(')
1248        .trim_end_matches(')');
1249
1250    let state = if status_str.eq_ignore_ascii_case("running") {
1251        "On"
1252    } else {
1253        "Off"
1254    };
1255
1256    let mut info_str = state.to_string();
1257    if !adapter_str.is_empty() {
1258        info_str.push_str(&format!(" ({})", adapter_str));
1259    }
1260
1261    if state == "On" {
1262        let connected_names: Vec<String> = if devices_str.is_empty() {
1263            Vec::new()
1264        } else {
1265            devices_str
1266                .split(',')
1267                .map(|s| s.trim().to_string())
1268                .filter(|s| !s.is_empty())
1269                .collect()
1270        };
1271
1272        info_str.push_str(&format!(" - {} connected", connected_names.len()));
1273        if !connected_names.is_empty() {
1274            info_str.push_str(&format!(" ({})", connected_names.join(", ")));
1275        }
1276    }
1277    Some(info_str)
1278}
1279
1280fn detect_bluetooth() -> Option<String> {
1281    #[cfg(target_os = "linux")]
1282    {
1283        if let Ok(entries) = std::fs::read_dir("/sys/class/bluetooth") {
1284            let mut hcis = Vec::new();
1285            for entry in entries.filter_map(|e| e.ok()) {
1286                let name = entry.file_name().to_string_lossy().to_string();
1287                if name.starts_with("hci") {
1288                    hcis.push(name);
1289                }
1290            }
1291            hcis.sort();
1292
1293            if !hcis.is_empty() {
1294                let hci = &hcis[0];
1295                let mut state = "Off";
1296                if let Ok(subdirs) = std::fs::read_dir(format!("/sys/class/bluetooth/{}", hci)) {
1297                    for sub in subdirs.filter_map(|e| e.ok()) {
1298                        let sub_name = sub.file_name().to_string_lossy().to_string();
1299                        if sub_name.starts_with("rfkill") {
1300                            if let Ok(st) = std::fs::read_to_string(sub.path().join("state")) {
1301                                if st.trim() == "1" || st.trim() == "3" {
1302                                    state = "On";
1303                                }
1304                            }
1305                        }
1306                    }
1307                }
1308
1309                let mut hw_info = None;
1310                if let Ok(canonical_device) =
1311                    std::fs::canonicalize(format!("/sys/class/bluetooth/{}/device", hci))
1312                {
1313                    let mut current = Some(canonical_device);
1314                    while let Some(path) = current {
1315                        let id_vendor = path.join("idVendor");
1316                        let id_product = path.join("idProduct");
1317                        let pci_vendor = path.join("vendor");
1318                        let pci_device = path.join("device");
1319
1320                        if id_vendor.exists() && id_product.exists() {
1321                            if let (Ok(v), Ok(p)) = (
1322                                std::fs::read_to_string(id_vendor),
1323                                std::fs::read_to_string(id_product),
1324                            ) {
1325                                let v_clean = v.trim();
1326                                let p_clean = p.trim();
1327                                let vendor_name = lookup_usb_vendor(v_clean);
1328                                let product_name = lookup_usb_device(v_clean, p_clean);
1329                                match (vendor_name, product_name) {
1330                                    (Some(v_name), Some(p_name)) => {
1331                                        let v_disp = v_name
1332                                            .replace(", Inc.", "")
1333                                            .replace(" Corporation", "")
1334                                            .replace(" Co., Ltd.", "")
1335                                            .replace(" Co., Ltd", "");
1336                                        hw_info = Some(format!("{} {}", v_disp, p_name));
1337                                    }
1338                                    (Some(v_name), None) => {
1339                                        let v_disp = v_name
1340                                            .replace(", Inc.", "")
1341                                            .replace(" Corporation", "")
1342                                            .replace(" Co., Ltd.", "")
1343                                            .replace(" Co., Ltd", "");
1344                                        hw_info = Some(v_disp);
1345                                    }
1346                                    _ => {}
1347                                }
1348                                break;
1349                            }
1350                        } else if pci_vendor.exists()
1351                            && pci_device.exists()
1352                            && !pci_vendor.is_dir()
1353                            && !pci_device.is_dir()
1354                        {
1355                            if let (Ok(v), Ok(d)) = (
1356                                std::fs::read_to_string(pci_vendor),
1357                                std::fs::read_to_string(pci_device),
1358                            ) {
1359                                let v_clean = v.trim().trim_start_matches("0x").to_lowercase();
1360                                let d_clean = d.trim().trim_start_matches("0x").to_lowercase();
1361                                let vendor_name = lookup_pci_vendor(&v_clean);
1362                                let product_name =
1363                                    crate::gpu::lookup_pci_device(&v_clean, &d_clean);
1364                                match (vendor_name, product_name) {
1365                                    (Some(v_name), Some(p_name)) => {
1366                                        let v_disp = v_name
1367                                            .replace(", Inc.", "")
1368                                            .replace(" Corporation", "")
1369                                            .replace(" Co., Ltd.", "")
1370                                            .replace(" Co., Ltd", "");
1371                                        hw_info = Some(format!("{} {}", v_disp, p_name));
1372                                    }
1373                                    (Some(v_name), None) => {
1374                                        let v_disp = v_name
1375                                            .replace(", Inc.", "")
1376                                            .replace(" Corporation", "")
1377                                            .replace(" Co., Ltd.", "")
1378                                            .replace(" Co., Ltd", "");
1379                                        hw_info = Some(v_disp);
1380                                    }
1381                                    _ => {}
1382                                }
1383                                break;
1384                            }
1385                        }
1386                        current = path.parent().map(|p| p.to_path_buf());
1387                    }
1388                }
1389
1390                let mut connected_names = Vec::new();
1391                if let Ok(output) = std::process::Command::new("bluetoothctl")
1392                    .args(["devices", "Connected"])
1393                    .output()
1394                {
1395                    if let Ok(stdout) = String::from_utf8(output.stdout) {
1396                        for line in stdout.lines() {
1397                            let trimmed = line.trim();
1398                            if trimmed.starts_with("Device ") {
1399                                let parts: Vec<&str> = trimmed.split_whitespace().collect();
1400                                if parts.len() >= 3 {
1401                                    let name = parts[2..].join(" ");
1402                                    connected_names.push(name);
1403                                }
1404                            }
1405                        }
1406                    }
1407                }
1408                let mut info_str = state.to_string();
1409                info_str.push_str(&format!(" [{}]", hci));
1410                if let Some(hw) = hw_info {
1411                    info_str.push_str(&format!(" ({})", hw));
1412                }
1413
1414                if state == "On" {
1415                    info_str.push_str(&format!(" - {} connected", connected_names.len()));
1416                    if !connected_names.is_empty() {
1417                        info_str.push_str(&format!(" ({})", connected_names.join(", ")));
1418                    }
1419                }
1420
1421                return Some(info_str);
1422            }
1423        }
1424        None
1425    }
1426
1427    #[cfg(target_os = "macos")]
1428    {
1429        if let Ok(output) = std::process::Command::new("system_profiler")
1430            .arg("SPBluetoothDataType")
1431            .output()
1432        {
1433            if let Ok(stdout) = String::from_utf8(output.stdout) {
1434                return parse_macos_bluetooth(&stdout);
1435            }
1436        }
1437        None
1438    }
1439
1440    #[cfg(target_os = "windows")]
1441    {
1442        let cmd = "$state = (Get-Service -Name bthserv -ErrorAction SilentlyContinue).Status; \
1443                   $adapter = (Get-PnpDevice -Class Bluetooth -ErrorAction SilentlyContinue | Where-Object {$_.FriendlyName -match 'Adapter|Controller|Radio|Intel|Realtek|Broadcom'} | Select-Object -First 1 -ExpandProperty FriendlyName); \
1444                   $devices = (Get-PnpDevice -Class Bluetooth -Status OK -ErrorAction SilentlyContinue | Where-Object {$_.FriendlyName -notmatch 'Adapter|Enumerator|Controller|LE Device|RFCOMM|Module|Service|Generic|Computer|Protocol|Phone|Device'} | Select-Object -ExpandProperty FriendlyName); \
1445                   Write-Output \"$state|$adapter|($($devices -join ','))\"";
1446
1447        if let Ok(output) = std::process::Command::new("powershell")
1448            .args(["-Command", cmd])
1449            .output()
1450        {
1451            if let Ok(stdout) = String::from_utf8(output.stdout) {
1452                return parse_windows_bluetooth_output(&stdout);
1453            }
1454        }
1455        None
1456    }
1457
1458    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
1459    {
1460        None
1461    }
1462}
1463
1464#[allow(dead_code)]
1465fn is_real_camera(name: &str) -> bool {
1466    let name_lower = name.to_lowercase();
1467    !name_lower.contains("infrared")
1468        && !name_lower.contains("ir camera")
1469        && !name_lower.contains("integrated i")
1470        && !name_lower.contains("integrated ir")
1471        && !name_lower.contains("depth camera")
1472}
1473
1474#[allow(dead_code)]
1475fn clean_camera_name(name: &str) -> String {
1476    let trimmed = name.trim();
1477    if trimmed.starts_with("Integrated Camera:") {
1478        return "Integrated Camera".to_string();
1479    }
1480    if trimmed.starts_with("Integrated Webcam:") {
1481        return "Integrated Webcam".to_string();
1482    }
1483    trimmed.to_string()
1484}
1485
1486#[allow(dead_code)]
1487fn parse_macos_camera(stdout: &str) -> Vec<String> {
1488    let mut devices = Vec::new();
1489    let mut in_cameras = false;
1490    for line in stdout.lines() {
1491        let trimmed = line.trim();
1492        let indent = line.len() - line.trim_start().len();
1493        if trimmed.starts_with("Video Support:")
1494            || trimmed.starts_with("Camera:")
1495            || trimmed.starts_with("Cameras:")
1496        {
1497            in_cameras = true;
1498            continue;
1499        }
1500        if in_cameras {
1501            if indent < 4
1502                && !trimmed.is_empty()
1503                && !trimmed.starts_with("Camera")
1504                && !trimmed.starts_with("Video Support")
1505            {
1506                in_cameras = false;
1507                continue;
1508            }
1509            if (indent == 4 || indent == 6 || indent == 8) && trimmed.ends_with(':') {
1510                let name = trimmed.trim_end_matches(':').trim().to_string();
1511                if !name.is_empty() && is_real_camera(&name) {
1512                    let cleaned = clean_camera_name(&name);
1513                    if !devices.contains(&cleaned) {
1514                        devices.push(cleaned);
1515                    }
1516                }
1517            }
1518        }
1519    }
1520    devices
1521}
1522
1523#[allow(dead_code)]
1524fn parse_macos_gamepad(usb_stdout: &str, bt_stdout: &str) -> Vec<String> {
1525    let mut gamepads = Vec::new();
1526    let keywords = [
1527        "controller",
1528        "gamepad",
1529        "joystick",
1530        "xbox",
1531        "playstation",
1532        "dualshock",
1533        "dualsense",
1534        "nintendo",
1535        "joy-con",
1536        "joycon",
1537    ];
1538
1539    let is_gamepad = |name: &str| -> bool {
1540        let name_lower = name.to_lowercase();
1541        keywords.iter().any(|&kw| name_lower.contains(kw))
1542    };
1543
1544    // Parse USB
1545    for line in usb_stdout.lines() {
1546        let trimmed = line.trim();
1547        let indent = line.len() - line.trim_start().len();
1548        if (indent == 4 || indent == 6 || indent == 8) && trimmed.ends_with(':') {
1549            let name = trimmed.trim_end_matches(':').trim().to_string();
1550            if is_gamepad(&name) && !gamepads.contains(&name) {
1551                gamepads.push(name);
1552            }
1553        }
1554    }
1555
1556    // Parse Bluetooth
1557    let mut current_device = None;
1558    for line in bt_stdout.lines() {
1559        let trimmed = line.trim();
1560        let indent = line.len() - line.trim_start().len();
1561        if indent >= 8 && trimmed.ends_with(':') {
1562            current_device = Some(trimmed.trim_end_matches(':').trim().to_string());
1563        } else if trimmed.starts_with("Connected: Yes") || trimmed.starts_with("Connection: Yes") {
1564            if let Some(ref dev) = current_device {
1565                if is_gamepad(dev) && !gamepads.contains(dev) {
1566                    gamepads.push(dev.clone());
1567                }
1568            }
1569        }
1570    }
1571
1572    gamepads
1573}
1574
1575fn detect_camera() -> Vec<String> {
1576    #[cfg(target_os = "linux")]
1577    {
1578        let mut cameras = Vec::new();
1579        if let Ok(entries) = std::fs::read_dir("/sys/class/video4linux") {
1580            for entry in entries.filter_map(|e| e.ok()) {
1581                let path = entry.path().join("name");
1582                if path.exists() {
1583                    if let Ok(name) = std::fs::read_to_string(path) {
1584                        let trimmed = name.trim().to_string();
1585                        if !trimmed.is_empty() && is_real_camera(&trimmed) {
1586                            let cleaned = clean_camera_name(&trimmed);
1587                            if !cameras.contains(&cleaned) {
1588                                cameras.push(cleaned);
1589                            }
1590                        }
1591                    }
1592                }
1593            }
1594        }
1595        cameras
1596    }
1597
1598    #[cfg(target_os = "macos")]
1599    {
1600        if let Ok(output) = std::process::Command::new("system_profiler")
1601            .arg("SPCameraDataType")
1602            .output()
1603        {
1604            if let Ok(stdout) = String::from_utf8(output.stdout) {
1605                return parse_macos_camera(&stdout);
1606            }
1607        }
1608        Vec::new()
1609    }
1610
1611    #[cfg(target_os = "windows")]
1612    {
1613        let cmd = "Get-PnpDevice -Class Camera,Image -PresentOnly -ErrorAction SilentlyContinue | Where-Object { $_.Status -eq 'OK' } | Select-Object -ExpandProperty FriendlyName";
1614        if let Ok(output) = std::process::Command::new("powershell")
1615            .args(["-Command", cmd])
1616            .output()
1617        {
1618            if let Ok(stdout) = String::from_utf8(output.stdout) {
1619                let mut cameras = Vec::new();
1620                for line in stdout.lines() {
1621                    let trimmed = line.trim().to_string();
1622                    if !trimmed.is_empty() && is_real_camera(&trimmed) {
1623                        let cleaned = clean_camera_name(&trimmed);
1624                        if !cameras.contains(&cleaned) {
1625                            cameras.push(cleaned);
1626                        }
1627                    }
1628                }
1629                return cameras;
1630            }
1631        }
1632        Vec::new()
1633    }
1634
1635    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
1636    {
1637        Vec::new()
1638    }
1639}
1640
1641fn detect_gamepad() -> Vec<String> {
1642    #[cfg(target_os = "linux")]
1643    {
1644        let mut gamepads = Vec::new();
1645        if let Ok(entries) = std::fs::read_dir("/sys/class/input") {
1646            for entry in entries.filter_map(|e| e.ok()) {
1647                let name = entry.file_name().to_string_lossy().to_string();
1648                if name.starts_with("js") {
1649                    let path = entry.path().join("device/name");
1650                    if path.exists() {
1651                        if let Ok(dev_name) = std::fs::read_to_string(path) {
1652                            let trimmed = dev_name.trim().to_string();
1653                            if !trimmed.is_empty() && !gamepads.contains(&trimmed) {
1654                                gamepads.push(trimmed);
1655                            }
1656                        }
1657                    }
1658                }
1659            }
1660        }
1661        gamepads
1662    }
1663
1664    #[cfg(target_os = "macos")]
1665    {
1666        let usb_stdout = std::process::Command::new("system_profiler")
1667            .arg("SPUSBDataType")
1668            .output()
1669            .ok()
1670            .and_then(|o| String::from_utf8(o.stdout).ok())
1671            .unwrap_or_default();
1672
1673        let bt_stdout = std::process::Command::new("system_profiler")
1674            .arg("SPBluetoothDataType")
1675            .output()
1676            .ok()
1677            .and_then(|o| String::from_utf8(o.stdout).ok())
1678            .unwrap_or_default();
1679
1680        parse_macos_gamepad(&usb_stdout, &bt_stdout)
1681    }
1682
1683    #[cfg(target_os = "windows")]
1684    {
1685        let cmd = "Get-PnpDevice -PresentOnly -ErrorAction SilentlyContinue | Where-Object { $_.Class -eq 'HIDClass' -and ($_.HardwareID -match 'HID_DEVICE_SYSTEM_GAME' -or $_.HardwareID -match 'HID_DEVICE_GAME') -or $_.FriendlyName -match 'Xbox Controller|Gamepad|Joystick' } | Select-Object -ExpandProperty FriendlyName";
1686        if let Ok(output) = std::process::Command::new("powershell")
1687            .args(["-Command", cmd])
1688            .output()
1689        {
1690            if let Ok(stdout) = String::from_utf8(output.stdout) {
1691                let mut gamepads = Vec::new();
1692                for line in stdout.lines() {
1693                    let trimmed = line.trim().to_string();
1694                    if !trimmed.is_empty() && !gamepads.contains(&trimmed) {
1695                        gamepads.push(trimmed);
1696                    }
1697                }
1698                return gamepads;
1699            }
1700        }
1701        Vec::new()
1702    }
1703
1704    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
1705    {
1706        Vec::new()
1707    }
1708}
1709
1710fn detect_audio(_sys: &sysinfo::System) -> Option<String> {
1711    #[cfg(target_os = "linux")]
1712    {
1713        // 1. Detect audio server
1714        let mut server = None;
1715        for process in _sys.processes().values() {
1716            let name = process.name().to_string_lossy().to_lowercase();
1717            if name.contains("pipewire") {
1718                server = Some("PipeWire");
1719                break;
1720            } else if name.contains("pulseaudio") {
1721                server = Some("PulseAudio");
1722            }
1723        }
1724        let server_str = server.unwrap_or("ALSA");
1725
1726        // 2. Detect hardware cards
1727        let mut devices = Vec::new();
1728        if let Ok(content) = std::fs::read_to_string("/proc/asound/cards") {
1729            devices = parse_asound_cards(&content, "/proc/asound");
1730        }
1731
1732        if !devices.is_empty() {
1733            Some(format!("{} ({})", server_str, devices.join(", ")))
1734        } else {
1735            Some(server_str.to_string())
1736        }
1737    }
1738
1739    #[cfg(target_os = "macos")]
1740    {
1741        let mut devices = Vec::new();
1742        if let Ok(output) = std::process::Command::new("system_profiler")
1743            .arg("SPAudioDataType")
1744            .output()
1745        {
1746            if let Ok(stdout) = String::from_utf8(output.stdout) {
1747                let mut in_devices = false;
1748                for line in stdout.lines() {
1749                    let trimmed = line.trim();
1750                    let indent = line.len() - line.trim_start().len();
1751                    if trimmed.starts_with("Devices:") {
1752                        in_devices = true;
1753                        continue;
1754                    }
1755                    if in_devices {
1756                        if indent <= 4 && !trimmed.is_empty() && !trimmed.starts_with("Devices:") {
1757                            in_devices = false;
1758                            continue;
1759                        }
1760                        if indent == 8 && trimmed.ends_with(':') {
1761                            let name = trimmed.trim_end_matches(':').trim().to_string();
1762                            if !name.is_empty() && !devices.contains(&name) {
1763                                devices.push(name);
1764                            }
1765                        }
1766                    }
1767                }
1768            }
1769        }
1770
1771        if !devices.is_empty() {
1772            Some(format!("CoreAudio ({})", devices.join(", ")))
1773        } else {
1774            Some("CoreAudio".to_string())
1775        }
1776    }
1777
1778    #[cfg(target_os = "windows")]
1779    {
1780        let mut devices = Vec::new();
1781        if let Ok(output) = std::process::Command::new("wmic")
1782            .args(["path", "Win32_SoundDevice", "get", "Name", "/value"])
1783            .output()
1784        {
1785            if let Ok(stdout) = String::from_utf8(output.stdout) {
1786                for line in stdout.lines() {
1787                    let line = line.trim();
1788                    if line.starts_with("Name=") {
1789                        let name = line.strip_prefix("Name=").unwrap_or("").trim().to_string();
1790                        if !name.is_empty() && !devices.contains(&name) {
1791                            devices.push(name);
1792                        }
1793                    }
1794                }
1795            }
1796        }
1797
1798        if !devices.is_empty() {
1799            Some(format!("Windows Audio ({})", devices.join(", ")))
1800        } else {
1801            Some("Windows Audio".to_string())
1802        }
1803    }
1804
1805    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
1806    {
1807        None
1808    }
1809}
1810
1811#[allow(dead_code)]
1812fn parse_asound_cards(content: &str, asound_dir: &str) -> Vec<String> {
1813    let mut devices = Vec::new();
1814    if let Ok(entries) = std::fs::read_dir(asound_dir) {
1815        for entry in entries.filter_map(|e| e.ok()) {
1816            let path = entry.path();
1817            if path.is_dir() {
1818                let name = entry.file_name().to_string_lossy().to_string();
1819                if name.starts_with("card") {
1820                    if let Ok(sub_entries) = std::fs::read_dir(&path) {
1821                        for sub_entry in sub_entries.filter_map(|se| se.ok()) {
1822                            let sub_path = sub_entry.path();
1823                            let sub_name = sub_entry.file_name().to_string_lossy().to_string();
1824                            if sub_name.starts_with("codec#") {
1825                                if let Ok(codec_content) = std::fs::read_to_string(&sub_path) {
1826                                    for line in codec_content.lines() {
1827                                        if let Some(stripped) = line.strip_prefix("Codec: ") {
1828                                            let codec_name = stripped.trim().to_string();
1829                                            if !codec_name.is_empty()
1830                                                && !devices.contains(&codec_name)
1831                                            {
1832                                                devices.push(codec_name);
1833                                            }
1834                                        }
1835                                    }
1836                                }
1837                            }
1838                        }
1839                    }
1840                }
1841            }
1842        }
1843    }
1844
1845    if devices.is_empty() {
1846        for line in content.lines() {
1847            if let Some(idx) = line.find("]: ") {
1848                let desc = line[idx + 3..].trim();
1849                let device_name = if let Some(dash_idx) = desc.find(" - ") {
1850                    desc[dash_idx + 3..].trim()
1851                } else {
1852                    desc
1853                };
1854                if !device_name.is_empty() && !devices.contains(&device_name.to_string()) {
1855                    devices.push(device_name.to_string());
1856                }
1857            }
1858        }
1859    }
1860    devices
1861}
1862
1863fn detect_motherboard() -> Option<String> {
1864    #[cfg(target_os = "macos")]
1865    {
1866        extern "C" {
1867            fn sysctlbyname(
1868                name: *const i8,
1869                oldp: *mut std::ffi::c_void,
1870                oldlenp: *mut usize,
1871                newp: *mut std::ffi::c_void,
1872                newlen: usize,
1873            ) -> i32;
1874        }
1875
1876        let name_c = std::ffi::CString::new("hw.model").ok();
1877        let mut model_str = None;
1878
1879        if let Some(name) = name_c {
1880            let mut size: usize = 0;
1881            unsafe {
1882                sysctlbyname(
1883                    name.as_ptr(),
1884                    std::ptr::null_mut(),
1885                    &mut size,
1886                    std::ptr::null_mut(),
1887                    0,
1888                );
1889            }
1890            if size > 0 {
1891                let mut buf = vec![0u8; size];
1892                let res = unsafe {
1893                    sysctlbyname(
1894                        name.as_ptr(),
1895                        buf.as_mut_ptr() as *mut std::ffi::c_void,
1896                        &mut size,
1897                        std::ptr::null_mut(),
1898                        0,
1899                    )
1900                };
1901                if res == 0 {
1902                    if let Some(pos) = buf.iter().position(|&x| x == 0) {
1903                        buf.truncate(pos);
1904                    }
1905                    if let Ok(model) = String::from_utf8(buf) {
1906                        let trimmed = model.trim();
1907                        if !trimmed.is_empty() {
1908                            model_str = Some(trimmed.to_string());
1909                        }
1910                    }
1911                }
1912            }
1913        }
1914
1915        if model_str.is_some() {
1916            return model_str;
1917        }
1918
1919        if let Ok(output) = std::process::Command::new("sysctl")
1920            .args(["-n", "hw.model"])
1921            .output()
1922        {
1923            if let Ok(model) = String::from_utf8(output.stdout) {
1924                let trimmed = model.trim();
1925                if !trimmed.is_empty() {
1926                    return Some(trimmed.to_string());
1927                }
1928            }
1929        }
1930        None
1931    }
1932
1933    #[cfg(target_os = "windows")]
1934    {
1935        let manufacturer = win_reg::get_reg_string(
1936            win_reg::HKEY_LOCAL_MACHINE,
1937            "HARDWARE\\DESCRIPTION\\System\\BIOS",
1938            "BaseBoardManufacturer",
1939        )
1940        .unwrap_or_default();
1941        let product = win_reg::get_reg_string(
1942            win_reg::HKEY_LOCAL_MACHINE,
1943            "HARDWARE\\DESCRIPTION\\System\\BIOS",
1944            "BaseBoardProduct",
1945        )
1946        .unwrap_or_default();
1947
1948        let manufacturer = manufacturer.trim();
1949        let product = product.trim();
1950
1951        if !manufacturer.is_empty() && !product.is_empty() {
1952            return Some(format!("{} {}", manufacturer, product));
1953        } else if !product.is_empty() {
1954            return Some(product.to_string());
1955        } else if !manufacturer.is_empty() {
1956            return Some(manufacturer.to_string());
1957        }
1958
1959        if let Ok(output) = std::process::Command::new("wmic")
1960            .args(["baseboard", "get", "manufacturer,product", "/value"])
1961            .output()
1962        {
1963            if let Ok(stdout) = String::from_utf8(output.stdout) {
1964                let mut manufacturer = String::new();
1965                let mut product = String::new();
1966                for line in stdout.lines() {
1967                    let line = line.trim();
1968                    if line.starts_with("Manufacturer=") {
1969                        manufacturer = line
1970                            .strip_prefix("Manufacturer=")
1971                            .unwrap_or("")
1972                            .trim()
1973                            .to_string();
1974                    } else if line.starts_with("Product=") {
1975                        product = line
1976                            .strip_prefix("Product=")
1977                            .unwrap_or("")
1978                            .trim()
1979                            .to_string();
1980                    }
1981                }
1982                if !manufacturer.is_empty() && !product.is_empty() {
1983                    return Some(format!("{} {}", manufacturer, product));
1984                } else if !product.is_empty() {
1985                    return Some(product);
1986                } else if !manufacturer.is_empty() {
1987                    return Some(manufacturer);
1988                }
1989            }
1990        }
1991        None
1992    }
1993
1994    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
1995    {
1996        let vendor = std::fs::read_to_string("/sys/class/dmi/id/board_vendor")
1997            .map(|s| s.trim().to_string())
1998            .ok();
1999        let name = std::fs::read_to_string("/sys/class/dmi/id/board_name")
2000            .map(|s| s.trim().to_string())
2001            .ok();
2002
2003        match (vendor, name) {
2004            (Some(v), Some(n)) if !v.is_empty() && !n.is_empty() => {
2005                if n.starts_with(&v) {
2006                    Some(n)
2007                } else {
2008                    Some(format!("{} {}", v, n))
2009                }
2010            }
2011            (Some(v), _) if !v.is_empty() => Some(v),
2012            (_, Some(n)) if !n.is_empty() => Some(n),
2013            _ => None,
2014        }
2015    }
2016}
2017
2018fn detect_bios() -> Option<String> {
2019    #[cfg(target_os = "macos")]
2020    {
2021        if let Ok(output) = std::process::Command::new("system_profiler")
2022            .arg("SPHardwareDataType")
2023            .output()
2024        {
2025            if let Ok(stdout) = String::from_utf8(output.stdout) {
2026                for line in stdout.lines() {
2027                    let line = line.trim();
2028                    if line.starts_with("System Firmware Version:")
2029                        || line.starts_with("Boot ROM Version:")
2030                    {
2031                        if let Some(val) = line.split(':').nth(1) {
2032                            return Some(val.trim().to_string());
2033                        }
2034                    }
2035                }
2036            }
2037        }
2038        None
2039    }
2040
2041    #[cfg(target_os = "windows")]
2042    {
2043        let manufacturer = win_reg::get_reg_string(
2044            win_reg::HKEY_LOCAL_MACHINE,
2045            "HARDWARE\\DESCRIPTION\\System\\BIOS",
2046            "BIOSVendor",
2047        )
2048        .unwrap_or_default();
2049        let version = win_reg::get_reg_string(
2050            win_reg::HKEY_LOCAL_MACHINE,
2051            "HARDWARE\\DESCRIPTION\\System\\BIOS",
2052            "BIOSVersion",
2053        )
2054        .unwrap_or_default();
2055        let raw_date = win_reg::get_reg_string(
2056            win_reg::HKEY_LOCAL_MACHINE,
2057            "HARDWARE\\DESCRIPTION\\System\\BIOS",
2058            "BIOSReleaseDate",
2059        )
2060        .unwrap_or_default();
2061
2062        let manufacturer = manufacturer.trim();
2063        let version = version.trim();
2064        let raw_date = raw_date.trim();
2065
2066        let date = if raw_date.len() >= 8 {
2067            let year = &raw_date[0..4];
2068            let month = &raw_date[4..6];
2069            let day = &raw_date[6..8];
2070            format!("{}/{}/{}", month, day, year)
2071        } else {
2072            raw_date.to_string()
2073        };
2074
2075        let mut parts = Vec::new();
2076        if !manufacturer.is_empty() {
2077            parts.push(manufacturer.to_string());
2078        }
2079        if !version.is_empty() {
2080            parts.push(version.to_string());
2081        }
2082        let mut res = parts.join(" ");
2083        if !date.is_empty() {
2084            if res.is_empty() {
2085                res = date;
2086            } else {
2087                res = format!("{} ({})", res, date);
2088            }
2089        }
2090        if !res.is_empty() {
2091            return Some(res);
2092        }
2093
2094        if let Ok(output) = std::process::Command::new("wmic")
2095            .args([
2096                "bios",
2097                "get",
2098                "manufacturer,smbiosbiosversion,releasedate",
2099                "/value",
2100            ])
2101            .output()
2102        {
2103            if let Ok(stdout) = String::from_utf8(output.stdout) {
2104                let mut manufacturer = String::new();
2105                let mut version = String::new();
2106                let mut date = String::new();
2107                for line in stdout.lines() {
2108                    let line = line.trim();
2109                    if line.starts_with("Manufacturer=") {
2110                        manufacturer = line
2111                            .strip_prefix("Manufacturer=")
2112                            .unwrap_or("")
2113                            .trim()
2114                            .to_string();
2115                    } else if line.starts_with("SMBIOSBIOSVersion=") {
2116                        version = line
2117                            .strip_prefix("SMBIOSBIOSVersion=")
2118                            .unwrap_or("")
2119                            .trim()
2120                            .to_string();
2121                    } else if line.starts_with("ReleaseDate=") {
2122                        let raw_date = line.strip_prefix("ReleaseDate=").unwrap_or("").trim();
2123                        if raw_date.len() >= 8 {
2124                            let year = &raw_date[0..4];
2125                            let month = &raw_date[4..6];
2126                            let day = &raw_date[6..8];
2127                            date = format!("{}/{}/{}", month, day, year);
2128                        } else {
2129                            date = raw_date.to_string();
2130                        }
2131                    }
2132                }
2133
2134                let mut parts = Vec::new();
2135                if !manufacturer.is_empty() {
2136                    parts.push(manufacturer);
2137                }
2138                if !version.is_empty() {
2139                    parts.push(version);
2140                }
2141                let mut res = parts.join(" ");
2142                if !date.is_empty() {
2143                    if res.is_empty() {
2144                        res = date;
2145                    } else {
2146                        res = format!("{} ({})", res, date);
2147                    }
2148                }
2149                if !res.is_empty() {
2150                    return Some(res);
2151                }
2152            }
2153        }
2154        None
2155    }
2156
2157    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
2158    {
2159        let vendor = std::fs::read_to_string("/sys/class/dmi/id/bios_vendor")
2160            .map(|s| s.trim().to_string())
2161            .ok();
2162        let version = std::fs::read_to_string("/sys/class/dmi/id/bios_version")
2163            .map(|s| s.trim().to_string())
2164            .ok();
2165        let date = std::fs::read_to_string("/sys/class/dmi/id/bios_date")
2166            .map(|s| s.trim().to_string())
2167            .ok();
2168
2169        let mut parts = Vec::new();
2170        if let Some(v) = vendor {
2171            if !v.is_empty() {
2172                parts.push(v);
2173            }
2174        }
2175        if let Some(ver) = version {
2176            let mut ver_cleaned = ver;
2177            while ver_cleaned.contains(" )") {
2178                ver_cleaned = ver_cleaned.replace(" )", ")");
2179            }
2180            let ver_cleaned = ver_cleaned.trim().to_string();
2181            if !ver_cleaned.is_empty() {
2182                parts.push(ver_cleaned);
2183            }
2184        }
2185        let mut res = parts.join(" ");
2186        if let Some(d) = date {
2187            if !d.is_empty() {
2188                if res.is_empty() {
2189                    res = d;
2190                } else {
2191                    res = format!("{} ({})", res, d);
2192                }
2193            }
2194        }
2195        if !res.is_empty() {
2196            Some(res)
2197        } else {
2198            None
2199        }
2200    }
2201}
2202
2203#[allow(dead_code)]
2204fn parse_monitor_name_from_edid(edid: &[u8]) -> Option<String> {
2205    if edid.len() < 128 {
2206        return None;
2207    }
2208    let offsets = [54, 72, 90, 108];
2209    for &offset in &offsets {
2210        if offset + 18 <= edid.len() {
2211            let block = &edid[offset..offset + 18];
2212            if block[0] == 0x00 && block[1] == 0x00 && block[2] == 0x00 && block[3] == 0xFC {
2213                let name_bytes = &block[4..17];
2214                let name = String::from_utf8_lossy(name_bytes);
2215                let cleaned = name.trim().replace('\0', "").to_string();
2216                if !cleaned.is_empty() {
2217                    return Some(cleaned);
2218                }
2219            }
2220        }
2221    }
2222    None
2223}
2224
2225#[allow(dead_code)]
2226fn parse_refresh_rate_from_edid(edid: &[u8]) -> Option<f64> {
2227    if edid.len() < 72 {
2228        return None;
2229    }
2230    let block = &edid[54..72];
2231    let pixel_clock = ((block[1] as u32) << 8) | (block[0] as u32);
2232    if pixel_clock == 0 {
2233        return None;
2234    }
2235    let pixel_clock_hz = pixel_clock * 10_000;
2236    let h_active = (block[2] as u32) | (((block[4] as u32) & 0xF0) << 4);
2237    let h_blanking = (block[3] as u32) | (((block[4] as u32) & 0x0F) << 8);
2238    let v_active = (block[5] as u32) | (((block[7] as u32) & 0xF0) << 4);
2239    let v_blanking = (block[6] as u32) | (((block[7] as u32) & 0x0F) << 8);
2240
2241    let h_total = h_active + h_blanking;
2242    let v_total = v_active + v_blanking;
2243    if h_total == 0 || v_total == 0 {
2244        return None;
2245    }
2246
2247    let refresh = (pixel_clock_hz as f64) / ((h_total * v_total) as f64);
2248    Some((refresh * 100.0).round() / 100.0)
2249}
2250
2251#[allow(dead_code)]
2252fn format_refresh_rate(refresh: f64) -> String {
2253    if (refresh - refresh.round()).abs() < 0.01 {
2254        format!("{:.0}", refresh)
2255    } else {
2256        format!("{:.2}", refresh)
2257    }
2258}
2259
2260#[allow(dead_code)]
2261fn parse_serial_number_from_edid(edid: &[u8]) -> Option<String> {
2262    if edid.len() < 128 {
2263        return None;
2264    }
2265    // 1. Try finding ASCII Serial Number descriptor block (tag 0xFF)
2266    let offsets = [54, 72, 90, 108];
2267    for &offset in &offsets {
2268        if offset + 18 <= edid.len() {
2269            let block = &edid[offset..offset + 18];
2270            if block[0] == 0x00 && block[1] == 0x00 && block[2] == 0x00 && block[3] == 0xFF {
2271                let serial_bytes = &block[4..17];
2272                let serial = String::from_utf8_lossy(serial_bytes);
2273                let cleaned = serial.trim().replace('\0', "").to_string();
2274                if !cleaned.is_empty() {
2275                    return Some(cleaned);
2276                }
2277            }
2278        }
2279    }
2280
2281    // 2. Fallback to the 32-bit numeric serial number at offset 12-15
2282    let serial_num = ((edid[15] as u32) << 24)
2283        | ((edid[14] as u32) << 16)
2284        | ((edid[13] as u32) << 8)
2285        | (edid[12] as u32);
2286    if serial_num != 0 && serial_num != 0xFFFFFFFF {
2287        return Some(serial_num.to_string());
2288    }
2289
2290    None
2291}
2292
2293#[allow(dead_code)]
2294fn get_monitor_name_for_port(port: &str) -> Option<String> {
2295    if let Ok(entries) = std::fs::read_dir("/sys/class/drm") {
2296        for entry in entries.filter_map(|e| e.ok()) {
2297            let name = entry.file_name().to_string_lossy().to_string();
2298            if name.ends_with(port) {
2299                let edid_path = entry.path().join("edid");
2300                if edid_path.exists() {
2301                    if let Ok(edid_bytes) = std::fs::read(&edid_path) {
2302                        if let Some(monitor_name) = parse_monitor_name_from_edid(&edid_bytes) {
2303                            return Some(monitor_name);
2304                        }
2305                    }
2306                }
2307            }
2308        }
2309    }
2310    None
2311}
2312
2313fn detect_displays() -> Vec<String> {
2314    #[cfg(target_os = "macos")]
2315    {
2316        if let Ok(output) = std::process::Command::new("system_profiler")
2317            .arg("SPDisplaysDataType")
2318            .output()
2319        {
2320            if let Ok(stdout) = String::from_utf8(output.stdout) {
2321                return parse_macos_displays(&stdout);
2322            }
2323        }
2324        Vec::new()
2325    }
2326
2327    #[cfg(target_os = "windows")]
2328    {
2329        let mut displays = Vec::new();
2330        if let Ok(output) = std::process::Command::new("wmic")
2331            .args([
2332                "path",
2333                "Win32_VideoController",
2334                "get",
2335                "Name,CurrentHorizontalResolution,CurrentVerticalResolution,CurrentRefreshRate",
2336                "/value",
2337            ])
2338            .output()
2339        {
2340            if let Ok(stdout) = String::from_utf8(output.stdout) {
2341                let mut name = String::new();
2342                let mut width = String::new();
2343                let mut height = String::new();
2344                let mut refresh = String::new();
2345                for line in stdout.lines() {
2346                    let line = line.trim();
2347                    if line.starts_with("Name=") {
2348                        name = line.strip_prefix("Name=").unwrap_or("").trim().to_string();
2349                    } else if line.starts_with("CurrentHorizontalResolution=") {
2350                        width = line
2351                            .strip_prefix("CurrentHorizontalResolution=")
2352                            .unwrap_or("")
2353                            .trim()
2354                            .to_string();
2355                    } else if line.starts_with("CurrentVerticalResolution=") {
2356                        height = line
2357                            .strip_prefix("CurrentVerticalResolution=")
2358                            .unwrap_or("")
2359                            .trim()
2360                            .to_string();
2361                    } else if line.starts_with("CurrentRefreshRate=") {
2362                        refresh = line
2363                            .strip_prefix("CurrentRefreshRate=")
2364                            .unwrap_or("")
2365                            .trim()
2366                            .to_string();
2367                    }
2368
2369                    if !name.is_empty()
2370                        && !width.is_empty()
2371                        && !height.is_empty()
2372                        && !refresh.is_empty()
2373                    {
2374                        displays.push(format!("{} ({}x{} @ {}Hz)", name, width, height, refresh));
2375                        name.clear();
2376                        width.clear();
2377                        height.clear();
2378                        refresh.clear();
2379                    }
2380                }
2381                if !name.is_empty() && !width.is_empty() && !height.is_empty() {
2382                    displays.push(format!("{} ({}x{})", name, width, height));
2383                }
2384            }
2385        }
2386        displays
2387    }
2388
2389    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
2390    {
2391        let mut displays = Vec::new();
2392
2393        // Try reading directly from sysfs first for best performance
2394        if let Ok(entries) = std::fs::read_dir("/sys/class/drm") {
2395            for entry in entries.filter_map(|e| e.ok()) {
2396                let path = entry.path();
2397                let status_path = path.join("status");
2398                let modes_path = path.join("modes");
2399                let edid_path = path.join("edid");
2400                if status_path.exists() && modes_path.exists() {
2401                    if let Ok(status) = std::fs::read_to_string(&status_path) {
2402                        if status.trim() == "connected" {
2403                            if let Ok(modes) = std::fs::read_to_string(&modes_path) {
2404                                if let Some(first_mode) = modes.lines().next() {
2405                                    let res = first_mode.trim().to_string();
2406                                    let port = entry.file_name().to_string_lossy().to_string();
2407                                    let clean_port = if let Some(idx) = port.find('-') {
2408                                        port[idx + 1..].to_string()
2409                                    } else {
2410                                        port
2411                                    };
2412
2413                                    let edid_bytes = if edid_path.exists() {
2414                                        std::fs::read(&edid_path).ok()
2415                                    } else {
2416                                        None
2417                                    };
2418
2419                                    let name = edid_bytes
2420                                        .as_ref()
2421                                        .and_then(|bytes| parse_monitor_name_from_edid(bytes))
2422                                        .unwrap_or(clean_port);
2423
2424                                    let refresh = edid_bytes
2425                                        .as_ref()
2426                                        .and_then(|bytes| parse_refresh_rate_from_edid(bytes));
2427
2428                                    let serial = edid_bytes
2429                                        .as_ref()
2430                                        .and_then(|bytes| parse_serial_number_from_edid(bytes));
2431
2432                                    let display_name = if let Some(ref s) = serial {
2433                                        format!("{} #{}", name, s)
2434                                    } else {
2435                                        name
2436                                    };
2437
2438                                    if let Some(r) = refresh {
2439                                        displays.push(format!(
2440                                            "{} ({} @ {}Hz)",
2441                                            display_name,
2442                                            res,
2443                                            format_refresh_rate(r)
2444                                        ));
2445                                    } else {
2446                                        displays.push(format!("{} ({})", display_name, res));
2447                                    }
2448                                }
2449                            }
2450                        }
2451                    }
2452                }
2453            }
2454        }
2455
2456        // Fallback to xrandr only if sysfs yielded no connected displays
2457        if displays.is_empty() {
2458            if let Ok(output) = std::process::Command::new("xrandr")
2459                .arg("--current")
2460                .output()
2461            {
2462                if let Ok(stdout) = String::from_utf8(output.stdout) {
2463                    displays = parse_xrandr_displays(&stdout);
2464                }
2465            }
2466        }
2467
2468        displays
2469    }
2470}
2471
2472#[cfg(target_os = "macos")]
2473fn parse_macos_displays(stdout: &str) -> Vec<String> {
2474    let mut displays = Vec::new();
2475    let mut current_name = None;
2476    let mut current_res = None;
2477    let mut in_displays = false;
2478
2479    for line in stdout.lines() {
2480        let trimmed = line.trim();
2481        let indent = line.len() - line.trim_start().len();
2482
2483        if trimmed.starts_with("Displays:") {
2484            in_displays = true;
2485            continue;
2486        }
2487
2488        if in_displays {
2489            if indent < 8 && !trimmed.is_empty() && !trimmed.starts_with("Displays:") {
2490                in_displays = false;
2491                continue;
2492            }
2493
2494            if trimmed.ends_with(':') && !trimmed.starts_with("UI Looks like:") {
2495                let name = trimmed.trim_end_matches(':').trim().to_string();
2496                current_name = Some(name);
2497            } else if trimmed.starts_with("Resolution:") {
2498                let res = trimmed.strip_prefix("Resolution:").unwrap_or("").trim();
2499                let cleaned = res.replace(" ", "");
2500                current_res = Some(cleaned);
2501            } else if trimmed.starts_with("UI Looks like:") {
2502                if let Some(res) = current_res.take() {
2503                    let name_str = current_name.take().unwrap_or_else(|| "Display".to_string());
2504                    if let Some(idx) = trimmed.find('@') {
2505                        let freq = trimmed[idx..].trim();
2506                        let freq_clean = freq.replace(" ", "").replace(".00", "");
2507                        displays.push(format!(
2508                            "{} ({} @ {})",
2509                            name_str,
2510                            res,
2511                            freq_clean.trim_start_matches('@')
2512                        ));
2513                    } else {
2514                        displays.push(format!("{} ({})", name_str, res));
2515                    }
2516                }
2517            }
2518        }
2519    }
2520    if let Some(res) = current_res {
2521        let name_str = current_name.unwrap_or_else(|| "Display".to_string());
2522        displays.push(format!("{} ({})", name_str, res));
2523    }
2524    displays
2525}
2526
2527#[cfg(not(any(target_os = "macos", target_os = "windows")))]
2528fn parse_xrandr_displays(stdout: &str) -> Vec<String> {
2529    let mut displays = Vec::new();
2530    let mut current_display = None;
2531    let mut current_port = None;
2532    for line in stdout.lines() {
2533        let line = line.trim();
2534        if line.contains(" connected ") {
2535            let parts: Vec<&str> = line.split_whitespace().collect();
2536            if let Some(&port) = parts.first() {
2537                current_port = Some(port.to_string());
2538            }
2539            for part in parts {
2540                if part.contains('x') && part.contains('+') {
2541                    if let Some(res) = part.split('+').next() {
2542                        current_display = Some(res.to_string());
2543                    }
2544                }
2545            }
2546        } else if line.contains('*') {
2547            if let Some(res) = current_display.take() {
2548                let port = current_port.take().unwrap_or_default();
2549                let name = get_monitor_name_for_port(&port).unwrap_or_else(|| port.clone());
2550                let parts: Vec<&str> = line.split_whitespace().collect();
2551                let mut added = false;
2552                for part in parts {
2553                    if part.contains('*') {
2554                        let freq = part.trim_end_matches(['*', '+']);
2555                        displays.push(format!("{} ({} @ {}Hz)", name, res, freq));
2556                        added = true;
2557                        break;
2558                    }
2559                }
2560                if !added {
2561                    displays.push(format!("{} ({})", name, res));
2562                }
2563            }
2564        }
2565    }
2566    displays
2567}
2568
2569/// Format bytes into human-readable form (KB, MB, GB, etc.)
2570fn format_bytes(bytes: u64) -> String {
2571    const KB: u64 = 1024;
2572    const MB: u64 = KB * 1024;
2573    const GB: u64 = MB * 1024;
2574
2575    if bytes >= GB {
2576        format!("{:.1} GB", bytes as f64 / GB as f64)
2577    } else if bytes >= MB {
2578        format!("{:.1} MB", bytes as f64 / MB as f64)
2579    } else if bytes >= KB {
2580        format!("{:.1} KB", bytes as f64 / KB as f64)
2581    } else {
2582        format!("{} B", bytes)
2583    }
2584}
2585
2586/// Detects the active shell and retrieves its version.
2587fn detect_shell(sys: &sysinfo::System) -> Option<String> {
2588    // 1. Try to get shell from the SHELL env var
2589    let shell_env = std::env::var("SHELL").ok();
2590
2591    // Extract shell path and basename
2592    let (shell_path, shell_name) = if let Some(ref path_str) = shell_env {
2593        let path = std::path::Path::new(path_str);
2594        let name = path
2595            .file_name()
2596            .and_then(|n| n.to_str())
2597            .unwrap_or(path_str)
2598            .to_string();
2599        (path_str.clone(), name)
2600    } else {
2601        // 2. Fallback to process tree climbing
2602        let mut current_pid = sysinfo::get_current_pid().ok();
2603        let mut detected: Option<(String, String)> = None;
2604        let known_shells = [
2605            "bash",
2606            "zsh",
2607            "fish",
2608            "sh",
2609            "dash",
2610            "nu",
2611            "elvish",
2612            "tcsh",
2613            "csh",
2614            "ksh",
2615            "powershell",
2616            "pwsh",
2617            "cmd",
2618        ];
2619
2620        while let Some(pid) = current_pid {
2621            if let Some(process) = sys.process(pid) {
2622                let proc_name = process.name().to_string_lossy().to_string();
2623                let proc_name_lower = proc_name.to_lowercase();
2624
2625                // On Windows, the process name might be "powershell.exe" or "pwsh.exe"
2626                let clean_name = proc_name_lower
2627                    .strip_suffix(".exe")
2628                    .unwrap_or(&proc_name_lower)
2629                    .to_string();
2630
2631                if known_shells.contains(&clean_name.as_str()) {
2632                    detected = Some((proc_name.clone(), clean_name));
2633                    break;
2634                }
2635                current_pid = process.parent();
2636            } else {
2637                break;
2638            }
2639        }
2640
2641        if let Some((orig_name, clean_name)) = detected {
2642            (orig_name, clean_name)
2643        } else {
2644            // Default fallbacks
2645            #[cfg(target_os = "windows")]
2646            {
2647                if std::env::var("PSModulePath").is_ok() {
2648                    ("powershell.exe".to_string(), "powershell".to_string())
2649                } else {
2650                    ("cmd.exe".to_string(), "cmd".to_string())
2651                }
2652            }
2653            #[cfg(not(target_os = "windows"))]
2654            {
2655                ("sh".to_string(), "sh".to_string())
2656            }
2657        }
2658    };
2659
2660    // Now that we have the shell_path and shell_name, query its version
2661    let shell_name_lower = shell_name.to_lowercase();
2662    let shell_name_clean = shell_name_lower
2663        .strip_suffix(".exe")
2664        .unwrap_or(&shell_name_lower);
2665
2666    let version = detect_shell_version(&shell_path, shell_name_clean);
2667
2668    if let Some(ver) = version {
2669        Some(format!("{} {}", shell_name_clean, ver))
2670    } else {
2671        Some(shell_name_clean.to_string())
2672    }
2673}
2674
2675/// Helper to query standard shell executables for their versions.
2676fn detect_shell_version(shell_path: &str, shell_name: &str) -> Option<String> {
2677    // Select arguments based on shell
2678    let args = match shell_name {
2679        "powershell" => vec![
2680            "-NoProfile",
2681            "-Command",
2682            "$PSVersionTable.PSVersion.ToString()",
2683        ],
2684        "elvish" => vec!["-version"],
2685        _ => vec!["--version"],
2686    };
2687
2688    let output = std::process::Command::new(shell_path)
2689        .args(&args)
2690        .output()
2691        .or_else(|_| {
2692            // If the full path failed (e.g. SHELL was set to an invalid path),
2693            // try running by the shell name (searching the system PATH)
2694            std::process::Command::new(shell_name).args(&args).output()
2695        })
2696        .ok()?;
2697
2698    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
2699    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
2700
2701    // Some shells or errors might output to stderr
2702    let full_output = format!("{}\n{}", stdout, stderr);
2703
2704    parse_shell_version(shell_name, &full_output)
2705}
2706
2707/// Parses the output of a shell's version flag to find the version string.
2708fn parse_shell_version(shell_name: &str, output: &str) -> Option<String> {
2709    let output_trimmed = output.trim();
2710    if output_trimmed.is_empty() {
2711        return None;
2712    }
2713
2714    match shell_name {
2715        "bash" => {
2716            if let Some(pos) = output.find("version ") {
2717                let rest = &output[pos + 8..];
2718                let ver = rest
2719                    .split(|c: char| c.is_whitespace() || c == '(' || c == ',' || c == '-')
2720                    .next()
2721                    .unwrap_or("");
2722                if !ver.is_empty() {
2723                    return Some(ver.to_string());
2724                }
2725            }
2726        }
2727        "zsh" => {
2728            if let Some(pos) = output.find("zsh ") {
2729                let rest = &output[pos + 4..];
2730                let ver = rest
2731                    .split(|c: char| c.is_whitespace() || c == '(')
2732                    .next()
2733                    .unwrap_or("");
2734                if !ver.is_empty() {
2735                    return Some(ver.to_string());
2736                }
2737            }
2738        }
2739        "fish" => {
2740            if let Some(pos) = output.find("version ") {
2741                let rest = &output[pos + 8..];
2742                let ver = rest
2743                    .split(|c: char| c.is_whitespace() || c == '(' || c == ',')
2744                    .next()
2745                    .unwrap_or("");
2746                if !ver.is_empty() {
2747                    return Some(ver.to_string());
2748                }
2749            }
2750        }
2751        "nu" => {
2752            let ver = output_trimmed.split_whitespace().next().unwrap_or("");
2753            if !ver.is_empty() {
2754                return Some(ver.to_string());
2755            }
2756        }
2757        "pwsh" => {
2758            if let Some(pos) = output.find("PowerShell ") {
2759                let rest = &output[pos + 11..];
2760                let ver = rest.split_whitespace().next().unwrap_or("");
2761                if !ver.is_empty() {
2762                    return Some(ver.to_string());
2763                }
2764            }
2765        }
2766        "powershell" => {
2767            let ver = output_trimmed.split_whitespace().next().unwrap_or("");
2768            if !ver.is_empty() {
2769                return Some(ver.to_string());
2770            }
2771        }
2772        "elvish" => {
2773            let ver = output_trimmed.split_whitespace().next().unwrap_or("");
2774            if !ver.is_empty() {
2775                return Some(ver.to_string());
2776            }
2777        }
2778        "tcsh" => {
2779            if let Some(pos) = output.find("tcsh ") {
2780                let rest = &output[pos + 5..];
2781                let ver = rest.split_whitespace().next().unwrap_or("");
2782                if !ver.is_empty() {
2783                    return Some(ver.to_string());
2784                }
2785            }
2786        }
2787        _ => {
2788            // General generic version finder for "version " or any number pattern
2789            if let Some(pos) = output.to_lowercase().find("version ") {
2790                let rest = &output[pos + 8..];
2791                let ver = rest
2792                    .split(|c: char| c.is_whitespace() || c == '(' || c == ',' || c == '-')
2793                    .next()
2794                    .unwrap_or("");
2795                if !ver.is_empty() {
2796                    return Some(ver.to_string());
2797                }
2798            }
2799        }
2800    }
2801
2802    None
2803}
2804
2805#[allow(dead_code)]
2806fn parse_ini_key(content: &str, key: &str) -> Option<String> {
2807    for line in content.lines() {
2808        let line = line.trim();
2809        if line.starts_with('#') || line.starts_with(';') {
2810            continue;
2811        }
2812        if let Some(pos) = line.find('=') {
2813            let k = line[..pos].trim();
2814            if k == key {
2815                let v = line[pos + 1..].trim();
2816                let v = if (v.starts_with('"') && v.ends_with('"'))
2817                    || (v.starts_with('\'') && v.ends_with('\''))
2818                {
2819                    if v.len() >= 2 {
2820                        v[1..v.len() - 1].to_string()
2821                    } else {
2822                        v.to_string()
2823                    }
2824                } else {
2825                    v.to_string()
2826                };
2827                if !v.is_empty() {
2828                    return Some(v);
2829                }
2830            }
2831        }
2832    }
2833    None
2834}
2835
2836#[cfg(target_os = "linux")]
2837fn get_gtk_setting(key: &str) -> Option<String> {
2838    let home = dirs::home_dir()?;
2839    let paths = [
2840        home.join(".config/gtk-4.0/settings.ini"),
2841        home.join(".config/gtk-3.0/settings.ini"),
2842        home.join(".config/gtk-2.0/settings.ini"),
2843        home.join(".gtkrc-2.0"),
2844    ];
2845
2846    for path in &paths {
2847        if path.exists() {
2848            if let Ok(contents) = std::fs::read_to_string(path) {
2849                if let Some(val) = parse_ini_key(&contents, key) {
2850                    return Some(val);
2851                }
2852            }
2853        }
2854    }
2855    None
2856}
2857
2858#[cfg(target_os = "linux")]
2859fn query_gsettings(schema: &str, key: &str) -> Option<String> {
2860    let output = std::process::Command::new("gsettings")
2861        .args(["get", schema, key])
2862        .output()
2863        .ok()?;
2864    if output.status.success() {
2865        let val = String::from_utf8_lossy(&output.stdout).trim().to_string();
2866        let val = val.trim_matches('\'').trim_matches('"').to_string();
2867        if !val.is_empty() && val != "''" && val != "\"\"" {
2868            return Some(val);
2869        }
2870    }
2871    None
2872}
2873
2874#[cfg(target_os = "linux")]
2875fn get_kde_setting(key: &str) -> Option<String> {
2876    let home = dirs::home_dir()?;
2877    let path = home.join(".config/kdeglobals");
2878    if path.exists() {
2879        if let Ok(contents) = std::fs::read_to_string(path) {
2880            return parse_ini_key(&contents, key);
2881        }
2882    }
2883    None
2884}
2885
2886#[cfg(target_os = "linux")]
2887fn detect_ui_theme_and_fonts() -> (
2888    Option<String>,
2889    Option<String>,
2890    Option<String>,
2891    Option<String>,
2892) {
2893    // GTK Settings
2894    let gtk_theme = get_gtk_setting("gtk-theme-name")
2895        .or_else(|| query_gsettings("org.gnome.desktop.interface", "gtk-theme"));
2896    let gtk_icons = get_gtk_setting("gtk-icon-theme-name")
2897        .or_else(|| query_gsettings("org.gnome.desktop.interface", "icon-theme"));
2898    let gtk_cursor = get_gtk_setting("gtk-cursor-theme-name")
2899        .or_else(|| query_gsettings("org.gnome.desktop.interface", "cursor-theme"));
2900    let gtk_font = get_gtk_setting("gtk-font-name")
2901        .or_else(|| query_gsettings("org.gnome.desktop.interface", "font-name"));
2902
2903    // KDE/Qt Settings
2904    let qt_theme = get_kde_setting("widgetStyle").or_else(|| get_kde_setting("ColorScheme"));
2905    let qt_icons = get_kde_setting("iconTheme");
2906    let qt_cursor = {
2907        let home = dirs::home_dir();
2908        home.and_then(|h| {
2909            let path = h.join(".config/kcminputrc");
2910            if path.exists() {
2911                std::fs::read_to_string(path)
2912                    .ok()
2913                    .and_then(|contents| parse_ini_key(&contents, "theme"))
2914            } else {
2915                None
2916            }
2917        })
2918    };
2919    let qt_font = get_kde_setting("font").map(|f| {
2920        let parts: Vec<&str> = f.split(',').collect();
2921        if parts.len() >= 2 {
2922            let name = parts[0].trim();
2923            let size = parts[1].trim();
2924            format!("{} ({}pt)", name, size)
2925        } else {
2926            f
2927        }
2928    });
2929
2930    // Check which desktop environment is in use to prioritize formatting
2931    let de = std::env::var("XDG_CURRENT_DESKTOP")
2932        .or_else(|_| std::env::var("DESKTOP_SESSION"))
2933        .unwrap_or_default()
2934        .to_lowercase();
2935
2936    let is_kde = de.contains("kde") || de.contains("plasma");
2937
2938    let theme = if is_kde {
2939        match (qt_theme, gtk_theme) {
2940            (Some(qt), Some(gt)) => Some(format!("{} [Qt], {} [GTK]", qt, gt)),
2941            (Some(qt), None) => Some(format!("{} [Qt]", qt)),
2942            (None, Some(gt)) => Some(format!("{} [GTK]", gt)),
2943            (None, None) => None,
2944        }
2945    } else {
2946        match (gtk_theme, qt_theme) {
2947            (Some(gt), Some(qt)) => Some(format!("{} [GTK], {} [Qt]", gt, qt)),
2948            (Some(gt), None) => Some(format!("{} [GTK]", gt)),
2949            (None, Some(qt)) => Some(format!("{} [Qt]", qt)),
2950            (None, None) => None,
2951        }
2952    };
2953
2954    let icons = if is_kde {
2955        match (qt_icons, gtk_icons) {
2956            (Some(qi), Some(gi)) => Some(format!("{} [Qt], {} [GTK]", qi, gi)),
2957            (Some(qi), None) => Some(format!("{} [Qt]", qi)),
2958            (None, Some(gi)) => Some(format!("{} [GTK]", gi)),
2959            (None, None) => None,
2960        }
2961    } else {
2962        match (gtk_icons, qt_icons) {
2963            (Some(gi), Some(qi)) => Some(format!("{} [GTK], {} [Qt]", gi, qi)),
2964            (Some(gi), None) => Some(format!("{} [GTK]", gi)),
2965            (None, Some(qi)) => Some(format!("{} [Qt]", qi)),
2966            (None, None) => None,
2967        }
2968    };
2969
2970    let cursor = if is_kde {
2971        match (qt_cursor, gtk_cursor) {
2972            (Some(qc), Some(gc)) => Some(format!("{} [Qt], {} [GTK]", qc, gc)),
2973            (Some(qc), None) => Some(format!("{} [Qt]", qc)),
2974            (None, Some(gc)) => Some(format!("{} [GTK]", gc)),
2975            (None, None) => None,
2976        }
2977    } else {
2978        match (gtk_cursor, qt_cursor) {
2979            (Some(gc), Some(qc)) => Some(format!("{} [GTK], {} [Qt]", gc, qc)),
2980            (Some(gc), None) => Some(format!("{} [GTK]", gc)),
2981            (None, Some(qc)) => Some(format!("{} [Qt]", qc)),
2982            (None, None) => None,
2983        }
2984    };
2985
2986    let font = if is_kde {
2987        match (qt_font, gtk_font) {
2988            (Some(qf), Some(gf)) => Some(format!("{} [Qt], {} [GTK]", qf, gf)),
2989            (Some(qf), None) => Some(format!("{} [Qt]", qf)),
2990            (None, Some(gf)) => Some(format!("{} [GTK]", gf)),
2991            (None, None) => None,
2992        }
2993    } else {
2994        match (gtk_font, qt_font) {
2995            (Some(gf), Some(qf)) => Some(format!("{} [GTK], {} [Qt]", gf, qf)),
2996            (Some(gf), None) => Some(format!("{} [GTK]", gf)),
2997            (None, Some(qf)) => Some(format!("{} [Qt]", qf)),
2998            (None, None) => None,
2999        }
3000    };
3001
3002    (theme, icons, cursor, font)
3003}
3004
3005#[cfg(target_os = "macos")]
3006fn detect_ui_theme_and_fonts() -> (
3007    Option<String>,
3008    Option<String>,
3009    Option<String>,
3010    Option<String>,
3011) {
3012    let interface_style = std::process::Command::new("defaults")
3013        .args(["read", "-g", "AppleInterfaceStyle"])
3014        .output()
3015        .ok()
3016        .and_then(|o| {
3017            if o.status.success() {
3018                Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
3019            } else {
3020                None
3021            }
3022        });
3023
3024    let theme = match interface_style {
3025        Some(style) => Some(format!("Aqua ({})", style)),
3026        None => Some("Aqua (Light)".to_string()),
3027    };
3028
3029    (theme, None, None, Some("San Francisco".to_string()))
3030}
3031
3032#[cfg(target_os = "windows")]
3033fn detect_ui_theme_and_fonts() -> (
3034    Option<String>,
3035    Option<String>,
3036    Option<String>,
3037    Option<String>,
3038) {
3039    let theme = {
3040        let apps_light = win_reg::get_reg_u32(
3041            win_reg::HKEY_CURRENT_USER,
3042            "Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize",
3043            "AppsUseLightTheme",
3044        );
3045
3046        let apps_dark = apps_light.map(|val| val == 0);
3047
3048        match apps_dark {
3049            Some(true) => Some("Dark".to_string()),
3050            Some(false) => Some("Light".to_string()),
3051            None => {
3052                let output = std::process::Command::new("reg")
3053                    .args([
3054                        "query",
3055                        r"HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize",
3056                        "/v",
3057                        "AppsUseLightTheme",
3058                    ])
3059                    .output()
3060                    .ok();
3061
3062                let cmd_dark = output.and_then(|o| {
3063                    if o.status.success() {
3064                        let s = String::from_utf8_lossy(&o.stdout);
3065                        if s.contains("0x0") {
3066                            Some(true)
3067                        } else if s.contains("0x1") {
3068                            Some(false)
3069                        } else {
3070                            None
3071                        }
3072                    } else {
3073                        None
3074                    }
3075                });
3076
3077                match cmd_dark {
3078                    Some(true) => Some("Dark".to_string()),
3079                    Some(false) => Some("Light".to_string()),
3080                    None => Some("Unknown".to_string()),
3081                }
3082            }
3083        }
3084    };
3085
3086    (theme, None, None, Some("Segoe UI".to_string()))
3087}
3088
3089#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
3090fn detect_ui_theme_and_fonts() -> (
3091    Option<String>,
3092    Option<String>,
3093    Option<String>,
3094    Option<String>,
3095) {
3096    (None, None, None, None)
3097}
3098
3099fn detect_terminal(sys: &System) -> Option<String> {
3100    if let Ok(prog) = std::env::var("TERM_PROGRAM") {
3101        if !prog.is_empty() {
3102            return Some(prog);
3103        }
3104    }
3105    if let Ok(prog) = std::env::var("TERMINAL_EMULATOR") {
3106        if !prog.is_empty() {
3107            return Some(prog);
3108        }
3109    }
3110    if std::env::var("ALACRITTY_LOG").is_ok() || std::env::var("ALACRITTY_WINDOW_ID").is_ok() {
3111        return Some("alacritty".to_string());
3112    }
3113
3114    let current_pid = sysinfo::Pid::from_u32(std::process::id());
3115    let mut current_proc = sys.process(current_pid);
3116
3117    let known_terms = [
3118        "kitty",
3119        "alacritty",
3120        "wezterm",
3121        "gnome-terminal",
3122        "konsole",
3123        "iterm2",
3124        "Terminal",
3125        "rio",
3126        "foot",
3127        "tilix",
3128        "xfce4-terminal",
3129        "terminator",
3130        "st",
3131        "urxvt",
3132        "ptyxis",
3133    ];
3134
3135    let mut depth = 0;
3136    while let Some(proc) = current_proc {
3137        if depth > 5 {
3138            break;
3139        }
3140        let name = proc.name().to_string_lossy().to_lowercase();
3141
3142        for term in &known_terms {
3143            if name == *term || name.ends_with(term) || (name.contains(term) && term.len() > 3) {
3144                return Some(term.to_string());
3145            }
3146        }
3147
3148        if let Some(parent_pid) = proc.parent() {
3149            current_proc = sys.process(parent_pid);
3150        } else {
3151            break;
3152        }
3153        depth += 1;
3154    }
3155
3156    if let Ok(term) = std::env::var("TERM") {
3157        if term != "xterm-256color" && term != "xterm" && term != "linux" && term != "cygwin" {
3158            if let Some(stripped) = term.strip_prefix("xterm-") {
3159                return Some(stripped.to_string());
3160            }
3161            return Some(term);
3162        }
3163    }
3164
3165    None
3166}
3167
3168fn get_default_monospace_font() -> Option<String> {
3169    #[cfg(target_os = "linux")]
3170    {
3171        if let Ok(output) = std::process::Command::new("fc-match")
3172            .arg("monospace")
3173            .output()
3174        {
3175            if output.status.success() {
3176                let s = String::from_utf8_lossy(&output.stdout);
3177                if let Some(start) = s.find('"') {
3178                    if let Some(end) = s[start + 1..].find('"') {
3179                        return Some(s[start + 1..start + 1 + end].to_string());
3180                    }
3181                }
3182            }
3183        }
3184        return None;
3185    }
3186
3187    #[cfg(target_os = "macos")]
3188    {
3189        return Some("SF Mono".to_string());
3190    }
3191
3192    #[cfg(target_os = "windows")]
3193    {
3194        return Some("Consolas".to_string());
3195    }
3196
3197    #[allow(unreachable_code)]
3198    None
3199}
3200
3201fn detect_terminal_font(terminal: Option<&str>) -> Option<String> {
3202    let term = terminal?;
3203    let term_lower = term.to_lowercase();
3204    let home = dirs::home_dir()?;
3205
3206    if term_lower.contains("kitty") {
3207        let conf_path = home.join(".config/kitty/kitty.conf");
3208        if let Ok(content) = std::fs::read_to_string(&conf_path) {
3209            let mut family = None;
3210            let mut size = None;
3211            for line in content.lines() {
3212                let line = line.trim();
3213                if line.starts_with("font_family") {
3214                    let parts: Vec<&str> = line.split_whitespace().collect();
3215                    if parts.len() >= 2 {
3216                        family = Some(parts[1..].join(" "));
3217                    }
3218                } else if line.starts_with("font_size") {
3219                    let parts: Vec<&str> = line.split_whitespace().collect();
3220                    if parts.len() >= 2 {
3221                        size = Some(parts[1].to_string());
3222                    }
3223                }
3224            }
3225            match (family, size) {
3226                (Some(f), Some(s)) => return Some(format!("{} ({})", f, s)),
3227                (Some(f), None) => return Some(f),
3228                (None, Some(s)) => {
3229                    let fallback =
3230                        get_default_monospace_font().unwrap_or_else(|| "Default".to_string());
3231                    return Some(format!("{} ({})", fallback, s));
3232                }
3233                (None, None) => {}
3234            }
3235        }
3236    } else if term_lower.contains("alacritty") {
3237        let paths = [
3238            home.join(".config/alacritty/alacritty.toml"),
3239            home.join(".config/alacritty/alacritty.yml"),
3240            home.join(".alacritty.toml"),
3241            home.join(".alacritty.yml"),
3242        ];
3243        for path in paths {
3244            if let Ok(content) = std::fs::read_to_string(&path) {
3245                let mut family = None;
3246                let mut size = None;
3247                for line in content.lines() {
3248                    let line = line.trim();
3249                    if line.starts_with("family") {
3250                        if let Some(idx) = line.find('=') {
3251                            let val = line[idx + 1..].trim().trim_matches('"').trim_matches('\'');
3252                            family = Some(val.to_string());
3253                        } else if let Some(idx) = line.find(':') {
3254                            let val = line[idx + 1..].trim().trim_matches('"').trim_matches('\'');
3255                            family = Some(val.to_string());
3256                        }
3257                    } else if line.starts_with("size") {
3258                        if let Some(idx) = line.find('=') {
3259                            size = Some(line[idx + 1..].trim().to_string());
3260                        } else if let Some(idx) = line.find(':') {
3261                            size = Some(line[idx + 1..].trim().to_string());
3262                        }
3263                    }
3264                }
3265                match (family, size) {
3266                    (Some(f), Some(s)) => return Some(format!("{} ({})", f, s)),
3267                    (Some(f), None) => return Some(f),
3268                    (None, Some(s)) => {
3269                        let fallback =
3270                            get_default_monospace_font().unwrap_or_else(|| "Default".to_string());
3271                        return Some(format!("{} ({})", fallback, s));
3272                    }
3273                    (None, None) => {}
3274                }
3275            }
3276        }
3277    } else if term_lower.contains("wezterm") {
3278        let paths = [
3279            home.join(".wezterm.lua"),
3280            home.join(".config/wezterm/wezterm.lua"),
3281        ];
3282        for path in paths {
3283            if let Ok(content) = std::fs::read_to_string(&path) {
3284                let mut family = None;
3285                let mut size = None;
3286                for line in content.lines() {
3287                    if line.contains("wezterm.font") {
3288                        if let Some(start) = line.find("wezterm.font") {
3289                            let rest = &line[start..];
3290                            if let Some(quote1) = rest.find('\'').or(rest.find('"')) {
3291                                let quote_char = rest.chars().nth(quote1).unwrap();
3292                                if let Some(quote2) = rest[quote1 + 1..].find(quote_char) {
3293                                    family =
3294                                        Some(rest[quote1 + 1..quote1 + 1 + quote2].to_string());
3295                                }
3296                            }
3297                        }
3298                    }
3299                    if line.contains("font_size") {
3300                        if let Some(idx) = line.find('=') {
3301                            let val = line[idx + 1..].trim().trim_end_matches(',');
3302                            size = Some(val.to_string());
3303                        }
3304                    }
3305                }
3306                match (family, size) {
3307                    (Some(f), Some(s)) => return Some(format!("{} ({})", f, s)),
3308                    (Some(f), None) => return Some(f),
3309                    (None, Some(s)) => {
3310                        let fallback =
3311                            get_default_monospace_font().unwrap_or_else(|| "Default".to_string());
3312                        return Some(format!("{} ({})", fallback, s));
3313                    }
3314                    (None, None) => {}
3315                }
3316            }
3317        }
3318    } else if term_lower.contains("foot") {
3319        let conf_path = home.join(".config/foot/foot.ini");
3320        if let Ok(content) = std::fs::read_to_string(&conf_path) {
3321            for line in content.lines() {
3322                let line = line.trim();
3323                if line.starts_with("font=") {
3324                    let val = line.trim_start_matches("font=");
3325                    let parts: Vec<&str> = val.split(':').collect();
3326                    let family = parts[0].trim();
3327                    let mut size = None;
3328                    for part in &parts[1..] {
3329                        if part.starts_with("size=") {
3330                            size = Some(part.trim_start_matches("size=").trim());
3331                        }
3332                    }
3333                    if let Some(s) = size {
3334                        return Some(format!("{} ({})", family, s));
3335                    } else {
3336                        return Some(family.to_string());
3337                    }
3338                }
3339            }
3340        }
3341    } else if term_lower.contains("ptyxis") {
3342        #[cfg(target_os = "linux")]
3343        if let Ok(output) = std::process::Command::new("gsettings")
3344            .args(["get", "org.gnome.Ptyxis", "use-system-font"])
3345            .output()
3346        {
3347            if output.status.success() {
3348                let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
3349                if s == "false" {
3350                    if let Ok(font_out) = std::process::Command::new("gsettings")
3351                        .args(["get", "org.gnome.Ptyxis", "font-name"])
3352                        .output()
3353                    {
3354                        if font_out.status.success() {
3355                            let mut font_str =
3356                                String::from_utf8_lossy(&font_out.stdout).trim().to_string();
3357                            font_str = font_str.trim_matches('\'').to_string();
3358                            if !font_str.is_empty() {
3359                                if let Some(last_space) = font_str.rfind(' ') {
3360                                    let family = &font_str[..last_space];
3361                                    let size = &font_str[last_space + 1..];
3362                                    if size.chars().all(|c| c.is_ascii_digit() || c == '.') {
3363                                        return Some(format!("{} ({})", family, size));
3364                                    }
3365                                }
3366                                return Some(font_str);
3367                            }
3368                        }
3369                    }
3370                } else {
3371                    if let Ok(font_out) = std::process::Command::new("gsettings")
3372                        .args(["get", "org.gnome.desktop.interface", "monospace-font-name"])
3373                        .output()
3374                    {
3375                        if font_out.status.success() {
3376                            let mut font_str =
3377                                String::from_utf8_lossy(&font_out.stdout).trim().to_string();
3378                            font_str = font_str.trim_matches('\'').to_string();
3379                            if !font_str.is_empty() {
3380                                if let Some(last_space) = font_str.rfind(' ') {
3381                                    let family = &font_str[..last_space];
3382                                    let size = &font_str[last_space + 1..];
3383                                    if size.chars().all(|c| c.is_ascii_digit() || c == '.') {
3384                                        return Some(format!("{} ({})", family, size));
3385                                    }
3386                                }
3387                                return Some(font_str);
3388                            }
3389                        }
3390                    }
3391                    return get_default_monospace_font();
3392                }
3393            }
3394        }
3395    } else if term_lower.contains("konsole") {
3396        let rc_path = home.join(".config/konsolerc");
3397        let mut profile_name = "Default.profile".to_string();
3398        if let Ok(content) = std::fs::read_to_string(&rc_path) {
3399            for line in content.lines() {
3400                let line = line.trim();
3401                if line.starts_with("DefaultProfile=") {
3402                    profile_name = line.trim_start_matches("DefaultProfile=").to_string();
3403                    break;
3404                }
3405            }
3406        }
3407        let profile_path = home.join(".local/share/konsole").join(profile_name);
3408        if let Ok(content) = std::fs::read_to_string(&profile_path) {
3409            for line in content.lines() {
3410                let line = line.trim();
3411                if line.starts_with("Font=") {
3412                    let val = line.trim_start_matches("Font=");
3413                    let parts: Vec<&str> = val.split(',').collect();
3414                    if !parts.is_empty() {
3415                        let family = parts[0];
3416                        if parts.len() > 1 {
3417                            let size = parts[1];
3418                            return Some(format!("{} ({})", family, size));
3419                        }
3420                        return Some(family.to_string());
3421                    }
3422                }
3423            }
3424        }
3425        return get_default_monospace_font();
3426    }
3427
3428    #[cfg(target_os = "macos")]
3429    if term_lower == "iterm.app" || term_lower.contains("iterm2") {
3430        if let Ok(output) = std::process::Command::new("defaults")
3431            .args(["read", "com.googlecode.iterm2", "Normal Font"])
3432            .output()
3433        {
3434            if let Ok(s) = String::from_utf8(output.stdout) {
3435                let font = s.trim();
3436                if !font.is_empty() {
3437                    return Some(font.to_string());
3438                }
3439            }
3440        }
3441    }
3442
3443    None
3444}
3445
3446#[cfg(test)]
3447mod tests {
3448    use super::*;
3449
3450    #[test]
3451    fn test_format_bytes() {
3452        assert_eq!(format_bytes(500), "500 B");
3453        assert_eq!(format_bytes(1024), "1.0 KB");
3454        assert_eq!(format_bytes(1024 * 1024), "1.0 MB");
3455        assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB");
3456        assert_eq!(format_bytes(1536), "1.5 KB");
3457    }
3458
3459    #[test]
3460    fn test_parse_ini_key() {
3461        let sample = r#"[Settings]
3462gtk-theme-name=Adwaita-dark
3463# Comment line
3464  ; Another comment
3465gtk-icon-theme-name = "Adwaita"
3466gtk-font-name='Cantarell 11'
3467invalid_line_no_equal
3468empty_value = 
3469"#;
3470        assert_eq!(
3471            parse_ini_key(sample, "gtk-theme-name"),
3472            Some("Adwaita-dark".to_string())
3473        );
3474        assert_eq!(
3475            parse_ini_key(sample, "gtk-icon-theme-name"),
3476            Some("Adwaita".to_string())
3477        );
3478        assert_eq!(
3479            parse_ini_key(sample, "gtk-font-name"),
3480            Some("Cantarell 11".to_string())
3481        );
3482        assert_eq!(parse_ini_key(sample, "invalid_line_no_equal"), None);
3483        assert_eq!(parse_ini_key(sample, "empty_value"), None);
3484        assert_eq!(parse_ini_key(sample, "nonexistent"), None);
3485    }
3486
3487    #[test]
3488    fn test_system_info_collect() {
3489        let opts = crate::fetch::CollectOptions::default();
3490        let res = SystemInfo::collect(opts);
3491        assert!(res.is_ok());
3492        let info = res.unwrap();
3493        assert!(!info.os.is_empty());
3494        assert!(info.cpu_cores > 0);
3495    }
3496
3497    #[cfg(target_os = "macos")]
3498    #[test]
3499    fn test_parse_macos_displays() {
3500        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";
3501        let parsed = parse_macos_displays(sample);
3502        assert_eq!(parsed, vec!["Color LCD (3024x1964 @ 60Hz)".to_string()]);
3503    }
3504
3505    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
3506    #[test]
3507    fn test_parse_xrandr_displays() {
3508        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";
3509        let parsed = parse_xrandr_displays(sample);
3510        assert_eq!(parsed, vec!["DP-1 (2560x1440 @ 143.97Hz)".to_string()]);
3511    }
3512
3513    #[test]
3514    fn test_parse_refresh_rate_from_edid() {
3515        // Construct a mock EDID block with a DTD at byte 54
3516        let mut edid = vec![0u8; 128];
3517        // Pixel clock = 14850 -> 0x3A02 (in 10 kHz units -> 148.5 MHz)
3518        edid[54] = 0x02;
3519        edid[55] = 0x3A;
3520        // H Active = 1920 (0x780), H Blanking = 280 (0x118)
3521        edid[56] = 0x80; // Low 8 bits of H Active
3522        edid[57] = 0x18; // Low 8 bits of H Blanking
3523        edid[58] = 0x71; // High 4 bits: H Active (7), H Blanking (1)
3524                         // V Active = 1080 (0x438), V Blanking = 45 (0x2D)
3525        edid[59] = 0x38; // Low 8 bits of V Active
3526        edid[60] = 0x2D; // Low 8 bits of V Blanking
3527        edid[61] = 0x40; // High 4 bits: V Active (4), V Blanking (0)
3528
3529        let refresh = parse_refresh_rate_from_edid(&edid);
3530        assert!(refresh.is_some());
3531        // 148,500,000 / ((1920 + 280) * (1080 + 45)) = 60 Hz
3532        assert_eq!(refresh.unwrap(), 60.0);
3533    }
3534
3535    #[test]
3536    fn test_format_refresh_rate() {
3537        assert_eq!(format_refresh_rate(60.0), "60");
3538        assert_eq!(format_refresh_rate(59.94), "59.94");
3539        assert_eq!(format_refresh_rate(143.971), "143.97");
3540    }
3541
3542    #[test]
3543    fn test_parse_serial_number_from_edid() {
3544        let mut edid = vec![0u8; 128];
3545        // 1. Test fallback 32-bit numeric serial
3546        edid[12] = 0x78;
3547        edid[13] = 0x56;
3548        edid[14] = 0x34;
3549        edid[15] = 0x12; // 0x12345678 = 305419896
3550        assert_eq!(
3551            parse_serial_number_from_edid(&edid),
3552            Some("305419896".to_string())
3553        );
3554
3555        // 2. Test ASCII Monitor Serial Number descriptor block (tag 0xFF) at offset 72
3556        edid[72] = 0x00;
3557        edid[73] = 0x00;
3558        edid[74] = 0x00;
3559        edid[75] = 0xFF; // ASCII Serial Number descriptor tag
3560        let serial_str = b"CN0123456789\n";
3561        for i in 0..serial_str.len() {
3562            edid[76 + i] = serial_str[i];
3563        }
3564        assert_eq!(
3565            parse_serial_number_from_edid(&edid),
3566            Some("CN0123456789".to_string())
3567        );
3568    }
3569
3570    #[test]
3571    fn test_parse_asound_cards() {
3572        let sample = " 0 [PCH            ]: HDA-Intel - HDA Intel PCH\n 1 [NVidia         ]: HDA-Intel - HDA NVIDIA HDMI\n 2 [sofhdadsp      ]: sof-hda-dsp - sof-hda-dsp\n                      DellInc.-Inspiron1676302_in_1-0DR8JD\n";
3573        let parsed = parse_asound_cards(sample, "/nonexistent");
3574        assert_eq!(
3575            parsed,
3576            vec![
3577                "HDA Intel PCH".to_string(),
3578                "HDA NVIDIA HDMI".to_string(),
3579                "sof-hda-dsp".to_string()
3580            ]
3581        );
3582    }
3583
3584    #[test]
3585    fn test_parse_proc_net_route() {
3586        let sample =
3587            "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n\
3588                      wlan0\t0000A8C0\t00000000\t0001\t0\t0\t600\t0000FFFF\t0\t0\t0\n\
3589                      wlan0\t00000000\t0100A8C0\t0003\t0\t0\t600\t00000000\t0\t0\t0\n";
3590        assert_eq!(parse_proc_net_route(sample), Some("wlan0".to_string()));
3591
3592        let sample_no_default =
3593            "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n\
3594                                 wlan0\t0000A8C0\t00000000\t0001\t0\t0\t600\t0000FFFF\t0\t0\t0\n";
3595        assert_eq!(parse_proc_net_route(sample_no_default), None);
3596    }
3597
3598    #[test]
3599    fn test_parse_airport_output() {
3600        let sample = "     agrCtlRSSI: -45\n     lastTxRate: 866\n           SSID: MyHomeWiFi\n";
3601        assert_eq!(
3602            parse_airport_output(sample),
3603            Some("MyHomeWiFi (↑866 Mbps)".to_string())
3604        );
3605
3606        let sample_no_rate = "     agrCtlRSSI: -45\n           SSID: GuestNetwork\n";
3607        assert_eq!(
3608            parse_airport_output(sample_no_rate),
3609            Some("GuestNetwork".to_string())
3610        );
3611    }
3612
3613    #[test]
3614    fn test_parse_netsh_output() {
3615        let sample = "    Name                   : Wi-Fi\n    State                  : connected\n    SSID                   : Office_Wi-Fi\n    Receive rate (Mbps)    : 433\n    Transmit rate (Mbps)   : 866\n    Band                   : 5 GHz\n";
3616        assert_eq!(
3617            parse_netsh_output(sample),
3618            Some("Office_Wi-Fi (5 GHz [↓433 Mbps ↑866 Mbps])".to_string())
3619        );
3620    }
3621
3622    #[test]
3623    fn test_parse_iw_link_output() {
3624        let sample = "Connected to 84:78:48:dc:97:23 (on wlp2s0)\n        SSID: OfficeNet\n        freq: 6135.0\n        rx bitrate: 6.0 MBit/s\n        tx bitrate: 864.6 MBit/s 160MHz HE-MCS 4\n";
3625        let (ssid, links) = parse_iw_link_output(sample);
3626        assert_eq!(ssid, Some("OfficeNet".to_string()));
3627        assert_eq!(links.len(), 1);
3628        assert_eq!(links[0].freq, Some(6135.0));
3629        assert_eq!(links[0].rx_rate, Some("6.0 MBit/s".to_string()));
3630        assert_eq!(links[0].tx_rate, Some("864.6 MBit/s".to_string()));
3631
3632        // MLO multi-link mock output
3633        let sample_mlo = "Connected to aa:bb:cc:dd:ee:ff (on wlan0)\n        SSID: HomeWiFi\n        freq: 5180.0\n        rx bitrate: 866.0 MBit/s\n        tx bitrate: 866.0 MBit/s\nConnected to aa:bb:cc:dd:ee:01 (on wlan0)\n        freq: 6135.0\n        rx bitrate: 1200.0 MBit/s\n        tx bitrate: 1200.0 MBit/s\n";
3634        let (ssid_mlo, links_mlo) = parse_iw_link_output(sample_mlo);
3635        assert_eq!(ssid_mlo, Some("HomeWiFi".to_string()));
3636        assert_eq!(links_mlo.len(), 2);
3637        assert_eq!(links_mlo[0].freq, Some(5180.0));
3638        assert_eq!(links_mlo[1].freq, Some(6135.0));
3639    }
3640
3641    #[test]
3642    fn test_parse_macos_bluetooth() {
3643        let sample = "Bluetooth:\n\n      Bluetooth Power: On\n      Chipset: BCM4350\n      Devices (Connected):\n          Sony WH-1000XM4:\n              Address: AA-BB-CC\n              Connected: Yes\n          Logitech MX Master:\n              Address: DD-EE-FF\n              Connected: Yes\n";
3644        assert_eq!(
3645            parse_macos_bluetooth(sample),
3646            Some(
3647                "On (Apple BCM4350) - 2 connected (Sony WH-1000XM4, Logitech MX Master)"
3648                    .to_string()
3649            )
3650        );
3651
3652        let sample_off = "Bluetooth:\n\n      Bluetooth Power: Off\n";
3653        assert_eq!(
3654            parse_macos_bluetooth(sample_off),
3655            Some("Off (Apple Bluetooth)".to_string())
3656        );
3657
3658        let sample_state_on = "Bluetooth:\n\n      State: On\n      Chipset: BCM_4388\n";
3659        assert_eq!(
3660            parse_macos_bluetooth(sample_state_on),
3661            Some("On (Apple BCM_4388) - 0 connected".to_string())
3662        );
3663    }
3664
3665    #[test]
3666    fn test_parse_windows_bluetooth_output() {
3667        let sample =
3668            "Running | Intel(R) Wireless Bluetooth(R) | (Sony WH-1000XM4,Logitech MX Master)\n";
3669        assert_eq!(
3670            parse_windows_bluetooth_output(sample),
3671            Some("On (Intel(R) Wireless Bluetooth(R)) - 2 connected (Sony WH-1000XM4, Logitech MX Master)".to_string())
3672        );
3673
3674        let sample_off = "Stopped | | ()\n";
3675        assert_eq!(
3676            parse_windows_bluetooth_output(sample_off),
3677            Some("Off".to_string())
3678        );
3679    }
3680
3681    #[test]
3682    fn test_parse_shell_version() {
3683        // Bash
3684        let bash_out = "GNU bash, version 5.2.15(1)-release (x86_64-pc-linux-gnu)\nCopyright (C) 2022 Free Software Foundation, Inc.";
3685        assert_eq!(
3686            parse_shell_version("bash", bash_out),
3687            Some("5.2.15".to_string())
3688        );
3689
3690        // Zsh
3691        let zsh_out = "zsh 5.9 (x86_64-pc-linux-gnu)";
3692        assert_eq!(parse_shell_version("zsh", zsh_out), Some("5.9".to_string()));
3693
3694        // Fish
3695        let fish_out = "fish, version 3.6.0";
3696        assert_eq!(
3697            parse_shell_version("fish", fish_out),
3698            Some("3.6.0".to_string())
3699        );
3700
3701        // Nushell
3702        let nu_out = "0.93.0";
3703        assert_eq!(
3704            parse_shell_version("nu", nu_out),
3705            Some("0.93.0".to_string())
3706        );
3707
3708        // PowerShell Core
3709        let pwsh_out = "PowerShell 7.4.1";
3710        assert_eq!(
3711            parse_shell_version("pwsh", pwsh_out),
3712            Some("7.4.1".to_string())
3713        );
3714
3715        // Windows PowerShell
3716        let powershell_out = "5.1.22621.2428";
3717        assert_eq!(
3718            parse_shell_version("powershell", powershell_out),
3719            Some("5.1.22621.2428".to_string())
3720        );
3721
3722        // Elvish
3723        let elvish_out = "0.20.1";
3724        assert_eq!(
3725            parse_shell_version("elvish", elvish_out),
3726            Some("0.20.1".to_string())
3727        );
3728
3729        // Tcsh
3730        let tcsh_out = "tcsh 6.24.10 (Astron) 2023-04-20 (x86_64-amd-linux) options wide,nls,dl,al,kan,sm,color,filec";
3731        assert_eq!(
3732            parse_shell_version("tcsh", tcsh_out),
3733            Some("6.24.10".to_string())
3734        );
3735
3736        // Generic fallback
3737        let custom_out = "CustomShell version 1.2.3-patch4";
3738        assert_eq!(
3739            parse_shell_version("custom", custom_out),
3740            Some("1.2.3".to_string())
3741        );
3742    }
3743
3744    #[test]
3745    fn test_parse_macos_camera() {
3746        let sample = "Camera:\n\n    FaceTime HD Camera:\n\n      Model ID: UVC Camera VendorID_1452 ProductID_34068\n      Unique ID: 0x8020000005ac8514\n";
3747        assert_eq!(
3748            parse_macos_camera(sample),
3749            vec!["FaceTime HD Camera".to_string()]
3750        );
3751    }
3752
3753    #[test]
3754    fn test_parse_macos_gamepad() {
3755        let usb_sample = "USB 3.1 Bus:\n\n    Xbox Wireless Controller:\n\n      Product ID: 0x02e0\n      Vendor ID: 0x045e\n";
3756        let bt_sample = "Bluetooth:\n\n      Devices (Connected):\n          DualSense Wireless Controller:\n              Address: AA-BB-CC\n              Connected: Yes\n";
3757        let parsed = parse_macos_gamepad(usb_sample, bt_sample);
3758        assert_eq!(
3759            parsed,
3760            vec![
3761                "Xbox Wireless Controller".to_string(),
3762                "DualSense Wireless Controller".to_string()
3763            ]
3764        );
3765    }
3766}
3767
3768#[cfg(target_os = "windows")]
3769#[allow(clippy::upper_case_acronyms)]
3770mod win_reg {
3771    use std::ffi::OsStr;
3772    use std::os::windows::ffi::OsStrExt;
3773    use std::ptr;
3774
3775    type HKEY = *mut std::ffi::c_void;
3776    pub const HKEY_LOCAL_MACHINE: HKEY = 0x80000002 as HKEY;
3777    pub const HKEY_CURRENT_USER: HKEY = 0x80000001 as HKEY;
3778    const KEY_READ: u32 = 0x20019;
3779
3780    extern "system" {
3781        fn RegOpenKeyExW(
3782            hKey: HKEY,
3783            lpSubKey: *const u16,
3784            ulOptions: u32,
3785            samDesired: u32,
3786            phkResult: *mut HKEY,
3787        ) -> i32;
3788
3789        fn RegQueryValueExW(
3790            hKey: HKEY,
3791            lpValueName: *const u16,
3792            lpReserved: *mut u32,
3793            lpType: *mut u32,
3794            lpData: *mut u8,
3795            lpcbData: *mut u32,
3796        ) -> i32;
3797
3798        fn RegCloseKey(hKey: HKEY) -> i32;
3799    }
3800
3801    pub fn get_reg_string(hkey: HKEY, subkey: &str, value: &str) -> Option<String> {
3802        let subkey_w: Vec<u16> = OsStr::new(subkey).encode_wide().chain(Some(0)).collect();
3803        let value_w: Vec<u16> = OsStr::new(value).encode_wide().chain(Some(0)).collect();
3804        let mut hk: HKEY = ptr::null_mut();
3805
3806        let res = unsafe { RegOpenKeyExW(hkey, subkey_w.as_ptr(), 0, KEY_READ, &mut hk) };
3807        if res != 0 {
3808            return None;
3809        }
3810
3811        let mut size: u32 = 0;
3812        let mut ty: u32 = 0;
3813        unsafe {
3814            RegQueryValueExW(
3815                hk,
3816                value_w.as_ptr(),
3817                ptr::null_mut(),
3818                &mut ty,
3819                ptr::null_mut(),
3820                &mut size,
3821            );
3822        }
3823
3824        if size == 0 {
3825            unsafe {
3826                RegCloseKey(hk);
3827            }
3828            return None;
3829        }
3830
3831        let mut buf = vec![0u8; size as usize];
3832        let res = unsafe {
3833            RegQueryValueExW(
3834                hk,
3835                value_w.as_ptr(),
3836                ptr::null_mut(),
3837                &mut ty,
3838                buf.as_mut_ptr(),
3839                &mut size,
3840            )
3841        };
3842        unsafe {
3843            RegCloseKey(hk);
3844        }
3845
3846        if res == 0 {
3847            if ty == 1 || ty == 2 {
3848                // REG_SZ or REG_EXPAND_SZ
3849                let words = unsafe {
3850                    std::slice::from_raw_parts(buf.as_ptr() as *const u16, buf.len() / 2)
3851                };
3852                let len = words.iter().position(|&x| x == 0).unwrap_or(words.len());
3853                String::from_utf16(&words[..len]).ok()
3854            } else {
3855                None
3856            }
3857        } else {
3858            None
3859        }
3860    }
3861
3862    pub fn get_reg_u32(hkey: HKEY, subkey: &str, value: &str) -> Option<u32> {
3863        let subkey_w: Vec<u16> = OsStr::new(subkey).encode_wide().chain(Some(0)).collect();
3864        let value_w: Vec<u16> = OsStr::new(value).encode_wide().chain(Some(0)).collect();
3865        let mut hk: HKEY = ptr::null_mut();
3866
3867        let res = unsafe { RegOpenKeyExW(hkey, subkey_w.as_ptr(), 0, KEY_READ, &mut hk) };
3868        if res != 0 {
3869            return None;
3870        }
3871
3872        let mut size: u32 = 4;
3873        let mut ty: u32 = 0;
3874        let mut val: u32 = 0;
3875        let res = unsafe {
3876            RegQueryValueExW(
3877                hk,
3878                value_w.as_ptr(),
3879                ptr::null_mut(),
3880                &mut ty,
3881                &mut val as *mut u32 as *mut u8,
3882                &mut size,
3883            )
3884        };
3885        unsafe {
3886            RegCloseKey(hk);
3887        }
3888
3889        if res == 0 && ty == 4 {
3890            // REG_DWORD
3891            Some(val)
3892        } else {
3893            None
3894        }
3895    }
3896}