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