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