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