Skip to main content

retch_sysinfo/
fetch.rs

1// SPDX-FileCopyrightText: 2026 Ken Tobias
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4//! System information gathering.
5//!
6//! Uses the `sysinfo` crate and other heuristics to collect details
7//! about the OS, hardware, and environment.
8
9use crate::gpu;
10use chrono::TimeZone;
11use sysinfo::{Components, System, Users};
12
13/// Options for controlling what system information is gathered.
14///
15/// This decouples the collection logic from the CLI argument parser,
16/// allowing `retch-sysinfo` to be used as a standalone library.
17#[derive(Debug, Default, Clone)]
18pub struct CollectOptions {
19    /// Whether to collect all fields (long mode) or only the primary/default ones.
20    pub long: bool,
21    /// List of fields that are requested to be displayed. If None, all fields are collected.
22    pub fields: Option<Vec<String>>,
23}
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 = crate::disk::detect_logical_disks();
219            let format_disk = |(mount, total, avail, fs): &(String, u64, u64, String)| {
220                let total_gb = *total as f64 / 1024.0 / 1024.0 / 1024.0;
221                let avail_gb = *avail as f64 / 1024.0 / 1024.0 / 1024.0;
222                format!(
223                    "{} ({}): {:.1} GB free / {:.1} GB",
224                    mount, fs, avail_gb, total_gb
225                )
226            };
227            if !opts.long {
228                let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/"));
229                let home_path = std::path::Path::new(&home);
230                let best = disks_list
231                    .iter()
232                    .filter(|(mp, ..)| home_path.starts_with(mp))
233                    .max_by_key(|(mp, ..)| std::path::Path::new(mp).components().count());
234                if let Some(disk) = best {
235                    vec![format_disk(disk)]
236                } else {
237                    disks_list.iter().map(format_disk).collect()
238                }
239            } else {
240                disks_list.iter().map(format_disk).collect()
241            }
242        } else {
243            Vec::new()
244        };
245
246        let battery = if should_collect("battery") {
247            crate::battery::get_battery_info().map(|bat| {
248                let pct = bat.percentage;
249                let state = match bat.state {
250                    crate::battery::BatteryState::Charging => "charging",
251                    crate::battery::BatteryState::Discharging => "discharging",
252                    crate::battery::BatteryState::Full => "full",
253                    _ => "not charging",
254                };
255                let vendor = bat.vendor;
256                let model = bat.model;
257
258                // Format time remaining as "Xh Ym" or "Xd Yh"
259                let time_str = match bat.state {
260                    crate::battery::BatteryState::Charging => bat.time_remaining.map(|d| {
261                        let total_mins = d.as_secs() / 60;
262                        let hours = total_mins / 60;
263                        let mins = total_mins % 60;
264                        if hours >= 24 {
265                            let days = hours / 24;
266                            let rem_hours = hours % 24;
267                            format!("{}d {}h until full", days, rem_hours)
268                        } else if hours > 0 {
269                            format!("{}h {}m until full", hours, mins)
270                        } else {
271                            format!("{}m until full", mins)
272                        }
273                    }),
274                    crate::battery::BatteryState::Discharging => bat.time_remaining.map(|d| {
275                        let total_mins = d.as_secs() / 60;
276                        let hours = total_mins / 60;
277                        let mins = total_mins % 60;
278                        if hours >= 24 {
279                            let days = hours / 24;
280                            let rem_hours = hours % 24;
281                            format!("{}d {}h remaining", days, rem_hours)
282                        } else if hours > 0 {
283                            format!("{}h {}m remaining", hours, mins)
284                        } else {
285                            format!("{}m remaining", mins)
286                        }
287                    }),
288                    _ => None,
289                };
290
291                let mut parts = vec![state.to_string()];
292                if let Some(t) = time_str {
293                    parts.insert(0, t);
294                }
295                if let Some(health) = bat.health {
296                    if health < 99.0 {
297                        parts.push(format!("{:.0}% health", health));
298                    }
299                }
300
301                let base = format!("{:.0}% ({})", pct, parts.join(", "));
302
303                match (vendor, model) {
304                    (Some(v), Some(m)) => format!("{} [{} {}]", base, v, m),
305                    (Some(v), None) => format!("{} [{}]", base, v),
306                    _ => base,
307                }
308            })
309        } else {
310            None
311        };
312
313        let arch = System::cpu_arch();
314
315        let processes = if should_collect("procs") || should_collect("audio") {
316            sys.processes().len()
317        } else {
318            0
319        };
320
321        let load_avg = {
322            let avg = System::load_average();
323            if avg.one > 0.0 || avg.five > 0.0 {
324                Some(format!(
325                    "{:.2}, {:.2}, {:.2}",
326                    avg.one, avg.five, avg.fifteen
327                ))
328            } else {
329                None
330            }
331        };
332
333        // Compute slow system queries concurrently in parallel threads
334        let (
335            gpu,
336            packages,
337            public_ip,
338            (local_ip, active_interface),
339            motherboard,
340            bios,
341            displays,
342            audio,
343            wifi,
344            bluetooth,
345            (ui_theme, icons, cursor, font),
346            camera,
347            gamepad,
348            physical_disks,
349            physical_memory,
350        ) = std::thread::scope(|s| {
351            let gpu_handle = if should_collect("gpu") {
352                Some(s.spawn(|| {
353                    gpu::detect_gpus()
354                        .into_iter()
355                        .map(|g| g.format())
356                        .collect::<Vec<String>>()
357                }))
358            } else {
359                None
360            };
361            let packages_handle = if should_collect("packages") {
362                Some(s.spawn(crate::packages::detect_packages))
363            } else {
364                None
365            };
366            let public_ip_handle = if should_collect("public ip") {
367                Some(s.spawn(crate::network::detect_public_ip))
368            } else {
369                None
370            };
371            let network_ips_handle = if should_collect("net") {
372                Some(s.spawn(crate::network::detect_active_interface_and_local_ip))
373            } else {
374                None
375            };
376            let motherboard_handle = if should_collect("motherboard") {
377                Some(s.spawn(crate::motherboard::detect_motherboard))
378            } else {
379                None
380            };
381            let bios_handle = if should_collect("bios") {
382                Some(s.spawn(crate::bios::detect_bios))
383            } else {
384                None
385            };
386            let displays_handle = if should_collect("display") {
387                Some(s.spawn(crate::display::detect_displays))
388            } else {
389                None
390            };
391            let audio_handle = if should_collect("audio") {
392                Some(s.spawn(|| crate::audio::detect_audio(&sys)))
393            } else {
394                None
395            };
396            let wifi_handle = if should_collect("wifi") {
397                Some(s.spawn(crate::network::detect_wifi))
398            } else {
399                None
400            };
401            let bluetooth_handle = if should_collect("bluetooth") {
402                Some(s.spawn(crate::bluetooth::detect_bluetooth))
403            } else {
404                None
405            };
406            let ui_theme_and_fonts_handle = if should_collect("theme")
407                || should_collect("icons")
408                || should_collect("cursor")
409                || should_collect("font")
410            {
411                Some(s.spawn(crate::theme::detect_ui_theme_and_fonts))
412            } else {
413                None
414            };
415            let camera_handle = if should_collect("camera") {
416                Some(s.spawn(crate::camera::detect_camera))
417            } else {
418                None
419            };
420            let gamepad_handle = if should_collect("gamepad") {
421                Some(s.spawn(crate::gamepad::detect_gamepad))
422            } else {
423                None
424            };
425            let physical_disks_handle = if should_collect("phys disk") {
426                Some(s.spawn(crate::disk::detect_physical_disks))
427            } else {
428                None
429            };
430            let physical_memory_handle = if should_collect("phys mem") {
431                Some(s.spawn(crate::memory::detect_physical_memory))
432            } else {
433                None
434            };
435
436            (
437                gpu_handle
438                    .map(|h| h.join().unwrap_or_default())
439                    .unwrap_or_default(),
440                packages_handle.and_then(|h| h.join().ok().flatten()),
441                public_ip_handle.and_then(|h| h.join().ok().flatten()),
442                network_ips_handle
443                    .map(|h| h.join().unwrap_or((None, None)))
444                    .unwrap_or((None, None)),
445                motherboard_handle.and_then(|h| h.join().ok().flatten()),
446                bios_handle.and_then(|h| h.join().ok().flatten()),
447                displays_handle
448                    .map(|h| h.join().unwrap_or_default())
449                    .unwrap_or_default(),
450                audio_handle.and_then(|h| h.join().ok().flatten()),
451                wifi_handle.and_then(|h| h.join().ok().flatten()),
452                bluetooth_handle.and_then(|h| h.join().ok().flatten()),
453                ui_theme_and_fonts_handle
454                    .map(|h| h.join().unwrap_or((None, None, None, None)))
455                    .unwrap_or((None, None, None, None)),
456                camera_handle
457                    .map(|h| h.join().unwrap_or_default())
458                    .unwrap_or_default(),
459                gamepad_handle
460                    .map(|h| h.join().unwrap_or_default())
461                    .unwrap_or_default(),
462                physical_disks_handle
463                    .map(|h| h.join().unwrap_or_default())
464                    .unwrap_or_default(),
465                physical_memory_handle.and_then(|h| h.join().ok().flatten()),
466            )
467        });
468
469        let mut temps: Vec<String> = if should_collect("temp") {
470            Components::new_with_refreshed_list()
471                .iter()
472                .filter_map(|c| {
473                    c.temperature().and_then(|t| {
474                        if t > 0.0 {
475                            Some(format!("{}: {:.0}°C", c.label(), t))
476                        } else {
477                            None
478                        }
479                    })
480                })
481                .collect()
482        } else {
483            Vec::new()
484        };
485
486        // Sort so CPU temperatures appear first
487        temps.sort_by(|a, b| {
488            let a_cpu = a.to_lowercase().contains("cpu") || a.to_lowercase().contains("core");
489            let b_cpu = b.to_lowercase().contains("cpu") || b.to_lowercase().contains("core");
490            b_cpu.cmp(&a_cpu)
491        });
492
493        let networks = if should_collect("net") {
494            crate::network::detect_networks(active_interface.as_deref(), local_ip.as_deref())
495        } else {
496            Vec::new()
497        };
498
499        let boot_timestamp = System::boot_time();
500        let boot_dt = chrono::Local
501            .timestamp_opt(boot_timestamp as i64, 0)
502            .single()
503            .map(|dt| dt.format("%Y-%m-%dT%H:%M:%S%:z").to_string())
504            .unwrap_or_else(|| boot_timestamp.to_string());
505        let boot_time = boot_dt;
506
507        // Environment-based info
508        let shell = if should_collect("shell") {
509            crate::shell::detect_shell(&sys)
510        } else {
511            None
512        };
513        let terminal = if should_collect("terminal") {
514            crate::terminal::detect_terminal(&sys)
515        } else {
516            None
517        };
518        let terminal_font = if should_collect("terminal font")
519            || should_collect("terminal-font")
520            || should_collect("terminal_font")
521        {
522            crate::terminal::detect_terminal_font(terminal.as_deref())
523        } else {
524            None
525        };
526        let desktop = if should_collect("desktop") {
527            std::env::var("XDG_CURRENT_DESKTOP")
528                .or_else(|_| std::env::var("DESKTOP_SESSION"))
529                .ok()
530        } else {
531            None
532        };
533
534        // CPU frequency (current from sysinfo + min/max range from sysfs)
535        let cpu_freq = if should_collect("cpu-freq")
536            || should_collect("cpu freq")
537            || should_collect("cpu_freq")
538        {
539            sys.cpus().first().map(|c| {
540                let current = format!("{:.2} GHz", c.frequency() as f64 / 1000.0);
541                if let Some((min_khz, max_khz)) = detect_cpu_freq_range() {
542                    let min_ghz = min_khz as f64 / 1_000_000.0;
543                    let max_ghz = max_khz as f64 / 1_000_000.0;
544                    format!("{} ({:.2} \u{2013} {:.2} GHz)", current, min_ghz, max_ghz)
545                } else {
546                    current
547                }
548            })
549        } else {
550            None
551        };
552
553        // CPU cache sizes
554        let cpu_cache = if should_collect("cpu-cache")
555            || should_collect("cpu cache")
556            || should_collect("cpu_cache")
557        {
558            detect_cpu_cache()
559        } else {
560            None
561        };
562
563        // CPU usage — refresh twice with a short sleep so sysinfo has a delta
564        let cpu_usage = if should_collect("cpu-usage")
565            || should_collect("cpu usage")
566            || should_collect("cpu_usage")
567        {
568            std::thread::sleep(std::time::Duration::from_millis(200));
569            sys.refresh_cpu_usage();
570            let usage: f32 =
571                sys.cpus().iter().map(|c| c.cpu_usage()).sum::<f32>() / sys.cpus().len() as f32;
572            #[cfg(not(target_os = "windows"))]
573            {
574                let avg = System::load_average();
575                let load_str = format!("{:.2}, {:.2}, {:.2}", avg.one, avg.five, avg.fifteen);
576                if usage > 0.0 {
577                    Some(format!("{:.1}% (load: {})", usage, load_str))
578                } else if avg.one > 0.0 {
579                    Some(format!("load: {}", load_str))
580                } else {
581                    None
582                }
583            }
584            #[cfg(target_os = "windows")]
585            {
586                if usage > 0.0 {
587                    Some(format!("{:.1}%", usage))
588                } else {
589                    None
590                }
591            }
592        } else {
593            None
594        };
595
596        // Current logged in user
597        let current_user = std::env::var("USER").ok();
598
599        // Number of interactive users (UID >= 1000, excluding system accounts)
600        let users = if should_collect("users") {
601            Users::new_with_refreshed_list()
602                .iter()
603                .filter(|user| {
604                    // UID is exposed via Display
605                    let uid_str = user.id().to_string();
606                    if let Ok(uid) = uid_str.parse::<u32>() {
607                        uid >= 1000
608                    } else {
609                        false
610                    }
611                })
612                .count()
613        } else {
614            0
615        };
616
617        Ok(Self {
618            os,
619            kernel,
620            hostname,
621            arch,
622            cpu,
623            cpu_cores,
624            cpu_core_info,
625            memory,
626            swap,
627            uptime,
628            processes,
629            load_avg,
630            disks,
631            temps,
632            networks,
633            boot_time,
634            battery,
635            shell,
636            terminal,
637            desktop,
638            cpu_freq,
639            users,
640            gpu,
641            packages,
642            current_user,
643            local_ip,
644            public_ip,
645            active_interface,
646            motherboard,
647            bios,
648            displays,
649            audio,
650            wifi,
651            bluetooth,
652            ui_theme,
653            icons,
654            cursor,
655            font,
656            terminal_font,
657            camera,
658            gamepad,
659            cpu_cache,
660            cpu_usage,
661            physical_disks,
662            physical_memory,
663        })
664    }
665}
666
667/// Detects CPU cache sizes.
668///
669/// Linux: reads from `/sys/devices/system/cpu/cpu0/cache/` sysfs entries.
670/// macOS: reads `hw.l1dcachesize`, `hw.l1icachesize`, `hw.l2cachesize`, `hw.l3cachesize` via sysctlbyname.
671/// Returns `None` on Windows or if data is unavailable.
672pub fn detect_cpu_cache() -> Option<String> {
673    #[cfg(target_os = "linux")]
674    {
675        use std::fs;
676        let cache_dir = std::path::Path::new("/sys/devices/system/cpu/cpu0/cache");
677        if !cache_dir.exists() {
678            return None;
679        }
680
681        struct CacheEntry {
682            level: u32,
683            kind: String,
684            size_kb: u64,
685        }
686
687        let mut entries: Vec<CacheEntry> = Vec::new();
688
689        let Ok(indices) = fs::read_dir(cache_dir) else {
690            return None;
691        };
692
693        for entry in indices.flatten() {
694            let path = entry.path();
695            // Skip non-index entries (e.g. the uevent file)
696            if !path.is_dir() {
697                continue;
698            }
699            let level_str = match fs::read_to_string(path.join("level")) {
700                Ok(s) => s,
701                Err(_) => continue,
702            };
703            let level: u32 = match level_str.trim().parse() {
704                Ok(n) => n,
705                Err(_) => continue,
706            };
707            let kind = match fs::read_to_string(path.join("type")) {
708                Ok(s) => s.trim().to_string(),
709                Err(_) => continue,
710            };
711            let size_str = match fs::read_to_string(path.join("size")) {
712                Ok(s) => s,
713                Err(_) => continue,
714            };
715            let size_raw = size_str.trim();
716            let size_kb: u64 = if let Some(k) = size_raw.strip_suffix('K') {
717                match k.parse() {
718                    Ok(n) => n,
719                    Err(_) => continue,
720                }
721            } else if let Some(m) = size_raw.strip_suffix('M') {
722                match m.parse::<u64>() {
723                    Ok(n) => n * 1024,
724                    Err(_) => continue,
725                }
726            } else {
727                match size_raw.parse() {
728                    Ok(n) => n,
729                    Err(_) => continue,
730                }
731            };
732
733            if kind != "Instruction" && kind != "Data" && kind != "Unified" {
734                continue;
735            }
736
737            entries.push(CacheEntry {
738                level,
739                kind,
740                size_kb,
741            });
742        }
743
744        if entries.is_empty() {
745            return None;
746        }
747
748        entries.sort_by_key(|e| (e.level, e.kind.clone()));
749
750        let fmt_size = |kb: u64| -> String {
751            if kb >= 1024 && kb.is_multiple_of(1024) {
752                format!("{}M", kb / 1024)
753            } else if kb >= 1024 {
754                format!("{:.2}M", kb as f64 / 1024.0)
755                    .trim_end_matches('0')
756                    .trim_end_matches('.')
757                    .to_string()
758                    + "M"
759            } else {
760                format!("{}K", kb)
761            }
762        };
763
764        // Deduplicate by label (cpu0 cache dir lists each index separately)
765        let mut seen = std::collections::HashSet::new();
766        let mut parts: Vec<String> = Vec::new();
767        for e in &entries {
768            let label = match (e.level, e.kind.as_str()) {
769                (1, "Data") => "L1d".to_string(),
770                (1, "Instruction") => "L1i".to_string(),
771                (1, "Unified") => "L1".to_string(),
772                (n, _) => format!("L{}", n),
773            };
774            if seen.insert(label.clone()) {
775                parts.push(format!("{}: {}", label, fmt_size(e.size_kb)));
776            }
777        }
778
779        if parts.is_empty() {
780            None
781        } else {
782            Some(parts.join(", "))
783        }
784    }
785    #[cfg(target_os = "macos")]
786    {
787        extern "C" {
788            fn sysctlbyname(
789                name: *const i8,
790                oldp: *mut std::ffi::c_void,
791                oldlenp: *mut usize,
792                newp: *mut std::ffi::c_void,
793                newlen: usize,
794            ) -> i32;
795        }
796
797        let read_u64 = |key: &str| -> Option<u64> {
798            let name = std::ffi::CString::new(key).ok()?;
799            let mut value: u64 = 0;
800            let mut size = std::mem::size_of::<u64>();
801            let ret = unsafe {
802                sysctlbyname(
803                    name.as_ptr(),
804                    &mut value as *mut u64 as *mut std::ffi::c_void,
805                    &mut size,
806                    std::ptr::null_mut(),
807                    0,
808                )
809            };
810            if ret == 0 && value > 0 {
811                Some(value)
812            } else {
813                None
814            }
815        };
816
817        let fmt_bytes = |bytes: u64| -> String {
818            if bytes >= 1024 * 1024 {
819                format!("{}M", bytes / (1024 * 1024))
820            } else {
821                format!("{}K", bytes / 1024)
822            }
823        };
824
825        let mut parts = Vec::new();
826        if let Some(v) = read_u64("hw.l1dcachesize") {
827            parts.push(format!("L1d: {}", fmt_bytes(v)));
828        }
829        if let Some(v) = read_u64("hw.l1icachesize") {
830            parts.push(format!("L1i: {}", fmt_bytes(v)));
831        }
832        if let Some(v) = read_u64("hw.l2cachesize") {
833            parts.push(format!("L2: {}", fmt_bytes(v)));
834        }
835        if let Some(v) = read_u64("hw.l3cachesize") {
836            parts.push(format!("L3: {}", fmt_bytes(v)));
837        }
838
839        if parts.is_empty() {
840            None
841        } else {
842            Some(parts.join(", "))
843        }
844    }
845    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
846    {
847        None
848    }
849}
850
851/// Formats a CPU core topology string.
852///
853/// Returns `"NP + NE / NT"` on Intel hybrid CPUs (different max frequencies per cluster),
854/// `"NC / NT"` when physical < logical (hyperthreading), or `"N cores"` otherwise.
855pub fn format_cpu_cores(logical: usize, physical: Option<usize>) -> String {
856    // Linux: detect Intel hybrid via cpufreq policy max-frequency grouping
857    #[cfg(target_os = "linux")]
858    if let Some(hybrid) = detect_hybrid_cores(logical) {
859        return hybrid;
860    }
861
862    // macOS: detect Apple Silicon P/E cores via hw.perflevel* sysctls
863    #[cfg(target_os = "macos")]
864    if let Some(hybrid) = detect_macos_hybrid_cores(logical) {
865        return hybrid;
866    }
867
868    match physical {
869        Some(p) if p < logical => format!("{}C / {}T", p, logical),
870        _ => format!("{} cores", logical),
871    }
872}
873
874/// On Linux, detects Intel hybrid topology (P-cores + E-cores) by grouping CPUs
875/// by their maximum cpufreq frequency. Returns `None` if not hybrid or unavailable.
876#[cfg(target_os = "linux")]
877fn detect_hybrid_cores(logical: usize) -> Option<String> {
878    use std::collections::HashMap;
879    use std::fs;
880
881    let cpufreq = std::path::Path::new("/sys/devices/system/cpu/cpufreq");
882    if !cpufreq.exists() {
883        return None;
884    }
885
886    // Map max_freq → number of CPUs in that policy
887    let mut freq_to_count: HashMap<u64, usize> = HashMap::new();
888    let mut total_accounted = 0usize;
889
890    let Ok(policies) = fs::read_dir(cpufreq) else {
891        return None;
892    };
893
894    for policy in policies.flatten() {
895        let path = policy.path();
896        if !path.is_dir() {
897            continue;
898        }
899        let max_freq_str = fs::read_to_string(path.join("cpuinfo_max_freq")).ok()?;
900        let max_freq: u64 = max_freq_str.trim().parse().ok()?;
901        let affected = fs::read_to_string(path.join("affected_cpus")).ok()?;
902        let count = affected.split_whitespace().count();
903        *freq_to_count.entry(max_freq).or_insert(0) += count;
904        total_accounted += count;
905    }
906
907    // Only report hybrid if we have exactly 2 frequency tiers and they account for all threads
908    if freq_to_count.len() != 2 || total_accounted != logical {
909        return None;
910    }
911
912    let mut tiers: Vec<(u64, usize)> = freq_to_count.into_iter().collect();
913    tiers.sort_by_key(|t| std::cmp::Reverse(t.0)); // highest freq first = P-cores
914    let (_, p_count) = tiers[0];
915    let (_, e_count) = tiers[1];
916
917    Some(format!("{}P + {}E / {}T", p_count, e_count, logical))
918}
919
920/// On macOS Apple Silicon, detects P/E cores via `hw.nperflevels` and
921/// `hw.perflevelN.logicalcpu` sysctls. Returns `None` on Intel Macs or if unavailable.
922#[cfg(target_os = "macos")]
923fn detect_macos_hybrid_cores(logical: usize) -> Option<String> {
924    extern "C" {
925        fn sysctlbyname(
926            name: *const i8,
927            oldp: *mut std::ffi::c_void,
928            oldlenp: *mut usize,
929            newp: *mut std::ffi::c_void,
930            newlen: usize,
931        ) -> i32;
932    }
933
934    let read_u32 = |key: &str| -> Option<u32> {
935        let name = std::ffi::CString::new(key).ok()?;
936        let mut value: u32 = 0;
937        let mut size = std::mem::size_of::<u32>();
938        let ret = unsafe {
939            sysctlbyname(
940                name.as_ptr(),
941                &mut value as *mut u32 as *mut std::ffi::c_void,
942                &mut size,
943                std::ptr::null_mut(),
944                0,
945            )
946        };
947        if ret == 0 {
948            Some(value)
949        } else {
950            None
951        }
952    };
953
954    // hw.nperflevels == 2 on M-series (P + E), absent or 1 on Intel
955    let nlevels = read_u32("hw.nperflevels")?;
956    if nlevels != 2 {
957        return None;
958    }
959
960    let p_cores = read_u32("hw.perflevel0.logicalcpu")? as usize;
961    let e_cores = read_u32("hw.perflevel1.logicalcpu")? as usize;
962
963    if p_cores + e_cores != logical {
964        return None;
965    }
966
967    Some(format!("{}P + {}E / {}T", p_cores, e_cores, logical))
968}
969
970/// Returns the overall (min_khz, max_khz) CPU frequency range from sysfs cpufreq policies.
971/// min is the smallest `cpuinfo_min_freq` across all policies; max is the largest `cpuinfo_max_freq`.
972pub fn detect_cpu_freq_range() -> Option<(u64, u64)> {
973    #[cfg(target_os = "linux")]
974    {
975        use std::fs;
976        let cpufreq = std::path::Path::new("/sys/devices/system/cpu/cpufreq");
977        if !cpufreq.exists() {
978            return None;
979        }
980        let mut global_min: Option<u64> = None;
981        let mut global_max: Option<u64> = None;
982        let Ok(policies) = fs::read_dir(cpufreq) else {
983            return None;
984        };
985        for policy in policies.flatten() {
986            let path = policy.path();
987            if !path.is_dir() {
988                continue;
989            }
990            if let Ok(s) = fs::read_to_string(path.join("cpuinfo_min_freq")) {
991                if let Ok(v) = s.trim().parse::<u64>() {
992                    global_min = Some(global_min.map_or(v, |m: u64| m.min(v)));
993                }
994            }
995            if let Ok(s) = fs::read_to_string(path.join("cpuinfo_max_freq")) {
996                if let Ok(v) = s.trim().parse::<u64>() {
997                    global_max = Some(global_max.map_or(v, |m: u64| m.max(v)));
998                }
999            }
1000        }
1001        match (global_min, global_max) {
1002            (Some(min), Some(max)) => Some((min, max)),
1003            _ => None,
1004        }
1005    }
1006    #[cfg(not(target_os = "linux"))]
1007    {
1008        None
1009    }
1010}
1011
1012#[cfg(test)]
1013mod tests {
1014    use super::*;
1015
1016    #[test]
1017    fn test_format_cpu_cores_no_hyperthreading() {
1018        // Physical == logical: show plain "N cores"
1019        assert_eq!(format_cpu_cores(4, Some(4)), "4 cores");
1020    }
1021
1022    #[test]
1023    fn test_format_cpu_cores_hyperthreaded() {
1024        // Physical < logical: show "NC / NT"
1025        assert_eq!(format_cpu_cores(16, Some(8)), "8C / 16T");
1026    }
1027
1028    #[test]
1029    fn test_format_cpu_cores_unknown_physical() {
1030        // No physical count available: fall back to "N cores"
1031        assert_eq!(format_cpu_cores(8, None), "8 cores");
1032    }
1033
1034    #[test]
1035    fn test_format_cpu_cores_physical_equals_zero() {
1036        // Degenerate: physical reported as 0 — treat same as unknown
1037        // physical(0) < logical(8), so would print "0C / 8T"; acceptable but
1038        // let's confirm the branch taken
1039        let result = format_cpu_cores(8, Some(0));
1040        assert!(result.contains("8"), "should mention 8 threads: {}", result);
1041    }
1042
1043    #[cfg(target_os = "linux")]
1044    #[test]
1045    fn test_detect_cpu_cache_returns_some_on_linux() {
1046        // On a real Linux machine the sysfs cache dir exists; result should be Some
1047        // and contain at least one cache level label.
1048        if std::path::Path::new("/sys/devices/system/cpu/cpu0/cache").exists() {
1049            let result = detect_cpu_cache();
1050            assert!(result.is_some(), "expected cache info on Linux with sysfs");
1051            let s = result.unwrap();
1052            assert!(
1053                s.contains("L1") || s.contains("L2") || s.contains("L3"),
1054                "expected cache level labels, got: {}",
1055                s
1056            );
1057        }
1058    }
1059
1060    #[cfg(target_os = "linux")]
1061    #[test]
1062    fn test_detect_cpu_freq_range_returns_ordered_pair() {
1063        if std::path::Path::new("/sys/devices/system/cpu/cpufreq").exists() {
1064            if let Some((min, max)) = detect_cpu_freq_range() {
1065                assert!(
1066                    min <= max,
1067                    "min freq should be <= max freq: {} > {}",
1068                    min,
1069                    max
1070                );
1071                assert!(min > 0, "min freq should be positive");
1072            }
1073        }
1074    }
1075}