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