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