Skip to main content

retch_cli/
fetch.rs

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