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
194/// Builds the `CpuRefreshKind` for a run, or `None` when no selected field needs the
195/// sysinfo CPU list at all.
196///
197/// The two flags inside `CpuRefreshKind` are the expensive ones, and neither is needed
198/// for the `cpu` field itself: brand and core count come from the *static* CPU list,
199/// which `RefreshKind::with_cpu` populates whatever refresh kind it is handed
200/// (sysinfo builds it on the first refresh regardless — `unix/linux/cpu.rs` guards it
201/// with `if first || refresh_kind.cpu_usage()`, and `windows/cpu.rs::init_cpus` reads
202/// the brand via `GetSystemInfo` unconditionally).
203///
204/// Frequency is what costs: on Windows `init_cpus` calls `get_frequencies()` only when
205/// `refresh_kind.frequency()` is set, and that first touch of the performance-counter
206/// machinery measured ~195 ms on arrakis — paid by every run, including `--short`,
207/// which does not display a frequency. Asking only for what a selected field reads
208/// drops `System::new_with_specifics` to ~0.5 ms there.
209fn cpu_refresh_kind(
210    want_cpu: bool,
211    want_freq: bool,
212    want_usage: bool,
213) -> Option<sysinfo::CpuRefreshKind> {
214    if !(want_cpu || want_freq || want_usage) {
215        return None;
216    }
217    let mut kind = sysinfo::CpuRefreshKind::nothing();
218    if want_freq {
219        kind = kind.with_frequency();
220    }
221    // Only the non-Windows `cpu-usage` arm reads sysinfo's counters; Windows diffs its
222    // own `GetSystemTimes` samples (see the `cpu_usage` block below), so asking sysinfo
223    // to prime a reading nothing consumes would be pure cost.
224    if want_usage && !cfg!(target_os = "windows") {
225        kind = kind.with_cpu_usage();
226    }
227    Some(kind)
228}
229
230/// Whether to call `System::load_average()` at all.
231///
232/// Two reasons to skip it, and the second is Windows-only:
233///
234/// 1. It was ungated, so a run that never displays `Load` still paid for it — `load` is
235///    `min_mode: Standard` in `retch-cli`'s field table, so `--short` and any narrow
236///    `--fields` selection excludes it.
237/// 2. On Windows the answer is always `0.0, 0.0, 0.0`. sysinfo has no native
238///    load-average source there, so it synthesises one: `init_load_avg()` opens a PDH
239///    query on `\System\Cpu Queue Length` and registers a callback that decays a
240///    process-local static, with `SAMPLING_INTERVAL = 5` **seconds** between samples.
241///    The static starts at zero and retch exits long before the first callback fires,
242///    so the caller's `avg.one > 0.0` guard has never once been true on Windows — while
243///    the PDH setup itself measured ~183–194 ms. That is why NOTES §6a lists `load`
244///    under "deliberately not implemented on Windows"; this makes the code agree.
245fn should_probe_load(want_load: bool) -> bool {
246    want_load && !cfg!(target_os = "windows")
247}
248
249/// Whether `sys` must be built with the process list.
250///
251/// `procs` counts processes and `audio` consults them, but `shell` and `terminal` depend
252/// on the list just as much: both identify the running program by walking the process
253/// tree up from retch's own pid, and `sys.process(pid)` finds nothing in a list that was
254/// never loaded. Keyed on `procs`/`audio` alone, that walk only worked when one of those
255/// happened to be selected as well, so `--fields shell` on its own fell through to
256/// guessing from the environment — on Windows under PowerShell 7 it reported
257/// `powershell 5.1`, the shell `PSModulePath` implies, instead of the `pwsh` running it.
258fn needs_process_list(procs: bool, audio: bool, shell: bool, terminal: bool) -> bool {
259    procs || audio || shell || terminal
260}
261
262impl SystemInfo {
263    /// Collects system information using sysinfo and environment probes.
264    ///
265    /// This method aggregates data from the operating system, hardware,
266    /// and current user environment into a `SystemInfo` struct.
267    pub fn collect(opts: CollectOptions) -> anyhow::Result<Self> {
268        let should_collect = |field_name: &str| -> bool {
269            match &opts.fields {
270                Some(fields) => {
271                    let norm_field = field_name.to_lowercase().replace(['-', '_'], " ");
272                    let norm_field_no_spaces = norm_field.replace(' ', "");
273                    fields.iter().any(|f| {
274                        let norm_f = f.to_lowercase().replace(['-', '_'], " ");
275                        norm_f == norm_field || norm_f.replace(' ', "") == norm_field_no_spaces
276                    })
277                }
278                None => true,
279            }
280        };
281
282        let mut refresh_kind = sysinfo::RefreshKind::nothing();
283        // `cpu-cache` is deliberately absent: `detect_cpu_cache()` reads its own source
284        // and never touches `sys`, so it never needed the CPU list.
285        if let Some(cpu_kind) = cpu_refresh_kind(
286            should_collect("cpu"),
287            should_collect("cpu-freq"),
288            should_collect("cpu-usage"),
289        ) {
290            refresh_kind = refresh_kind.with_cpu(cpu_kind);
291        }
292        if should_collect("memory")
293            || should_collect("swap")
294            || should_collect("phys mem")
295            || should_collect("phys-mem")
296        {
297            refresh_kind = refresh_kind.with_memory(sysinfo::MemoryRefreshKind::everything());
298        }
299        if needs_process_list(
300            should_collect("procs"),
301            should_collect("audio"),
302            should_collect("shell"),
303            should_collect("terminal"),
304        ) {
305            refresh_kind = refresh_kind.with_processes(sysinfo::ProcessRefreshKind::nothing());
306        }
307
308        // `mut` is only needed off-Windows (refresh_cpu_usage below); on Windows CPU usage
309        // comes from GetSystemTimes, so `sys` is never mutated there.
310        #[cfg_attr(target_os = "windows", allow(unused_mut))]
311        let mut sys = System::new_with_specifics(refresh_kind);
312
313        let os = System::long_os_version()
314            .or_else(System::name)
315            .unwrap_or_else(|| "Unknown".to_string());
316
317        let kernel = System::kernel_version();
318        let hostname = System::host_name();
319
320        let cpu = if should_collect("cpu") {
321            sys.cpus()
322                .first()
323                .map(|c| c.brand().to_string())
324                .unwrap_or_else(|| "Unknown CPU".to_string())
325        } else {
326            String::new()
327        };
328
329        let cpu_cores = if should_collect("cpu") {
330            sys.cpus().len()
331        } else {
332            0
333        };
334        let cpu_core_info = if should_collect("cpu") {
335            format_cpu_cores(cpu_cores, System::physical_core_count())
336        } else {
337            String::new()
338        };
339
340        let memory = if should_collect("memory") {
341            let total_mem = sys.total_memory() as f64 / 1024.0 / 1024.0 / 1024.0;
342            let used_mem = sys.used_memory() as f64 / 1024.0 / 1024.0 / 1024.0;
343            format!("{:.1} / {:.1} GB", used_mem, total_mem)
344        } else {
345            String::new()
346        };
347
348        let swap = if should_collect("swap") {
349            let total_swap = sys.total_swap() as f64 / 1024.0 / 1024.0 / 1024.0;
350            let used_swap = sys.used_swap() as f64 / 1024.0 / 1024.0 / 1024.0;
351            if total_swap > 0.0 {
352                format!("{:.1} / {:.1} GB", used_swap, total_swap)
353            } else {
354                "No swap".to_string()
355            }
356        } else {
357            String::new()
358        };
359
360        let uptime = format!("{}s", System::uptime());
361
362        let disks: Vec<String> = if should_collect("disk") {
363            let disks_list = crate::disk::detect_logical_disks(opts.full);
364            let format_disk = |(mount, total, avail, fs): &(String, u64, u64, String)| {
365                let total_gb = *total as f64 / 1024.0 / 1024.0 / 1024.0;
366                let avail_gb = *avail as f64 / 1024.0 / 1024.0 / 1024.0;
367                format!(
368                    "{} ({}): {:.1} GB free / {:.1} GB",
369                    mount, fs, avail_gb, total_gb
370                )
371            };
372            if !opts.long {
373                let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/"));
374                let home_path = std::path::Path::new(&home);
375                let best = disks_list
376                    .iter()
377                    .filter(|(mp, ..)| home_path.starts_with(mp))
378                    .max_by_key(|(mp, ..)| std::path::Path::new(mp).components().count());
379                if let Some(disk) = best {
380                    vec![format_disk(disk)]
381                } else {
382                    disks_list.iter().map(format_disk).collect()
383                }
384            } else {
385                disks_list.iter().map(format_disk).collect()
386            }
387        } else {
388            Vec::new()
389        };
390
391        let battery = if should_collect("battery") {
392            crate::battery::get_battery_info().map(|bat| {
393                let pct = bat.percentage;
394                let state = match bat.state {
395                    crate::battery::BatteryState::Charging => "charging",
396                    crate::battery::BatteryState::Discharging => "discharging",
397                    crate::battery::BatteryState::Full => "full",
398                    _ => "not charging",
399                };
400                let vendor = bat.vendor;
401                let model = bat.model;
402
403                // Format time remaining as "Xh Ym" or "Xd Yh"
404                let time_str = match bat.state {
405                    crate::battery::BatteryState::Charging => bat.time_remaining.map(|d| {
406                        let total_mins = d.as_secs() / 60;
407                        let hours = total_mins / 60;
408                        let mins = total_mins % 60;
409                        if hours >= 24 {
410                            let days = hours / 24;
411                            let rem_hours = hours % 24;
412                            format!("{}d {}h until full", days, rem_hours)
413                        } else if hours > 0 {
414                            format!("{}h {}m until full", hours, mins)
415                        } else {
416                            format!("{}m until full", mins)
417                        }
418                    }),
419                    crate::battery::BatteryState::Discharging => bat.time_remaining.map(|d| {
420                        let total_mins = d.as_secs() / 60;
421                        let hours = total_mins / 60;
422                        let mins = total_mins % 60;
423                        if hours >= 24 {
424                            let days = hours / 24;
425                            let rem_hours = hours % 24;
426                            format!("{}d {}h remaining", days, rem_hours)
427                        } else if hours > 0 {
428                            format!("{}h {}m remaining", hours, mins)
429                        } else {
430                            format!("{}m remaining", mins)
431                        }
432                    }),
433                    _ => None,
434                };
435
436                let mut parts = vec![state.to_string()];
437                if let Some(t) = time_str {
438                    parts.insert(0, t);
439                }
440                if let Some(health) = bat.health {
441                    if health < 99.0 {
442                        parts.push(format!("{:.0}% health", health));
443                    }
444                }
445
446                let base = format!("{:.0}% ({})", pct, parts.join(", "));
447
448                match (vendor, model) {
449                    (Some(v), Some(m)) => format!("{} [{} {}]", base, v, m),
450                    (Some(v), None) => format!("{} [{}]", base, v),
451                    _ => base,
452                }
453            })
454        } else {
455            None
456        };
457
458        let arch = System::cpu_arch();
459
460        let processes = if should_collect("procs") || should_collect("audio") {
461            sys.processes().len()
462        } else {
463            0
464        };
465
466        let load_avg = if should_probe_load(should_collect("load")) {
467            let avg = System::load_average();
468            if avg.one > 0.0 || avg.five > 0.0 {
469                Some(format!(
470                    "{:.2}, {:.2}, {:.2}",
471                    avg.one, avg.five, avg.fifteen
472                ))
473            } else {
474                None
475            }
476        } else {
477            None
478        };
479
480        // Windows: sample cumulative CPU times before the concurrent probes run, so CPU
481        // usage can be computed over the real collection window below. In a normal run the
482        // window is already long enough; a floor is enforced later only for tiny requests.
483        #[cfg(target_os = "windows")]
484        let cpu_sample0 = win_cpu::sample();
485        #[cfg(target_os = "windows")]
486        let cpu_t0 = std::time::Instant::now();
487
488        // Disk/network I/O are rates, and the kernel only exposes cumulative counters, so
489        // they need two samples and an interval. Take the first here — before the
490        // concurrent probes — and diff it after them, making the collection window the
491        // sampling window. That is the v0.3.49 `cpu-usage` approach, and it is why these
492        // fields cost no wall-clock in a normal run where fastfetch spends a full second.
493        let want_disk_io = should_collect("disk-io") || should_collect("disk io");
494        let want_net_io = should_collect("net-io") || should_collect("net io");
495        let disk_io_sample0 = if want_disk_io {
496            crate::io::sample_disk_io()
497        } else {
498            Vec::new()
499        };
500        let net_io_sample0 = if want_net_io {
501            crate::io::sample_net_io()
502        } else {
503            Vec::new()
504        };
505        let io_t0 = std::time::Instant::now();
506
507        // Compute slow system queries concurrently in parallel threads
508        let (
509            gpu,
510            packages,
511            public_ip,
512            (local_ip, active_interface),
513            motherboard,
514            bios,
515            displays,
516            audio,
517            wifi,
518            bluetooth,
519            (ui_theme, icons, cursor, font),
520            camera,
521            gamepad,
522            physical_disks,
523            physical_memory,
524            weather,
525            btrfs,
526            zpool,
527            (media, player),
528            gpu_apis,
529            shell,
530        ) = std::thread::scope(|s| {
531            let gpu_handle = if should_collect("gpu") {
532                Some(s.spawn(|| {
533                    gpu::detect_gpus()
534                        .into_iter()
535                        .map(|g| g.format())
536                        .collect::<Vec<String>>()
537                }))
538            } else {
539                None
540            };
541            let packages_handle = if should_collect("packages") {
542                Some(s.spawn(crate::packages::detect_packages))
543            } else {
544                None
545            };
546            let public_ip_handle = if should_collect("public ip") {
547                Some(s.spawn(crate::network::detect_public_ip))
548            } else {
549                None
550            };
551            let network_ips_handle = if should_collect("net") {
552                Some(s.spawn(crate::network::detect_active_interface_and_local_ip))
553            } else {
554                None
555            };
556            let motherboard_handle = if should_collect("motherboard") {
557                Some(s.spawn(crate::motherboard::detect_motherboard))
558            } else {
559                None
560            };
561            let bios_handle = if should_collect("bios") {
562                Some(s.spawn(crate::bios::detect_bios))
563            } else {
564                None
565            };
566            let displays_handle = if should_collect("display") {
567                Some(s.spawn(crate::display::detect_displays))
568            } else {
569                None
570            };
571            let audio_handle = if should_collect("audio") {
572                Some(s.spawn(|| crate::audio::detect_audio(&sys)))
573            } else {
574                None
575            };
576            let wifi_handle = if should_collect("wifi") {
577                Some(s.spawn(crate::network::detect_wifi))
578            } else {
579                None
580            };
581            let bluetooth_handle = if should_collect("bluetooth") {
582                Some(s.spawn(crate::bluetooth::detect_bluetooth))
583            } else {
584                None
585            };
586            let ui_theme_and_fonts_handle = if should_collect("theme")
587                || should_collect("icons")
588                || should_collect("cursor")
589                || should_collect("font")
590            {
591                Some(s.spawn(crate::theme::detect_ui_theme_and_fonts))
592            } else {
593                None
594            };
595            let camera_handle = if should_collect("camera") {
596                Some(s.spawn(crate::camera::detect_camera))
597            } else {
598                None
599            };
600            let gamepad_handle = if should_collect("gamepad") {
601                Some(s.spawn(crate::gamepad::detect_gamepad))
602            } else {
603                None
604            };
605            let physical_disks_handle = if should_collect("phys disk") {
606                Some(s.spawn(crate::disk::detect_physical_disks))
607            } else {
608                None
609            };
610            let physical_memory_handle = if should_collect("phys mem") {
611                Some(s.spawn(crate::memory::detect_physical_memory))
612            } else {
613                None
614            };
615            let weather_location = opts.weather_location.clone();
616            let weather_unit = opts.weather_unit;
617            let weather_handle = if should_collect("weather") {
618                Some(s.spawn(move || {
619                    crate::weather::detect_weather(weather_location.as_deref(), weather_unit)
620                }))
621            } else {
622                None
623            };
624            let btrfs_handle = if should_collect("btrfs") {
625                Some(s.spawn(crate::btrfs::detect_btrfs))
626            } else {
627                None
628            };
629            let zpool_handle = if should_collect("zpool") {
630                Some(s.spawn(crate::zfs::detect_zpool))
631            } else {
632                None
633            };
634            let media_handle = if should_collect("media") || should_collect("player") {
635                Some(s.spawn(crate::media::detect_media))
636            } else {
637                None
638            };
639            // Vulkan/OpenGL/OpenCL are collected together: all three dlopen a loader and
640            // talk to the same driver stack, so splitting them across threads would buy
641            // contention rather than overlap.
642            let gpu_apis_handle =
643                if should_collect("vulkan") || should_collect("opengl") || should_collect("opencl")
644                {
645                    Some(s.spawn(crate::gpu_api::detect_gpu_apis))
646                } else {
647                    None
648                };
649            // `shell` reports the running shell's version by spawning the shell to ask it,
650            // which on Windows means starting PowerShell — measured at ~570 ms on arrakis.
651            // Until v0.17.6 that ran serially after this scope and was the largest single
652            // cost in `--long` and `--full`; here it overlaps the other probes instead. It
653            // only reads `sys`, as `audio` does.
654            let shell_handle = if should_collect("shell") {
655                Some(s.spawn(|| crate::shell::detect_shell(&sys)))
656            } else {
657                None
658            };
659
660            (
661                gpu_handle
662                    .map(|h| h.join().unwrap_or_default())
663                    .unwrap_or_default(),
664                packages_handle.and_then(|h| h.join().ok().flatten()),
665                public_ip_handle.and_then(|h| h.join().ok().flatten()),
666                network_ips_handle
667                    .map(|h| h.join().unwrap_or((None, None)))
668                    .unwrap_or((None, None)),
669                motherboard_handle.and_then(|h| h.join().ok().flatten()),
670                bios_handle.and_then(|h| h.join().ok().flatten()),
671                displays_handle
672                    .map(|h| h.join().unwrap_or_default())
673                    .unwrap_or_default(),
674                audio_handle.and_then(|h| h.join().ok().flatten()),
675                wifi_handle.and_then(|h| h.join().ok().flatten()),
676                bluetooth_handle.and_then(|h| h.join().ok().flatten()),
677                ui_theme_and_fonts_handle
678                    .map(|h| h.join().unwrap_or((None, None, None, None)))
679                    .unwrap_or((None, None, None, None)),
680                camera_handle
681                    .map(|h| h.join().unwrap_or_default())
682                    .unwrap_or_default(),
683                gamepad_handle
684                    .map(|h| h.join().unwrap_or_default())
685                    .unwrap_or_default(),
686                physical_disks_handle
687                    .map(|h| h.join().unwrap_or_default())
688                    .unwrap_or_default(),
689                physical_memory_handle.and_then(|h| h.join().ok().flatten()),
690                weather_handle.and_then(|h| h.join().ok().flatten()),
691                btrfs_handle
692                    .map(|h| h.join().unwrap_or_default())
693                    .unwrap_or_default(),
694                zpool_handle
695                    .map(|h| h.join().unwrap_or_default())
696                    .unwrap_or_default(),
697                media_handle
698                    .map(|h| h.join().unwrap_or((None, None)))
699                    .unwrap_or((None, None)),
700                gpu_apis_handle
701                    .map(|h| h.join().unwrap_or_default())
702                    .unwrap_or_default(),
703                shell_handle.and_then(|h| h.join().ok().flatten()),
704            )
705        });
706
707        let mut temps: Vec<String> = if should_collect("temp") {
708            Components::new_with_refreshed_list()
709                .iter()
710                .filter_map(|c| {
711                    c.temperature().and_then(|t| {
712                        if t > 0.0 {
713                            Some(format!("{}: {:.0}°C", c.label(), t))
714                        } else {
715                            None
716                        }
717                    })
718                })
719                .collect()
720        } else {
721            Vec::new()
722        };
723
724        // Sort so CPU temperatures appear first
725        temps.sort_by(|a, b| {
726            let a_cpu = a.to_lowercase().contains("cpu") || a.to_lowercase().contains("core");
727            let b_cpu = b.to_lowercase().contains("cpu") || b.to_lowercase().contains("core");
728            b_cpu.cmp(&a_cpu)
729        });
730
731        let networks = if should_collect("net") {
732            crate::network::detect_networks(active_interface.as_deref(), local_ip.as_deref())
733        } else {
734            Vec::new()
735        };
736
737        let boot_timestamp = System::boot_time();
738        let boot_dt = chrono::Local
739            .timestamp_opt(boot_timestamp as i64, 0)
740            .single()
741            .map(|dt| dt.format("%Y-%m-%dT%H:%M:%S%:z").to_string())
742            .unwrap_or_else(|| boot_timestamp.to_string());
743        let boot_time = boot_dt;
744
745        // Environment-based info. (`shell` is collected inside the concurrent scope above.)
746        let terminal = if should_collect("terminal") {
747            crate::terminal::detect_terminal(&sys)
748        } else {
749            None
750        };
751        let terminal_font = if should_collect("terminal font")
752            || should_collect("terminal-font")
753            || should_collect("terminal_font")
754        {
755            crate::terminal::detect_terminal_font(terminal.as_deref())
756        } else {
757            None
758        };
759        let desktop = if should_collect("desktop") {
760            std::env::var("XDG_CURRENT_DESKTOP")
761                .or_else(|_| std::env::var("DESKTOP_SESSION"))
762                .or_else(|_| std::env::var("XDG_SESSION_DESKTOP"))
763                .or_else(|_| std::env::var("GDMSESSION"))
764                .ok()
765                .map(|s| normalize_desktop_name(&s))
766                .filter(|s| !s.is_empty())
767                .or_else(detect_desktop_from_proc)
768        } else {
769            None
770        };
771
772        // CPU frequency (current from sysinfo + min/max range from sysfs)
773        let cpu_freq = if should_collect("cpu-freq")
774            || should_collect("cpu freq")
775            || should_collect("cpu_freq")
776        {
777            sys.cpus().first().map(|c| {
778                let current = format!("{:.2} GHz", c.frequency() as f64 / 1000.0);
779                if let Some((min_khz, max_khz)) = detect_cpu_freq_range() {
780                    let min_ghz = min_khz as f64 / 1_000_000.0;
781                    let max_ghz = max_khz as f64 / 1_000_000.0;
782                    format!("{} ({:.2} \u{2013} {:.2} GHz)", current, min_ghz, max_ghz)
783                } else {
784                    current
785                }
786            })
787        } else {
788            None
789        };
790
791        // CPU cache sizes
792        let cpu_cache = if should_collect("cpu-cache")
793            || should_collect("cpu cache")
794            || should_collect("cpu_cache")
795        {
796            detect_cpu_cache()
797        } else {
798            None
799        };
800
801        // CPU usage. On Unix, sysinfo needs a delta between two refreshes and enforces a
802        // ~200 ms minimum interval, so we sleep once. On Windows we instead diff the
803        // GetSystemTimes sample taken before the concurrent scope against a fresh one — the
804        // collection window is the delta, so no sleep is added to the run.
805        let cpu_usage = if should_collect("cpu-usage")
806            || should_collect("cpu usage")
807            || should_collect("cpu_usage")
808        {
809            #[cfg(not(target_os = "windows"))]
810            {
811                std::thread::sleep(std::time::Duration::from_millis(200));
812                sys.refresh_cpu_usage();
813                let usage: f32 =
814                    sys.cpus().iter().map(|c| c.cpu_usage()).sum::<f32>() / sys.cpus().len() as f32;
815                let avg = System::load_average();
816                let load_str = format!("{:.2}, {:.2}, {:.2}", avg.one, avg.five, avg.fifteen);
817                if usage > 0.0 {
818                    Some(format!("{:.1}% (load: {})", usage, load_str))
819                } else if avg.one > 0.0 {
820                    Some(format!("load: {}", load_str))
821                } else {
822                    None
823                }
824            }
825            #[cfg(target_os = "windows")]
826            {
827                // The concurrent scope above is usually the sampling window; only top it up
828                // to a ~100 ms floor when few fields were requested (so an isolated
829                // `--fields cpu-usage` still reads sensibly rather than sampling noise).
830                let floor = std::time::Duration::from_millis(100);
831                let elapsed = cpu_t0.elapsed();
832                if elapsed < floor {
833                    std::thread::sleep(floor - elapsed);
834                }
835                match (cpu_sample0, win_cpu::sample()) {
836                    (Some(s0), Some(s1)) => {
837                        let usage = win_cpu::usage_percent(s0, s1);
838                        if usage > 0.0 {
839                            Some(format!("{:.1}%", usage))
840                        } else {
841                            None
842                        }
843                    }
844                    _ => None,
845                }
846            }
847        } else {
848            None
849        };
850
851        // Second I/O sample. Deliberately after the `cpu_usage` block above: on Unix that
852        // block sleeps 200 ms for sysinfo's minimum refresh interval, and taking the
853        // second sample afterwards folds that sleep into the window instead of paying for
854        // it twice. The floor below therefore only ever fires for a request so small that
855        // neither the concurrent scope nor the CPU sleep happened.
856        let (disk_io, net_io) = if want_disk_io || want_net_io {
857            let floor = std::time::Duration::from_millis(100);
858            let elapsed = io_t0.elapsed();
859            if elapsed < floor {
860                std::thread::sleep(floor - elapsed);
861            }
862            let elapsed_secs = io_t0.elapsed().as_secs_f64();
863            let disk_io = if want_disk_io {
864                crate::io::compute_rates(
865                    &disk_io_sample0,
866                    &crate::io::sample_disk_io(),
867                    elapsed_secs,
868                )
869                .iter()
870                .map(|r| crate::io::format_io_line(r, "R", "W"))
871                .collect()
872            } else {
873                Vec::new()
874            };
875            let net_io = if want_net_io {
876                let rates = crate::io::compute_rates(
877                    &net_io_sample0,
878                    &crate::io::sample_net_io(),
879                    elapsed_secs,
880                );
881                crate::io::select_net_rates(rates, active_interface.as_deref())
882                    .iter()
883                    .map(|r| crate::io::format_io_line(r, "RX", "TX"))
884                    .collect()
885            } else {
886                Vec::new()
887            };
888            (disk_io, net_io)
889        } else {
890            (Vec::new(), Vec::new())
891        };
892
893        let init_system = if should_collect("init") || should_collect("init system") {
894            detect_init_system()
895        } else {
896            None
897        };
898
899        let chassis = if should_collect("chassis") {
900            detect_chassis()
901        } else {
902            None
903        };
904
905        let locale = if should_collect("locale") {
906            std::env::var("LC_ALL")
907                .ok()
908                .filter(|s| !s.is_empty())
909                .or_else(|| std::env::var("LC_MESSAGES").ok().filter(|s| !s.is_empty()))
910                .or_else(|| std::env::var("LANG").ok().filter(|s| !s.is_empty()))
911        } else {
912            None
913        };
914
915        let bootmgr = if should_collect("bootmgr") || should_collect("boot") {
916            detect_bootmgr()
917        } else {
918            None
919        };
920
921        let login_manager = if should_collect("login-manager") || should_collect("lm") {
922            detect_login_manager()
923        } else {
924            None
925        };
926
927        let brightness = if should_collect("brightness") {
928            detect_brightness()
929        } else {
930            None
931        };
932
933        let power_adapter = if should_collect("power-adapter") {
934            detect_power_adapter()
935        } else {
936            None
937        };
938
939        // Keyboards and mice come from one file read, so they are collected together and then
940        // split rather than parsing `/proc/bus/input/devices` twice.
941        let (keyboard, mouse) = if should_collect("keyboard") || should_collect("mouse") {
942            let (kbds, mice) = crate::input::detect_input_devices();
943            (
944                if should_collect("keyboard") {
945                    kbds
946                } else {
947                    Vec::new()
948                },
949                if should_collect("mouse") {
950                    mice
951                } else {
952                    Vec::new()
953                },
954            )
955        } else {
956            (Vec::new(), Vec::new())
957        };
958
959        let tpm = if should_collect("tpm") {
960            detect_tpm()
961        } else {
962            None
963        };
964
965        let editor = if should_collect("editor") {
966            std::env::var("VISUAL")
967                .ok()
968                .filter(|s| !s.is_empty())
969                .or_else(|| std::env::var("EDITOR").ok().filter(|s| !s.is_empty()))
970        } else {
971            None
972        };
973
974        let wm = if should_collect("wm") || should_collect("window manager") {
975            crate::wm::detect_wm()
976        } else {
977            None
978        };
979
980        let dns = if should_collect("dns") {
981            crate::network::detect_dns()
982        } else {
983            Vec::new()
984        };
985
986        let domain = if should_collect("domain") {
987            crate::network::detect_domain()
988        } else {
989            None
990        };
991
992        let domain_search = if should_collect("domain-search") || should_collect("domain search") {
993            crate::network::detect_domain_search()
994        } else {
995            Vec::new()
996        };
997
998        let terminal_size = if should_collect("terminal size")
999            || should_collect("terminal-size")
1000            || should_collect("terminal_size")
1001        {
1002            crate::terminal::detect_terminal_size()
1003        } else {
1004            None
1005        };
1006
1007        // Current logged in user
1008        let current_user = std::env::var("USER").ok();
1009
1010        // Number of interactive users. On Unix, count local human accounts (UID >= 1000,
1011        // excluding system accounts). On Windows, `sysinfo` keys users by SID (which won't
1012        // parse as a UID), so count active interactive login sessions via the WTS API
1013        // instead. A 0 result is suppressed at display time (see `display.rs`).
1014        let users = if should_collect("users") {
1015            #[cfg(target_os = "windows")]
1016            {
1017                crate::win_users::active_user_session_count()
1018            }
1019            #[cfg(not(target_os = "windows"))]
1020            {
1021                Users::new_with_refreshed_list()
1022                    .iter()
1023                    .filter(|user| {
1024                        // UID is exposed via Display
1025                        user.id()
1026                            .to_string()
1027                            .parse::<u32>()
1028                            .map(|uid| uid >= 1000)
1029                            .unwrap_or(false)
1030                    })
1031                    .count()
1032            }
1033        } else {
1034            0
1035        };
1036
1037        let wm_theme = if should_collect("wm-theme")
1038            || should_collect("wm theme")
1039            || should_collect("wm_theme")
1040        {
1041            crate::theme::detect_wm_theme(wm.as_deref(), desktop.as_deref())
1042        } else {
1043            None
1044        };
1045
1046        let wallpaper = if should_collect("wallpaper") {
1047            crate::theme::detect_wallpaper(desktop.as_deref(), wm.as_deref())
1048        } else {
1049            None
1050        };
1051
1052        let terminal_theme = if should_collect("terminal-theme")
1053            || should_collect("terminal theme")
1054            || should_collect("terminal_theme")
1055        {
1056            crate::terminal::detect_terminal_theme(terminal.as_deref())
1057        } else {
1058            None
1059        };
1060
1061        Ok(Self {
1062            os,
1063            kernel,
1064            hostname,
1065            arch,
1066            cpu,
1067            cpu_cores,
1068            cpu_core_info,
1069            memory,
1070            swap,
1071            uptime,
1072            processes,
1073            load_avg,
1074            disks,
1075            temps,
1076            networks,
1077            boot_time,
1078            battery,
1079            shell,
1080            terminal,
1081            desktop,
1082            cpu_freq,
1083            users,
1084            gpu,
1085            packages,
1086            current_user,
1087            local_ip,
1088            public_ip,
1089            active_interface,
1090            motherboard,
1091            bios,
1092            displays,
1093            audio,
1094            wifi,
1095            bluetooth,
1096            ui_theme,
1097            icons,
1098            cursor,
1099            font,
1100            terminal_font,
1101            camera,
1102            gamepad,
1103            cpu_cache,
1104            cpu_usage,
1105            physical_disks,
1106            vulkan: gpu_apis.vulkan,
1107            opengl: gpu_apis.opengl,
1108            opencl: gpu_apis.opencl,
1109            disk_io,
1110            net_io,
1111            physical_memory,
1112            init_system,
1113            chassis,
1114            locale,
1115            bootmgr,
1116            editor,
1117            weather,
1118            wm,
1119            dns,
1120            domain,
1121            domain_search,
1122            terminal_size,
1123            btrfs,
1124            zpool,
1125            login_manager,
1126            brightness,
1127            power_adapter,
1128            keyboard,
1129            mouse,
1130            tpm,
1131            media,
1132            player,
1133            wm_theme,
1134            wallpaper,
1135            terminal_theme,
1136        })
1137    }
1138}
1139
1140/// Detects CPU cache sizes.
1141///
1142/// Linux: reads from `/sys/devices/system/cpu/cpu0/cache/` sysfs entries.
1143/// macOS: reads `hw.l1dcachesize`, `hw.l1icachesize`, `hw.l2cachesize`, `hw.l3cachesize` via sysctlbyname.
1144/// Returns `None` on Windows or if data is unavailable.
1145pub fn detect_cpu_cache() -> Option<String> {
1146    #[cfg(target_os = "linux")]
1147    {
1148        use std::fs;
1149        let cache_dir = std::path::Path::new("/sys/devices/system/cpu/cpu0/cache");
1150        if !cache_dir.exists() {
1151            return None;
1152        }
1153
1154        struct CacheEntry {
1155            level: u32,
1156            kind: String,
1157            size_kb: u64,
1158        }
1159
1160        let mut entries: Vec<CacheEntry> = Vec::new();
1161
1162        let Ok(indices) = fs::read_dir(cache_dir) else {
1163            return None;
1164        };
1165
1166        for entry in indices.flatten() {
1167            let path = entry.path();
1168            // Skip non-index entries (e.g. the uevent file)
1169            if !path.is_dir() {
1170                continue;
1171            }
1172            let level_str = match fs::read_to_string(path.join("level")) {
1173                Ok(s) => s,
1174                Err(_) => continue,
1175            };
1176            let level: u32 = match level_str.trim().parse() {
1177                Ok(n) => n,
1178                Err(_) => continue,
1179            };
1180            let kind = match fs::read_to_string(path.join("type")) {
1181                Ok(s) => s.trim().to_string(),
1182                Err(_) => continue,
1183            };
1184            let size_str = match fs::read_to_string(path.join("size")) {
1185                Ok(s) => s,
1186                Err(_) => continue,
1187            };
1188            let size_raw = size_str.trim();
1189            let size_kb: u64 = if let Some(k) = size_raw.strip_suffix('K') {
1190                match k.parse() {
1191                    Ok(n) => n,
1192                    Err(_) => continue,
1193                }
1194            } else if let Some(m) = size_raw.strip_suffix('M') {
1195                match m.parse::<u64>() {
1196                    Ok(n) => n * 1024,
1197                    Err(_) => continue,
1198                }
1199            } else {
1200                match size_raw.parse() {
1201                    Ok(n) => n,
1202                    Err(_) => continue,
1203                }
1204            };
1205
1206            if kind != "Instruction" && kind != "Data" && kind != "Unified" {
1207                continue;
1208            }
1209
1210            entries.push(CacheEntry {
1211                level,
1212                kind,
1213                size_kb,
1214            });
1215        }
1216
1217        if entries.is_empty() {
1218            return None;
1219        }
1220
1221        entries.sort_by_key(|e| (e.level, e.kind.clone()));
1222
1223        let fmt_size = |kb: u64| -> String {
1224            if kb >= 1024 && kb.is_multiple_of(1024) {
1225                format!("{}M", kb / 1024)
1226            } else if kb >= 1024 {
1227                format!("{:.2}M", kb as f64 / 1024.0)
1228                    .trim_end_matches('0')
1229                    .trim_end_matches('.')
1230                    .to_string()
1231                    + "M"
1232            } else {
1233                format!("{}K", kb)
1234            }
1235        };
1236
1237        // Deduplicate by label (cpu0 cache dir lists each index separately)
1238        let mut seen = std::collections::HashSet::new();
1239        let mut parts: Vec<String> = Vec::new();
1240        for e in &entries {
1241            let label = match (e.level, e.kind.as_str()) {
1242                (1, "Data") => "L1d".to_string(),
1243                (1, "Instruction") => "L1i".to_string(),
1244                (1, "Unified") => "L1".to_string(),
1245                (n, _) => format!("L{}", n),
1246            };
1247            if seen.insert(label.clone()) {
1248                parts.push(format!("{}: {}", label, fmt_size(e.size_kb)));
1249            }
1250        }
1251
1252        if parts.is_empty() {
1253            None
1254        } else {
1255            Some(parts.join(", "))
1256        }
1257    }
1258    #[cfg(target_os = "macos")]
1259    {
1260        extern "C" {
1261            fn sysctlbyname(
1262                name: *const i8,
1263                oldp: *mut std::ffi::c_void,
1264                oldlenp: *mut usize,
1265                newp: *mut std::ffi::c_void,
1266                newlen: usize,
1267            ) -> i32;
1268        }
1269
1270        let read_u64 = |key: &str| -> Option<u64> {
1271            let name = std::ffi::CString::new(key).ok()?;
1272            let mut value: u64 = 0;
1273            let mut size = std::mem::size_of::<u64>();
1274            let ret = unsafe {
1275                sysctlbyname(
1276                    name.as_ptr(),
1277                    &mut value as *mut u64 as *mut std::ffi::c_void,
1278                    &mut size,
1279                    std::ptr::null_mut(),
1280                    0,
1281                )
1282            };
1283            if ret == 0 && value > 0 {
1284                Some(value)
1285            } else {
1286                None
1287            }
1288        };
1289
1290        let fmt_bytes = |bytes: u64| -> String {
1291            if bytes >= 1024 * 1024 {
1292                format!("{}M", bytes / (1024 * 1024))
1293            } else {
1294                format!("{}K", bytes / 1024)
1295            }
1296        };
1297
1298        let mut parts = Vec::new();
1299        if let Some(v) = read_u64("hw.l1dcachesize") {
1300            parts.push(format!("L1d: {}", fmt_bytes(v)));
1301        }
1302        if let Some(v) = read_u64("hw.l1icachesize") {
1303            parts.push(format!("L1i: {}", fmt_bytes(v)));
1304        }
1305        if let Some(v) = read_u64("hw.l2cachesize") {
1306            parts.push(format!("L2: {}", fmt_bytes(v)));
1307        }
1308        if let Some(v) = read_u64("hw.l3cachesize") {
1309            parts.push(format!("L3: {}", fmt_bytes(v)));
1310        }
1311
1312        if parts.is_empty() {
1313            None
1314        } else {
1315            Some(parts.join(", "))
1316        }
1317    }
1318    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
1319    {
1320        None
1321    }
1322}
1323
1324/// Formats a CPU core topology string.
1325///
1326/// Returns `"NP + NE / NT"` on Intel hybrid CPUs (different max frequencies per cluster),
1327/// `"NC / NT"` when physical < logical (hyperthreading), or `"N cores"` otherwise.
1328pub fn format_cpu_cores(logical: usize, physical: Option<usize>) -> String {
1329    // Linux: detect Intel hybrid via cpufreq policy max-frequency grouping
1330    #[cfg(target_os = "linux")]
1331    if let Some(hybrid) = detect_hybrid_cores(logical) {
1332        return hybrid;
1333    }
1334
1335    // macOS: detect Apple Silicon P/E cores via hw.perflevel* sysctls
1336    #[cfg(target_os = "macos")]
1337    if let Some(hybrid) = detect_macos_hybrid_cores(logical) {
1338        return hybrid;
1339    }
1340
1341    format_cpu_cores_plain(logical, physical)
1342}
1343
1344/// Pure fallback formatter used when no hybrid (P/E) topology is detected.
1345///
1346/// `Some(p)` with `p < logical` → `"{p}C / {logical}T"` (SMT/hyperthreading present);
1347/// otherwise `"{logical} cores"`. This is split out of [`format_cpu_cores`] so it can
1348/// be unit-tested deterministically: [`format_cpu_cores`] reads the *host's* real CPU
1349/// topology (`/sys/.../cpufreq` on Linux, `hw.perflevel*` sysctls on macOS) and returns
1350/// a `"NP + ME / KT"` string on hybrid machines, so calling it with fixed arguments does
1351/// not exercise this fallback path on such hardware (which is exactly what made the old
1352/// tests fail on Intel P/E hybrids).
1353fn format_cpu_cores_plain(logical: usize, physical: Option<usize>) -> String {
1354    match physical {
1355        Some(p) if p < logical => format!("{}C / {}T", p, logical),
1356        _ => format!("{} cores", logical),
1357    }
1358}
1359
1360/// On Linux, detects Intel hybrid topology (P-cores + E-cores) by grouping CPUs
1361/// by their maximum cpufreq frequency. Returns `None` if not hybrid or unavailable.
1362#[cfg(target_os = "linux")]
1363fn detect_hybrid_cores(logical: usize) -> Option<String> {
1364    use std::collections::HashMap;
1365    use std::fs;
1366
1367    let cpufreq = std::path::Path::new("/sys/devices/system/cpu/cpufreq");
1368    if !cpufreq.exists() {
1369        return None;
1370    }
1371
1372    // Map max_freq → number of CPUs in that policy
1373    let mut freq_to_count: HashMap<u64, usize> = HashMap::new();
1374    let mut total_accounted = 0usize;
1375
1376    let Ok(policies) = fs::read_dir(cpufreq) else {
1377        return None;
1378    };
1379
1380    for policy in policies.flatten() {
1381        let path = policy.path();
1382        if !path.is_dir() {
1383            continue;
1384        }
1385        let max_freq_str = fs::read_to_string(path.join("cpuinfo_max_freq")).ok()?;
1386        let max_freq: u64 = max_freq_str.trim().parse().ok()?;
1387        let affected = fs::read_to_string(path.join("affected_cpus")).ok()?;
1388        let count = affected.split_whitespace().count();
1389        *freq_to_count.entry(max_freq).or_insert(0) += count;
1390        total_accounted += count;
1391    }
1392
1393    // Only report hybrid if we have exactly 2 frequency tiers and they account for all threads
1394    if freq_to_count.len() != 2 || total_accounted != logical {
1395        return None;
1396    }
1397
1398    let mut tiers: Vec<(u64, usize)> = freq_to_count.into_iter().collect();
1399    tiers.sort_by_key(|t| std::cmp::Reverse(t.0)); // highest freq first = P-cores
1400    let (_, p_count) = tiers[0];
1401    let (_, e_count) = tiers[1];
1402
1403    Some(format!("{}P + {}E / {}T", p_count, e_count, logical))
1404}
1405
1406/// On macOS Apple Silicon, detects P/E cores via `hw.nperflevels` and
1407/// `hw.perflevelN.logicalcpu` sysctls. Returns `None` on Intel Macs or if unavailable.
1408#[cfg(target_os = "macos")]
1409fn detect_macos_hybrid_cores(logical: usize) -> Option<String> {
1410    extern "C" {
1411        fn sysctlbyname(
1412            name: *const i8,
1413            oldp: *mut std::ffi::c_void,
1414            oldlenp: *mut usize,
1415            newp: *mut std::ffi::c_void,
1416            newlen: usize,
1417        ) -> i32;
1418    }
1419
1420    let read_u32 = |key: &str| -> Option<u32> {
1421        let name = std::ffi::CString::new(key).ok()?;
1422        let mut value: u32 = 0;
1423        let mut size = std::mem::size_of::<u32>();
1424        let ret = unsafe {
1425            sysctlbyname(
1426                name.as_ptr(),
1427                &mut value as *mut u32 as *mut std::ffi::c_void,
1428                &mut size,
1429                std::ptr::null_mut(),
1430                0,
1431            )
1432        };
1433        if ret == 0 {
1434            Some(value)
1435        } else {
1436            None
1437        }
1438    };
1439
1440    // hw.nperflevels == 2 on M-series (P + E), absent or 1 on Intel
1441    let nlevels = read_u32("hw.nperflevels")?;
1442    if nlevels != 2 {
1443        return None;
1444    }
1445
1446    let p_cores = read_u32("hw.perflevel0.logicalcpu")? as usize;
1447    let e_cores = read_u32("hw.perflevel1.logicalcpu")? as usize;
1448
1449    if p_cores + e_cores != logical {
1450        return None;
1451    }
1452
1453    Some(format!("{}P + {}E / {}T", p_cores, e_cores, logical))
1454}
1455
1456/// Returns the overall (min_khz, max_khz) CPU frequency range from sysfs cpufreq policies.
1457/// min is the smallest `cpuinfo_min_freq` across all policies; max is the largest `cpuinfo_max_freq`.
1458pub fn detect_cpu_freq_range() -> Option<(u64, u64)> {
1459    #[cfg(target_os = "linux")]
1460    {
1461        use std::fs;
1462        let cpufreq = std::path::Path::new("/sys/devices/system/cpu/cpufreq");
1463        if !cpufreq.exists() {
1464            return None;
1465        }
1466        let mut global_min: Option<u64> = None;
1467        let mut global_max: Option<u64> = None;
1468        let Ok(policies) = fs::read_dir(cpufreq) else {
1469            return None;
1470        };
1471        for policy in policies.flatten() {
1472            let path = policy.path();
1473            if !path.is_dir() {
1474                continue;
1475            }
1476            if let Ok(s) = fs::read_to_string(path.join("cpuinfo_min_freq")) {
1477                if let Ok(v) = s.trim().parse::<u64>() {
1478                    global_min = Some(global_min.map_or(v, |m: u64| m.min(v)));
1479                }
1480            }
1481            if let Ok(s) = fs::read_to_string(path.join("cpuinfo_max_freq")) {
1482                if let Ok(v) = s.trim().parse::<u64>() {
1483                    global_max = Some(global_max.map_or(v, |m: u64| m.max(v)));
1484                }
1485            }
1486        }
1487        match (global_min, global_max) {
1488            (Some(min), Some(max)) => Some((min, max)),
1489            _ => None,
1490        }
1491    }
1492    #[cfg(not(target_os = "linux"))]
1493    {
1494        None
1495    }
1496}
1497
1498#[cfg(not(target_os = "linux"))]
1499fn detect_desktop_from_proc() -> Option<String> {
1500    None
1501}
1502
1503#[cfg(target_os = "linux")]
1504fn detect_desktop_from_proc() -> Option<String> {
1505    const DE_PROCS: &[(&str, &str)] = &[
1506        ("gnome-shell", "GNOME"),
1507        ("plasmashell", "KDE Plasma"),
1508        ("xfce4-session", "XFCE"),
1509        ("mate-session", "MATE"),
1510        ("cinnamon", "Cinnamon"),
1511        ("budgie-daemon", "Budgie"),
1512        ("budgie-panel", "Budgie"),
1513        ("lxsession", "LXDE"),
1514        ("lxqt-session", "LXQt"),
1515        ("deepin-session", "Deepin"),
1516        ("dde-session-daemon", "Deepin"),
1517        ("gala", "Pantheon"),
1518        ("enlightenment", "Enlightenment"),
1519    ];
1520    let Ok(entries) = std::fs::read_dir("/proc") else {
1521        return None;
1522    };
1523    for entry in entries.filter_map(|e| e.ok()) {
1524        let path = entry.path();
1525        if !path.is_dir() {
1526            continue;
1527        }
1528        let Ok(comm) = std::fs::read_to_string(path.join("comm")) else {
1529            continue;
1530        };
1531        let comm = comm.trim().to_lowercase();
1532        for (proc_name, de_name) in DE_PROCS {
1533            if comm == *proc_name || comm.starts_with(proc_name) {
1534                return Some(de_name.to_string());
1535            }
1536        }
1537    }
1538    None
1539}
1540
1541fn normalize_desktop_name(raw: &str) -> String {
1542    let s = raw.trim();
1543    // Canonical casing for well-known desktop environments
1544    match s.to_lowercase().as_str() {
1545        "gnome" => "GNOME".to_string(),
1546        "kde" | "kde plasma" | "plasma" => "KDE Plasma".to_string(),
1547        "xfce" => "XFCE".to_string(),
1548        "lxde" => "LXDE".to_string(),
1549        "lxqt" => "LXQt".to_string(),
1550        "mate" => "MATE".to_string(),
1551        "cinnamon" => "Cinnamon".to_string(),
1552        "budgie" => "Budgie".to_string(),
1553        "deepin" => "Deepin".to_string(),
1554        "pantheon" => "Pantheon".to_string(),
1555        "unity" => "Unity".to_string(),
1556        "enlightenment" | "e" => "Enlightenment".to_string(),
1557        _ => {
1558            // Title-case if it's all lowercase; otherwise preserve as-is
1559            if s.chars().all(|c| c.is_lowercase() || !c.is_alphabetic()) {
1560                let mut chars = s.chars();
1561                match chars.next() {
1562                    None => String::new(),
1563                    Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
1564                }
1565            } else {
1566                s.to_string()
1567            }
1568        }
1569    }
1570}
1571
1572fn detect_init_system() -> Option<String> {
1573    #[cfg(target_os = "linux")]
1574    {
1575        let comm = std::fs::read_to_string("/proc/1/comm")
1576            .map(|s| s.trim().to_string())
1577            .ok()
1578            .filter(|s| !s.is_empty());
1579        if let Some(name) = comm {
1580            return Some(name);
1581        }
1582        std::fs::read_link("/proc/1/exe").ok().and_then(|p| {
1583            p.file_name()
1584                .and_then(|n| n.to_str())
1585                .map(|s| s.to_string())
1586        })
1587    }
1588    #[cfg(target_os = "macos")]
1589    {
1590        Some("launchd".to_string())
1591    }
1592    #[cfg(target_os = "windows")]
1593    {
1594        Some("SCM".to_string())
1595    }
1596    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
1597    {
1598        None
1599    }
1600}
1601
1602fn detect_chassis() -> Option<String> {
1603    #[cfg(target_os = "linux")]
1604    {
1605        let raw = std::fs::read_to_string("/sys/class/dmi/id/chassis_type").ok()?;
1606        let n: u32 = raw.trim().parse().ok()?;
1607        let label = match n {
1608            3 => "Desktop",
1609            4 => "Low-Profile Desktop",
1610            6 => "Mini Tower",
1611            7 => "Tower",
1612            8 | 9 | 10 | 14 | 31 | 32 => "Laptop",
1613            11 => "Handheld",
1614            13 => "All-in-One",
1615            17 => "Main Server",
1616            23 => "Rack Server",
1617            28 => "Blade",
1618            30 => "Tablet",
1619            35 => "Mini PC",
1620            36 => "Stick PC",
1621            _ => return None,
1622        };
1623        Some(label.to_string())
1624    }
1625    #[cfg(target_os = "macos")]
1626    {
1627        let output = std::process::Command::new("sysctl")
1628            .args(["-n", "hw.model"])
1629            .output()
1630            .ok()?;
1631        let model = String::from_utf8(output.stdout).ok()?;
1632        let model = model.trim();
1633        if model.contains("MacBook") {
1634            Some("Laptop".to_string())
1635        } else if model.contains("MacPro") {
1636            Some("Desktop".to_string())
1637        } else if model.contains("Macmini") || model.contains("Mac mini") {
1638            Some("Mini PC".to_string())
1639        } else if model.contains("iMac") {
1640            Some("All-in-One".to_string())
1641        } else {
1642            Some(model.to_string())
1643        }
1644    }
1645    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
1646    {
1647        None
1648    }
1649}
1650
1651fn detect_bootmgr() -> Option<String> {
1652    #[cfg(target_os = "linux")]
1653    {
1654        use std::path::Path;
1655        let is_uefi = Path::new("/sys/firmware/efi").exists();
1656        if Path::new("/boot/loader/entries").exists()
1657            || Path::new("/boot/loader/loader.conf").exists()
1658            || Path::new("/efi/loader/loader.conf").exists()
1659        {
1660            return Some("systemd-boot".to_string());
1661        }
1662        if Path::new("/boot/grub2/grub.cfg").exists() || Path::new("/boot/grub2").exists() {
1663            return Some("GRUB 2".to_string());
1664        }
1665        if Path::new("/boot/grub/grub.cfg").exists() || Path::new("/boot/grub").exists() {
1666            return Some("GRUB".to_string());
1667        }
1668        if is_uefi {
1669            Some("UEFI".to_string())
1670        } else {
1671            Some("BIOS".to_string())
1672        }
1673    }
1674    #[cfg(target_os = "macos")]
1675    {
1676        Some("Apple Boot ROM".to_string())
1677    }
1678    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
1679    {
1680        None
1681    }
1682}
1683
1684/// Detects the active display/login manager (GDM, SDDM, LightDM, …).
1685///
1686/// Linux only. Resolves the `display-manager.service` systemd alias symlink
1687/// (`/etc/systemd/system/display-manager.service` → e.g. `…/gdm.service`) and prettifies
1688/// the unit name via [`login_manager_from_unit`]. This is the cheapest reliable signal on
1689/// any systemd system (no subprocess, single `read_link`); non-systemd setups return `None`.
1690fn detect_login_manager() -> Option<String> {
1691    #[cfg(target_os = "linux")]
1692    {
1693        let target = std::fs::read_link("/etc/systemd/system/display-manager.service").ok()?;
1694        let unit = target.file_name().and_then(|n| n.to_str())?;
1695        login_manager_from_unit(unit)
1696    }
1697    #[cfg(target_os = "macos")]
1698    {
1699        // macOS has exactly one login manager and always has: loginwindow. There is no
1700        // choice to detect, so the only informative part is its version, which is what
1701        // fastfetch reports too ("Login Window 9.0").
1702        let plist = std::fs::read_to_string(LOGINWINDOW_PLIST).ok()?;
1703        Some(format_login_window(parse_plist_string(
1704            &plist,
1705            "CFBundleShortVersionString",
1706        )))
1707    }
1708    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
1709    {
1710        None
1711    }
1712}
1713
1714/// Path to loginwindow's bundle metadata.
1715#[cfg(target_os = "macos")]
1716const LOGINWINDOW_PLIST: &str = "/System/Library/CoreServices/loginwindow.app/Contents/Info.plist";
1717
1718/// Extract a `<string>` value for `key` from an XML property list.
1719///
1720/// **Deliberately a small scanner rather than a plist dependency or a `defaults` call.**
1721/// This file is plain XML on macOS 26 (verified: it begins `<?xml ve`, not `bplist`), the
1722/// crate has a zero-subprocess policy for detection, and pulling in a plist parser to read
1723/// one string would be disproportionate. It is pure, so it is unit-tested against a
1724/// verbatim excerpt rather than the host's own file.
1725///
1726/// Returns `None` rather than guessing if the structure is not the expected
1727/// `<key>K</key><string>V</string>` pairing — a version is better omitted than invented.
1728#[cfg(any(target_os = "macos", test))]
1729fn parse_plist_string(xml: &str, key: &str) -> Option<String> {
1730    let needle = format!("<key>{key}</key>");
1731    let rest = xml.split_once(&needle)?.1;
1732    let open = rest.find("<string>")?;
1733    // Guard against the key's value being a non-string type: if another <key> appears
1734    // before the next <string>, this key does not have a string value.
1735    if let Some(next_key) = rest.find("<key>") {
1736        if next_key < open {
1737            return None;
1738        }
1739    }
1740    let after = &rest[open + "<string>".len()..];
1741    let end = after.find("</string>")?;
1742    let value = after[..end].trim();
1743    (!value.is_empty()).then(|| value.to_string())
1744}
1745
1746/// Render the macOS login manager, with its version when one could be read.
1747///
1748/// Pure and separate from the file read so the formatting is testable without `/System`.
1749#[cfg(any(target_os = "macos", test))]
1750fn format_login_window(version: Option<String>) -> String {
1751    match version {
1752        Some(v) => format!("Login Window {v}"),
1753        None => "Login Window".to_string(),
1754    }
1755}
1756
1757/// Pure helper: maps a systemd display-manager unit file name to a display name.
1758///
1759/// Strips a trailing `.service` and prettifies well-known managers; unknown managers are
1760/// Title-cased so new ones still render reasonably. Returns `None` for an empty stem.
1761/// Split out from [`detect_login_manager`] so it is unit-testable without touching `/etc`.
1762#[cfg(target_os = "linux")]
1763fn login_manager_from_unit(unit: &str) -> Option<String> {
1764    let stem = unit.strip_suffix(".service").unwrap_or(unit).trim();
1765    if stem.is_empty() {
1766        return None;
1767    }
1768    let pretty = match stem.to_lowercase().as_str() {
1769        "gdm" | "gdm3" => "GDM",
1770        "sddm" => "SDDM",
1771        "lightdm" => "LightDM",
1772        "lxdm" => "LXDM",
1773        "xdm" => "XDM",
1774        "ly" => "Ly",
1775        "greetd" => "greetd",
1776        "slim" => "SLiM",
1777        "nodm" => "nodm",
1778        "entrance" => "Entrance",
1779        _ => {
1780            // Title-case the first letter, keep the rest as-is (e.g. "emptty" → "Emptty").
1781            let mut chars = stem.chars();
1782            return chars
1783                .next()
1784                .map(|c| c.to_uppercase().collect::<String>() + chars.as_str());
1785        }
1786    };
1787    Some(pretty.to_string())
1788}
1789
1790/// Detects the current backlight brightness as a percentage.
1791///
1792/// Linux only. Reads `brightness` and `max_brightness` from the first
1793/// `/sys/class/backlight/*` device (preferring a vendor backlight over a raw ACPI one), and
1794/// formats via [`brightness_percent`]. Machines with no backlight (most desktops) return
1795/// `None`, so the field simply does not render.
1796fn detect_brightness() -> Option<String> {
1797    #[cfg(target_os = "linux")]
1798    {
1799        use std::path::Path;
1800        let dir = Path::new("/sys/class/backlight");
1801        if !dir.exists() {
1802            return None;
1803        }
1804        // Collect device dirs; prefer a vendor/GPU backlight (e.g. intel_backlight,
1805        // amdgpu_bl0) over a generic ACPI one (acpi_video0) when several are present.
1806        let mut devices: Vec<std::path::PathBuf> = std::fs::read_dir(dir)
1807            .ok()?
1808            .flatten()
1809            .map(|e| e.path())
1810            .collect();
1811        devices.sort_by_key(|p| {
1812            let name = p
1813                .file_name()
1814                .and_then(|n| n.to_str())
1815                .unwrap_or("")
1816                .to_lowercase();
1817            // Lower sort key = higher preference.
1818            if name.contains("acpi") || name.contains("video") {
1819                1
1820            } else {
1821                0
1822            }
1823        });
1824        for dev in devices {
1825            let cur = std::fs::read_to_string(dev.join("brightness"))
1826                .ok()
1827                .and_then(|s| s.trim().parse::<u64>().ok());
1828            let max = std::fs::read_to_string(dev.join("max_brightness"))
1829                .ok()
1830                .and_then(|s| s.trim().parse::<u64>().ok());
1831            if let (Some(cur), Some(max)) = (cur, max) {
1832                if let Some(pct) = brightness_percent(cur, max) {
1833                    return Some(pct);
1834                }
1835            }
1836        }
1837        None
1838    }
1839    #[cfg(target_os = "macos")]
1840    {
1841        let (value, min, max) = crate::macos_ffi::get_backlight_brightness()?;
1842        // Rebased onto the shared `brightness_percent(cur, max)` helper by subtracting the
1843        // floor, so the percentage arithmetic lives in one tested place rather than two.
1844        // macOS reports min = 0 in practice (measured: 0 / 32768 / 65536), but the range
1845        // is expressed as a triple and a nonzero floor would otherwise inflate the figure.
1846        let span = max.checked_sub(min)?;
1847        let level = value.checked_sub(min)?;
1848        if span <= 0 || level < 0 {
1849            return None;
1850        }
1851        brightness_percent(level as u64, span as u64)
1852    }
1853    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
1854    {
1855        None
1856    }
1857}
1858
1859/// Pure helper: formats a raw brightness/max pair as a rounded percentage string.
1860///
1861/// Returns `None` when `max` is 0 (divide-by-zero guard). Split out from
1862/// [`detect_brightness`] so it is unit-testable without a real backlight device.
1863///
1864/// Shared by both backlight arms: Linux passes the raw `brightness`/`max_brightness`
1865/// pair, and macOS passes the `AppleARMBacklight` triple rebased onto a zero floor, so
1866/// the percentage arithmetic exists in exactly one tested place.
1867#[cfg(any(target_os = "linux", target_os = "macos"))]
1868fn brightness_percent(cur: u64, max: u64) -> Option<String> {
1869    if max == 0 {
1870        return None;
1871    }
1872    let pct = (cur as f64 / max as f64 * 100.0).round() as u64;
1873    Some(format!("{}%", pct))
1874}
1875
1876/// Detects the AC power adapter (name + connection state).
1877///
1878/// Linux only. Scans `/sys/class/power_supply/*` for a `Mains`-type supply (the AC
1879/// adapter), reads its `online` flag, and formats via [`format_power_adapter`]. Wattage is
1880/// not reported: `Mains` entries rarely expose it in sysfs, so emitting it would be
1881/// unreliable. Returns `None` when no AC adapter is present (e.g. a desktop with no
1882/// power_supply class, or a battery-only view).
1883fn detect_power_adapter() -> Option<String> {
1884    #[cfg(target_os = "linux")]
1885    {
1886        use std::path::Path;
1887        let dir = Path::new("/sys/class/power_supply");
1888        if !dir.exists() {
1889            return None;
1890        }
1891        for entry in std::fs::read_dir(dir).ok()?.flatten() {
1892            let path = entry.path();
1893            let supply_type = std::fs::read_to_string(path.join("type"))
1894                .map(|s| s.trim().to_string())
1895                .unwrap_or_default();
1896            if supply_type != "Mains" {
1897                continue;
1898            }
1899            let name = path
1900                .file_name()
1901                .and_then(|n| n.to_str())
1902                .unwrap_or("AC")
1903                .to_string();
1904            let online = std::fs::read_to_string(path.join("online"))
1905                .map(|s| s.trim().to_string())
1906                .unwrap_or_default();
1907            return Some(format_power_adapter(&name, &online));
1908        }
1909        None
1910    }
1911    #[cfg(target_os = "macos")]
1912    {
1913        // `IOPSCopyExternalPowerAdapterDetails` returns NULL on battery, so absence *is*
1914        // the unplugged signal and there is no separate flag to read. It also exposes no
1915        // `Name`, so macOS reports the wattage the Linux arm cannot — the reverse of the
1916        // Linux trade, and the reason these two arms format differently.
1917        let watts = crate::macos_ffi::get_power_adapter_watts()?;
1918        Some(format_power_adapter_watts(watts))
1919    }
1920    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
1921    {
1922        None
1923    }
1924}
1925
1926/// Render a macOS power adapter from its wattage.
1927///
1928/// Separate from [`format_power_adapter`] because the two platforms have different facts
1929/// to report: Linux has a supply *name* and an `online` flag but no wattage; macOS has
1930/// wattage and no name, and reports nothing at all when unplugged. A non-positive wattage
1931/// is dropped rather than printed — `0W (connected)` describes no real adapter.
1932#[cfg(any(target_os = "macos", test))]
1933fn format_power_adapter_watts(watts: i64) -> String {
1934    if watts > 0 {
1935        format!("{watts}W (connected)")
1936    } else {
1937        "connected".to_string()
1938    }
1939}
1940
1941/// Pure helper: formats an AC adapter name + `online` flag ("0"/"1") into a display string.
1942///
1943/// Split out from [`detect_power_adapter`] so it is unit-testable without a real adapter.
1944#[cfg(target_os = "linux")]
1945fn format_power_adapter(name: &str, online: &str) -> String {
1946    let state = match online.trim() {
1947        "1" => "connected",
1948        "0" => "not connected",
1949        _ => "unknown",
1950    };
1951    format!("{} ({})", name, state)
1952}
1953
1954/// Detects the Trusted Platform Module's specification version (e.g. "2.0").
1955///
1956/// Linux only. Reads `tpm_version_major` from the first `/sys/class/tpm/*` device and formats
1957/// via [`format_tpm_version`]. A machine with no TPM has no such class directory and returns
1958/// `None`, so the field does not render. The version is deliberately *not* guessed from the
1959/// device's mere presence: a TPM whose version cannot be read is reported as absent rather
1960/// than as a version that was never confirmed.
1961fn detect_tpm() -> Option<String> {
1962    #[cfg(target_os = "linux")]
1963    {
1964        use std::path::Path;
1965        let dir = Path::new("/sys/class/tpm");
1966        if !dir.exists() {
1967            return None;
1968        }
1969        let mut devices: Vec<std::path::PathBuf> = std::fs::read_dir(dir)
1970            .ok()?
1971            .flatten()
1972            .map(|e| e.path())
1973            .collect();
1974        // `tpm0` before `tpm1`, so a multi-TPM machine reports a stable one run to run.
1975        devices.sort();
1976        for dev in devices {
1977            if let Ok(major) = std::fs::read_to_string(dev.join("tpm_version_major")) {
1978                if let Some(v) = format_tpm_version(major.trim()) {
1979                    return Some(v);
1980                }
1981            }
1982        }
1983        None
1984    }
1985    #[cfg(not(target_os = "linux"))]
1986    {
1987        None
1988    }
1989}
1990
1991/// Pure helper: maps sysfs `tpm_version_major` to a TPM specification version string.
1992///
1993/// The kernel exposes only the major number, but the published specification names are "1.2"
1994/// and "2.0" — not "1.0"/"2.0" — so the minor part is a lookup, not arithmetic. An
1995/// unrecognised or unparseable major yields `None` rather than an invented version. Split out
1996/// from [`detect_tpm`] so it is unit-testable without a real TPM.
1997#[cfg(target_os = "linux")]
1998fn format_tpm_version(major: &str) -> Option<String> {
1999    match major.trim() {
2000        "1" => Some("1.2".to_string()),
2001        "2" => Some("2.0".to_string()),
2002        _ => None,
2003    }
2004}
2005
2006/// Windows CPU-usage sampling via `GetSystemTimes` (kernel32, default-linked).
2007///
2008/// Replaces the per-run 200 ms sleep sysinfo needs for a usage delta: two samples are
2009/// diffed across the existing concurrent-probe window instead, so no sleep is added.
2010#[cfg(target_os = "windows")]
2011mod win_cpu {
2012    #[repr(C)]
2013    struct FileTime {
2014        low: u32,
2015        high: u32,
2016    }
2017
2018    impl FileTime {
2019        fn ticks(&self) -> u64 {
2020            ((self.high as u64) << 32) | self.low as u64
2021        }
2022    }
2023
2024    extern "system" {
2025        fn GetSystemTimes(idle: *mut FileTime, kernel: *mut FileTime, user: *mut FileTime) -> i32;
2026    }
2027
2028    /// Cumulative `(idle, kernel, user)` CPU ticks (100 ns units). `kernel` includes idle,
2029    /// per the Win32 contract. `None` if the call fails.
2030    pub fn sample() -> Option<(u64, u64, u64)> {
2031        let mut idle = FileTime { low: 0, high: 0 };
2032        let mut kernel = FileTime { low: 0, high: 0 };
2033        let mut user = FileTime { low: 0, high: 0 };
2034        // SAFETY: three valid, writable FILETIME out-parameters.
2035        let ok = unsafe { GetSystemTimes(&mut idle, &mut kernel, &mut user) };
2036        if ok == 0 {
2037            None
2038        } else {
2039            Some((idle.ticks(), kernel.ticks(), user.ticks()))
2040        }
2041    }
2042
2043    /// System-wide CPU busy percentage between two `sample()` snapshots. Because `kernel`
2044    /// includes idle, `total = Δkernel + Δuser` and `busy = total − Δidle`.
2045    pub fn usage_percent(s0: (u64, u64, u64), s1: (u64, u64, u64)) -> f32 {
2046        let idle = s1.0.saturating_sub(s0.0);
2047        let kernel = s1.1.saturating_sub(s0.1);
2048        let user = s1.2.saturating_sub(s0.2);
2049        let total = kernel + user;
2050        if total == 0 {
2051            0.0
2052        } else {
2053            (100.0 * total.saturating_sub(idle) as f64 / total as f64) as f32
2054        }
2055    }
2056
2057    #[cfg(test)]
2058    mod layout {
2059        use std::mem::size_of;
2060
2061        // Two u32 FILETIME words = 8 bytes; the ticks() reader depends on this.
2062        #[test]
2063        fn filetime_size() {
2064            assert_eq!(size_of::<super::FileTime>(), 8);
2065        }
2066    }
2067}
2068
2069#[cfg(test)]
2070mod tests {
2071
2072    #[test]
2073    fn cpu_refresh_kind_is_none_when_no_cpu_field_is_selected() {
2074        assert!(cpu_refresh_kind(false, false, false).is_none());
2075    }
2076
2077    #[test]
2078    fn cpu_refresh_kind_for_plain_cpu_asks_for_neither_flag() {
2079        // Brand and core count come from the static CPU list. Asking for frequency here
2080        // is what made every `--short` run pay ~195 ms of performance-counter setup on
2081        // Windows for a field it does not display.
2082        let kind = cpu_refresh_kind(true, false, false).expect("cpu selected");
2083        assert!(!kind.frequency(), "plain `cpu` must not request frequency");
2084        assert!(!kind.cpu_usage(), "plain `cpu` must not request cpu usage");
2085    }
2086
2087    #[test]
2088    fn cpu_refresh_kind_asks_for_frequency_only_for_cpu_freq() {
2089        let kind = cpu_refresh_kind(false, true, false).expect("cpu-freq selected");
2090        assert!(
2091            kind.frequency(),
2092            "`cpu-freq` reads Cpu::frequency() and must request it"
2093        );
2094    }
2095
2096    #[test]
2097    fn cpu_refresh_kind_asks_for_usage_only_off_windows() {
2098        let kind = cpu_refresh_kind(false, false, true).expect("cpu-usage selected");
2099        if cfg!(target_os = "windows") {
2100            // The Windows arm diffs its own GetSystemTimes samples and never reads
2101            // sysinfo's counters, so priming them would be unread cost.
2102            assert!(!kind.cpu_usage());
2103        } else {
2104            // The Unix arm deltas against this baseline; without it the reading is zero.
2105            assert!(kind.cpu_usage());
2106        }
2107        assert!(!kind.frequency(), "cpu-usage must not drag in frequency");
2108    }
2109
2110    #[test]
2111    fn load_is_probed_only_when_selected_and_never_on_windows() {
2112        assert!(
2113            !should_probe_load(false),
2114            "an unselected `load` must not be probed on any platform"
2115        );
2116        if cfg!(target_os = "windows") {
2117            assert!(
2118                !should_probe_load(true),
2119                "sysinfo's Windows load average samples every 5 s from a zeroed static,                  so it can only ever report 0.00 in a process this short-lived"
2120            );
2121        } else {
2122            assert!(should_probe_load(true));
2123        }
2124    }
2125
2126    #[test]
2127    fn shell_and_terminal_each_load_the_process_list_on_their_own() {
2128        // Both walk the process tree from retch's own pid. Before v0.17.6 only `procs` and
2129        // `audio` loaded the list, so `--fields shell` alone found no tree to walk and
2130        // reported the shell the environment implied rather than the one running retch.
2131        assert!(needs_process_list(false, false, true, false), "shell alone");
2132        assert!(
2133            needs_process_list(false, false, false, true),
2134            "terminal alone"
2135        );
2136        assert!(needs_process_list(true, false, false, false), "procs alone");
2137        assert!(needs_process_list(false, true, false, false), "audio alone");
2138        assert!(
2139            !needs_process_list(false, false, false, false),
2140            "no consumer selected: the list must not be loaded"
2141        );
2142    }
2143
2144    use super::*;
2145
2146    #[cfg(target_os = "linux")]
2147    #[test]
2148    fn test_login_manager_from_unit() {
2149        assert_eq!(
2150            login_manager_from_unit("gdm.service").as_deref(),
2151            Some("GDM")
2152        );
2153        assert_eq!(
2154            login_manager_from_unit("gdm3.service").as_deref(),
2155            Some("GDM")
2156        );
2157        assert_eq!(
2158            login_manager_from_unit("sddm.service").as_deref(),
2159            Some("SDDM")
2160        );
2161        assert_eq!(
2162            login_manager_from_unit("lightdm.service").as_deref(),
2163            Some("LightDM")
2164        );
2165        // Unknown manager: Title-cased, .service stripped, rest preserved.
2166        assert_eq!(
2167            login_manager_from_unit("emptty.service").as_deref(),
2168            Some("Emptty")
2169        );
2170        // No .service suffix is tolerated.
2171        assert_eq!(login_manager_from_unit("ly").as_deref(), Some("Ly"));
2172        // Empty / suffix-only stems yield None.
2173        assert_eq!(login_manager_from_unit("").as_deref(), None);
2174        assert_eq!(login_manager_from_unit(".service").as_deref(), None);
2175    }
2176
2177    #[cfg(any(target_os = "linux", target_os = "macos"))]
2178    #[test]
2179    fn test_brightness_percent() {
2180        assert_eq!(brightness_percent(50, 100).as_deref(), Some("50%"));
2181        assert_eq!(brightness_percent(100, 100).as_deref(), Some("100%"));
2182        assert_eq!(brightness_percent(0, 100).as_deref(), Some("0%"));
2183        // Rounding: 133/255 ≈ 52.16% → 52%.
2184        assert_eq!(brightness_percent(133, 255).as_deref(), Some("52%"));
2185        // Divide-by-zero guard.
2186        assert_eq!(brightness_percent(10, 0), None);
2187    }
2188
2189    /// Verbatim excerpt from `/System/Library/CoreServices/loginwindow.app/Contents/
2190    /// Info.plist` on macOS 26 — the file is plain XML there, not a binary plist, which is
2191    /// what makes a dependency-free scan reasonable.
2192    #[test]
2193    fn test_parse_plist_string() {
2194        const PLIST: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
2195<plist version="1.0">
2196<dict>
2197	<key>CFBundleName</key>
2198	<string>loginwindow</string>
2199	<key>CFBundleShortVersionString</key>
2200	<string>9.0</string>
2201	<key>CFBundleVersion</key>
2202	<string>3085.6.3</string>
2203</dict>
2204</plist>"#;
2205        assert_eq!(
2206            parse_plist_string(PLIST, "CFBundleShortVersionString").as_deref(),
2207            Some("9.0")
2208        );
2209        assert_eq!(
2210            parse_plist_string(PLIST, "CFBundleVersion").as_deref(),
2211            Some("3085.6.3")
2212        );
2213        // A key that is not present yields None rather than the next string in the file,
2214        // which is the failure mode a naive scan would have.
2215        assert_eq!(parse_plist_string(PLIST, "NoSuchKey"), None);
2216    }
2217
2218    /// A key whose value is not a string must not borrow the *next* key's string.
2219    ///
2220    /// Without the intervening-`<key>` guard this returns `"unrelated"` for `Flag` — a
2221    /// confidently wrong version number rather than an absent one.
2222    #[test]
2223    fn test_parse_plist_string_rejects_a_non_string_value() {
2224        const PLIST: &str = r#"<dict>
2225	<key>Flag</key>
2226	<true/>
2227	<key>Other</key>
2228	<string>unrelated</string>
2229</dict>"#;
2230        assert_eq!(parse_plist_string(PLIST, "Flag"), None);
2231        assert_eq!(
2232            parse_plist_string(PLIST, "Other").as_deref(),
2233            Some("unrelated")
2234        );
2235    }
2236
2237    #[test]
2238    fn test_format_login_window() {
2239        assert_eq!(format_login_window(Some("9.0".into())), "Login Window 9.0");
2240        // No version read: still name the manager rather than reporting nothing, since
2241        // macOS always has exactly one and its presence is not in doubt.
2242        assert_eq!(format_login_window(None), "Login Window");
2243    }
2244
2245    #[test]
2246    fn test_format_power_adapter_watts() {
2247        assert_eq!(format_power_adapter_watts(96), "96W (connected)");
2248        // A non-positive wattage describes no real adapter, so the number is dropped
2249        // rather than printed as "0W (connected)".
2250        assert_eq!(format_power_adapter_watts(0), "connected");
2251        assert_eq!(format_power_adapter_watts(-1), "connected");
2252    }
2253
2254    /// The macOS backlight triple is rebased onto a zero floor before being handed to the
2255    /// shared percentage helper. Pins that arithmetic with the values this machine
2256    /// actually reports, plus a nonzero-floor case the hardware here does not produce.
2257    #[cfg(any(target_os = "linux", target_os = "macos"))]
2258    #[test]
2259    fn test_backlight_triple_rebasing() {
2260        // Measured on an M3 Pro: value 32768, min 0, max 65536.
2261        assert_eq!(
2262            brightness_percent((32768i64 - 0) as u64, (65536i64 - 0) as u64).as_deref(),
2263            Some("50%")
2264        );
2265        // A nonzero floor must not inflate the reading: halfway between 100 and 300 is
2266        // 50%, not the 67% a naive value/max would give.
2267        assert_eq!(
2268            brightness_percent((200i64 - 100) as u64, (300i64 - 100) as u64).as_deref(),
2269            Some("50%")
2270        );
2271    }
2272
2273    #[cfg(target_os = "linux")]
2274    #[test]
2275    fn test_format_power_adapter() {
2276        assert_eq!(format_power_adapter("AC", "1"), "AC (connected)");
2277        assert_eq!(format_power_adapter("ADP1", "0"), "ADP1 (not connected)");
2278        // Missing/garbage online flag degrades to "unknown" rather than panicking.
2279        assert_eq!(format_power_adapter("AC", ""), "AC (unknown)");
2280    }
2281
2282    #[cfg(target_os = "linux")]
2283    #[test]
2284    fn test_format_tpm_version() {
2285        // The spec names are 1.2 and 2.0, so the minor part is a lookup, not "major.0".
2286        assert_eq!(format_tpm_version("2").as_deref(), Some("2.0"));
2287        assert_eq!(format_tpm_version("1").as_deref(), Some("1.2"));
2288        // sysfs reads carry a trailing newline.
2289        assert_eq!(format_tpm_version("2\n").as_deref(), Some("2.0"));
2290        // An unrecognised or unreadable major is reported as absent, never invented.
2291        assert_eq!(format_tpm_version("3"), None);
2292        assert_eq!(format_tpm_version(""), None);
2293        assert_eq!(format_tpm_version("garbage"), None);
2294    }
2295
2296    #[cfg(target_os = "windows")]
2297    #[test]
2298    fn test_win_cpu_usage_percent() {
2299        use super::win_cpu::usage_percent;
2300        // kernel includes idle. Δidle=50, Δkernel=100 (incl. idle), Δuser=50 → total=150,
2301        // busy=150-50=100 → 66.67%.
2302        let u = usage_percent((0, 0, 0), (50, 100, 50));
2303        assert!((u - 66.6667).abs() < 0.01, "got {}", u);
2304
2305        // Fully idle: Δidle == Δkernel, Δuser=0 → 0%.
2306        assert_eq!(usage_percent((0, 0, 0), (100, 100, 0)), 0.0);
2307
2308        // Fully busy: no idle delta → 100%.
2309        assert_eq!(usage_percent((0, 0, 0), (0, 100, 100)), 100.0);
2310
2311        // No time elapsed (zero total) → 0%, no divide-by-zero.
2312        assert_eq!(usage_percent((5, 10, 10), (5, 10, 10)), 0.0);
2313    }
2314
2315    // NOTE: these exercise the pure fallback formatter `format_cpu_cores_plain`, not the
2316    // public `format_cpu_cores`. The latter first reads the *host's* real CPU topology and
2317    // returns a "NP + ME / KT" hybrid string on Intel P/E (and Apple Silicon) machines,
2318    // ignoring the passed-in counts — so calling it with fixed args is machine-dependent
2319    // and fails on hybrids (an i7-1360P produced "8P + 8E / 16T" for `(16, Some(8))`).
2320    #[test]
2321    fn test_format_cpu_cores_no_hyperthreading() {
2322        // Physical == logical: show plain "N cores"
2323        assert_eq!(format_cpu_cores_plain(4, Some(4)), "4 cores");
2324    }
2325
2326    #[test]
2327    fn test_format_cpu_cores_hyperthreaded() {
2328        // Physical < logical: show "NC / NT"
2329        assert_eq!(format_cpu_cores_plain(16, Some(8)), "8C / 16T");
2330    }
2331
2332    #[test]
2333    fn test_format_cpu_cores_unknown_physical() {
2334        // No physical count available: fall back to "N cores"
2335        assert_eq!(format_cpu_cores_plain(8, None), "8 cores");
2336    }
2337
2338    #[test]
2339    fn test_format_cpu_cores_physical_equals_zero() {
2340        // Degenerate: physical reported as 0 — treat same as unknown
2341        // physical(0) < logical(8), so would print "0C / 8T"; acceptable but
2342        // let's confirm the branch taken
2343        let result = format_cpu_cores_plain(8, Some(0));
2344        assert!(result.contains("8"), "should mention 8 threads: {}", result);
2345    }
2346
2347    #[cfg(target_os = "linux")]
2348    #[test]
2349    fn test_detect_cpu_cache_returns_some_on_linux() {
2350        // On a real Linux machine the sysfs cache dir exists; result should be Some
2351        // and contain at least one cache level label.
2352        if std::path::Path::new("/sys/devices/system/cpu/cpu0/cache").exists() {
2353            let result = detect_cpu_cache();
2354            assert!(result.is_some(), "expected cache info on Linux with sysfs");
2355            let s = result.unwrap();
2356            assert!(
2357                s.contains("L1") || s.contains("L2") || s.contains("L3"),
2358                "expected cache level labels, got: {}",
2359                s
2360            );
2361        }
2362    }
2363
2364    #[test]
2365    fn test_normalize_desktop_name_known() {
2366        assert_eq!(normalize_desktop_name("gnome"), "GNOME");
2367        assert_eq!(normalize_desktop_name("GNOME"), "GNOME");
2368        assert_eq!(normalize_desktop_name("kde"), "KDE Plasma");
2369        assert_eq!(normalize_desktop_name("plasma"), "KDE Plasma");
2370        assert_eq!(normalize_desktop_name("KDE Plasma"), "KDE Plasma");
2371        assert_eq!(normalize_desktop_name("xfce"), "XFCE");
2372        assert_eq!(normalize_desktop_name("lxqt"), "LXQt");
2373        assert_eq!(normalize_desktop_name("mate"), "MATE");
2374        assert_eq!(normalize_desktop_name("cinnamon"), "Cinnamon");
2375        assert_eq!(normalize_desktop_name("e"), "Enlightenment");
2376    }
2377
2378    #[test]
2379    fn test_normalize_desktop_name_unknown_lowercase() {
2380        // Unknown all-lowercase names get title-cased.
2381        assert_eq!(normalize_desktop_name("budgie"), "Budgie");
2382        assert_eq!(normalize_desktop_name("niri"), "Niri");
2383    }
2384
2385    #[test]
2386    fn test_normalize_desktop_name_unknown_mixed() {
2387        // Unknown mixed-case names are preserved as-is.
2388        assert_eq!(normalize_desktop_name("MyDE"), "MyDE");
2389    }
2390
2391    #[test]
2392    fn test_normalize_desktop_name_trims_whitespace() {
2393        assert_eq!(normalize_desktop_name("  gnome  "), "GNOME");
2394        assert_eq!(normalize_desktop_name(" niri "), "Niri");
2395    }
2396
2397    #[cfg(target_os = "linux")]
2398    #[test]
2399    fn test_detect_desktop_from_proc_returns_option() {
2400        // Just verify it runs without panicking and returns a sane value.
2401        let result = detect_desktop_from_proc();
2402        if let Some(ref de) = result {
2403            assert!(!de.is_empty(), "desktop name should not be empty");
2404        }
2405    }
2406
2407    #[cfg(target_os = "linux")]
2408    #[test]
2409    fn test_detect_cpu_freq_range_returns_ordered_pair() {
2410        if std::path::Path::new("/sys/devices/system/cpu/cpufreq").exists() {
2411            if let Some((min, max)) = detect_cpu_freq_range() {
2412                assert!(
2413                    min <= max,
2414                    "min freq should be <= max freq: {} > {}",
2415                    min,
2416                    max
2417                );
2418                assert!(min > 0, "min freq should be positive");
2419            }
2420        }
2421    }
2422}