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}
22
23#[derive(Debug)]
28pub struct SystemInfo {
29 pub os: String,
31 pub kernel: Option<String>,
33 pub hostname: Option<String>,
35 pub arch: String,
37 pub cpu: String,
39 pub cpu_cores: usize,
41 pub cpu_core_info: String,
43 pub memory: String,
45 pub swap: String,
47 pub uptime: String,
49 pub processes: usize,
51 pub load_avg: Option<String>,
53 pub disks: Vec<String>,
55 pub temps: Vec<String>,
57 pub networks: Vec<String>,
59 pub boot_time: String,
61 pub battery: Option<String>,
63 pub shell: Option<String>,
65 pub terminal: Option<String>,
67 pub desktop: Option<String>,
69 pub cpu_freq: Option<String>,
71 pub users: usize,
73 pub gpu: Vec<String>,
75 pub packages: Option<usize>,
77 pub current_user: Option<String>,
79 pub local_ip: Option<String>,
81 pub public_ip: Option<String>,
83 pub active_interface: Option<String>,
85 pub motherboard: Option<String>,
87 pub bios: Option<String>,
89 pub displays: Vec<String>,
91 pub audio: Option<String>,
93 pub wifi: Option<String>,
95 pub bluetooth: Option<String>,
97 pub ui_theme: Option<String>,
99 pub icons: Option<String>,
101 pub cursor: Option<String>,
103 pub font: Option<String>,
105 pub terminal_font: Option<String>,
107 pub camera: Vec<String>,
109 pub gamepad: Vec<String>,
111 pub cpu_cache: Option<String>,
113 pub cpu_usage: Option<String>,
115}
116
117impl SystemInfo {
118 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 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 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 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 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 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 let cpu_cache = detect_cpu_cache();
398
399 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 let current_user = std::env::var("USER").ok();
429
430 let users = Users::new_with_refreshed_list()
432 .iter()
433 .filter(|user| {
434 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
492pub 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 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 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
676pub fn format_cpu_cores(logical: usize, physical: Option<usize>) -> String {
681 #[cfg(target_os = "linux")]
683 if let Some(hybrid) = detect_hybrid_cores(logical) {
684 return hybrid;
685 }
686
687 #[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#[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 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 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)); 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#[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 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
795pub 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 assert_eq!(format_cpu_cores(4, Some(4)), "4 cores");
845 }
846
847 #[test]
848 fn test_format_cpu_cores_hyperthreaded() {
849 assert_eq!(format_cpu_cores(16, Some(8)), "8C / 16T");
851 }
852
853 #[test]
854 fn test_format_cpu_cores_unknown_physical() {
855 assert_eq!(format_cpu_cores(8, None), "8 cores");
857 }
858
859 #[test]
860 fn test_format_cpu_cores_physical_equals_zero() {
861 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 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}