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