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