Skip to main content

retch_sysinfo/
fetch.rs

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