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    /// Whether to collect all fields (long mode) or only the primary/default ones.
20    pub long: bool,
21    /// List of fields that are requested to be displayed. If None, all fields are collected.
22    pub fields: Option<Vec<String>>,
23    /// Optional location override for weather lookup (city, ZIP, airport code, coordinates).
24    pub weather_location: Option<String>,
25}
26
27/// Comprehensive system information data structure.
28///
29/// This struct holds all the metrics collected from the system,
30/// ranging from OS details to hardware specs and network status.
31#[derive(Debug)]
32pub struct SystemInfo {
33    /// Operating system name and version.
34    pub os: String,
35    /// Kernel version.
36    pub kernel: Option<String>,
37    /// System hostname.
38    pub hostname: Option<String>,
39    /// CPU architecture (e.g., x86_64).
40    pub arch: String,
41    /// CPU model brand string.
42    pub cpu: String,
43    /// Total number of logical CPU cores.
44    pub cpu_cores: usize,
45    /// Formatted core topology string (e.g. "8C / 16T" or "6P + 4E / 16T").
46    pub cpu_core_info: String,
47    /// Formatted memory usage (Used / Total).
48    pub memory: String,
49    /// Formatted swap usage (Used / Total).
50    pub swap: String,
51    /// System uptime formatted as a duration.
52    pub uptime: String,
53    /// Number of currently running processes.
54    pub processes: usize,
55    /// Load average (1, 5, 15 minutes).
56    pub load_avg: Option<String>,
57    /// List of mounted disks with usage information.
58    pub disks: Vec<String>,
59    /// Hardware component temperatures.
60    pub temps: Vec<String>,
61    /// Network interface statistics and status.
62    pub networks: Vec<String>,
63    /// System boot time in ISO 8601 format.
64    pub boot_time: String,
65    /// Battery status (currently placeholder for future feature).
66    pub battery: Option<String>,
67    /// Path to the current user's shell.
68    pub shell: Option<String>,
69    /// Name of the terminal emulator in use.
70    pub terminal: Option<String>,
71    /// Detected desktop environment or window manager.
72    pub desktop: Option<String>,
73    /// Current CPU frequency (formatted).
74    pub cpu_freq: Option<String>,
75    /// Number of interactive users (UID >= 1000).
76    pub users: usize,
77    /// List of detected GPUs with model names.
78    pub gpu: Vec<String>,
79    /// Total count of installed packages across supported managers.
80    pub packages: Option<usize>,
81    /// Name of the user running the process.
82    pub current_user: Option<String>,
83    /// Primary local IP address.
84    pub local_ip: Option<String>,
85    /// Public IP address (best effort).
86    pub public_ip: Option<String>,
87    /// Name of the active/default network interface.
88    pub active_interface: Option<String>,
89    /// Detected motherboard name and manufacturer.
90    pub motherboard: Option<String>,
91    /// Detected BIOS details.
92    pub bios: Option<String>,
93    /// List of connected display resolutions and refresh rates.
94    pub displays: Vec<String>,
95    /// Detected active audio driver/server and devices.
96    pub audio: Option<String>,
97    /// Connected Wi-Fi SSID and speed.
98    pub wifi: Option<String>,
99    /// Bluetooth power status.
100    pub bluetooth: Option<String>,
101    /// UI Theme (GTK, Qt, macOS, Windows).
102    pub ui_theme: Option<String>,
103    /// Icon theme (GTK/Qt).
104    pub icons: Option<String>,
105    /// Cursor theme (GTK/Qt).
106    pub cursor: Option<String>,
107    /// System Font.
108    pub font: Option<String>,
109    /// Terminal Font (configured in terminal emulator).
110    pub terminal_font: Option<String>,
111    /// Connected camera/webcam names.
112    pub camera: Vec<String>,
113    /// Connected gamepad/controller names.
114    pub gamepad: Vec<String>,
115    /// CPU cache sizes (L1d, L1i, L2, L3).
116    pub cpu_cache: Option<String>,
117    /// Current CPU utilization as a percentage.
118    pub cpu_usage: Option<String>,
119    /// Physical disk models, sizes, and types.
120    pub physical_disks: Vec<String>,
121    /// Physical memory (RAM) slot summary — type, speed, capacity.
122    pub physical_memory: Option<String>,
123    /// PID 1 / init system (systemd, runit, OpenRC, launchd, etc.).
124    pub init_system: Option<String>,
125    /// Chassis type (Desktop, Laptop, Server, etc.).
126    pub chassis: Option<String>,
127    /// System locale (from $LANG / $LC_ALL).
128    pub locale: Option<String>,
129    /// Second-stage bootloader (GRUB, systemd-boot, etc.).
130    pub bootmgr: Option<String>,
131    /// Default editor ($VISUAL / $EDITOR).
132    pub editor: Option<String>,
133    /// Current weather from wttr.in.
134    pub weather: Option<String>,
135}
136
137impl SystemInfo {
138    /// Collects system information using sysinfo and environment probes.
139    ///
140    /// This method aggregates data from the operating system, hardware,
141    /// and current user environment into a `SystemInfo` struct.
142    pub fn collect(opts: CollectOptions) -> anyhow::Result<Self> {
143        let should_collect = |field_name: &str| -> bool {
144            if opts.long {
145                return true;
146            }
147            match &opts.fields {
148                Some(fields) => {
149                    let norm_field = field_name.to_lowercase().replace(['-', '_'], " ");
150                    let norm_field_no_spaces = norm_field.replace(' ', "");
151                    fields.iter().any(|f| {
152                        let norm_f = f.to_lowercase().replace(['-', '_'], " ");
153                        norm_f == norm_field || norm_f.replace(' ', "") == norm_field_no_spaces
154                    })
155                }
156                None => true,
157            }
158        };
159
160        let mut refresh_kind = sysinfo::RefreshKind::nothing();
161        if should_collect("cpu")
162            || should_collect("cpu usage")
163            || should_collect("cpu-usage")
164            || should_collect("cpu cache")
165            || should_collect("cpu-cache")
166        {
167            refresh_kind = refresh_kind.with_cpu(sysinfo::CpuRefreshKind::everything());
168        }
169        if should_collect("memory")
170            || should_collect("swap")
171            || should_collect("phys mem")
172            || should_collect("phys-mem")
173        {
174            refresh_kind = refresh_kind.with_memory(sysinfo::MemoryRefreshKind::everything());
175        }
176        if should_collect("procs") || should_collect("audio") {
177            refresh_kind = refresh_kind.with_processes(sysinfo::ProcessRefreshKind::nothing());
178        }
179
180        let mut sys = System::new_with_specifics(refresh_kind);
181
182        let os = System::long_os_version()
183            .or_else(System::name)
184            .unwrap_or_else(|| "Unknown".to_string());
185
186        let kernel = System::kernel_version();
187        let hostname = System::host_name();
188
189        let cpu = if should_collect("cpu") {
190            sys.cpus()
191                .first()
192                .map(|c| c.brand().to_string())
193                .unwrap_or_else(|| "Unknown CPU".to_string())
194        } else {
195            String::new()
196        };
197
198        let cpu_cores = if should_collect("cpu") {
199            sys.cpus().len()
200        } else {
201            0
202        };
203        let cpu_core_info = if should_collect("cpu") {
204            format_cpu_cores(cpu_cores, System::physical_core_count())
205        } else {
206            String::new()
207        };
208
209        let memory = if should_collect("memory") {
210            let total_mem = sys.total_memory() as f64 / 1024.0 / 1024.0 / 1024.0;
211            let used_mem = sys.used_memory() as f64 / 1024.0 / 1024.0 / 1024.0;
212            format!("{:.1} / {:.1} GB", used_mem, total_mem)
213        } else {
214            String::new()
215        };
216
217        let swap = if should_collect("swap") {
218            let total_swap = sys.total_swap() as f64 / 1024.0 / 1024.0 / 1024.0;
219            let used_swap = sys.used_swap() as f64 / 1024.0 / 1024.0 / 1024.0;
220            if total_swap > 0.0 {
221                format!("{:.1} / {:.1} GB", used_swap, total_swap)
222            } else {
223                "No swap".to_string()
224            }
225        } else {
226            String::new()
227        };
228
229        let uptime = format!("{}s", System::uptime());
230
231        let disks: Vec<String> = if should_collect("disk") {
232            let disks_list = crate::disk::detect_logical_disks();
233            let format_disk = |(mount, total, avail, fs): &(String, u64, u64, String)| {
234                let total_gb = *total as f64 / 1024.0 / 1024.0 / 1024.0;
235                let avail_gb = *avail as f64 / 1024.0 / 1024.0 / 1024.0;
236                format!(
237                    "{} ({}): {:.1} GB free / {:.1} GB",
238                    mount, fs, avail_gb, total_gb
239                )
240            };
241            if !opts.long {
242                let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/"));
243                let home_path = std::path::Path::new(&home);
244                let best = disks_list
245                    .iter()
246                    .filter(|(mp, ..)| home_path.starts_with(mp))
247                    .max_by_key(|(mp, ..)| std::path::Path::new(mp).components().count());
248                if let Some(disk) = best {
249                    vec![format_disk(disk)]
250                } else {
251                    disks_list.iter().map(format_disk).collect()
252                }
253            } else {
254                disks_list.iter().map(format_disk).collect()
255            }
256        } else {
257            Vec::new()
258        };
259
260        let battery = if should_collect("battery") {
261            crate::battery::get_battery_info().map(|bat| {
262                let pct = bat.percentage;
263                let state = match bat.state {
264                    crate::battery::BatteryState::Charging => "charging",
265                    crate::battery::BatteryState::Discharging => "discharging",
266                    crate::battery::BatteryState::Full => "full",
267                    _ => "not charging",
268                };
269                let vendor = bat.vendor;
270                let model = bat.model;
271
272                // Format time remaining as "Xh Ym" or "Xd Yh"
273                let time_str = match bat.state {
274                    crate::battery::BatteryState::Charging => bat.time_remaining.map(|d| {
275                        let total_mins = d.as_secs() / 60;
276                        let hours = total_mins / 60;
277                        let mins = total_mins % 60;
278                        if hours >= 24 {
279                            let days = hours / 24;
280                            let rem_hours = hours % 24;
281                            format!("{}d {}h until full", days, rem_hours)
282                        } else if hours > 0 {
283                            format!("{}h {}m until full", hours, mins)
284                        } else {
285                            format!("{}m until full", mins)
286                        }
287                    }),
288                    crate::battery::BatteryState::Discharging => bat.time_remaining.map(|d| {
289                        let total_mins = d.as_secs() / 60;
290                        let hours = total_mins / 60;
291                        let mins = total_mins % 60;
292                        if hours >= 24 {
293                            let days = hours / 24;
294                            let rem_hours = hours % 24;
295                            format!("{}d {}h remaining", days, rem_hours)
296                        } else if hours > 0 {
297                            format!("{}h {}m remaining", hours, mins)
298                        } else {
299                            format!("{}m remaining", mins)
300                        }
301                    }),
302                    _ => None,
303                };
304
305                let mut parts = vec![state.to_string()];
306                if let Some(t) = time_str {
307                    parts.insert(0, t);
308                }
309                if let Some(health) = bat.health {
310                    if health < 99.0 {
311                        parts.push(format!("{:.0}% health", health));
312                    }
313                }
314
315                let base = format!("{:.0}% ({})", pct, parts.join(", "));
316
317                match (vendor, model) {
318                    (Some(v), Some(m)) => format!("{} [{} {}]", base, v, m),
319                    (Some(v), None) => format!("{} [{}]", base, v),
320                    _ => base,
321                }
322            })
323        } else {
324            None
325        };
326
327        let arch = System::cpu_arch();
328
329        let processes = if should_collect("procs") || should_collect("audio") {
330            sys.processes().len()
331        } else {
332            0
333        };
334
335        let load_avg = {
336            let avg = System::load_average();
337            if avg.one > 0.0 || avg.five > 0.0 {
338                Some(format!(
339                    "{:.2}, {:.2}, {:.2}",
340                    avg.one, avg.five, avg.fifteen
341                ))
342            } else {
343                None
344            }
345        };
346
347        // Compute slow system queries concurrently in parallel threads
348        let (
349            gpu,
350            packages,
351            public_ip,
352            (local_ip, active_interface),
353            motherboard,
354            bios,
355            displays,
356            audio,
357            wifi,
358            bluetooth,
359            (ui_theme, icons, cursor, font),
360            camera,
361            gamepad,
362            physical_disks,
363            physical_memory,
364            weather,
365        ) = std::thread::scope(|s| {
366            let gpu_handle = if should_collect("gpu") {
367                Some(s.spawn(|| {
368                    gpu::detect_gpus()
369                        .into_iter()
370                        .map(|g| g.format())
371                        .collect::<Vec<String>>()
372                }))
373            } else {
374                None
375            };
376            let packages_handle = if should_collect("packages") {
377                Some(s.spawn(crate::packages::detect_packages))
378            } else {
379                None
380            };
381            let public_ip_handle = if should_collect("public ip") {
382                Some(s.spawn(crate::network::detect_public_ip))
383            } else {
384                None
385            };
386            let network_ips_handle = if should_collect("net") {
387                Some(s.spawn(crate::network::detect_active_interface_and_local_ip))
388            } else {
389                None
390            };
391            let motherboard_handle = if should_collect("motherboard") {
392                Some(s.spawn(crate::motherboard::detect_motherboard))
393            } else {
394                None
395            };
396            let bios_handle = if should_collect("bios") {
397                Some(s.spawn(crate::bios::detect_bios))
398            } else {
399                None
400            };
401            let displays_handle = if should_collect("display") {
402                Some(s.spawn(crate::display::detect_displays))
403            } else {
404                None
405            };
406            let audio_handle = if should_collect("audio") {
407                Some(s.spawn(|| crate::audio::detect_audio(&sys)))
408            } else {
409                None
410            };
411            let wifi_handle = if should_collect("wifi") {
412                Some(s.spawn(crate::network::detect_wifi))
413            } else {
414                None
415            };
416            let bluetooth_handle = if should_collect("bluetooth") {
417                Some(s.spawn(crate::bluetooth::detect_bluetooth))
418            } else {
419                None
420            };
421            let ui_theme_and_fonts_handle = if should_collect("theme")
422                || should_collect("icons")
423                || should_collect("cursor")
424                || should_collect("font")
425            {
426                Some(s.spawn(crate::theme::detect_ui_theme_and_fonts))
427            } else {
428                None
429            };
430            let camera_handle = if should_collect("camera") {
431                Some(s.spawn(crate::camera::detect_camera))
432            } else {
433                None
434            };
435            let gamepad_handle = if should_collect("gamepad") {
436                Some(s.spawn(crate::gamepad::detect_gamepad))
437            } else {
438                None
439            };
440            let physical_disks_handle = if should_collect("phys disk") {
441                Some(s.spawn(crate::disk::detect_physical_disks))
442            } else {
443                None
444            };
445            let physical_memory_handle = if should_collect("phys mem") {
446                Some(s.spawn(crate::memory::detect_physical_memory))
447            } else {
448                None
449            };
450            let weather_location = opts.weather_location.clone();
451            let weather_handle = if should_collect("weather") {
452                Some(s.spawn(move || crate::weather::detect_weather(weather_location.as_deref())))
453            } else {
454                None
455            };
456
457            (
458                gpu_handle
459                    .map(|h| h.join().unwrap_or_default())
460                    .unwrap_or_default(),
461                packages_handle.and_then(|h| h.join().ok().flatten()),
462                public_ip_handle.and_then(|h| h.join().ok().flatten()),
463                network_ips_handle
464                    .map(|h| h.join().unwrap_or((None, None)))
465                    .unwrap_or((None, None)),
466                motherboard_handle.and_then(|h| h.join().ok().flatten()),
467                bios_handle.and_then(|h| h.join().ok().flatten()),
468                displays_handle
469                    .map(|h| h.join().unwrap_or_default())
470                    .unwrap_or_default(),
471                audio_handle.and_then(|h| h.join().ok().flatten()),
472                wifi_handle.and_then(|h| h.join().ok().flatten()),
473                bluetooth_handle.and_then(|h| h.join().ok().flatten()),
474                ui_theme_and_fonts_handle
475                    .map(|h| h.join().unwrap_or((None, None, None, None)))
476                    .unwrap_or((None, None, None, None)),
477                camera_handle
478                    .map(|h| h.join().unwrap_or_default())
479                    .unwrap_or_default(),
480                gamepad_handle
481                    .map(|h| h.join().unwrap_or_default())
482                    .unwrap_or_default(),
483                physical_disks_handle
484                    .map(|h| h.join().unwrap_or_default())
485                    .unwrap_or_default(),
486                physical_memory_handle.and_then(|h| h.join().ok().flatten()),
487                weather_handle.and_then(|h| h.join().ok().flatten()),
488            )
489        });
490
491        let mut temps: Vec<String> = if should_collect("temp") {
492            Components::new_with_refreshed_list()
493                .iter()
494                .filter_map(|c| {
495                    c.temperature().and_then(|t| {
496                        if t > 0.0 {
497                            Some(format!("{}: {:.0}°C", c.label(), t))
498                        } else {
499                            None
500                        }
501                    })
502                })
503                .collect()
504        } else {
505            Vec::new()
506        };
507
508        // Sort so CPU temperatures appear first
509        temps.sort_by(|a, b| {
510            let a_cpu = a.to_lowercase().contains("cpu") || a.to_lowercase().contains("core");
511            let b_cpu = b.to_lowercase().contains("cpu") || b.to_lowercase().contains("core");
512            b_cpu.cmp(&a_cpu)
513        });
514
515        let networks = if should_collect("net") {
516            crate::network::detect_networks(active_interface.as_deref(), local_ip.as_deref())
517        } else {
518            Vec::new()
519        };
520
521        let boot_timestamp = System::boot_time();
522        let boot_dt = chrono::Local
523            .timestamp_opt(boot_timestamp as i64, 0)
524            .single()
525            .map(|dt| dt.format("%Y-%m-%dT%H:%M:%S%:z").to_string())
526            .unwrap_or_else(|| boot_timestamp.to_string());
527        let boot_time = boot_dt;
528
529        // Environment-based info
530        let shell = if should_collect("shell") {
531            crate::shell::detect_shell(&sys)
532        } else {
533            None
534        };
535        let terminal = if should_collect("terminal") {
536            crate::terminal::detect_terminal(&sys)
537        } else {
538            None
539        };
540        let terminal_font = if should_collect("terminal font")
541            || should_collect("terminal-font")
542            || should_collect("terminal_font")
543        {
544            crate::terminal::detect_terminal_font(terminal.as_deref())
545        } else {
546            None
547        };
548        let desktop = if should_collect("desktop") {
549            std::env::var("XDG_CURRENT_DESKTOP")
550                .or_else(|_| std::env::var("DESKTOP_SESSION"))
551                .ok()
552        } else {
553            None
554        };
555
556        // CPU frequency (current from sysinfo + min/max range from sysfs)
557        let cpu_freq = if should_collect("cpu-freq")
558            || should_collect("cpu freq")
559            || should_collect("cpu_freq")
560        {
561            sys.cpus().first().map(|c| {
562                let current = format!("{:.2} GHz", c.frequency() as f64 / 1000.0);
563                if let Some((min_khz, max_khz)) = detect_cpu_freq_range() {
564                    let min_ghz = min_khz as f64 / 1_000_000.0;
565                    let max_ghz = max_khz as f64 / 1_000_000.0;
566                    format!("{} ({:.2} \u{2013} {:.2} GHz)", current, min_ghz, max_ghz)
567                } else {
568                    current
569                }
570            })
571        } else {
572            None
573        };
574
575        // CPU cache sizes
576        let cpu_cache = if should_collect("cpu-cache")
577            || should_collect("cpu cache")
578            || should_collect("cpu_cache")
579        {
580            detect_cpu_cache()
581        } else {
582            None
583        };
584
585        // CPU usage — refresh twice with a short sleep so sysinfo has a delta
586        let cpu_usage = if should_collect("cpu-usage")
587            || should_collect("cpu usage")
588            || should_collect("cpu_usage")
589        {
590            std::thread::sleep(std::time::Duration::from_millis(200));
591            sys.refresh_cpu_usage();
592            let usage: f32 =
593                sys.cpus().iter().map(|c| c.cpu_usage()).sum::<f32>() / sys.cpus().len() as f32;
594            #[cfg(not(target_os = "windows"))]
595            {
596                let avg = System::load_average();
597                let load_str = format!("{:.2}, {:.2}, {:.2}", avg.one, avg.five, avg.fifteen);
598                if usage > 0.0 {
599                    Some(format!("{:.1}% (load: {})", usage, load_str))
600                } else if avg.one > 0.0 {
601                    Some(format!("load: {}", load_str))
602                } else {
603                    None
604                }
605            }
606            #[cfg(target_os = "windows")]
607            {
608                if usage > 0.0 {
609                    Some(format!("{:.1}%", usage))
610                } else {
611                    None
612                }
613            }
614        } else {
615            None
616        };
617
618        let init_system = if should_collect("init") || should_collect("init system") {
619            detect_init_system()
620        } else {
621            None
622        };
623
624        let chassis = if should_collect("chassis") {
625            detect_chassis()
626        } else {
627            None
628        };
629
630        let locale = if should_collect("locale") {
631            std::env::var("LC_ALL")
632                .ok()
633                .filter(|s| !s.is_empty())
634                .or_else(|| std::env::var("LC_MESSAGES").ok().filter(|s| !s.is_empty()))
635                .or_else(|| std::env::var("LANG").ok().filter(|s| !s.is_empty()))
636        } else {
637            None
638        };
639
640        let bootmgr = if should_collect("bootmgr") || should_collect("boot") {
641            detect_bootmgr()
642        } else {
643            None
644        };
645
646        let editor = if should_collect("editor") {
647            std::env::var("VISUAL")
648                .ok()
649                .filter(|s| !s.is_empty())
650                .or_else(|| std::env::var("EDITOR").ok().filter(|s| !s.is_empty()))
651        } else {
652            None
653        };
654
655        // Current logged in user
656        let current_user = std::env::var("USER").ok();
657
658        // Number of interactive users (UID >= 1000, excluding system accounts)
659        let users = if should_collect("users") {
660            Users::new_with_refreshed_list()
661                .iter()
662                .filter(|user| {
663                    // UID is exposed via Display
664                    let uid_str = user.id().to_string();
665                    if let Ok(uid) = uid_str.parse::<u32>() {
666                        uid >= 1000
667                    } else {
668                        false
669                    }
670                })
671                .count()
672        } else {
673            0
674        };
675
676        Ok(Self {
677            os,
678            kernel,
679            hostname,
680            arch,
681            cpu,
682            cpu_cores,
683            cpu_core_info,
684            memory,
685            swap,
686            uptime,
687            processes,
688            load_avg,
689            disks,
690            temps,
691            networks,
692            boot_time,
693            battery,
694            shell,
695            terminal,
696            desktop,
697            cpu_freq,
698            users,
699            gpu,
700            packages,
701            current_user,
702            local_ip,
703            public_ip,
704            active_interface,
705            motherboard,
706            bios,
707            displays,
708            audio,
709            wifi,
710            bluetooth,
711            ui_theme,
712            icons,
713            cursor,
714            font,
715            terminal_font,
716            camera,
717            gamepad,
718            cpu_cache,
719            cpu_usage,
720            physical_disks,
721            physical_memory,
722            init_system,
723            chassis,
724            locale,
725            bootmgr,
726            editor,
727            weather,
728        })
729    }
730}
731
732/// Detects CPU cache sizes.
733///
734/// Linux: reads from `/sys/devices/system/cpu/cpu0/cache/` sysfs entries.
735/// macOS: reads `hw.l1dcachesize`, `hw.l1icachesize`, `hw.l2cachesize`, `hw.l3cachesize` via sysctlbyname.
736/// Returns `None` on Windows or if data is unavailable.
737pub fn detect_cpu_cache() -> Option<String> {
738    #[cfg(target_os = "linux")]
739    {
740        use std::fs;
741        let cache_dir = std::path::Path::new("/sys/devices/system/cpu/cpu0/cache");
742        if !cache_dir.exists() {
743            return None;
744        }
745
746        struct CacheEntry {
747            level: u32,
748            kind: String,
749            size_kb: u64,
750        }
751
752        let mut entries: Vec<CacheEntry> = Vec::new();
753
754        let Ok(indices) = fs::read_dir(cache_dir) else {
755            return None;
756        };
757
758        for entry in indices.flatten() {
759            let path = entry.path();
760            // Skip non-index entries (e.g. the uevent file)
761            if !path.is_dir() {
762                continue;
763            }
764            let level_str = match fs::read_to_string(path.join("level")) {
765                Ok(s) => s,
766                Err(_) => continue,
767            };
768            let level: u32 = match level_str.trim().parse() {
769                Ok(n) => n,
770                Err(_) => continue,
771            };
772            let kind = match fs::read_to_string(path.join("type")) {
773                Ok(s) => s.trim().to_string(),
774                Err(_) => continue,
775            };
776            let size_str = match fs::read_to_string(path.join("size")) {
777                Ok(s) => s,
778                Err(_) => continue,
779            };
780            let size_raw = size_str.trim();
781            let size_kb: u64 = if let Some(k) = size_raw.strip_suffix('K') {
782                match k.parse() {
783                    Ok(n) => n,
784                    Err(_) => continue,
785                }
786            } else if let Some(m) = size_raw.strip_suffix('M') {
787                match m.parse::<u64>() {
788                    Ok(n) => n * 1024,
789                    Err(_) => continue,
790                }
791            } else {
792                match size_raw.parse() {
793                    Ok(n) => n,
794                    Err(_) => continue,
795                }
796            };
797
798            if kind != "Instruction" && kind != "Data" && kind != "Unified" {
799                continue;
800            }
801
802            entries.push(CacheEntry {
803                level,
804                kind,
805                size_kb,
806            });
807        }
808
809        if entries.is_empty() {
810            return None;
811        }
812
813        entries.sort_by_key(|e| (e.level, e.kind.clone()));
814
815        let fmt_size = |kb: u64| -> String {
816            if kb >= 1024 && kb.is_multiple_of(1024) {
817                format!("{}M", kb / 1024)
818            } else if kb >= 1024 {
819                format!("{:.2}M", kb as f64 / 1024.0)
820                    .trim_end_matches('0')
821                    .trim_end_matches('.')
822                    .to_string()
823                    + "M"
824            } else {
825                format!("{}K", kb)
826            }
827        };
828
829        // Deduplicate by label (cpu0 cache dir lists each index separately)
830        let mut seen = std::collections::HashSet::new();
831        let mut parts: Vec<String> = Vec::new();
832        for e in &entries {
833            let label = match (e.level, e.kind.as_str()) {
834                (1, "Data") => "L1d".to_string(),
835                (1, "Instruction") => "L1i".to_string(),
836                (1, "Unified") => "L1".to_string(),
837                (n, _) => format!("L{}", n),
838            };
839            if seen.insert(label.clone()) {
840                parts.push(format!("{}: {}", label, fmt_size(e.size_kb)));
841            }
842        }
843
844        if parts.is_empty() {
845            None
846        } else {
847            Some(parts.join(", "))
848        }
849    }
850    #[cfg(target_os = "macos")]
851    {
852        extern "C" {
853            fn sysctlbyname(
854                name: *const i8,
855                oldp: *mut std::ffi::c_void,
856                oldlenp: *mut usize,
857                newp: *mut std::ffi::c_void,
858                newlen: usize,
859            ) -> i32;
860        }
861
862        let read_u64 = |key: &str| -> Option<u64> {
863            let name = std::ffi::CString::new(key).ok()?;
864            let mut value: u64 = 0;
865            let mut size = std::mem::size_of::<u64>();
866            let ret = unsafe {
867                sysctlbyname(
868                    name.as_ptr(),
869                    &mut value as *mut u64 as *mut std::ffi::c_void,
870                    &mut size,
871                    std::ptr::null_mut(),
872                    0,
873                )
874            };
875            if ret == 0 && value > 0 {
876                Some(value)
877            } else {
878                None
879            }
880        };
881
882        let fmt_bytes = |bytes: u64| -> String {
883            if bytes >= 1024 * 1024 {
884                format!("{}M", bytes / (1024 * 1024))
885            } else {
886                format!("{}K", bytes / 1024)
887            }
888        };
889
890        let mut parts = Vec::new();
891        if let Some(v) = read_u64("hw.l1dcachesize") {
892            parts.push(format!("L1d: {}", fmt_bytes(v)));
893        }
894        if let Some(v) = read_u64("hw.l1icachesize") {
895            parts.push(format!("L1i: {}", fmt_bytes(v)));
896        }
897        if let Some(v) = read_u64("hw.l2cachesize") {
898            parts.push(format!("L2: {}", fmt_bytes(v)));
899        }
900        if let Some(v) = read_u64("hw.l3cachesize") {
901            parts.push(format!("L3: {}", fmt_bytes(v)));
902        }
903
904        if parts.is_empty() {
905            None
906        } else {
907            Some(parts.join(", "))
908        }
909    }
910    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
911    {
912        None
913    }
914}
915
916/// Formats a CPU core topology string.
917///
918/// Returns `"NP + NE / NT"` on Intel hybrid CPUs (different max frequencies per cluster),
919/// `"NC / NT"` when physical < logical (hyperthreading), or `"N cores"` otherwise.
920pub fn format_cpu_cores(logical: usize, physical: Option<usize>) -> String {
921    // Linux: detect Intel hybrid via cpufreq policy max-frequency grouping
922    #[cfg(target_os = "linux")]
923    if let Some(hybrid) = detect_hybrid_cores(logical) {
924        return hybrid;
925    }
926
927    // macOS: detect Apple Silicon P/E cores via hw.perflevel* sysctls
928    #[cfg(target_os = "macos")]
929    if let Some(hybrid) = detect_macos_hybrid_cores(logical) {
930        return hybrid;
931    }
932
933    match physical {
934        Some(p) if p < logical => format!("{}C / {}T", p, logical),
935        _ => format!("{} cores", logical),
936    }
937}
938
939/// On Linux, detects Intel hybrid topology (P-cores + E-cores) by grouping CPUs
940/// by their maximum cpufreq frequency. Returns `None` if not hybrid or unavailable.
941#[cfg(target_os = "linux")]
942fn detect_hybrid_cores(logical: usize) -> Option<String> {
943    use std::collections::HashMap;
944    use std::fs;
945
946    let cpufreq = std::path::Path::new("/sys/devices/system/cpu/cpufreq");
947    if !cpufreq.exists() {
948        return None;
949    }
950
951    // Map max_freq → number of CPUs in that policy
952    let mut freq_to_count: HashMap<u64, usize> = HashMap::new();
953    let mut total_accounted = 0usize;
954
955    let Ok(policies) = fs::read_dir(cpufreq) else {
956        return None;
957    };
958
959    for policy in policies.flatten() {
960        let path = policy.path();
961        if !path.is_dir() {
962            continue;
963        }
964        let max_freq_str = fs::read_to_string(path.join("cpuinfo_max_freq")).ok()?;
965        let max_freq: u64 = max_freq_str.trim().parse().ok()?;
966        let affected = fs::read_to_string(path.join("affected_cpus")).ok()?;
967        let count = affected.split_whitespace().count();
968        *freq_to_count.entry(max_freq).or_insert(0) += count;
969        total_accounted += count;
970    }
971
972    // Only report hybrid if we have exactly 2 frequency tiers and they account for all threads
973    if freq_to_count.len() != 2 || total_accounted != logical {
974        return None;
975    }
976
977    let mut tiers: Vec<(u64, usize)> = freq_to_count.into_iter().collect();
978    tiers.sort_by_key(|t| std::cmp::Reverse(t.0)); // highest freq first = P-cores
979    let (_, p_count) = tiers[0];
980    let (_, e_count) = tiers[1];
981
982    Some(format!("{}P + {}E / {}T", p_count, e_count, logical))
983}
984
985/// On macOS Apple Silicon, detects P/E cores via `hw.nperflevels` and
986/// `hw.perflevelN.logicalcpu` sysctls. Returns `None` on Intel Macs or if unavailable.
987#[cfg(target_os = "macos")]
988fn detect_macos_hybrid_cores(logical: usize) -> Option<String> {
989    extern "C" {
990        fn sysctlbyname(
991            name: *const i8,
992            oldp: *mut std::ffi::c_void,
993            oldlenp: *mut usize,
994            newp: *mut std::ffi::c_void,
995            newlen: usize,
996        ) -> i32;
997    }
998
999    let read_u32 = |key: &str| -> Option<u32> {
1000        let name = std::ffi::CString::new(key).ok()?;
1001        let mut value: u32 = 0;
1002        let mut size = std::mem::size_of::<u32>();
1003        let ret = unsafe {
1004            sysctlbyname(
1005                name.as_ptr(),
1006                &mut value as *mut u32 as *mut std::ffi::c_void,
1007                &mut size,
1008                std::ptr::null_mut(),
1009                0,
1010            )
1011        };
1012        if ret == 0 {
1013            Some(value)
1014        } else {
1015            None
1016        }
1017    };
1018
1019    // hw.nperflevels == 2 on M-series (P + E), absent or 1 on Intel
1020    let nlevels = read_u32("hw.nperflevels")?;
1021    if nlevels != 2 {
1022        return None;
1023    }
1024
1025    let p_cores = read_u32("hw.perflevel0.logicalcpu")? as usize;
1026    let e_cores = read_u32("hw.perflevel1.logicalcpu")? as usize;
1027
1028    if p_cores + e_cores != logical {
1029        return None;
1030    }
1031
1032    Some(format!("{}P + {}E / {}T", p_cores, e_cores, logical))
1033}
1034
1035/// Returns the overall (min_khz, max_khz) CPU frequency range from sysfs cpufreq policies.
1036/// min is the smallest `cpuinfo_min_freq` across all policies; max is the largest `cpuinfo_max_freq`.
1037pub fn detect_cpu_freq_range() -> Option<(u64, u64)> {
1038    #[cfg(target_os = "linux")]
1039    {
1040        use std::fs;
1041        let cpufreq = std::path::Path::new("/sys/devices/system/cpu/cpufreq");
1042        if !cpufreq.exists() {
1043            return None;
1044        }
1045        let mut global_min: Option<u64> = None;
1046        let mut global_max: Option<u64> = None;
1047        let Ok(policies) = fs::read_dir(cpufreq) else {
1048            return None;
1049        };
1050        for policy in policies.flatten() {
1051            let path = policy.path();
1052            if !path.is_dir() {
1053                continue;
1054            }
1055            if let Ok(s) = fs::read_to_string(path.join("cpuinfo_min_freq")) {
1056                if let Ok(v) = s.trim().parse::<u64>() {
1057                    global_min = Some(global_min.map_or(v, |m: u64| m.min(v)));
1058                }
1059            }
1060            if let Ok(s) = fs::read_to_string(path.join("cpuinfo_max_freq")) {
1061                if let Ok(v) = s.trim().parse::<u64>() {
1062                    global_max = Some(global_max.map_or(v, |m: u64| m.max(v)));
1063                }
1064            }
1065        }
1066        match (global_min, global_max) {
1067            (Some(min), Some(max)) => Some((min, max)),
1068            _ => None,
1069        }
1070    }
1071    #[cfg(not(target_os = "linux"))]
1072    {
1073        None
1074    }
1075}
1076
1077fn detect_init_system() -> Option<String> {
1078    #[cfg(target_os = "linux")]
1079    {
1080        let comm = std::fs::read_to_string("/proc/1/comm")
1081            .map(|s| s.trim().to_string())
1082            .ok()
1083            .filter(|s| !s.is_empty());
1084        if let Some(name) = comm {
1085            return Some(name);
1086        }
1087        std::fs::read_link("/proc/1/exe").ok().and_then(|p| {
1088            p.file_name()
1089                .and_then(|n| n.to_str())
1090                .map(|s| s.to_string())
1091        })
1092    }
1093    #[cfg(target_os = "macos")]
1094    {
1095        Some("launchd".to_string())
1096    }
1097    #[cfg(target_os = "windows")]
1098    {
1099        Some("SCM".to_string())
1100    }
1101    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
1102    {
1103        None
1104    }
1105}
1106
1107fn detect_chassis() -> Option<String> {
1108    #[cfg(target_os = "linux")]
1109    {
1110        let raw = std::fs::read_to_string("/sys/class/dmi/id/chassis_type").ok()?;
1111        let n: u32 = raw.trim().parse().ok()?;
1112        let label = match n {
1113            3 => "Desktop",
1114            4 => "Low-Profile Desktop",
1115            6 => "Mini Tower",
1116            7 => "Tower",
1117            8 | 9 | 10 | 14 | 31 | 32 => "Laptop",
1118            11 => "Handheld",
1119            13 => "All-in-One",
1120            17 => "Main Server",
1121            23 => "Rack Server",
1122            28 => "Blade",
1123            30 => "Tablet",
1124            35 => "Mini PC",
1125            36 => "Stick PC",
1126            _ => return None,
1127        };
1128        Some(label.to_string())
1129    }
1130    #[cfg(target_os = "macos")]
1131    {
1132        let output = std::process::Command::new("sysctl")
1133            .args(["-n", "hw.model"])
1134            .output()
1135            .ok()?;
1136        let model = String::from_utf8(output.stdout).ok()?;
1137        let model = model.trim();
1138        if model.contains("MacBook") {
1139            Some("Laptop".to_string())
1140        } else if model.contains("MacPro") {
1141            Some("Desktop".to_string())
1142        } else if model.contains("Macmini") || model.contains("Mac mini") {
1143            Some("Mini PC".to_string())
1144        } else if model.contains("iMac") {
1145            Some("All-in-One".to_string())
1146        } else {
1147            Some(model.to_string())
1148        }
1149    }
1150    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
1151    {
1152        None
1153    }
1154}
1155
1156fn detect_bootmgr() -> Option<String> {
1157    #[cfg(target_os = "linux")]
1158    {
1159        use std::path::Path;
1160        let is_uefi = Path::new("/sys/firmware/efi").exists();
1161        if Path::new("/boot/loader/entries").exists()
1162            || Path::new("/boot/loader/loader.conf").exists()
1163            || Path::new("/efi/loader/loader.conf").exists()
1164        {
1165            return Some("systemd-boot".to_string());
1166        }
1167        if Path::new("/boot/grub2/grub.cfg").exists() || Path::new("/boot/grub2").exists() {
1168            return Some("GRUB 2".to_string());
1169        }
1170        if Path::new("/boot/grub/grub.cfg").exists() || Path::new("/boot/grub").exists() {
1171            return Some("GRUB".to_string());
1172        }
1173        if is_uefi {
1174            Some("UEFI".to_string())
1175        } else {
1176            Some("BIOS".to_string())
1177        }
1178    }
1179    #[cfg(target_os = "macos")]
1180    {
1181        Some("Apple Boot ROM".to_string())
1182    }
1183    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
1184    {
1185        None
1186    }
1187}
1188
1189#[cfg(test)]
1190mod tests {
1191    use super::*;
1192
1193    #[test]
1194    fn test_format_cpu_cores_no_hyperthreading() {
1195        // Physical == logical: show plain "N cores"
1196        assert_eq!(format_cpu_cores(4, Some(4)), "4 cores");
1197    }
1198
1199    #[test]
1200    fn test_format_cpu_cores_hyperthreaded() {
1201        // Physical < logical: show "NC / NT"
1202        assert_eq!(format_cpu_cores(16, Some(8)), "8C / 16T");
1203    }
1204
1205    #[test]
1206    fn test_format_cpu_cores_unknown_physical() {
1207        // No physical count available: fall back to "N cores"
1208        assert_eq!(format_cpu_cores(8, None), "8 cores");
1209    }
1210
1211    #[test]
1212    fn test_format_cpu_cores_physical_equals_zero() {
1213        // Degenerate: physical reported as 0 — treat same as unknown
1214        // physical(0) < logical(8), so would print "0C / 8T"; acceptable but
1215        // let's confirm the branch taken
1216        let result = format_cpu_cores(8, Some(0));
1217        assert!(result.contains("8"), "should mention 8 threads: {}", result);
1218    }
1219
1220    #[cfg(target_os = "linux")]
1221    #[test]
1222    fn test_detect_cpu_cache_returns_some_on_linux() {
1223        // On a real Linux machine the sysfs cache dir exists; result should be Some
1224        // and contain at least one cache level label.
1225        if std::path::Path::new("/sys/devices/system/cpu/cpu0/cache").exists() {
1226            let result = detect_cpu_cache();
1227            assert!(result.is_some(), "expected cache info on Linux with sysfs");
1228            let s = result.unwrap();
1229            assert!(
1230                s.contains("L1") || s.contains("L2") || s.contains("L3"),
1231                "expected cache level labels, got: {}",
1232                s
1233            );
1234        }
1235    }
1236
1237    #[cfg(target_os = "linux")]
1238    #[test]
1239    fn test_detect_cpu_freq_range_returns_ordered_pair() {
1240        if std::path::Path::new("/sys/devices/system/cpu/cpufreq").exists() {
1241            if let Some((min, max)) = detect_cpu_freq_range() {
1242                assert!(
1243                    min <= max,
1244                    "min freq should be <= max freq: {} > {}",
1245                    min,
1246                    max
1247                );
1248                assert!(min > 0, "min freq should be positive");
1249            }
1250        }
1251    }
1252}