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