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