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