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