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