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