Skip to main content

retch_sysinfo/
fetch.rs

1// SPDX-FileCopyrightText: 2026 Ken Tobias
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4//! System information gathering.
5//!
6//! Uses the `sysinfo` crate and other heuristics to collect details
7//! about the OS, hardware, and environment.
8
9use crate::gpu;
10use chrono::TimeZone;
11use sysinfo::{Components, System};
12// `Users` is only used for the non-Windows user count; on Windows the WTS-based
13// `win_users` path is used instead, so importing it there would be an unused import.
14#[cfg(not(target_os = "windows"))]
15use sysinfo::Users;
16
17/// Options for controlling what system information is gathered.
18///
19/// This decouples the collection logic from the CLI argument parser,
20/// allowing `retch-sysinfo` to be used as a standalone library.
21#[derive(Debug, Default, Clone)]
22pub struct CollectOptions {
23    /// Show all disk mounts (long/full mode); when false, shows only the home-directory mount.
24    pub long: bool,
25    /// Include FUSE mounts (full mode only).
26    pub full: bool,
27    /// List of fields that are requested to be displayed. If None, all fields are collected.
28    pub fields: Option<Vec<String>>,
29    /// Optional location override for weather lookup (city, ZIP, airport code, coordinates).
30    pub weather_location: Option<String>,
31    /// Temperature unit for weather display.
32    pub weather_unit: crate::weather::WeatherUnit,
33}
34
35/// Comprehensive system information data structure.
36///
37/// This struct holds all the metrics collected from the system,
38/// ranging from OS details to hardware specs and network status.
39#[derive(Debug)]
40pub struct SystemInfo {
41    /// Operating system name and version.
42    pub os: String,
43    /// Kernel version.
44    pub kernel: Option<String>,
45    /// System hostname.
46    pub hostname: Option<String>,
47    /// CPU architecture (e.g., x86_64).
48    pub arch: String,
49    /// CPU model brand string.
50    pub cpu: String,
51    /// Total number of logical CPU cores.
52    pub cpu_cores: usize,
53    /// Formatted core topology string (e.g. "8C / 16T" or "6P + 4E / 16T").
54    pub cpu_core_info: String,
55    /// Formatted memory usage (Used / Total).
56    pub memory: String,
57    /// Formatted swap usage (Used / Total).
58    pub swap: String,
59    /// System uptime formatted as a duration.
60    pub uptime: String,
61    /// Number of currently running processes.
62    pub processes: usize,
63    /// Load average (1, 5, 15 minutes).
64    pub load_avg: Option<String>,
65    /// List of mounted disks with usage information.
66    pub disks: Vec<String>,
67    /// Hardware component temperatures.
68    pub temps: Vec<String>,
69    /// Network interface statistics and status.
70    pub networks: Vec<crate::network::NetworkInterface>,
71    /// System boot time in ISO 8601 format.
72    pub boot_time: String,
73    /// Battery status (currently placeholder for future feature).
74    pub battery: Option<String>,
75    /// Path to the current user's shell.
76    pub shell: Option<String>,
77    /// Name of the terminal emulator in use.
78    pub terminal: Option<String>,
79    /// Detected desktop environment or window manager.
80    pub desktop: Option<String>,
81    /// Current CPU frequency (formatted).
82    pub cpu_freq: Option<String>,
83    /// Number of interactive users (UID >= 1000).
84    pub users: usize,
85    /// List of detected GPUs with model names.
86    pub gpu: Vec<String>,
87    /// Total count of installed packages across supported managers.
88    pub packages: Option<usize>,
89    /// Name of the user running the process.
90    pub current_user: Option<String>,
91    /// Primary local IP address.
92    pub local_ip: Option<String>,
93    /// Public IP address (best effort).
94    pub public_ip: Option<String>,
95    /// Name of the active/default network interface.
96    pub active_interface: Option<String>,
97    /// Detected motherboard name and manufacturer.
98    pub motherboard: Option<String>,
99    /// Detected BIOS details.
100    pub bios: Option<String>,
101    /// List of connected display resolutions and refresh rates.
102    pub displays: Vec<String>,
103    /// Detected active audio driver/server and devices.
104    pub audio: Option<String>,
105    /// Connected Wi-Fi SSID and speed.
106    pub wifi: Option<String>,
107    /// Bluetooth power status.
108    pub bluetooth: Option<String>,
109    /// UI Theme (GTK, Qt, macOS, Windows).
110    pub ui_theme: Option<String>,
111    /// Icon theme (GTK/Qt).
112    pub icons: Option<String>,
113    /// Cursor theme (GTK/Qt).
114    pub cursor: Option<String>,
115    /// System Font.
116    pub font: Option<String>,
117    /// Terminal Font (configured in terminal emulator).
118    pub terminal_font: Option<String>,
119    /// Connected camera/webcam names.
120    pub camera: Vec<String>,
121    /// Connected gamepad/controller names.
122    pub gamepad: Vec<String>,
123    /// CPU cache sizes (L1d, L1i, L2, L3).
124    pub cpu_cache: Option<String>,
125    /// Current CPU utilization as a percentage.
126    pub cpu_usage: Option<String>,
127    /// Physical disk models, sizes, and types.
128    pub physical_disks: Vec<String>,
129    /// Vulkan API version and driver, e.g. `1.4.354 - radv [Mesa 26.1.8]`. Linux only.
130    pub vulkan: Option<String>,
131    /// OpenGL version string from a headless context. Linux only.
132    pub opengl: Option<String>,
133    /// OpenCL platform version, provider, and the device it exposes (if any). Linux only.
134    pub opencl: Option<String>,
135    /// Per-disk read/write throughput over the collection window. Linux only.
136    pub disk_io: Vec<String>,
137    /// Per-interface RX/TX throughput over the collection window. Linux only.
138    pub net_io: Vec<String>,
139    /// Physical memory (RAM) slot summary — type, speed, capacity.
140    pub physical_memory: Option<String>,
141    /// PID 1 / init system (systemd, runit, OpenRC, launchd, etc.).
142    pub init_system: Option<String>,
143    /// Chassis type (Desktop, Laptop, Server, etc.).
144    pub chassis: Option<String>,
145    /// System locale (from $LANG / $LC_ALL).
146    pub locale: Option<String>,
147    /// Second-stage bootloader (GRUB, systemd-boot, etc.).
148    pub bootmgr: Option<String>,
149    /// Default editor ($VISUAL / $EDITOR).
150    pub editor: Option<String>,
151    /// Current weather from Open-Meteo.
152    pub weather: Option<String>,
153    /// Active window manager name.
154    pub wm: Option<String>,
155    /// Configured DNS nameservers.
156    pub dns: Vec<String>,
157    /// Configured DNS domain name (Linux: the default-route interface's own domain; falls
158    /// back to `domain`/first `search` in resolv.conf. See [`crate::network::detect_domain`]).
159    pub domain: Option<String>,
160    /// Per-interface DNS search domain lists (from resolvectl or resolv.conf `search`),
161    /// excluding systemd routing-only (`~`-prefixed) domains.
162    pub domain_search: Vec<String>,
163    /// Terminal dimensions as "COLSxROWS".
164    pub terminal_size: Option<String>,
165    /// Mounted btrfs filesystems with label and space allocation.
166    pub btrfs: Vec<String>,
167    /// Imported ZFS pools with allocation and health status.
168    pub zpool: Vec<String>,
169    /// Active display/login manager (GDM, SDDM, LightDM, …). Linux only.
170    pub login_manager: Option<String>,
171    /// Current backlight brightness as a percentage. Linux only.
172    pub brightness: Option<String>,
173    /// AC power adapter name and connection state. Linux only.
174    pub power_adapter: Option<String>,
175    /// Connected keyboards. Linux only; see [`crate::input`] for why a device can be
176    /// deliberately absent from both this and [`Self::mouse`].
177    pub keyboard: Vec<String>,
178    /// Connected pointing devices (mice, touchpads, tablets). Linux only.
179    pub mouse: Vec<String>,
180    /// TPM specification version (e.g. "2.0"). Linux only.
181    pub tpm: Option<String>,
182    /// Currently playing media track (e.g. "Artist - Title").
183    pub media: Option<String>,
184    /// Active media player (e.g. "Spotify (Playing)").
185    pub player: Option<String>,
186    /// Active window manager theme / decoration style.
187    pub wm_theme: Option<String>,
188    /// Active desktop background wallpaper file path or URI.
189    pub wallpaper: Option<String>,
190    /// Terminal emulator color scheme / theme name.
191    pub terminal_theme: Option<String>,
192}
193
194impl SystemInfo {
195    /// Collects system information using sysinfo and environment probes.
196    ///
197    /// This method aggregates data from the operating system, hardware,
198    /// and current user environment into a `SystemInfo` struct.
199    pub fn collect(opts: CollectOptions) -> anyhow::Result<Self> {
200        let should_collect = |field_name: &str| -> bool {
201            match &opts.fields {
202                Some(fields) => {
203                    let norm_field = field_name.to_lowercase().replace(['-', '_'], " ");
204                    let norm_field_no_spaces = norm_field.replace(' ', "");
205                    fields.iter().any(|f| {
206                        let norm_f = f.to_lowercase().replace(['-', '_'], " ");
207                        norm_f == norm_field || norm_f.replace(' ', "") == norm_field_no_spaces
208                    })
209                }
210                None => true,
211            }
212        };
213
214        let mut refresh_kind = sysinfo::RefreshKind::nothing();
215        if should_collect("cpu")
216            || should_collect("cpu usage")
217            || should_collect("cpu-usage")
218            || should_collect("cpu cache")
219            || should_collect("cpu-cache")
220        {
221            refresh_kind = refresh_kind.with_cpu(sysinfo::CpuRefreshKind::everything());
222        }
223        if should_collect("memory")
224            || should_collect("swap")
225            || should_collect("phys mem")
226            || should_collect("phys-mem")
227        {
228            refresh_kind = refresh_kind.with_memory(sysinfo::MemoryRefreshKind::everything());
229        }
230        if should_collect("procs") || should_collect("audio") {
231            refresh_kind = refresh_kind.with_processes(sysinfo::ProcessRefreshKind::nothing());
232        }
233
234        // `mut` is only needed off-Windows (refresh_cpu_usage below); on Windows CPU usage
235        // comes from GetSystemTimes, so `sys` is never mutated there.
236        #[cfg_attr(target_os = "windows", allow(unused_mut))]
237        let mut sys = System::new_with_specifics(refresh_kind);
238
239        let os = System::long_os_version()
240            .or_else(System::name)
241            .unwrap_or_else(|| "Unknown".to_string());
242
243        let kernel = System::kernel_version();
244        let hostname = System::host_name();
245
246        let cpu = if should_collect("cpu") {
247            sys.cpus()
248                .first()
249                .map(|c| c.brand().to_string())
250                .unwrap_or_else(|| "Unknown CPU".to_string())
251        } else {
252            String::new()
253        };
254
255        let cpu_cores = if should_collect("cpu") {
256            sys.cpus().len()
257        } else {
258            0
259        };
260        let cpu_core_info = if should_collect("cpu") {
261            format_cpu_cores(cpu_cores, System::physical_core_count())
262        } else {
263            String::new()
264        };
265
266        let memory = if should_collect("memory") {
267            let total_mem = sys.total_memory() as f64 / 1024.0 / 1024.0 / 1024.0;
268            let used_mem = sys.used_memory() as f64 / 1024.0 / 1024.0 / 1024.0;
269            format!("{:.1} / {:.1} GB", used_mem, total_mem)
270        } else {
271            String::new()
272        };
273
274        let swap = if should_collect("swap") {
275            let total_swap = sys.total_swap() as f64 / 1024.0 / 1024.0 / 1024.0;
276            let used_swap = sys.used_swap() as f64 / 1024.0 / 1024.0 / 1024.0;
277            if total_swap > 0.0 {
278                format!("{:.1} / {:.1} GB", used_swap, total_swap)
279            } else {
280                "No swap".to_string()
281            }
282        } else {
283            String::new()
284        };
285
286        let uptime = format!("{}s", System::uptime());
287
288        let disks: Vec<String> = if should_collect("disk") {
289            let disks_list = crate::disk::detect_logical_disks(opts.full);
290            let format_disk = |(mount, total, avail, fs): &(String, u64, u64, String)| {
291                let total_gb = *total as f64 / 1024.0 / 1024.0 / 1024.0;
292                let avail_gb = *avail as f64 / 1024.0 / 1024.0 / 1024.0;
293                format!(
294                    "{} ({}): {:.1} GB free / {:.1} GB",
295                    mount, fs, avail_gb, total_gb
296                )
297            };
298            if !opts.long {
299                let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/"));
300                let home_path = std::path::Path::new(&home);
301                let best = disks_list
302                    .iter()
303                    .filter(|(mp, ..)| home_path.starts_with(mp))
304                    .max_by_key(|(mp, ..)| std::path::Path::new(mp).components().count());
305                if let Some(disk) = best {
306                    vec![format_disk(disk)]
307                } else {
308                    disks_list.iter().map(format_disk).collect()
309                }
310            } else {
311                disks_list.iter().map(format_disk).collect()
312            }
313        } else {
314            Vec::new()
315        };
316
317        let battery = if should_collect("battery") {
318            crate::battery::get_battery_info().map(|bat| {
319                let pct = bat.percentage;
320                let state = match bat.state {
321                    crate::battery::BatteryState::Charging => "charging",
322                    crate::battery::BatteryState::Discharging => "discharging",
323                    crate::battery::BatteryState::Full => "full",
324                    _ => "not charging",
325                };
326                let vendor = bat.vendor;
327                let model = bat.model;
328
329                // Format time remaining as "Xh Ym" or "Xd Yh"
330                let time_str = match bat.state {
331                    crate::battery::BatteryState::Charging => bat.time_remaining.map(|d| {
332                        let total_mins = d.as_secs() / 60;
333                        let hours = total_mins / 60;
334                        let mins = total_mins % 60;
335                        if hours >= 24 {
336                            let days = hours / 24;
337                            let rem_hours = hours % 24;
338                            format!("{}d {}h until full", days, rem_hours)
339                        } else if hours > 0 {
340                            format!("{}h {}m until full", hours, mins)
341                        } else {
342                            format!("{}m until full", mins)
343                        }
344                    }),
345                    crate::battery::BatteryState::Discharging => bat.time_remaining.map(|d| {
346                        let total_mins = d.as_secs() / 60;
347                        let hours = total_mins / 60;
348                        let mins = total_mins % 60;
349                        if hours >= 24 {
350                            let days = hours / 24;
351                            let rem_hours = hours % 24;
352                            format!("{}d {}h remaining", days, rem_hours)
353                        } else if hours > 0 {
354                            format!("{}h {}m remaining", hours, mins)
355                        } else {
356                            format!("{}m remaining", mins)
357                        }
358                    }),
359                    _ => None,
360                };
361
362                let mut parts = vec![state.to_string()];
363                if let Some(t) = time_str {
364                    parts.insert(0, t);
365                }
366                if let Some(health) = bat.health {
367                    if health < 99.0 {
368                        parts.push(format!("{:.0}% health", health));
369                    }
370                }
371
372                let base = format!("{:.0}% ({})", pct, parts.join(", "));
373
374                match (vendor, model) {
375                    (Some(v), Some(m)) => format!("{} [{} {}]", base, v, m),
376                    (Some(v), None) => format!("{} [{}]", base, v),
377                    _ => base,
378                }
379            })
380        } else {
381            None
382        };
383
384        let arch = System::cpu_arch();
385
386        let processes = if should_collect("procs") || should_collect("audio") {
387            sys.processes().len()
388        } else {
389            0
390        };
391
392        let load_avg = {
393            let avg = System::load_average();
394            if avg.one > 0.0 || avg.five > 0.0 {
395                Some(format!(
396                    "{:.2}, {:.2}, {:.2}",
397                    avg.one, avg.five, avg.fifteen
398                ))
399            } else {
400                None
401            }
402        };
403
404        // Windows: sample cumulative CPU times before the concurrent probes run, so CPU
405        // usage can be computed over the real collection window below. In a normal run the
406        // window is already long enough; a floor is enforced later only for tiny requests.
407        #[cfg(target_os = "windows")]
408        let cpu_sample0 = win_cpu::sample();
409        #[cfg(target_os = "windows")]
410        let cpu_t0 = std::time::Instant::now();
411
412        // Disk/network I/O are rates, and the kernel only exposes cumulative counters, so
413        // they need two samples and an interval. Take the first here — before the
414        // concurrent probes — and diff it after them, making the collection window the
415        // sampling window. That is the v0.3.49 `cpu-usage` approach, and it is why these
416        // fields cost no wall-clock in a normal run where fastfetch spends a full second.
417        let want_disk_io = should_collect("disk-io") || should_collect("disk io");
418        let want_net_io = should_collect("net-io") || should_collect("net io");
419        let disk_io_sample0 = if want_disk_io {
420            crate::io::sample_disk_io()
421        } else {
422            Vec::new()
423        };
424        let net_io_sample0 = if want_net_io {
425            crate::io::sample_net_io()
426        } else {
427            Vec::new()
428        };
429        let io_t0 = std::time::Instant::now();
430
431        // Compute slow system queries concurrently in parallel threads
432        let (
433            gpu,
434            packages,
435            public_ip,
436            (local_ip, active_interface),
437            motherboard,
438            bios,
439            displays,
440            audio,
441            wifi,
442            bluetooth,
443            (ui_theme, icons, cursor, font),
444            camera,
445            gamepad,
446            physical_disks,
447            physical_memory,
448            weather,
449            btrfs,
450            zpool,
451            (media, player),
452            gpu_apis,
453        ) = std::thread::scope(|s| {
454            let gpu_handle = if should_collect("gpu") {
455                Some(s.spawn(|| {
456                    gpu::detect_gpus()
457                        .into_iter()
458                        .map(|g| g.format())
459                        .collect::<Vec<String>>()
460                }))
461            } else {
462                None
463            };
464            let packages_handle = if should_collect("packages") {
465                Some(s.spawn(crate::packages::detect_packages))
466            } else {
467                None
468            };
469            let public_ip_handle = if should_collect("public ip") {
470                Some(s.spawn(crate::network::detect_public_ip))
471            } else {
472                None
473            };
474            let network_ips_handle = if should_collect("net") {
475                Some(s.spawn(crate::network::detect_active_interface_and_local_ip))
476            } else {
477                None
478            };
479            let motherboard_handle = if should_collect("motherboard") {
480                Some(s.spawn(crate::motherboard::detect_motherboard))
481            } else {
482                None
483            };
484            let bios_handle = if should_collect("bios") {
485                Some(s.spawn(crate::bios::detect_bios))
486            } else {
487                None
488            };
489            let displays_handle = if should_collect("display") {
490                Some(s.spawn(crate::display::detect_displays))
491            } else {
492                None
493            };
494            let audio_handle = if should_collect("audio") {
495                Some(s.spawn(|| crate::audio::detect_audio(&sys)))
496            } else {
497                None
498            };
499            let wifi_handle = if should_collect("wifi") {
500                Some(s.spawn(crate::network::detect_wifi))
501            } else {
502                None
503            };
504            let bluetooth_handle = if should_collect("bluetooth") {
505                Some(s.spawn(crate::bluetooth::detect_bluetooth))
506            } else {
507                None
508            };
509            let ui_theme_and_fonts_handle = if should_collect("theme")
510                || should_collect("icons")
511                || should_collect("cursor")
512                || should_collect("font")
513            {
514                Some(s.spawn(crate::theme::detect_ui_theme_and_fonts))
515            } else {
516                None
517            };
518            let camera_handle = if should_collect("camera") {
519                Some(s.spawn(crate::camera::detect_camera))
520            } else {
521                None
522            };
523            let gamepad_handle = if should_collect("gamepad") {
524                Some(s.spawn(crate::gamepad::detect_gamepad))
525            } else {
526                None
527            };
528            let physical_disks_handle = if should_collect("phys disk") {
529                Some(s.spawn(crate::disk::detect_physical_disks))
530            } else {
531                None
532            };
533            let physical_memory_handle = if should_collect("phys mem") {
534                Some(s.spawn(crate::memory::detect_physical_memory))
535            } else {
536                None
537            };
538            let weather_location = opts.weather_location.clone();
539            let weather_unit = opts.weather_unit;
540            let weather_handle = if should_collect("weather") {
541                Some(s.spawn(move || {
542                    crate::weather::detect_weather(weather_location.as_deref(), weather_unit)
543                }))
544            } else {
545                None
546            };
547            let btrfs_handle = if should_collect("btrfs") {
548                Some(s.spawn(crate::btrfs::detect_btrfs))
549            } else {
550                None
551            };
552            let zpool_handle = if should_collect("zpool") {
553                Some(s.spawn(crate::zfs::detect_zpool))
554            } else {
555                None
556            };
557            let media_handle = if should_collect("media") || should_collect("player") {
558                Some(s.spawn(crate::media::detect_media))
559            } else {
560                None
561            };
562            // Vulkan/OpenGL/OpenCL are collected together: all three dlopen a loader and
563            // talk to the same driver stack, so splitting them across threads would buy
564            // contention rather than overlap.
565            let gpu_apis_handle =
566                if should_collect("vulkan") || should_collect("opengl") || should_collect("opencl")
567                {
568                    Some(s.spawn(crate::gpu_api::detect_gpu_apis))
569                } else {
570                    None
571                };
572
573            (
574                gpu_handle
575                    .map(|h| h.join().unwrap_or_default())
576                    .unwrap_or_default(),
577                packages_handle.and_then(|h| h.join().ok().flatten()),
578                public_ip_handle.and_then(|h| h.join().ok().flatten()),
579                network_ips_handle
580                    .map(|h| h.join().unwrap_or((None, None)))
581                    .unwrap_or((None, None)),
582                motherboard_handle.and_then(|h| h.join().ok().flatten()),
583                bios_handle.and_then(|h| h.join().ok().flatten()),
584                displays_handle
585                    .map(|h| h.join().unwrap_or_default())
586                    .unwrap_or_default(),
587                audio_handle.and_then(|h| h.join().ok().flatten()),
588                wifi_handle.and_then(|h| h.join().ok().flatten()),
589                bluetooth_handle.and_then(|h| h.join().ok().flatten()),
590                ui_theme_and_fonts_handle
591                    .map(|h| h.join().unwrap_or((None, None, None, None)))
592                    .unwrap_or((None, None, None, None)),
593                camera_handle
594                    .map(|h| h.join().unwrap_or_default())
595                    .unwrap_or_default(),
596                gamepad_handle
597                    .map(|h| h.join().unwrap_or_default())
598                    .unwrap_or_default(),
599                physical_disks_handle
600                    .map(|h| h.join().unwrap_or_default())
601                    .unwrap_or_default(),
602                physical_memory_handle.and_then(|h| h.join().ok().flatten()),
603                weather_handle.and_then(|h| h.join().ok().flatten()),
604                btrfs_handle
605                    .map(|h| h.join().unwrap_or_default())
606                    .unwrap_or_default(),
607                zpool_handle
608                    .map(|h| h.join().unwrap_or_default())
609                    .unwrap_or_default(),
610                media_handle
611                    .map(|h| h.join().unwrap_or((None, None)))
612                    .unwrap_or((None, None)),
613                gpu_apis_handle
614                    .map(|h| h.join().unwrap_or_default())
615                    .unwrap_or_default(),
616            )
617        });
618
619        let mut temps: Vec<String> = if should_collect("temp") {
620            Components::new_with_refreshed_list()
621                .iter()
622                .filter_map(|c| {
623                    c.temperature().and_then(|t| {
624                        if t > 0.0 {
625                            Some(format!("{}: {:.0}°C", c.label(), t))
626                        } else {
627                            None
628                        }
629                    })
630                })
631                .collect()
632        } else {
633            Vec::new()
634        };
635
636        // Sort so CPU temperatures appear first
637        temps.sort_by(|a, b| {
638            let a_cpu = a.to_lowercase().contains("cpu") || a.to_lowercase().contains("core");
639            let b_cpu = b.to_lowercase().contains("cpu") || b.to_lowercase().contains("core");
640            b_cpu.cmp(&a_cpu)
641        });
642
643        let networks = if should_collect("net") {
644            crate::network::detect_networks(active_interface.as_deref(), local_ip.as_deref())
645        } else {
646            Vec::new()
647        };
648
649        let boot_timestamp = System::boot_time();
650        let boot_dt = chrono::Local
651            .timestamp_opt(boot_timestamp as i64, 0)
652            .single()
653            .map(|dt| dt.format("%Y-%m-%dT%H:%M:%S%:z").to_string())
654            .unwrap_or_else(|| boot_timestamp.to_string());
655        let boot_time = boot_dt;
656
657        // Environment-based info
658        let shell = if should_collect("shell") {
659            crate::shell::detect_shell(&sys)
660        } else {
661            None
662        };
663        let terminal = if should_collect("terminal") {
664            crate::terminal::detect_terminal(&sys)
665        } else {
666            None
667        };
668        let terminal_font = if should_collect("terminal font")
669            || should_collect("terminal-font")
670            || should_collect("terminal_font")
671        {
672            crate::terminal::detect_terminal_font(terminal.as_deref())
673        } else {
674            None
675        };
676        let desktop = if should_collect("desktop") {
677            std::env::var("XDG_CURRENT_DESKTOP")
678                .or_else(|_| std::env::var("DESKTOP_SESSION"))
679                .or_else(|_| std::env::var("XDG_SESSION_DESKTOP"))
680                .or_else(|_| std::env::var("GDMSESSION"))
681                .ok()
682                .map(|s| normalize_desktop_name(&s))
683                .filter(|s| !s.is_empty())
684                .or_else(detect_desktop_from_proc)
685        } else {
686            None
687        };
688
689        // CPU frequency (current from sysinfo + min/max range from sysfs)
690        let cpu_freq = if should_collect("cpu-freq")
691            || should_collect("cpu freq")
692            || should_collect("cpu_freq")
693        {
694            sys.cpus().first().map(|c| {
695                let current = format!("{:.2} GHz", c.frequency() as f64 / 1000.0);
696                if let Some((min_khz, max_khz)) = detect_cpu_freq_range() {
697                    let min_ghz = min_khz as f64 / 1_000_000.0;
698                    let max_ghz = max_khz as f64 / 1_000_000.0;
699                    format!("{} ({:.2} \u{2013} {:.2} GHz)", current, min_ghz, max_ghz)
700                } else {
701                    current
702                }
703            })
704        } else {
705            None
706        };
707
708        // CPU cache sizes
709        let cpu_cache = if should_collect("cpu-cache")
710            || should_collect("cpu cache")
711            || should_collect("cpu_cache")
712        {
713            detect_cpu_cache()
714        } else {
715            None
716        };
717
718        // CPU usage. On Unix, sysinfo needs a delta between two refreshes and enforces a
719        // ~200 ms minimum interval, so we sleep once. On Windows we instead diff the
720        // GetSystemTimes sample taken before the concurrent scope against a fresh one — the
721        // collection window is the delta, so no sleep is added to the run.
722        let cpu_usage = if should_collect("cpu-usage")
723            || should_collect("cpu usage")
724            || should_collect("cpu_usage")
725        {
726            #[cfg(not(target_os = "windows"))]
727            {
728                std::thread::sleep(std::time::Duration::from_millis(200));
729                sys.refresh_cpu_usage();
730                let usage: f32 =
731                    sys.cpus().iter().map(|c| c.cpu_usage()).sum::<f32>() / sys.cpus().len() as f32;
732                let avg = System::load_average();
733                let load_str = format!("{:.2}, {:.2}, {:.2}", avg.one, avg.five, avg.fifteen);
734                if usage > 0.0 {
735                    Some(format!("{:.1}% (load: {})", usage, load_str))
736                } else if avg.one > 0.0 {
737                    Some(format!("load: {}", load_str))
738                } else {
739                    None
740                }
741            }
742            #[cfg(target_os = "windows")]
743            {
744                // The concurrent scope above is usually the sampling window; only top it up
745                // to a ~100 ms floor when few fields were requested (so an isolated
746                // `--fields cpu-usage` still reads sensibly rather than sampling noise).
747                let floor = std::time::Duration::from_millis(100);
748                let elapsed = cpu_t0.elapsed();
749                if elapsed < floor {
750                    std::thread::sleep(floor - elapsed);
751                }
752                match (cpu_sample0, win_cpu::sample()) {
753                    (Some(s0), Some(s1)) => {
754                        let usage = win_cpu::usage_percent(s0, s1);
755                        if usage > 0.0 {
756                            Some(format!("{:.1}%", usage))
757                        } else {
758                            None
759                        }
760                    }
761                    _ => None,
762                }
763            }
764        } else {
765            None
766        };
767
768        // Second I/O sample. Deliberately after the `cpu_usage` block above: on Unix that
769        // block sleeps 200 ms for sysinfo's minimum refresh interval, and taking the
770        // second sample afterwards folds that sleep into the window instead of paying for
771        // it twice. The floor below therefore only ever fires for a request so small that
772        // neither the concurrent scope nor the CPU sleep happened.
773        let (disk_io, net_io) = if want_disk_io || want_net_io {
774            let floor = std::time::Duration::from_millis(100);
775            let elapsed = io_t0.elapsed();
776            if elapsed < floor {
777                std::thread::sleep(floor - elapsed);
778            }
779            let elapsed_secs = io_t0.elapsed().as_secs_f64();
780            let disk_io = if want_disk_io {
781                crate::io::compute_rates(
782                    &disk_io_sample0,
783                    &crate::io::sample_disk_io(),
784                    elapsed_secs,
785                )
786                .iter()
787                .map(|r| crate::io::format_io_line(r, "R", "W"))
788                .collect()
789            } else {
790                Vec::new()
791            };
792            let net_io = if want_net_io {
793                let rates = crate::io::compute_rates(
794                    &net_io_sample0,
795                    &crate::io::sample_net_io(),
796                    elapsed_secs,
797                );
798                crate::io::select_net_rates(rates, active_interface.as_deref())
799                    .iter()
800                    .map(|r| crate::io::format_io_line(r, "RX", "TX"))
801                    .collect()
802            } else {
803                Vec::new()
804            };
805            (disk_io, net_io)
806        } else {
807            (Vec::new(), Vec::new())
808        };
809
810        let init_system = if should_collect("init") || should_collect("init system") {
811            detect_init_system()
812        } else {
813            None
814        };
815
816        let chassis = if should_collect("chassis") {
817            detect_chassis()
818        } else {
819            None
820        };
821
822        let locale = if should_collect("locale") {
823            std::env::var("LC_ALL")
824                .ok()
825                .filter(|s| !s.is_empty())
826                .or_else(|| std::env::var("LC_MESSAGES").ok().filter(|s| !s.is_empty()))
827                .or_else(|| std::env::var("LANG").ok().filter(|s| !s.is_empty()))
828        } else {
829            None
830        };
831
832        let bootmgr = if should_collect("bootmgr") || should_collect("boot") {
833            detect_bootmgr()
834        } else {
835            None
836        };
837
838        let login_manager = if should_collect("login-manager") || should_collect("lm") {
839            detect_login_manager()
840        } else {
841            None
842        };
843
844        let brightness = if should_collect("brightness") {
845            detect_brightness()
846        } else {
847            None
848        };
849
850        let power_adapter = if should_collect("power-adapter") {
851            detect_power_adapter()
852        } else {
853            None
854        };
855
856        // Keyboards and mice come from one file read, so they are collected together and then
857        // split rather than parsing `/proc/bus/input/devices` twice.
858        let (keyboard, mouse) = if should_collect("keyboard") || should_collect("mouse") {
859            let (kbds, mice) = crate::input::detect_input_devices();
860            (
861                if should_collect("keyboard") {
862                    kbds
863                } else {
864                    Vec::new()
865                },
866                if should_collect("mouse") {
867                    mice
868                } else {
869                    Vec::new()
870                },
871            )
872        } else {
873            (Vec::new(), Vec::new())
874        };
875
876        let tpm = if should_collect("tpm") {
877            detect_tpm()
878        } else {
879            None
880        };
881
882        let editor = if should_collect("editor") {
883            std::env::var("VISUAL")
884                .ok()
885                .filter(|s| !s.is_empty())
886                .or_else(|| std::env::var("EDITOR").ok().filter(|s| !s.is_empty()))
887        } else {
888            None
889        };
890
891        let wm = if should_collect("wm") || should_collect("window manager") {
892            crate::wm::detect_wm()
893        } else {
894            None
895        };
896
897        let dns = if should_collect("dns") {
898            crate::network::detect_dns()
899        } else {
900            Vec::new()
901        };
902
903        let domain = if should_collect("domain") {
904            crate::network::detect_domain()
905        } else {
906            None
907        };
908
909        let domain_search = if should_collect("domain-search") || should_collect("domain search") {
910            crate::network::detect_domain_search()
911        } else {
912            Vec::new()
913        };
914
915        let terminal_size = if should_collect("terminal size")
916            || should_collect("terminal-size")
917            || should_collect("terminal_size")
918        {
919            crate::terminal::detect_terminal_size()
920        } else {
921            None
922        };
923
924        // Current logged in user
925        let current_user = std::env::var("USER").ok();
926
927        // Number of interactive users. On Unix, count local human accounts (UID >= 1000,
928        // excluding system accounts). On Windows, `sysinfo` keys users by SID (which won't
929        // parse as a UID), so count active interactive login sessions via the WTS API
930        // instead. A 0 result is suppressed at display time (see `display.rs`).
931        let users = if should_collect("users") {
932            #[cfg(target_os = "windows")]
933            {
934                crate::win_users::active_user_session_count()
935            }
936            #[cfg(not(target_os = "windows"))]
937            {
938                Users::new_with_refreshed_list()
939                    .iter()
940                    .filter(|user| {
941                        // UID is exposed via Display
942                        user.id()
943                            .to_string()
944                            .parse::<u32>()
945                            .map(|uid| uid >= 1000)
946                            .unwrap_or(false)
947                    })
948                    .count()
949            }
950        } else {
951            0
952        };
953
954        let wm_theme = if should_collect("wm-theme")
955            || should_collect("wm theme")
956            || should_collect("wm_theme")
957        {
958            crate::theme::detect_wm_theme(wm.as_deref(), desktop.as_deref())
959        } else {
960            None
961        };
962
963        let wallpaper = if should_collect("wallpaper") {
964            crate::theme::detect_wallpaper(desktop.as_deref(), wm.as_deref())
965        } else {
966            None
967        };
968
969        let terminal_theme = if should_collect("terminal-theme")
970            || should_collect("terminal theme")
971            || should_collect("terminal_theme")
972        {
973            crate::terminal::detect_terminal_theme(terminal.as_deref())
974        } else {
975            None
976        };
977
978        Ok(Self {
979            os,
980            kernel,
981            hostname,
982            arch,
983            cpu,
984            cpu_cores,
985            cpu_core_info,
986            memory,
987            swap,
988            uptime,
989            processes,
990            load_avg,
991            disks,
992            temps,
993            networks,
994            boot_time,
995            battery,
996            shell,
997            terminal,
998            desktop,
999            cpu_freq,
1000            users,
1001            gpu,
1002            packages,
1003            current_user,
1004            local_ip,
1005            public_ip,
1006            active_interface,
1007            motherboard,
1008            bios,
1009            displays,
1010            audio,
1011            wifi,
1012            bluetooth,
1013            ui_theme,
1014            icons,
1015            cursor,
1016            font,
1017            terminal_font,
1018            camera,
1019            gamepad,
1020            cpu_cache,
1021            cpu_usage,
1022            physical_disks,
1023            vulkan: gpu_apis.vulkan,
1024            opengl: gpu_apis.opengl,
1025            opencl: gpu_apis.opencl,
1026            disk_io,
1027            net_io,
1028            physical_memory,
1029            init_system,
1030            chassis,
1031            locale,
1032            bootmgr,
1033            editor,
1034            weather,
1035            wm,
1036            dns,
1037            domain,
1038            domain_search,
1039            terminal_size,
1040            btrfs,
1041            zpool,
1042            login_manager,
1043            brightness,
1044            power_adapter,
1045            keyboard,
1046            mouse,
1047            tpm,
1048            media,
1049            player,
1050            wm_theme,
1051            wallpaper,
1052            terminal_theme,
1053        })
1054    }
1055}
1056
1057/// Detects CPU cache sizes.
1058///
1059/// Linux: reads from `/sys/devices/system/cpu/cpu0/cache/` sysfs entries.
1060/// macOS: reads `hw.l1dcachesize`, `hw.l1icachesize`, `hw.l2cachesize`, `hw.l3cachesize` via sysctlbyname.
1061/// Returns `None` on Windows or if data is unavailable.
1062pub fn detect_cpu_cache() -> Option<String> {
1063    #[cfg(target_os = "linux")]
1064    {
1065        use std::fs;
1066        let cache_dir = std::path::Path::new("/sys/devices/system/cpu/cpu0/cache");
1067        if !cache_dir.exists() {
1068            return None;
1069        }
1070
1071        struct CacheEntry {
1072            level: u32,
1073            kind: String,
1074            size_kb: u64,
1075        }
1076
1077        let mut entries: Vec<CacheEntry> = Vec::new();
1078
1079        let Ok(indices) = fs::read_dir(cache_dir) else {
1080            return None;
1081        };
1082
1083        for entry in indices.flatten() {
1084            let path = entry.path();
1085            // Skip non-index entries (e.g. the uevent file)
1086            if !path.is_dir() {
1087                continue;
1088            }
1089            let level_str = match fs::read_to_string(path.join("level")) {
1090                Ok(s) => s,
1091                Err(_) => continue,
1092            };
1093            let level: u32 = match level_str.trim().parse() {
1094                Ok(n) => n,
1095                Err(_) => continue,
1096            };
1097            let kind = match fs::read_to_string(path.join("type")) {
1098                Ok(s) => s.trim().to_string(),
1099                Err(_) => continue,
1100            };
1101            let size_str = match fs::read_to_string(path.join("size")) {
1102                Ok(s) => s,
1103                Err(_) => continue,
1104            };
1105            let size_raw = size_str.trim();
1106            let size_kb: u64 = if let Some(k) = size_raw.strip_suffix('K') {
1107                match k.parse() {
1108                    Ok(n) => n,
1109                    Err(_) => continue,
1110                }
1111            } else if let Some(m) = size_raw.strip_suffix('M') {
1112                match m.parse::<u64>() {
1113                    Ok(n) => n * 1024,
1114                    Err(_) => continue,
1115                }
1116            } else {
1117                match size_raw.parse() {
1118                    Ok(n) => n,
1119                    Err(_) => continue,
1120                }
1121            };
1122
1123            if kind != "Instruction" && kind != "Data" && kind != "Unified" {
1124                continue;
1125            }
1126
1127            entries.push(CacheEntry {
1128                level,
1129                kind,
1130                size_kb,
1131            });
1132        }
1133
1134        if entries.is_empty() {
1135            return None;
1136        }
1137
1138        entries.sort_by_key(|e| (e.level, e.kind.clone()));
1139
1140        let fmt_size = |kb: u64| -> String {
1141            if kb >= 1024 && kb.is_multiple_of(1024) {
1142                format!("{}M", kb / 1024)
1143            } else if kb >= 1024 {
1144                format!("{:.2}M", kb as f64 / 1024.0)
1145                    .trim_end_matches('0')
1146                    .trim_end_matches('.')
1147                    .to_string()
1148                    + "M"
1149            } else {
1150                format!("{}K", kb)
1151            }
1152        };
1153
1154        // Deduplicate by label (cpu0 cache dir lists each index separately)
1155        let mut seen = std::collections::HashSet::new();
1156        let mut parts: Vec<String> = Vec::new();
1157        for e in &entries {
1158            let label = match (e.level, e.kind.as_str()) {
1159                (1, "Data") => "L1d".to_string(),
1160                (1, "Instruction") => "L1i".to_string(),
1161                (1, "Unified") => "L1".to_string(),
1162                (n, _) => format!("L{}", n),
1163            };
1164            if seen.insert(label.clone()) {
1165                parts.push(format!("{}: {}", label, fmt_size(e.size_kb)));
1166            }
1167        }
1168
1169        if parts.is_empty() {
1170            None
1171        } else {
1172            Some(parts.join(", "))
1173        }
1174    }
1175    #[cfg(target_os = "macos")]
1176    {
1177        extern "C" {
1178            fn sysctlbyname(
1179                name: *const i8,
1180                oldp: *mut std::ffi::c_void,
1181                oldlenp: *mut usize,
1182                newp: *mut std::ffi::c_void,
1183                newlen: usize,
1184            ) -> i32;
1185        }
1186
1187        let read_u64 = |key: &str| -> Option<u64> {
1188            let name = std::ffi::CString::new(key).ok()?;
1189            let mut value: u64 = 0;
1190            let mut size = std::mem::size_of::<u64>();
1191            let ret = unsafe {
1192                sysctlbyname(
1193                    name.as_ptr(),
1194                    &mut value as *mut u64 as *mut std::ffi::c_void,
1195                    &mut size,
1196                    std::ptr::null_mut(),
1197                    0,
1198                )
1199            };
1200            if ret == 0 && value > 0 {
1201                Some(value)
1202            } else {
1203                None
1204            }
1205        };
1206
1207        let fmt_bytes = |bytes: u64| -> String {
1208            if bytes >= 1024 * 1024 {
1209                format!("{}M", bytes / (1024 * 1024))
1210            } else {
1211                format!("{}K", bytes / 1024)
1212            }
1213        };
1214
1215        let mut parts = Vec::new();
1216        if let Some(v) = read_u64("hw.l1dcachesize") {
1217            parts.push(format!("L1d: {}", fmt_bytes(v)));
1218        }
1219        if let Some(v) = read_u64("hw.l1icachesize") {
1220            parts.push(format!("L1i: {}", fmt_bytes(v)));
1221        }
1222        if let Some(v) = read_u64("hw.l2cachesize") {
1223            parts.push(format!("L2: {}", fmt_bytes(v)));
1224        }
1225        if let Some(v) = read_u64("hw.l3cachesize") {
1226            parts.push(format!("L3: {}", fmt_bytes(v)));
1227        }
1228
1229        if parts.is_empty() {
1230            None
1231        } else {
1232            Some(parts.join(", "))
1233        }
1234    }
1235    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
1236    {
1237        None
1238    }
1239}
1240
1241/// Formats a CPU core topology string.
1242///
1243/// Returns `"NP + NE / NT"` on Intel hybrid CPUs (different max frequencies per cluster),
1244/// `"NC / NT"` when physical < logical (hyperthreading), or `"N cores"` otherwise.
1245pub fn format_cpu_cores(logical: usize, physical: Option<usize>) -> String {
1246    // Linux: detect Intel hybrid via cpufreq policy max-frequency grouping
1247    #[cfg(target_os = "linux")]
1248    if let Some(hybrid) = detect_hybrid_cores(logical) {
1249        return hybrid;
1250    }
1251
1252    // macOS: detect Apple Silicon P/E cores via hw.perflevel* sysctls
1253    #[cfg(target_os = "macos")]
1254    if let Some(hybrid) = detect_macos_hybrid_cores(logical) {
1255        return hybrid;
1256    }
1257
1258    format_cpu_cores_plain(logical, physical)
1259}
1260
1261/// Pure fallback formatter used when no hybrid (P/E) topology is detected.
1262///
1263/// `Some(p)` with `p < logical` → `"{p}C / {logical}T"` (SMT/hyperthreading present);
1264/// otherwise `"{logical} cores"`. This is split out of [`format_cpu_cores`] so it can
1265/// be unit-tested deterministically: [`format_cpu_cores`] reads the *host's* real CPU
1266/// topology (`/sys/.../cpufreq` on Linux, `hw.perflevel*` sysctls on macOS) and returns
1267/// a `"NP + ME / KT"` string on hybrid machines, so calling it with fixed arguments does
1268/// not exercise this fallback path on such hardware (which is exactly what made the old
1269/// tests fail on Intel P/E hybrids).
1270fn format_cpu_cores_plain(logical: usize, physical: Option<usize>) -> String {
1271    match physical {
1272        Some(p) if p < logical => format!("{}C / {}T", p, logical),
1273        _ => format!("{} cores", logical),
1274    }
1275}
1276
1277/// On Linux, detects Intel hybrid topology (P-cores + E-cores) by grouping CPUs
1278/// by their maximum cpufreq frequency. Returns `None` if not hybrid or unavailable.
1279#[cfg(target_os = "linux")]
1280fn detect_hybrid_cores(logical: usize) -> Option<String> {
1281    use std::collections::HashMap;
1282    use std::fs;
1283
1284    let cpufreq = std::path::Path::new("/sys/devices/system/cpu/cpufreq");
1285    if !cpufreq.exists() {
1286        return None;
1287    }
1288
1289    // Map max_freq → number of CPUs in that policy
1290    let mut freq_to_count: HashMap<u64, usize> = HashMap::new();
1291    let mut total_accounted = 0usize;
1292
1293    let Ok(policies) = fs::read_dir(cpufreq) else {
1294        return None;
1295    };
1296
1297    for policy in policies.flatten() {
1298        let path = policy.path();
1299        if !path.is_dir() {
1300            continue;
1301        }
1302        let max_freq_str = fs::read_to_string(path.join("cpuinfo_max_freq")).ok()?;
1303        let max_freq: u64 = max_freq_str.trim().parse().ok()?;
1304        let affected = fs::read_to_string(path.join("affected_cpus")).ok()?;
1305        let count = affected.split_whitespace().count();
1306        *freq_to_count.entry(max_freq).or_insert(0) += count;
1307        total_accounted += count;
1308    }
1309
1310    // Only report hybrid if we have exactly 2 frequency tiers and they account for all threads
1311    if freq_to_count.len() != 2 || total_accounted != logical {
1312        return None;
1313    }
1314
1315    let mut tiers: Vec<(u64, usize)> = freq_to_count.into_iter().collect();
1316    tiers.sort_by_key(|t| std::cmp::Reverse(t.0)); // highest freq first = P-cores
1317    let (_, p_count) = tiers[0];
1318    let (_, e_count) = tiers[1];
1319
1320    Some(format!("{}P + {}E / {}T", p_count, e_count, logical))
1321}
1322
1323/// On macOS Apple Silicon, detects P/E cores via `hw.nperflevels` and
1324/// `hw.perflevelN.logicalcpu` sysctls. Returns `None` on Intel Macs or if unavailable.
1325#[cfg(target_os = "macos")]
1326fn detect_macos_hybrid_cores(logical: usize) -> Option<String> {
1327    extern "C" {
1328        fn sysctlbyname(
1329            name: *const i8,
1330            oldp: *mut std::ffi::c_void,
1331            oldlenp: *mut usize,
1332            newp: *mut std::ffi::c_void,
1333            newlen: usize,
1334        ) -> i32;
1335    }
1336
1337    let read_u32 = |key: &str| -> Option<u32> {
1338        let name = std::ffi::CString::new(key).ok()?;
1339        let mut value: u32 = 0;
1340        let mut size = std::mem::size_of::<u32>();
1341        let ret = unsafe {
1342            sysctlbyname(
1343                name.as_ptr(),
1344                &mut value as *mut u32 as *mut std::ffi::c_void,
1345                &mut size,
1346                std::ptr::null_mut(),
1347                0,
1348            )
1349        };
1350        if ret == 0 {
1351            Some(value)
1352        } else {
1353            None
1354        }
1355    };
1356
1357    // hw.nperflevels == 2 on M-series (P + E), absent or 1 on Intel
1358    let nlevels = read_u32("hw.nperflevels")?;
1359    if nlevels != 2 {
1360        return None;
1361    }
1362
1363    let p_cores = read_u32("hw.perflevel0.logicalcpu")? as usize;
1364    let e_cores = read_u32("hw.perflevel1.logicalcpu")? as usize;
1365
1366    if p_cores + e_cores != logical {
1367        return None;
1368    }
1369
1370    Some(format!("{}P + {}E / {}T", p_cores, e_cores, logical))
1371}
1372
1373/// Returns the overall (min_khz, max_khz) CPU frequency range from sysfs cpufreq policies.
1374/// min is the smallest `cpuinfo_min_freq` across all policies; max is the largest `cpuinfo_max_freq`.
1375pub fn detect_cpu_freq_range() -> Option<(u64, u64)> {
1376    #[cfg(target_os = "linux")]
1377    {
1378        use std::fs;
1379        let cpufreq = std::path::Path::new("/sys/devices/system/cpu/cpufreq");
1380        if !cpufreq.exists() {
1381            return None;
1382        }
1383        let mut global_min: Option<u64> = None;
1384        let mut global_max: Option<u64> = None;
1385        let Ok(policies) = fs::read_dir(cpufreq) else {
1386            return None;
1387        };
1388        for policy in policies.flatten() {
1389            let path = policy.path();
1390            if !path.is_dir() {
1391                continue;
1392            }
1393            if let Ok(s) = fs::read_to_string(path.join("cpuinfo_min_freq")) {
1394                if let Ok(v) = s.trim().parse::<u64>() {
1395                    global_min = Some(global_min.map_or(v, |m: u64| m.min(v)));
1396                }
1397            }
1398            if let Ok(s) = fs::read_to_string(path.join("cpuinfo_max_freq")) {
1399                if let Ok(v) = s.trim().parse::<u64>() {
1400                    global_max = Some(global_max.map_or(v, |m: u64| m.max(v)));
1401                }
1402            }
1403        }
1404        match (global_min, global_max) {
1405            (Some(min), Some(max)) => Some((min, max)),
1406            _ => None,
1407        }
1408    }
1409    #[cfg(not(target_os = "linux"))]
1410    {
1411        None
1412    }
1413}
1414
1415#[cfg(not(target_os = "linux"))]
1416fn detect_desktop_from_proc() -> Option<String> {
1417    None
1418}
1419
1420#[cfg(target_os = "linux")]
1421fn detect_desktop_from_proc() -> Option<String> {
1422    const DE_PROCS: &[(&str, &str)] = &[
1423        ("gnome-shell", "GNOME"),
1424        ("plasmashell", "KDE Plasma"),
1425        ("xfce4-session", "XFCE"),
1426        ("mate-session", "MATE"),
1427        ("cinnamon", "Cinnamon"),
1428        ("budgie-daemon", "Budgie"),
1429        ("budgie-panel", "Budgie"),
1430        ("lxsession", "LXDE"),
1431        ("lxqt-session", "LXQt"),
1432        ("deepin-session", "Deepin"),
1433        ("dde-session-daemon", "Deepin"),
1434        ("gala", "Pantheon"),
1435        ("enlightenment", "Enlightenment"),
1436    ];
1437    let Ok(entries) = std::fs::read_dir("/proc") else {
1438        return None;
1439    };
1440    for entry in entries.filter_map(|e| e.ok()) {
1441        let path = entry.path();
1442        if !path.is_dir() {
1443            continue;
1444        }
1445        let Ok(comm) = std::fs::read_to_string(path.join("comm")) else {
1446            continue;
1447        };
1448        let comm = comm.trim().to_lowercase();
1449        for (proc_name, de_name) in DE_PROCS {
1450            if comm == *proc_name || comm.starts_with(proc_name) {
1451                return Some(de_name.to_string());
1452            }
1453        }
1454    }
1455    None
1456}
1457
1458fn normalize_desktop_name(raw: &str) -> String {
1459    let s = raw.trim();
1460    // Canonical casing for well-known desktop environments
1461    match s.to_lowercase().as_str() {
1462        "gnome" => "GNOME".to_string(),
1463        "kde" | "kde plasma" | "plasma" => "KDE Plasma".to_string(),
1464        "xfce" => "XFCE".to_string(),
1465        "lxde" => "LXDE".to_string(),
1466        "lxqt" => "LXQt".to_string(),
1467        "mate" => "MATE".to_string(),
1468        "cinnamon" => "Cinnamon".to_string(),
1469        "budgie" => "Budgie".to_string(),
1470        "deepin" => "Deepin".to_string(),
1471        "pantheon" => "Pantheon".to_string(),
1472        "unity" => "Unity".to_string(),
1473        "enlightenment" | "e" => "Enlightenment".to_string(),
1474        _ => {
1475            // Title-case if it's all lowercase; otherwise preserve as-is
1476            if s.chars().all(|c| c.is_lowercase() || !c.is_alphabetic()) {
1477                let mut chars = s.chars();
1478                match chars.next() {
1479                    None => String::new(),
1480                    Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
1481                }
1482            } else {
1483                s.to_string()
1484            }
1485        }
1486    }
1487}
1488
1489fn detect_init_system() -> Option<String> {
1490    #[cfg(target_os = "linux")]
1491    {
1492        let comm = std::fs::read_to_string("/proc/1/comm")
1493            .map(|s| s.trim().to_string())
1494            .ok()
1495            .filter(|s| !s.is_empty());
1496        if let Some(name) = comm {
1497            return Some(name);
1498        }
1499        std::fs::read_link("/proc/1/exe").ok().and_then(|p| {
1500            p.file_name()
1501                .and_then(|n| n.to_str())
1502                .map(|s| s.to_string())
1503        })
1504    }
1505    #[cfg(target_os = "macos")]
1506    {
1507        Some("launchd".to_string())
1508    }
1509    #[cfg(target_os = "windows")]
1510    {
1511        Some("SCM".to_string())
1512    }
1513    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
1514    {
1515        None
1516    }
1517}
1518
1519fn detect_chassis() -> Option<String> {
1520    #[cfg(target_os = "linux")]
1521    {
1522        let raw = std::fs::read_to_string("/sys/class/dmi/id/chassis_type").ok()?;
1523        let n: u32 = raw.trim().parse().ok()?;
1524        let label = match n {
1525            3 => "Desktop",
1526            4 => "Low-Profile Desktop",
1527            6 => "Mini Tower",
1528            7 => "Tower",
1529            8 | 9 | 10 | 14 | 31 | 32 => "Laptop",
1530            11 => "Handheld",
1531            13 => "All-in-One",
1532            17 => "Main Server",
1533            23 => "Rack Server",
1534            28 => "Blade",
1535            30 => "Tablet",
1536            35 => "Mini PC",
1537            36 => "Stick PC",
1538            _ => return None,
1539        };
1540        Some(label.to_string())
1541    }
1542    #[cfg(target_os = "macos")]
1543    {
1544        let output = std::process::Command::new("sysctl")
1545            .args(["-n", "hw.model"])
1546            .output()
1547            .ok()?;
1548        let model = String::from_utf8(output.stdout).ok()?;
1549        let model = model.trim();
1550        if model.contains("MacBook") {
1551            Some("Laptop".to_string())
1552        } else if model.contains("MacPro") {
1553            Some("Desktop".to_string())
1554        } else if model.contains("Macmini") || model.contains("Mac mini") {
1555            Some("Mini PC".to_string())
1556        } else if model.contains("iMac") {
1557            Some("All-in-One".to_string())
1558        } else {
1559            Some(model.to_string())
1560        }
1561    }
1562    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
1563    {
1564        None
1565    }
1566}
1567
1568fn detect_bootmgr() -> Option<String> {
1569    #[cfg(target_os = "linux")]
1570    {
1571        use std::path::Path;
1572        let is_uefi = Path::new("/sys/firmware/efi").exists();
1573        if Path::new("/boot/loader/entries").exists()
1574            || Path::new("/boot/loader/loader.conf").exists()
1575            || Path::new("/efi/loader/loader.conf").exists()
1576        {
1577            return Some("systemd-boot".to_string());
1578        }
1579        if Path::new("/boot/grub2/grub.cfg").exists() || Path::new("/boot/grub2").exists() {
1580            return Some("GRUB 2".to_string());
1581        }
1582        if Path::new("/boot/grub/grub.cfg").exists() || Path::new("/boot/grub").exists() {
1583            return Some("GRUB".to_string());
1584        }
1585        if is_uefi {
1586            Some("UEFI".to_string())
1587        } else {
1588            Some("BIOS".to_string())
1589        }
1590    }
1591    #[cfg(target_os = "macos")]
1592    {
1593        Some("Apple Boot ROM".to_string())
1594    }
1595    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
1596    {
1597        None
1598    }
1599}
1600
1601/// Detects the active display/login manager (GDM, SDDM, LightDM, …).
1602///
1603/// Linux only. Resolves the `display-manager.service` systemd alias symlink
1604/// (`/etc/systemd/system/display-manager.service` → e.g. `…/gdm.service`) and prettifies
1605/// the unit name via [`login_manager_from_unit`]. This is the cheapest reliable signal on
1606/// any systemd system (no subprocess, single `read_link`); non-systemd setups return `None`.
1607fn detect_login_manager() -> Option<String> {
1608    #[cfg(target_os = "linux")]
1609    {
1610        let target = std::fs::read_link("/etc/systemd/system/display-manager.service").ok()?;
1611        let unit = target.file_name().and_then(|n| n.to_str())?;
1612        login_manager_from_unit(unit)
1613    }
1614    #[cfg(not(target_os = "linux"))]
1615    {
1616        None
1617    }
1618}
1619
1620/// Pure helper: maps a systemd display-manager unit file name to a display name.
1621///
1622/// Strips a trailing `.service` and prettifies well-known managers; unknown managers are
1623/// Title-cased so new ones still render reasonably. Returns `None` for an empty stem.
1624/// Split out from [`detect_login_manager`] so it is unit-testable without touching `/etc`.
1625#[cfg(target_os = "linux")]
1626fn login_manager_from_unit(unit: &str) -> Option<String> {
1627    let stem = unit.strip_suffix(".service").unwrap_or(unit).trim();
1628    if stem.is_empty() {
1629        return None;
1630    }
1631    let pretty = match stem.to_lowercase().as_str() {
1632        "gdm" | "gdm3" => "GDM",
1633        "sddm" => "SDDM",
1634        "lightdm" => "LightDM",
1635        "lxdm" => "LXDM",
1636        "xdm" => "XDM",
1637        "ly" => "Ly",
1638        "greetd" => "greetd",
1639        "slim" => "SLiM",
1640        "nodm" => "nodm",
1641        "entrance" => "Entrance",
1642        _ => {
1643            // Title-case the first letter, keep the rest as-is (e.g. "emptty" → "Emptty").
1644            let mut chars = stem.chars();
1645            return chars
1646                .next()
1647                .map(|c| c.to_uppercase().collect::<String>() + chars.as_str());
1648        }
1649    };
1650    Some(pretty.to_string())
1651}
1652
1653/// Detects the current backlight brightness as a percentage.
1654///
1655/// Linux only. Reads `brightness` and `max_brightness` from the first
1656/// `/sys/class/backlight/*` device (preferring a vendor backlight over a raw ACPI one), and
1657/// formats via [`brightness_percent`]. Machines with no backlight (most desktops) return
1658/// `None`, so the field simply does not render.
1659fn detect_brightness() -> Option<String> {
1660    #[cfg(target_os = "linux")]
1661    {
1662        use std::path::Path;
1663        let dir = Path::new("/sys/class/backlight");
1664        if !dir.exists() {
1665            return None;
1666        }
1667        // Collect device dirs; prefer a vendor/GPU backlight (e.g. intel_backlight,
1668        // amdgpu_bl0) over a generic ACPI one (acpi_video0) when several are present.
1669        let mut devices: Vec<std::path::PathBuf> = std::fs::read_dir(dir)
1670            .ok()?
1671            .flatten()
1672            .map(|e| e.path())
1673            .collect();
1674        devices.sort_by_key(|p| {
1675            let name = p
1676                .file_name()
1677                .and_then(|n| n.to_str())
1678                .unwrap_or("")
1679                .to_lowercase();
1680            // Lower sort key = higher preference.
1681            if name.contains("acpi") || name.contains("video") {
1682                1
1683            } else {
1684                0
1685            }
1686        });
1687        for dev in devices {
1688            let cur = std::fs::read_to_string(dev.join("brightness"))
1689                .ok()
1690                .and_then(|s| s.trim().parse::<u64>().ok());
1691            let max = std::fs::read_to_string(dev.join("max_brightness"))
1692                .ok()
1693                .and_then(|s| s.trim().parse::<u64>().ok());
1694            if let (Some(cur), Some(max)) = (cur, max) {
1695                if let Some(pct) = brightness_percent(cur, max) {
1696                    return Some(pct);
1697                }
1698            }
1699        }
1700        None
1701    }
1702    #[cfg(not(target_os = "linux"))]
1703    {
1704        None
1705    }
1706}
1707
1708/// Pure helper: formats a raw brightness/max pair as a rounded percentage string.
1709///
1710/// Returns `None` when `max` is 0 (divide-by-zero guard). Split out from
1711/// [`detect_brightness`] so it is unit-testable without a real backlight device.
1712#[cfg(target_os = "linux")]
1713fn brightness_percent(cur: u64, max: u64) -> Option<String> {
1714    if max == 0 {
1715        return None;
1716    }
1717    let pct = (cur as f64 / max as f64 * 100.0).round() as u64;
1718    Some(format!("{}%", pct))
1719}
1720
1721/// Detects the AC power adapter (name + connection state).
1722///
1723/// Linux only. Scans `/sys/class/power_supply/*` for a `Mains`-type supply (the AC
1724/// adapter), reads its `online` flag, and formats via [`format_power_adapter`]. Wattage is
1725/// not reported: `Mains` entries rarely expose it in sysfs, so emitting it would be
1726/// unreliable. Returns `None` when no AC adapter is present (e.g. a desktop with no
1727/// power_supply class, or a battery-only view).
1728fn detect_power_adapter() -> Option<String> {
1729    #[cfg(target_os = "linux")]
1730    {
1731        use std::path::Path;
1732        let dir = Path::new("/sys/class/power_supply");
1733        if !dir.exists() {
1734            return None;
1735        }
1736        for entry in std::fs::read_dir(dir).ok()?.flatten() {
1737            let path = entry.path();
1738            let supply_type = std::fs::read_to_string(path.join("type"))
1739                .map(|s| s.trim().to_string())
1740                .unwrap_or_default();
1741            if supply_type != "Mains" {
1742                continue;
1743            }
1744            let name = path
1745                .file_name()
1746                .and_then(|n| n.to_str())
1747                .unwrap_or("AC")
1748                .to_string();
1749            let online = std::fs::read_to_string(path.join("online"))
1750                .map(|s| s.trim().to_string())
1751                .unwrap_or_default();
1752            return Some(format_power_adapter(&name, &online));
1753        }
1754        None
1755    }
1756    #[cfg(not(target_os = "linux"))]
1757    {
1758        None
1759    }
1760}
1761
1762/// Pure helper: formats an AC adapter name + `online` flag ("0"/"1") into a display string.
1763///
1764/// Split out from [`detect_power_adapter`] so it is unit-testable without a real adapter.
1765#[cfg(target_os = "linux")]
1766fn format_power_adapter(name: &str, online: &str) -> String {
1767    let state = match online.trim() {
1768        "1" => "connected",
1769        "0" => "not connected",
1770        _ => "unknown",
1771    };
1772    format!("{} ({})", name, state)
1773}
1774
1775/// Detects the Trusted Platform Module's specification version (e.g. "2.0").
1776///
1777/// Linux only. Reads `tpm_version_major` from the first `/sys/class/tpm/*` device and formats
1778/// via [`format_tpm_version`]. A machine with no TPM has no such class directory and returns
1779/// `None`, so the field does not render. The version is deliberately *not* guessed from the
1780/// device's mere presence: a TPM whose version cannot be read is reported as absent rather
1781/// than as a version that was never confirmed.
1782fn detect_tpm() -> Option<String> {
1783    #[cfg(target_os = "linux")]
1784    {
1785        use std::path::Path;
1786        let dir = Path::new("/sys/class/tpm");
1787        if !dir.exists() {
1788            return None;
1789        }
1790        let mut devices: Vec<std::path::PathBuf> = std::fs::read_dir(dir)
1791            .ok()?
1792            .flatten()
1793            .map(|e| e.path())
1794            .collect();
1795        // `tpm0` before `tpm1`, so a multi-TPM machine reports a stable one run to run.
1796        devices.sort();
1797        for dev in devices {
1798            if let Ok(major) = std::fs::read_to_string(dev.join("tpm_version_major")) {
1799                if let Some(v) = format_tpm_version(major.trim()) {
1800                    return Some(v);
1801                }
1802            }
1803        }
1804        None
1805    }
1806    #[cfg(not(target_os = "linux"))]
1807    {
1808        None
1809    }
1810}
1811
1812/// Pure helper: maps sysfs `tpm_version_major` to a TPM specification version string.
1813///
1814/// The kernel exposes only the major number, but the published specification names are "1.2"
1815/// and "2.0" — not "1.0"/"2.0" — so the minor part is a lookup, not arithmetic. An
1816/// unrecognised or unparseable major yields `None` rather than an invented version. Split out
1817/// from [`detect_tpm`] so it is unit-testable without a real TPM.
1818#[cfg(target_os = "linux")]
1819fn format_tpm_version(major: &str) -> Option<String> {
1820    match major.trim() {
1821        "1" => Some("1.2".to_string()),
1822        "2" => Some("2.0".to_string()),
1823        _ => None,
1824    }
1825}
1826
1827/// Windows CPU-usage sampling via `GetSystemTimes` (kernel32, default-linked).
1828///
1829/// Replaces the per-run 200 ms sleep sysinfo needs for a usage delta: two samples are
1830/// diffed across the existing concurrent-probe window instead, so no sleep is added.
1831#[cfg(target_os = "windows")]
1832mod win_cpu {
1833    #[repr(C)]
1834    struct FileTime {
1835        low: u32,
1836        high: u32,
1837    }
1838
1839    impl FileTime {
1840        fn ticks(&self) -> u64 {
1841            ((self.high as u64) << 32) | self.low as u64
1842        }
1843    }
1844
1845    extern "system" {
1846        fn GetSystemTimes(idle: *mut FileTime, kernel: *mut FileTime, user: *mut FileTime) -> i32;
1847    }
1848
1849    /// Cumulative `(idle, kernel, user)` CPU ticks (100 ns units). `kernel` includes idle,
1850    /// per the Win32 contract. `None` if the call fails.
1851    pub fn sample() -> Option<(u64, u64, u64)> {
1852        let mut idle = FileTime { low: 0, high: 0 };
1853        let mut kernel = FileTime { low: 0, high: 0 };
1854        let mut user = FileTime { low: 0, high: 0 };
1855        // SAFETY: three valid, writable FILETIME out-parameters.
1856        let ok = unsafe { GetSystemTimes(&mut idle, &mut kernel, &mut user) };
1857        if ok == 0 {
1858            None
1859        } else {
1860            Some((idle.ticks(), kernel.ticks(), user.ticks()))
1861        }
1862    }
1863
1864    /// System-wide CPU busy percentage between two `sample()` snapshots. Because `kernel`
1865    /// includes idle, `total = Δkernel + Δuser` and `busy = total − Δidle`.
1866    pub fn usage_percent(s0: (u64, u64, u64), s1: (u64, u64, u64)) -> f32 {
1867        let idle = s1.0.saturating_sub(s0.0);
1868        let kernel = s1.1.saturating_sub(s0.1);
1869        let user = s1.2.saturating_sub(s0.2);
1870        let total = kernel + user;
1871        if total == 0 {
1872            0.0
1873        } else {
1874            (100.0 * total.saturating_sub(idle) as f64 / total as f64) as f32
1875        }
1876    }
1877
1878    #[cfg(test)]
1879    mod layout {
1880        use std::mem::size_of;
1881
1882        // Two u32 FILETIME words = 8 bytes; the ticks() reader depends on this.
1883        #[test]
1884        fn filetime_size() {
1885            assert_eq!(size_of::<super::FileTime>(), 8);
1886        }
1887    }
1888}
1889
1890#[cfg(test)]
1891mod tests {
1892    use super::*;
1893
1894    #[cfg(target_os = "linux")]
1895    #[test]
1896    fn test_login_manager_from_unit() {
1897        assert_eq!(
1898            login_manager_from_unit("gdm.service").as_deref(),
1899            Some("GDM")
1900        );
1901        assert_eq!(
1902            login_manager_from_unit("gdm3.service").as_deref(),
1903            Some("GDM")
1904        );
1905        assert_eq!(
1906            login_manager_from_unit("sddm.service").as_deref(),
1907            Some("SDDM")
1908        );
1909        assert_eq!(
1910            login_manager_from_unit("lightdm.service").as_deref(),
1911            Some("LightDM")
1912        );
1913        // Unknown manager: Title-cased, .service stripped, rest preserved.
1914        assert_eq!(
1915            login_manager_from_unit("emptty.service").as_deref(),
1916            Some("Emptty")
1917        );
1918        // No .service suffix is tolerated.
1919        assert_eq!(login_manager_from_unit("ly").as_deref(), Some("Ly"));
1920        // Empty / suffix-only stems yield None.
1921        assert_eq!(login_manager_from_unit("").as_deref(), None);
1922        assert_eq!(login_manager_from_unit(".service").as_deref(), None);
1923    }
1924
1925    #[cfg(target_os = "linux")]
1926    #[test]
1927    fn test_brightness_percent() {
1928        assert_eq!(brightness_percent(50, 100).as_deref(), Some("50%"));
1929        assert_eq!(brightness_percent(100, 100).as_deref(), Some("100%"));
1930        assert_eq!(brightness_percent(0, 100).as_deref(), Some("0%"));
1931        // Rounding: 133/255 ≈ 52.16% → 52%.
1932        assert_eq!(brightness_percent(133, 255).as_deref(), Some("52%"));
1933        // Divide-by-zero guard.
1934        assert_eq!(brightness_percent(10, 0), None);
1935    }
1936
1937    #[cfg(target_os = "linux")]
1938    #[test]
1939    fn test_format_power_adapter() {
1940        assert_eq!(format_power_adapter("AC", "1"), "AC (connected)");
1941        assert_eq!(format_power_adapter("ADP1", "0"), "ADP1 (not connected)");
1942        // Missing/garbage online flag degrades to "unknown" rather than panicking.
1943        assert_eq!(format_power_adapter("AC", ""), "AC (unknown)");
1944    }
1945
1946    #[cfg(target_os = "linux")]
1947    #[test]
1948    fn test_format_tpm_version() {
1949        // The spec names are 1.2 and 2.0, so the minor part is a lookup, not "major.0".
1950        assert_eq!(format_tpm_version("2").as_deref(), Some("2.0"));
1951        assert_eq!(format_tpm_version("1").as_deref(), Some("1.2"));
1952        // sysfs reads carry a trailing newline.
1953        assert_eq!(format_tpm_version("2\n").as_deref(), Some("2.0"));
1954        // An unrecognised or unreadable major is reported as absent, never invented.
1955        assert_eq!(format_tpm_version("3"), None);
1956        assert_eq!(format_tpm_version(""), None);
1957        assert_eq!(format_tpm_version("garbage"), None);
1958    }
1959
1960    #[cfg(target_os = "windows")]
1961    #[test]
1962    fn test_win_cpu_usage_percent() {
1963        use super::win_cpu::usage_percent;
1964        // kernel includes idle. Δidle=50, Δkernel=100 (incl. idle), Δuser=50 → total=150,
1965        // busy=150-50=100 → 66.67%.
1966        let u = usage_percent((0, 0, 0), (50, 100, 50));
1967        assert!((u - 66.6667).abs() < 0.01, "got {}", u);
1968
1969        // Fully idle: Δidle == Δkernel, Δuser=0 → 0%.
1970        assert_eq!(usage_percent((0, 0, 0), (100, 100, 0)), 0.0);
1971
1972        // Fully busy: no idle delta → 100%.
1973        assert_eq!(usage_percent((0, 0, 0), (0, 100, 100)), 100.0);
1974
1975        // No time elapsed (zero total) → 0%, no divide-by-zero.
1976        assert_eq!(usage_percent((5, 10, 10), (5, 10, 10)), 0.0);
1977    }
1978
1979    // NOTE: these exercise the pure fallback formatter `format_cpu_cores_plain`, not the
1980    // public `format_cpu_cores`. The latter first reads the *host's* real CPU topology and
1981    // returns a "NP + ME / KT" hybrid string on Intel P/E (and Apple Silicon) machines,
1982    // ignoring the passed-in counts — so calling it with fixed args is machine-dependent
1983    // and fails on hybrids (an i7-1360P produced "8P + 8E / 16T" for `(16, Some(8))`).
1984    #[test]
1985    fn test_format_cpu_cores_no_hyperthreading() {
1986        // Physical == logical: show plain "N cores"
1987        assert_eq!(format_cpu_cores_plain(4, Some(4)), "4 cores");
1988    }
1989
1990    #[test]
1991    fn test_format_cpu_cores_hyperthreaded() {
1992        // Physical < logical: show "NC / NT"
1993        assert_eq!(format_cpu_cores_plain(16, Some(8)), "8C / 16T");
1994    }
1995
1996    #[test]
1997    fn test_format_cpu_cores_unknown_physical() {
1998        // No physical count available: fall back to "N cores"
1999        assert_eq!(format_cpu_cores_plain(8, None), "8 cores");
2000    }
2001
2002    #[test]
2003    fn test_format_cpu_cores_physical_equals_zero() {
2004        // Degenerate: physical reported as 0 — treat same as unknown
2005        // physical(0) < logical(8), so would print "0C / 8T"; acceptable but
2006        // let's confirm the branch taken
2007        let result = format_cpu_cores_plain(8, Some(0));
2008        assert!(result.contains("8"), "should mention 8 threads: {}", result);
2009    }
2010
2011    #[cfg(target_os = "linux")]
2012    #[test]
2013    fn test_detect_cpu_cache_returns_some_on_linux() {
2014        // On a real Linux machine the sysfs cache dir exists; result should be Some
2015        // and contain at least one cache level label.
2016        if std::path::Path::new("/sys/devices/system/cpu/cpu0/cache").exists() {
2017            let result = detect_cpu_cache();
2018            assert!(result.is_some(), "expected cache info on Linux with sysfs");
2019            let s = result.unwrap();
2020            assert!(
2021                s.contains("L1") || s.contains("L2") || s.contains("L3"),
2022                "expected cache level labels, got: {}",
2023                s
2024            );
2025        }
2026    }
2027
2028    #[test]
2029    fn test_normalize_desktop_name_known() {
2030        assert_eq!(normalize_desktop_name("gnome"), "GNOME");
2031        assert_eq!(normalize_desktop_name("GNOME"), "GNOME");
2032        assert_eq!(normalize_desktop_name("kde"), "KDE Plasma");
2033        assert_eq!(normalize_desktop_name("plasma"), "KDE Plasma");
2034        assert_eq!(normalize_desktop_name("KDE Plasma"), "KDE Plasma");
2035        assert_eq!(normalize_desktop_name("xfce"), "XFCE");
2036        assert_eq!(normalize_desktop_name("lxqt"), "LXQt");
2037        assert_eq!(normalize_desktop_name("mate"), "MATE");
2038        assert_eq!(normalize_desktop_name("cinnamon"), "Cinnamon");
2039        assert_eq!(normalize_desktop_name("e"), "Enlightenment");
2040    }
2041
2042    #[test]
2043    fn test_normalize_desktop_name_unknown_lowercase() {
2044        // Unknown all-lowercase names get title-cased.
2045        assert_eq!(normalize_desktop_name("budgie"), "Budgie");
2046        assert_eq!(normalize_desktop_name("niri"), "Niri");
2047    }
2048
2049    #[test]
2050    fn test_normalize_desktop_name_unknown_mixed() {
2051        // Unknown mixed-case names are preserved as-is.
2052        assert_eq!(normalize_desktop_name("MyDE"), "MyDE");
2053    }
2054
2055    #[test]
2056    fn test_normalize_desktop_name_trims_whitespace() {
2057        assert_eq!(normalize_desktop_name("  gnome  "), "GNOME");
2058        assert_eq!(normalize_desktop_name(" niri "), "Niri");
2059    }
2060
2061    #[cfg(target_os = "linux")]
2062    #[test]
2063    fn test_detect_desktop_from_proc_returns_option() {
2064        // Just verify it runs without panicking and returns a sane value.
2065        let result = detect_desktop_from_proc();
2066        if let Some(ref de) = result {
2067            assert!(!de.is_empty(), "desktop name should not be empty");
2068        }
2069    }
2070
2071    #[cfg(target_os = "linux")]
2072    #[test]
2073    fn test_detect_cpu_freq_range_returns_ordered_pair() {
2074        if std::path::Path::new("/sys/devices/system/cpu/cpufreq").exists() {
2075            if let Some((min, max)) = detect_cpu_freq_range() {
2076                assert!(
2077                    min <= max,
2078                    "min freq should be <= max freq: {} > {}",
2079                    min,
2080                    max
2081                );
2082                assert!(min > 0, "min freq should be positive");
2083            }
2084        }
2085    }
2086}