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