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