1use crate::gpu;
10use chrono::TimeZone;
11use sysinfo::{Components, System};
12#[cfg(not(target_os = "windows"))]
15use sysinfo::Users;
16
17#[derive(Debug, Default, Clone)]
22pub struct CollectOptions {
23 pub long: bool,
25 pub full: bool,
27 pub fields: Option<Vec<String>>,
29 pub weather_location: Option<String>,
31 pub weather_unit: crate::weather::WeatherUnit,
33}
34
35#[derive(Debug)]
40pub struct SystemInfo {
41 pub os: String,
43 pub kernel: Option<String>,
45 pub hostname: Option<String>,
47 pub arch: String,
49 pub cpu: String,
51 pub cpu_cores: usize,
53 pub cpu_core_info: String,
55 pub memory: String,
57 pub swap: String,
59 pub uptime: String,
61 pub processes: usize,
63 pub load_avg: Option<String>,
65 pub disks: Vec<String>,
67 pub temps: Vec<String>,
69 pub networks: Vec<String>,
71 pub boot_time: String,
73 pub battery: Option<String>,
75 pub shell: Option<String>,
77 pub terminal: Option<String>,
79 pub desktop: Option<String>,
81 pub cpu_freq: Option<String>,
83 pub users: usize,
85 pub gpu: Vec<String>,
87 pub packages: Option<usize>,
89 pub current_user: Option<String>,
91 pub local_ip: Option<String>,
93 pub public_ip: Option<String>,
95 pub active_interface: Option<String>,
97 pub motherboard: Option<String>,
99 pub bios: Option<String>,
101 pub displays: Vec<String>,
103 pub audio: Option<String>,
105 pub wifi: Option<String>,
107 pub bluetooth: Option<String>,
109 pub ui_theme: Option<String>,
111 pub icons: Option<String>,
113 pub cursor: Option<String>,
115 pub font: Option<String>,
117 pub terminal_font: Option<String>,
119 pub camera: Vec<String>,
121 pub gamepad: Vec<String>,
123 pub cpu_cache: Option<String>,
125 pub cpu_usage: Option<String>,
127 pub physical_disks: Vec<String>,
129 pub physical_memory: Option<String>,
131 pub init_system: Option<String>,
133 pub chassis: Option<String>,
135 pub locale: Option<String>,
137 pub bootmgr: Option<String>,
139 pub editor: Option<String>,
141 pub weather: Option<String>,
143 pub wm: Option<String>,
145 pub dns: Vec<String>,
147 pub domain: Option<String>,
150 pub domain_search: Vec<String>,
153 pub terminal_size: Option<String>,
155 pub btrfs: Vec<String>,
157 pub zpool: Vec<String>,
159 pub login_manager: Option<String>,
161 pub brightness: Option<String>,
163 pub power_adapter: Option<String>,
165}
166
167impl SystemInfo {
168 pub fn collect(opts: CollectOptions) -> anyhow::Result<Self> {
173 let should_collect = |field_name: &str| -> bool {
174 match &opts.fields {
175 Some(fields) => {
176 let norm_field = field_name.to_lowercase().replace(['-', '_'], " ");
177 let norm_field_no_spaces = norm_field.replace(' ', "");
178 fields.iter().any(|f| {
179 let norm_f = f.to_lowercase().replace(['-', '_'], " ");
180 norm_f == norm_field || norm_f.replace(' ', "") == norm_field_no_spaces
181 })
182 }
183 None => true,
184 }
185 };
186
187 let mut refresh_kind = sysinfo::RefreshKind::nothing();
188 if should_collect("cpu")
189 || should_collect("cpu usage")
190 || should_collect("cpu-usage")
191 || should_collect("cpu cache")
192 || should_collect("cpu-cache")
193 {
194 refresh_kind = refresh_kind.with_cpu(sysinfo::CpuRefreshKind::everything());
195 }
196 if should_collect("memory")
197 || should_collect("swap")
198 || should_collect("phys mem")
199 || should_collect("phys-mem")
200 {
201 refresh_kind = refresh_kind.with_memory(sysinfo::MemoryRefreshKind::everything());
202 }
203 if should_collect("procs") || should_collect("audio") {
204 refresh_kind = refresh_kind.with_processes(sysinfo::ProcessRefreshKind::nothing());
205 }
206
207 #[cfg_attr(target_os = "windows", allow(unused_mut))]
210 let mut sys = System::new_with_specifics(refresh_kind);
211
212 let os = System::long_os_version()
213 .or_else(System::name)
214 .unwrap_or_else(|| "Unknown".to_string());
215
216 let kernel = System::kernel_version();
217 let hostname = System::host_name();
218
219 let cpu = if should_collect("cpu") {
220 sys.cpus()
221 .first()
222 .map(|c| c.brand().to_string())
223 .unwrap_or_else(|| "Unknown CPU".to_string())
224 } else {
225 String::new()
226 };
227
228 let cpu_cores = if should_collect("cpu") {
229 sys.cpus().len()
230 } else {
231 0
232 };
233 let cpu_core_info = if should_collect("cpu") {
234 format_cpu_cores(cpu_cores, System::physical_core_count())
235 } else {
236 String::new()
237 };
238
239 let memory = if should_collect("memory") {
240 let total_mem = sys.total_memory() as f64 / 1024.0 / 1024.0 / 1024.0;
241 let used_mem = sys.used_memory() as f64 / 1024.0 / 1024.0 / 1024.0;
242 format!("{:.1} / {:.1} GB", used_mem, total_mem)
243 } else {
244 String::new()
245 };
246
247 let swap = if should_collect("swap") {
248 let total_swap = sys.total_swap() as f64 / 1024.0 / 1024.0 / 1024.0;
249 let used_swap = sys.used_swap() as f64 / 1024.0 / 1024.0 / 1024.0;
250 if total_swap > 0.0 {
251 format!("{:.1} / {:.1} GB", used_swap, total_swap)
252 } else {
253 "No swap".to_string()
254 }
255 } else {
256 String::new()
257 };
258
259 let uptime = format!("{}s", System::uptime());
260
261 let disks: Vec<String> = if should_collect("disk") {
262 let disks_list = crate::disk::detect_logical_disks(opts.full);
263 let format_disk = |(mount, total, avail, fs): &(String, u64, u64, String)| {
264 let total_gb = *total as f64 / 1024.0 / 1024.0 / 1024.0;
265 let avail_gb = *avail as f64 / 1024.0 / 1024.0 / 1024.0;
266 format!(
267 "{} ({}): {:.1} GB free / {:.1} GB",
268 mount, fs, avail_gb, total_gb
269 )
270 };
271 if !opts.long {
272 let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/"));
273 let home_path = std::path::Path::new(&home);
274 let best = disks_list
275 .iter()
276 .filter(|(mp, ..)| home_path.starts_with(mp))
277 .max_by_key(|(mp, ..)| std::path::Path::new(mp).components().count());
278 if let Some(disk) = best {
279 vec![format_disk(disk)]
280 } else {
281 disks_list.iter().map(format_disk).collect()
282 }
283 } else {
284 disks_list.iter().map(format_disk).collect()
285 }
286 } else {
287 Vec::new()
288 };
289
290 let battery = if should_collect("battery") {
291 crate::battery::get_battery_info().map(|bat| {
292 let pct = bat.percentage;
293 let state = match bat.state {
294 crate::battery::BatteryState::Charging => "charging",
295 crate::battery::BatteryState::Discharging => "discharging",
296 crate::battery::BatteryState::Full => "full",
297 _ => "not charging",
298 };
299 let vendor = bat.vendor;
300 let model = bat.model;
301
302 let time_str = match bat.state {
304 crate::battery::BatteryState::Charging => bat.time_remaining.map(|d| {
305 let total_mins = d.as_secs() / 60;
306 let hours = total_mins / 60;
307 let mins = total_mins % 60;
308 if hours >= 24 {
309 let days = hours / 24;
310 let rem_hours = hours % 24;
311 format!("{}d {}h until full", days, rem_hours)
312 } else if hours > 0 {
313 format!("{}h {}m until full", hours, mins)
314 } else {
315 format!("{}m until full", mins)
316 }
317 }),
318 crate::battery::BatteryState::Discharging => bat.time_remaining.map(|d| {
319 let total_mins = d.as_secs() / 60;
320 let hours = total_mins / 60;
321 let mins = total_mins % 60;
322 if hours >= 24 {
323 let days = hours / 24;
324 let rem_hours = hours % 24;
325 format!("{}d {}h remaining", days, rem_hours)
326 } else if hours > 0 {
327 format!("{}h {}m remaining", hours, mins)
328 } else {
329 format!("{}m remaining", mins)
330 }
331 }),
332 _ => None,
333 };
334
335 let mut parts = vec![state.to_string()];
336 if let Some(t) = time_str {
337 parts.insert(0, t);
338 }
339 if let Some(health) = bat.health {
340 if health < 99.0 {
341 parts.push(format!("{:.0}% health", health));
342 }
343 }
344
345 let base = format!("{:.0}% ({})", pct, parts.join(", "));
346
347 match (vendor, model) {
348 (Some(v), Some(m)) => format!("{} [{} {}]", base, v, m),
349 (Some(v), None) => format!("{} [{}]", base, v),
350 _ => base,
351 }
352 })
353 } else {
354 None
355 };
356
357 let arch = System::cpu_arch();
358
359 let processes = if should_collect("procs") || should_collect("audio") {
360 sys.processes().len()
361 } else {
362 0
363 };
364
365 let load_avg = {
366 let avg = System::load_average();
367 if avg.one > 0.0 || avg.five > 0.0 {
368 Some(format!(
369 "{:.2}, {:.2}, {:.2}",
370 avg.one, avg.five, avg.fifteen
371 ))
372 } else {
373 None
374 }
375 };
376
377 #[cfg(target_os = "windows")]
381 let cpu_sample0 = win_cpu::sample();
382 #[cfg(target_os = "windows")]
383 let cpu_t0 = std::time::Instant::now();
384
385 let (
387 gpu,
388 packages,
389 public_ip,
390 (local_ip, active_interface),
391 motherboard,
392 bios,
393 displays,
394 audio,
395 wifi,
396 bluetooth,
397 (ui_theme, icons, cursor, font),
398 camera,
399 gamepad,
400 physical_disks,
401 physical_memory,
402 weather,
403 btrfs,
404 zpool,
405 ) = std::thread::scope(|s| {
406 let gpu_handle = if should_collect("gpu") {
407 Some(s.spawn(|| {
408 gpu::detect_gpus()
409 .into_iter()
410 .map(|g| g.format())
411 .collect::<Vec<String>>()
412 }))
413 } else {
414 None
415 };
416 let packages_handle = if should_collect("packages") {
417 Some(s.spawn(crate::packages::detect_packages))
418 } else {
419 None
420 };
421 let public_ip_handle = if should_collect("public ip") {
422 Some(s.spawn(crate::network::detect_public_ip))
423 } else {
424 None
425 };
426 let network_ips_handle = if should_collect("net") {
427 Some(s.spawn(crate::network::detect_active_interface_and_local_ip))
428 } else {
429 None
430 };
431 let motherboard_handle = if should_collect("motherboard") {
432 Some(s.spawn(crate::motherboard::detect_motherboard))
433 } else {
434 None
435 };
436 let bios_handle = if should_collect("bios") {
437 Some(s.spawn(crate::bios::detect_bios))
438 } else {
439 None
440 };
441 let displays_handle = if should_collect("display") {
442 Some(s.spawn(crate::display::detect_displays))
443 } else {
444 None
445 };
446 let audio_handle = if should_collect("audio") {
447 Some(s.spawn(|| crate::audio::detect_audio(&sys)))
448 } else {
449 None
450 };
451 let wifi_handle = if should_collect("wifi") {
452 Some(s.spawn(crate::network::detect_wifi))
453 } else {
454 None
455 };
456 let bluetooth_handle = if should_collect("bluetooth") {
457 Some(s.spawn(crate::bluetooth::detect_bluetooth))
458 } else {
459 None
460 };
461 let ui_theme_and_fonts_handle = if should_collect("theme")
462 || should_collect("icons")
463 || should_collect("cursor")
464 || should_collect("font")
465 {
466 Some(s.spawn(crate::theme::detect_ui_theme_and_fonts))
467 } else {
468 None
469 };
470 let camera_handle = if should_collect("camera") {
471 Some(s.spawn(crate::camera::detect_camera))
472 } else {
473 None
474 };
475 let gamepad_handle = if should_collect("gamepad") {
476 Some(s.spawn(crate::gamepad::detect_gamepad))
477 } else {
478 None
479 };
480 let physical_disks_handle = if should_collect("phys disk") {
481 Some(s.spawn(crate::disk::detect_physical_disks))
482 } else {
483 None
484 };
485 let physical_memory_handle = if should_collect("phys mem") {
486 Some(s.spawn(crate::memory::detect_physical_memory))
487 } else {
488 None
489 };
490 let weather_location = opts.weather_location.clone();
491 let weather_unit = opts.weather_unit;
492 let weather_handle = if should_collect("weather") {
493 Some(s.spawn(move || {
494 crate::weather::detect_weather(weather_location.as_deref(), weather_unit)
495 }))
496 } else {
497 None
498 };
499 let btrfs_handle = if should_collect("btrfs") {
500 Some(s.spawn(crate::btrfs::detect_btrfs))
501 } else {
502 None
503 };
504 let zpool_handle = if should_collect("zpool") {
505 Some(s.spawn(crate::zfs::detect_zpool))
506 } else {
507 None
508 };
509
510 (
511 gpu_handle
512 .map(|h| h.join().unwrap_or_default())
513 .unwrap_or_default(),
514 packages_handle.and_then(|h| h.join().ok().flatten()),
515 public_ip_handle.and_then(|h| h.join().ok().flatten()),
516 network_ips_handle
517 .map(|h| h.join().unwrap_or((None, None)))
518 .unwrap_or((None, None)),
519 motherboard_handle.and_then(|h| h.join().ok().flatten()),
520 bios_handle.and_then(|h| h.join().ok().flatten()),
521 displays_handle
522 .map(|h| h.join().unwrap_or_default())
523 .unwrap_or_default(),
524 audio_handle.and_then(|h| h.join().ok().flatten()),
525 wifi_handle.and_then(|h| h.join().ok().flatten()),
526 bluetooth_handle.and_then(|h| h.join().ok().flatten()),
527 ui_theme_and_fonts_handle
528 .map(|h| h.join().unwrap_or((None, None, None, None)))
529 .unwrap_or((None, None, None, None)),
530 camera_handle
531 .map(|h| h.join().unwrap_or_default())
532 .unwrap_or_default(),
533 gamepad_handle
534 .map(|h| h.join().unwrap_or_default())
535 .unwrap_or_default(),
536 physical_disks_handle
537 .map(|h| h.join().unwrap_or_default())
538 .unwrap_or_default(),
539 physical_memory_handle.and_then(|h| h.join().ok().flatten()),
540 weather_handle.and_then(|h| h.join().ok().flatten()),
541 btrfs_handle
542 .map(|h| h.join().unwrap_or_default())
543 .unwrap_or_default(),
544 zpool_handle
545 .map(|h| h.join().unwrap_or_default())
546 .unwrap_or_default(),
547 )
548 });
549
550 let mut temps: Vec<String> = if should_collect("temp") {
551 Components::new_with_refreshed_list()
552 .iter()
553 .filter_map(|c| {
554 c.temperature().and_then(|t| {
555 if t > 0.0 {
556 Some(format!("{}: {:.0}°C", c.label(), t))
557 } else {
558 None
559 }
560 })
561 })
562 .collect()
563 } else {
564 Vec::new()
565 };
566
567 temps.sort_by(|a, b| {
569 let a_cpu = a.to_lowercase().contains("cpu") || a.to_lowercase().contains("core");
570 let b_cpu = b.to_lowercase().contains("cpu") || b.to_lowercase().contains("core");
571 b_cpu.cmp(&a_cpu)
572 });
573
574 let networks = if should_collect("net") {
575 crate::network::detect_networks(active_interface.as_deref(), local_ip.as_deref())
576 } else {
577 Vec::new()
578 };
579
580 let boot_timestamp = System::boot_time();
581 let boot_dt = chrono::Local
582 .timestamp_opt(boot_timestamp as i64, 0)
583 .single()
584 .map(|dt| dt.format("%Y-%m-%dT%H:%M:%S%:z").to_string())
585 .unwrap_or_else(|| boot_timestamp.to_string());
586 let boot_time = boot_dt;
587
588 let shell = if should_collect("shell") {
590 crate::shell::detect_shell(&sys)
591 } else {
592 None
593 };
594 let terminal = if should_collect("terminal") {
595 crate::terminal::detect_terminal(&sys)
596 } else {
597 None
598 };
599 let terminal_font = if should_collect("terminal font")
600 || should_collect("terminal-font")
601 || should_collect("terminal_font")
602 {
603 crate::terminal::detect_terminal_font(terminal.as_deref())
604 } else {
605 None
606 };
607 let desktop = if should_collect("desktop") {
608 std::env::var("XDG_CURRENT_DESKTOP")
609 .or_else(|_| std::env::var("DESKTOP_SESSION"))
610 .or_else(|_| std::env::var("XDG_SESSION_DESKTOP"))
611 .or_else(|_| std::env::var("GDMSESSION"))
612 .ok()
613 .map(|s| normalize_desktop_name(&s))
614 .filter(|s| !s.is_empty())
615 .or_else(detect_desktop_from_proc)
616 } else {
617 None
618 };
619
620 let cpu_freq = if should_collect("cpu-freq")
622 || should_collect("cpu freq")
623 || should_collect("cpu_freq")
624 {
625 sys.cpus().first().map(|c| {
626 let current = format!("{:.2} GHz", c.frequency() as f64 / 1000.0);
627 if let Some((min_khz, max_khz)) = detect_cpu_freq_range() {
628 let min_ghz = min_khz as f64 / 1_000_000.0;
629 let max_ghz = max_khz as f64 / 1_000_000.0;
630 format!("{} ({:.2} \u{2013} {:.2} GHz)", current, min_ghz, max_ghz)
631 } else {
632 current
633 }
634 })
635 } else {
636 None
637 };
638
639 let cpu_cache = if should_collect("cpu-cache")
641 || should_collect("cpu cache")
642 || should_collect("cpu_cache")
643 {
644 detect_cpu_cache()
645 } else {
646 None
647 };
648
649 let cpu_usage = if should_collect("cpu-usage")
654 || should_collect("cpu usage")
655 || should_collect("cpu_usage")
656 {
657 #[cfg(not(target_os = "windows"))]
658 {
659 std::thread::sleep(std::time::Duration::from_millis(200));
660 sys.refresh_cpu_usage();
661 let usage: f32 =
662 sys.cpus().iter().map(|c| c.cpu_usage()).sum::<f32>() / sys.cpus().len() as f32;
663 let avg = System::load_average();
664 let load_str = format!("{:.2}, {:.2}, {:.2}", avg.one, avg.five, avg.fifteen);
665 if usage > 0.0 {
666 Some(format!("{:.1}% (load: {})", usage, load_str))
667 } else if avg.one > 0.0 {
668 Some(format!("load: {}", load_str))
669 } else {
670 None
671 }
672 }
673 #[cfg(target_os = "windows")]
674 {
675 let floor = std::time::Duration::from_millis(100);
679 let elapsed = cpu_t0.elapsed();
680 if elapsed < floor {
681 std::thread::sleep(floor - elapsed);
682 }
683 match (cpu_sample0, win_cpu::sample()) {
684 (Some(s0), Some(s1)) => {
685 let usage = win_cpu::usage_percent(s0, s1);
686 if usage > 0.0 {
687 Some(format!("{:.1}%", usage))
688 } else {
689 None
690 }
691 }
692 _ => None,
693 }
694 }
695 } else {
696 None
697 };
698
699 let init_system = if should_collect("init") || should_collect("init system") {
700 detect_init_system()
701 } else {
702 None
703 };
704
705 let chassis = if should_collect("chassis") {
706 detect_chassis()
707 } else {
708 None
709 };
710
711 let locale = if should_collect("locale") {
712 std::env::var("LC_ALL")
713 .ok()
714 .filter(|s| !s.is_empty())
715 .or_else(|| std::env::var("LC_MESSAGES").ok().filter(|s| !s.is_empty()))
716 .or_else(|| std::env::var("LANG").ok().filter(|s| !s.is_empty()))
717 } else {
718 None
719 };
720
721 let bootmgr = if should_collect("bootmgr") || should_collect("boot") {
722 detect_bootmgr()
723 } else {
724 None
725 };
726
727 let login_manager = if should_collect("login-manager") || should_collect("lm") {
728 detect_login_manager()
729 } else {
730 None
731 };
732
733 let brightness = if should_collect("brightness") {
734 detect_brightness()
735 } else {
736 None
737 };
738
739 let power_adapter = if should_collect("power-adapter") {
740 detect_power_adapter()
741 } else {
742 None
743 };
744
745 let editor = if should_collect("editor") {
746 std::env::var("VISUAL")
747 .ok()
748 .filter(|s| !s.is_empty())
749 .or_else(|| std::env::var("EDITOR").ok().filter(|s| !s.is_empty()))
750 } else {
751 None
752 };
753
754 let wm = if should_collect("wm") || should_collect("window manager") {
755 crate::wm::detect_wm()
756 } else {
757 None
758 };
759
760 let dns = if should_collect("dns") {
761 crate::network::detect_dns()
762 } else {
763 Vec::new()
764 };
765
766 let domain = if should_collect("domain") {
767 crate::network::detect_domain()
768 } else {
769 None
770 };
771
772 let domain_search = if should_collect("domain-search") || should_collect("domain search") {
773 crate::network::detect_domain_search()
774 } else {
775 Vec::new()
776 };
777
778 let terminal_size = if should_collect("terminal size")
779 || should_collect("terminal-size")
780 || should_collect("terminal_size")
781 {
782 crate::terminal::detect_terminal_size()
783 } else {
784 None
785 };
786
787 let current_user = std::env::var("USER").ok();
789
790 let users = if should_collect("users") {
795 #[cfg(target_os = "windows")]
796 {
797 crate::win_users::active_user_session_count()
798 }
799 #[cfg(not(target_os = "windows"))]
800 {
801 Users::new_with_refreshed_list()
802 .iter()
803 .filter(|user| {
804 user.id()
806 .to_string()
807 .parse::<u32>()
808 .map(|uid| uid >= 1000)
809 .unwrap_or(false)
810 })
811 .count()
812 }
813 } else {
814 0
815 };
816
817 Ok(Self {
818 os,
819 kernel,
820 hostname,
821 arch,
822 cpu,
823 cpu_cores,
824 cpu_core_info,
825 memory,
826 swap,
827 uptime,
828 processes,
829 load_avg,
830 disks,
831 temps,
832 networks,
833 boot_time,
834 battery,
835 shell,
836 terminal,
837 desktop,
838 cpu_freq,
839 users,
840 gpu,
841 packages,
842 current_user,
843 local_ip,
844 public_ip,
845 active_interface,
846 motherboard,
847 bios,
848 displays,
849 audio,
850 wifi,
851 bluetooth,
852 ui_theme,
853 icons,
854 cursor,
855 font,
856 terminal_font,
857 camera,
858 gamepad,
859 cpu_cache,
860 cpu_usage,
861 physical_disks,
862 physical_memory,
863 init_system,
864 chassis,
865 locale,
866 bootmgr,
867 editor,
868 weather,
869 wm,
870 dns,
871 domain,
872 domain_search,
873 terminal_size,
874 btrfs,
875 zpool,
876 login_manager,
877 brightness,
878 power_adapter,
879 })
880 }
881}
882
883pub fn detect_cpu_cache() -> Option<String> {
889 #[cfg(target_os = "linux")]
890 {
891 use std::fs;
892 let cache_dir = std::path::Path::new("/sys/devices/system/cpu/cpu0/cache");
893 if !cache_dir.exists() {
894 return None;
895 }
896
897 struct CacheEntry {
898 level: u32,
899 kind: String,
900 size_kb: u64,
901 }
902
903 let mut entries: Vec<CacheEntry> = Vec::new();
904
905 let Ok(indices) = fs::read_dir(cache_dir) else {
906 return None;
907 };
908
909 for entry in indices.flatten() {
910 let path = entry.path();
911 if !path.is_dir() {
913 continue;
914 }
915 let level_str = match fs::read_to_string(path.join("level")) {
916 Ok(s) => s,
917 Err(_) => continue,
918 };
919 let level: u32 = match level_str.trim().parse() {
920 Ok(n) => n,
921 Err(_) => continue,
922 };
923 let kind = match fs::read_to_string(path.join("type")) {
924 Ok(s) => s.trim().to_string(),
925 Err(_) => continue,
926 };
927 let size_str = match fs::read_to_string(path.join("size")) {
928 Ok(s) => s,
929 Err(_) => continue,
930 };
931 let size_raw = size_str.trim();
932 let size_kb: u64 = if let Some(k) = size_raw.strip_suffix('K') {
933 match k.parse() {
934 Ok(n) => n,
935 Err(_) => continue,
936 }
937 } else if let Some(m) = size_raw.strip_suffix('M') {
938 match m.parse::<u64>() {
939 Ok(n) => n * 1024,
940 Err(_) => continue,
941 }
942 } else {
943 match size_raw.parse() {
944 Ok(n) => n,
945 Err(_) => continue,
946 }
947 };
948
949 if kind != "Instruction" && kind != "Data" && kind != "Unified" {
950 continue;
951 }
952
953 entries.push(CacheEntry {
954 level,
955 kind,
956 size_kb,
957 });
958 }
959
960 if entries.is_empty() {
961 return None;
962 }
963
964 entries.sort_by_key(|e| (e.level, e.kind.clone()));
965
966 let fmt_size = |kb: u64| -> String {
967 if kb >= 1024 && kb.is_multiple_of(1024) {
968 format!("{}M", kb / 1024)
969 } else if kb >= 1024 {
970 format!("{:.2}M", kb as f64 / 1024.0)
971 .trim_end_matches('0')
972 .trim_end_matches('.')
973 .to_string()
974 + "M"
975 } else {
976 format!("{}K", kb)
977 }
978 };
979
980 let mut seen = std::collections::HashSet::new();
982 let mut parts: Vec<String> = Vec::new();
983 for e in &entries {
984 let label = match (e.level, e.kind.as_str()) {
985 (1, "Data") => "L1d".to_string(),
986 (1, "Instruction") => "L1i".to_string(),
987 (1, "Unified") => "L1".to_string(),
988 (n, _) => format!("L{}", n),
989 };
990 if seen.insert(label.clone()) {
991 parts.push(format!("{}: {}", label, fmt_size(e.size_kb)));
992 }
993 }
994
995 if parts.is_empty() {
996 None
997 } else {
998 Some(parts.join(", "))
999 }
1000 }
1001 #[cfg(target_os = "macos")]
1002 {
1003 extern "C" {
1004 fn sysctlbyname(
1005 name: *const i8,
1006 oldp: *mut std::ffi::c_void,
1007 oldlenp: *mut usize,
1008 newp: *mut std::ffi::c_void,
1009 newlen: usize,
1010 ) -> i32;
1011 }
1012
1013 let read_u64 = |key: &str| -> Option<u64> {
1014 let name = std::ffi::CString::new(key).ok()?;
1015 let mut value: u64 = 0;
1016 let mut size = std::mem::size_of::<u64>();
1017 let ret = unsafe {
1018 sysctlbyname(
1019 name.as_ptr(),
1020 &mut value as *mut u64 as *mut std::ffi::c_void,
1021 &mut size,
1022 std::ptr::null_mut(),
1023 0,
1024 )
1025 };
1026 if ret == 0 && value > 0 {
1027 Some(value)
1028 } else {
1029 None
1030 }
1031 };
1032
1033 let fmt_bytes = |bytes: u64| -> String {
1034 if bytes >= 1024 * 1024 {
1035 format!("{}M", bytes / (1024 * 1024))
1036 } else {
1037 format!("{}K", bytes / 1024)
1038 }
1039 };
1040
1041 let mut parts = Vec::new();
1042 if let Some(v) = read_u64("hw.l1dcachesize") {
1043 parts.push(format!("L1d: {}", fmt_bytes(v)));
1044 }
1045 if let Some(v) = read_u64("hw.l1icachesize") {
1046 parts.push(format!("L1i: {}", fmt_bytes(v)));
1047 }
1048 if let Some(v) = read_u64("hw.l2cachesize") {
1049 parts.push(format!("L2: {}", fmt_bytes(v)));
1050 }
1051 if let Some(v) = read_u64("hw.l3cachesize") {
1052 parts.push(format!("L3: {}", fmt_bytes(v)));
1053 }
1054
1055 if parts.is_empty() {
1056 None
1057 } else {
1058 Some(parts.join(", "))
1059 }
1060 }
1061 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
1062 {
1063 None
1064 }
1065}
1066
1067pub fn format_cpu_cores(logical: usize, physical: Option<usize>) -> String {
1072 #[cfg(target_os = "linux")]
1074 if let Some(hybrid) = detect_hybrid_cores(logical) {
1075 return hybrid;
1076 }
1077
1078 #[cfg(target_os = "macos")]
1080 if let Some(hybrid) = detect_macos_hybrid_cores(logical) {
1081 return hybrid;
1082 }
1083
1084 format_cpu_cores_plain(logical, physical)
1085}
1086
1087fn format_cpu_cores_plain(logical: usize, physical: Option<usize>) -> String {
1097 match physical {
1098 Some(p) if p < logical => format!("{}C / {}T", p, logical),
1099 _ => format!("{} cores", logical),
1100 }
1101}
1102
1103#[cfg(target_os = "linux")]
1106fn detect_hybrid_cores(logical: usize) -> Option<String> {
1107 use std::collections::HashMap;
1108 use std::fs;
1109
1110 let cpufreq = std::path::Path::new("/sys/devices/system/cpu/cpufreq");
1111 if !cpufreq.exists() {
1112 return None;
1113 }
1114
1115 let mut freq_to_count: HashMap<u64, usize> = HashMap::new();
1117 let mut total_accounted = 0usize;
1118
1119 let Ok(policies) = fs::read_dir(cpufreq) else {
1120 return None;
1121 };
1122
1123 for policy in policies.flatten() {
1124 let path = policy.path();
1125 if !path.is_dir() {
1126 continue;
1127 }
1128 let max_freq_str = fs::read_to_string(path.join("cpuinfo_max_freq")).ok()?;
1129 let max_freq: u64 = max_freq_str.trim().parse().ok()?;
1130 let affected = fs::read_to_string(path.join("affected_cpus")).ok()?;
1131 let count = affected.split_whitespace().count();
1132 *freq_to_count.entry(max_freq).or_insert(0) += count;
1133 total_accounted += count;
1134 }
1135
1136 if freq_to_count.len() != 2 || total_accounted != logical {
1138 return None;
1139 }
1140
1141 let mut tiers: Vec<(u64, usize)> = freq_to_count.into_iter().collect();
1142 tiers.sort_by_key(|t| std::cmp::Reverse(t.0)); let (_, p_count) = tiers[0];
1144 let (_, e_count) = tiers[1];
1145
1146 Some(format!("{}P + {}E / {}T", p_count, e_count, logical))
1147}
1148
1149#[cfg(target_os = "macos")]
1152fn detect_macos_hybrid_cores(logical: usize) -> Option<String> {
1153 extern "C" {
1154 fn sysctlbyname(
1155 name: *const i8,
1156 oldp: *mut std::ffi::c_void,
1157 oldlenp: *mut usize,
1158 newp: *mut std::ffi::c_void,
1159 newlen: usize,
1160 ) -> i32;
1161 }
1162
1163 let read_u32 = |key: &str| -> Option<u32> {
1164 let name = std::ffi::CString::new(key).ok()?;
1165 let mut value: u32 = 0;
1166 let mut size = std::mem::size_of::<u32>();
1167 let ret = unsafe {
1168 sysctlbyname(
1169 name.as_ptr(),
1170 &mut value as *mut u32 as *mut std::ffi::c_void,
1171 &mut size,
1172 std::ptr::null_mut(),
1173 0,
1174 )
1175 };
1176 if ret == 0 {
1177 Some(value)
1178 } else {
1179 None
1180 }
1181 };
1182
1183 let nlevels = read_u32("hw.nperflevels")?;
1185 if nlevels != 2 {
1186 return None;
1187 }
1188
1189 let p_cores = read_u32("hw.perflevel0.logicalcpu")? as usize;
1190 let e_cores = read_u32("hw.perflevel1.logicalcpu")? as usize;
1191
1192 if p_cores + e_cores != logical {
1193 return None;
1194 }
1195
1196 Some(format!("{}P + {}E / {}T", p_cores, e_cores, logical))
1197}
1198
1199pub fn detect_cpu_freq_range() -> Option<(u64, u64)> {
1202 #[cfg(target_os = "linux")]
1203 {
1204 use std::fs;
1205 let cpufreq = std::path::Path::new("/sys/devices/system/cpu/cpufreq");
1206 if !cpufreq.exists() {
1207 return None;
1208 }
1209 let mut global_min: Option<u64> = None;
1210 let mut global_max: Option<u64> = None;
1211 let Ok(policies) = fs::read_dir(cpufreq) else {
1212 return None;
1213 };
1214 for policy in policies.flatten() {
1215 let path = policy.path();
1216 if !path.is_dir() {
1217 continue;
1218 }
1219 if let Ok(s) = fs::read_to_string(path.join("cpuinfo_min_freq")) {
1220 if let Ok(v) = s.trim().parse::<u64>() {
1221 global_min = Some(global_min.map_or(v, |m: u64| m.min(v)));
1222 }
1223 }
1224 if let Ok(s) = fs::read_to_string(path.join("cpuinfo_max_freq")) {
1225 if let Ok(v) = s.trim().parse::<u64>() {
1226 global_max = Some(global_max.map_or(v, |m: u64| m.max(v)));
1227 }
1228 }
1229 }
1230 match (global_min, global_max) {
1231 (Some(min), Some(max)) => Some((min, max)),
1232 _ => None,
1233 }
1234 }
1235 #[cfg(not(target_os = "linux"))]
1236 {
1237 None
1238 }
1239}
1240
1241#[cfg(not(target_os = "linux"))]
1242fn detect_desktop_from_proc() -> Option<String> {
1243 None
1244}
1245
1246#[cfg(target_os = "linux")]
1247fn detect_desktop_from_proc() -> Option<String> {
1248 const DE_PROCS: &[(&str, &str)] = &[
1249 ("gnome-shell", "GNOME"),
1250 ("plasmashell", "KDE Plasma"),
1251 ("xfce4-session", "XFCE"),
1252 ("mate-session", "MATE"),
1253 ("cinnamon", "Cinnamon"),
1254 ("budgie-daemon", "Budgie"),
1255 ("budgie-panel", "Budgie"),
1256 ("lxsession", "LXDE"),
1257 ("lxqt-session", "LXQt"),
1258 ("deepin-session", "Deepin"),
1259 ("dde-session-daemon", "Deepin"),
1260 ("gala", "Pantheon"),
1261 ("enlightenment", "Enlightenment"),
1262 ];
1263 let Ok(entries) = std::fs::read_dir("/proc") else {
1264 return None;
1265 };
1266 for entry in entries.filter_map(|e| e.ok()) {
1267 let path = entry.path();
1268 if !path.is_dir() {
1269 continue;
1270 }
1271 let Ok(comm) = std::fs::read_to_string(path.join("comm")) else {
1272 continue;
1273 };
1274 let comm = comm.trim().to_lowercase();
1275 for (proc_name, de_name) in DE_PROCS {
1276 if comm == *proc_name || comm.starts_with(proc_name) {
1277 return Some(de_name.to_string());
1278 }
1279 }
1280 }
1281 None
1282}
1283
1284fn normalize_desktop_name(raw: &str) -> String {
1285 let s = raw.trim();
1286 match s.to_lowercase().as_str() {
1288 "gnome" => "GNOME".to_string(),
1289 "kde" | "kde plasma" | "plasma" => "KDE Plasma".to_string(),
1290 "xfce" => "XFCE".to_string(),
1291 "lxde" => "LXDE".to_string(),
1292 "lxqt" => "LXQt".to_string(),
1293 "mate" => "MATE".to_string(),
1294 "cinnamon" => "Cinnamon".to_string(),
1295 "budgie" => "Budgie".to_string(),
1296 "deepin" => "Deepin".to_string(),
1297 "pantheon" => "Pantheon".to_string(),
1298 "unity" => "Unity".to_string(),
1299 "enlightenment" | "e" => "Enlightenment".to_string(),
1300 _ => {
1301 if s.chars().all(|c| c.is_lowercase() || !c.is_alphabetic()) {
1303 let mut chars = s.chars();
1304 match chars.next() {
1305 None => String::new(),
1306 Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
1307 }
1308 } else {
1309 s.to_string()
1310 }
1311 }
1312 }
1313}
1314
1315fn detect_init_system() -> Option<String> {
1316 #[cfg(target_os = "linux")]
1317 {
1318 let comm = std::fs::read_to_string("/proc/1/comm")
1319 .map(|s| s.trim().to_string())
1320 .ok()
1321 .filter(|s| !s.is_empty());
1322 if let Some(name) = comm {
1323 return Some(name);
1324 }
1325 std::fs::read_link("/proc/1/exe").ok().and_then(|p| {
1326 p.file_name()
1327 .and_then(|n| n.to_str())
1328 .map(|s| s.to_string())
1329 })
1330 }
1331 #[cfg(target_os = "macos")]
1332 {
1333 Some("launchd".to_string())
1334 }
1335 #[cfg(target_os = "windows")]
1336 {
1337 Some("SCM".to_string())
1338 }
1339 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
1340 {
1341 None
1342 }
1343}
1344
1345fn detect_chassis() -> Option<String> {
1346 #[cfg(target_os = "linux")]
1347 {
1348 let raw = std::fs::read_to_string("/sys/class/dmi/id/chassis_type").ok()?;
1349 let n: u32 = raw.trim().parse().ok()?;
1350 let label = match n {
1351 3 => "Desktop",
1352 4 => "Low-Profile Desktop",
1353 6 => "Mini Tower",
1354 7 => "Tower",
1355 8 | 9 | 10 | 14 | 31 | 32 => "Laptop",
1356 11 => "Handheld",
1357 13 => "All-in-One",
1358 17 => "Main Server",
1359 23 => "Rack Server",
1360 28 => "Blade",
1361 30 => "Tablet",
1362 35 => "Mini PC",
1363 36 => "Stick PC",
1364 _ => return None,
1365 };
1366 Some(label.to_string())
1367 }
1368 #[cfg(target_os = "macos")]
1369 {
1370 let output = std::process::Command::new("sysctl")
1371 .args(["-n", "hw.model"])
1372 .output()
1373 .ok()?;
1374 let model = String::from_utf8(output.stdout).ok()?;
1375 let model = model.trim();
1376 if model.contains("MacBook") {
1377 Some("Laptop".to_string())
1378 } else if model.contains("MacPro") {
1379 Some("Desktop".to_string())
1380 } else if model.contains("Macmini") || model.contains("Mac mini") {
1381 Some("Mini PC".to_string())
1382 } else if model.contains("iMac") {
1383 Some("All-in-One".to_string())
1384 } else {
1385 Some(model.to_string())
1386 }
1387 }
1388 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
1389 {
1390 None
1391 }
1392}
1393
1394fn detect_bootmgr() -> Option<String> {
1395 #[cfg(target_os = "linux")]
1396 {
1397 use std::path::Path;
1398 let is_uefi = Path::new("/sys/firmware/efi").exists();
1399 if Path::new("/boot/loader/entries").exists()
1400 || Path::new("/boot/loader/loader.conf").exists()
1401 || Path::new("/efi/loader/loader.conf").exists()
1402 {
1403 return Some("systemd-boot".to_string());
1404 }
1405 if Path::new("/boot/grub2/grub.cfg").exists() || Path::new("/boot/grub2").exists() {
1406 return Some("GRUB 2".to_string());
1407 }
1408 if Path::new("/boot/grub/grub.cfg").exists() || Path::new("/boot/grub").exists() {
1409 return Some("GRUB".to_string());
1410 }
1411 if is_uefi {
1412 Some("UEFI".to_string())
1413 } else {
1414 Some("BIOS".to_string())
1415 }
1416 }
1417 #[cfg(target_os = "macos")]
1418 {
1419 Some("Apple Boot ROM".to_string())
1420 }
1421 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
1422 {
1423 None
1424 }
1425}
1426
1427fn detect_login_manager() -> Option<String> {
1434 #[cfg(target_os = "linux")]
1435 {
1436 let target = std::fs::read_link("/etc/systemd/system/display-manager.service").ok()?;
1437 let unit = target.file_name().and_then(|n| n.to_str())?;
1438 login_manager_from_unit(unit)
1439 }
1440 #[cfg(not(target_os = "linux"))]
1441 {
1442 None
1443 }
1444}
1445
1446#[cfg(target_os = "linux")]
1452fn login_manager_from_unit(unit: &str) -> Option<String> {
1453 let stem = unit.strip_suffix(".service").unwrap_or(unit).trim();
1454 if stem.is_empty() {
1455 return None;
1456 }
1457 let pretty = match stem.to_lowercase().as_str() {
1458 "gdm" | "gdm3" => "GDM",
1459 "sddm" => "SDDM",
1460 "lightdm" => "LightDM",
1461 "lxdm" => "LXDM",
1462 "xdm" => "XDM",
1463 "ly" => "Ly",
1464 "greetd" => "greetd",
1465 "slim" => "SLiM",
1466 "nodm" => "nodm",
1467 "entrance" => "Entrance",
1468 _ => {
1469 let mut chars = stem.chars();
1471 return chars
1472 .next()
1473 .map(|c| c.to_uppercase().collect::<String>() + chars.as_str());
1474 }
1475 };
1476 Some(pretty.to_string())
1477}
1478
1479fn detect_brightness() -> Option<String> {
1486 #[cfg(target_os = "linux")]
1487 {
1488 use std::path::Path;
1489 let dir = Path::new("/sys/class/backlight");
1490 if !dir.exists() {
1491 return None;
1492 }
1493 let mut devices: Vec<std::path::PathBuf> = std::fs::read_dir(dir)
1496 .ok()?
1497 .flatten()
1498 .map(|e| e.path())
1499 .collect();
1500 devices.sort_by_key(|p| {
1501 let name = p
1502 .file_name()
1503 .and_then(|n| n.to_str())
1504 .unwrap_or("")
1505 .to_lowercase();
1506 if name.contains("acpi") || name.contains("video") {
1508 1
1509 } else {
1510 0
1511 }
1512 });
1513 for dev in devices {
1514 let cur = std::fs::read_to_string(dev.join("brightness"))
1515 .ok()
1516 .and_then(|s| s.trim().parse::<u64>().ok());
1517 let max = std::fs::read_to_string(dev.join("max_brightness"))
1518 .ok()
1519 .and_then(|s| s.trim().parse::<u64>().ok());
1520 if let (Some(cur), Some(max)) = (cur, max) {
1521 if let Some(pct) = brightness_percent(cur, max) {
1522 return Some(pct);
1523 }
1524 }
1525 }
1526 None
1527 }
1528 #[cfg(not(target_os = "linux"))]
1529 {
1530 None
1531 }
1532}
1533
1534#[cfg(target_os = "linux")]
1539fn brightness_percent(cur: u64, max: u64) -> Option<String> {
1540 if max == 0 {
1541 return None;
1542 }
1543 let pct = (cur as f64 / max as f64 * 100.0).round() as u64;
1544 Some(format!("{}%", pct))
1545}
1546
1547fn detect_power_adapter() -> Option<String> {
1555 #[cfg(target_os = "linux")]
1556 {
1557 use std::path::Path;
1558 let dir = Path::new("/sys/class/power_supply");
1559 if !dir.exists() {
1560 return None;
1561 }
1562 for entry in std::fs::read_dir(dir).ok()?.flatten() {
1563 let path = entry.path();
1564 let supply_type = std::fs::read_to_string(path.join("type"))
1565 .map(|s| s.trim().to_string())
1566 .unwrap_or_default();
1567 if supply_type != "Mains" {
1568 continue;
1569 }
1570 let name = path
1571 .file_name()
1572 .and_then(|n| n.to_str())
1573 .unwrap_or("AC")
1574 .to_string();
1575 let online = std::fs::read_to_string(path.join("online"))
1576 .map(|s| s.trim().to_string())
1577 .unwrap_or_default();
1578 return Some(format_power_adapter(&name, &online));
1579 }
1580 None
1581 }
1582 #[cfg(not(target_os = "linux"))]
1583 {
1584 None
1585 }
1586}
1587
1588#[cfg(target_os = "linux")]
1592fn format_power_adapter(name: &str, online: &str) -> String {
1593 let state = match online.trim() {
1594 "1" => "connected",
1595 "0" => "not connected",
1596 _ => "unknown",
1597 };
1598 format!("{} ({})", name, state)
1599}
1600
1601#[cfg(target_os = "windows")]
1606mod win_cpu {
1607 #[repr(C)]
1608 struct FileTime {
1609 low: u32,
1610 high: u32,
1611 }
1612
1613 impl FileTime {
1614 fn ticks(&self) -> u64 {
1615 ((self.high as u64) << 32) | self.low as u64
1616 }
1617 }
1618
1619 extern "system" {
1620 fn GetSystemTimes(idle: *mut FileTime, kernel: *mut FileTime, user: *mut FileTime) -> i32;
1621 }
1622
1623 pub fn sample() -> Option<(u64, u64, u64)> {
1626 let mut idle = FileTime { low: 0, high: 0 };
1627 let mut kernel = FileTime { low: 0, high: 0 };
1628 let mut user = FileTime { low: 0, high: 0 };
1629 let ok = unsafe { GetSystemTimes(&mut idle, &mut kernel, &mut user) };
1631 if ok == 0 {
1632 None
1633 } else {
1634 Some((idle.ticks(), kernel.ticks(), user.ticks()))
1635 }
1636 }
1637
1638 pub fn usage_percent(s0: (u64, u64, u64), s1: (u64, u64, u64)) -> f32 {
1641 let idle = s1.0.saturating_sub(s0.0);
1642 let kernel = s1.1.saturating_sub(s0.1);
1643 let user = s1.2.saturating_sub(s0.2);
1644 let total = kernel + user;
1645 if total == 0 {
1646 0.0
1647 } else {
1648 (100.0 * total.saturating_sub(idle) as f64 / total as f64) as f32
1649 }
1650 }
1651
1652 #[cfg(test)]
1653 mod layout {
1654 use std::mem::size_of;
1655
1656 #[test]
1658 fn filetime_size() {
1659 assert_eq!(size_of::<super::FileTime>(), 8);
1660 }
1661 }
1662}
1663
1664#[cfg(test)]
1665mod tests {
1666 use super::*;
1667
1668 #[cfg(target_os = "linux")]
1669 #[test]
1670 fn test_login_manager_from_unit() {
1671 assert_eq!(
1672 login_manager_from_unit("gdm.service").as_deref(),
1673 Some("GDM")
1674 );
1675 assert_eq!(
1676 login_manager_from_unit("gdm3.service").as_deref(),
1677 Some("GDM")
1678 );
1679 assert_eq!(
1680 login_manager_from_unit("sddm.service").as_deref(),
1681 Some("SDDM")
1682 );
1683 assert_eq!(
1684 login_manager_from_unit("lightdm.service").as_deref(),
1685 Some("LightDM")
1686 );
1687 assert_eq!(
1689 login_manager_from_unit("emptty.service").as_deref(),
1690 Some("Emptty")
1691 );
1692 assert_eq!(login_manager_from_unit("ly").as_deref(), Some("Ly"));
1694 assert_eq!(login_manager_from_unit("").as_deref(), None);
1696 assert_eq!(login_manager_from_unit(".service").as_deref(), None);
1697 }
1698
1699 #[cfg(target_os = "linux")]
1700 #[test]
1701 fn test_brightness_percent() {
1702 assert_eq!(brightness_percent(50, 100).as_deref(), Some("50%"));
1703 assert_eq!(brightness_percent(100, 100).as_deref(), Some("100%"));
1704 assert_eq!(brightness_percent(0, 100).as_deref(), Some("0%"));
1705 assert_eq!(brightness_percent(133, 255).as_deref(), Some("52%"));
1707 assert_eq!(brightness_percent(10, 0), None);
1709 }
1710
1711 #[cfg(target_os = "linux")]
1712 #[test]
1713 fn test_format_power_adapter() {
1714 assert_eq!(format_power_adapter("AC", "1"), "AC (connected)");
1715 assert_eq!(format_power_adapter("ADP1", "0"), "ADP1 (not connected)");
1716 assert_eq!(format_power_adapter("AC", ""), "AC (unknown)");
1718 }
1719
1720 #[cfg(target_os = "windows")]
1721 #[test]
1722 fn test_win_cpu_usage_percent() {
1723 use super::win_cpu::usage_percent;
1724 let u = usage_percent((0, 0, 0), (50, 100, 50));
1727 assert!((u - 66.6667).abs() < 0.01, "got {}", u);
1728
1729 assert_eq!(usage_percent((0, 0, 0), (100, 100, 0)), 0.0);
1731
1732 assert_eq!(usage_percent((0, 0, 0), (0, 100, 100)), 100.0);
1734
1735 assert_eq!(usage_percent((5, 10, 10), (5, 10, 10)), 0.0);
1737 }
1738
1739 #[test]
1745 fn test_format_cpu_cores_no_hyperthreading() {
1746 assert_eq!(format_cpu_cores_plain(4, Some(4)), "4 cores");
1748 }
1749
1750 #[test]
1751 fn test_format_cpu_cores_hyperthreaded() {
1752 assert_eq!(format_cpu_cores_plain(16, Some(8)), "8C / 16T");
1754 }
1755
1756 #[test]
1757 fn test_format_cpu_cores_unknown_physical() {
1758 assert_eq!(format_cpu_cores_plain(8, None), "8 cores");
1760 }
1761
1762 #[test]
1763 fn test_format_cpu_cores_physical_equals_zero() {
1764 let result = format_cpu_cores_plain(8, Some(0));
1768 assert!(result.contains("8"), "should mention 8 threads: {}", result);
1769 }
1770
1771 #[cfg(target_os = "linux")]
1772 #[test]
1773 fn test_detect_cpu_cache_returns_some_on_linux() {
1774 if std::path::Path::new("/sys/devices/system/cpu/cpu0/cache").exists() {
1777 let result = detect_cpu_cache();
1778 assert!(result.is_some(), "expected cache info on Linux with sysfs");
1779 let s = result.unwrap();
1780 assert!(
1781 s.contains("L1") || s.contains("L2") || s.contains("L3"),
1782 "expected cache level labels, got: {}",
1783 s
1784 );
1785 }
1786 }
1787
1788 #[test]
1789 fn test_normalize_desktop_name_known() {
1790 assert_eq!(normalize_desktop_name("gnome"), "GNOME");
1791 assert_eq!(normalize_desktop_name("GNOME"), "GNOME");
1792 assert_eq!(normalize_desktop_name("kde"), "KDE Plasma");
1793 assert_eq!(normalize_desktop_name("plasma"), "KDE Plasma");
1794 assert_eq!(normalize_desktop_name("KDE Plasma"), "KDE Plasma");
1795 assert_eq!(normalize_desktop_name("xfce"), "XFCE");
1796 assert_eq!(normalize_desktop_name("lxqt"), "LXQt");
1797 assert_eq!(normalize_desktop_name("mate"), "MATE");
1798 assert_eq!(normalize_desktop_name("cinnamon"), "Cinnamon");
1799 assert_eq!(normalize_desktop_name("e"), "Enlightenment");
1800 }
1801
1802 #[test]
1803 fn test_normalize_desktop_name_unknown_lowercase() {
1804 assert_eq!(normalize_desktop_name("budgie"), "Budgie");
1806 assert_eq!(normalize_desktop_name("niri"), "Niri");
1807 }
1808
1809 #[test]
1810 fn test_normalize_desktop_name_unknown_mixed() {
1811 assert_eq!(normalize_desktop_name("MyDE"), "MyDE");
1813 }
1814
1815 #[test]
1816 fn test_normalize_desktop_name_trims_whitespace() {
1817 assert_eq!(normalize_desktop_name(" gnome "), "GNOME");
1818 assert_eq!(normalize_desktop_name(" niri "), "Niri");
1819 }
1820
1821 #[cfg(target_os = "linux")]
1822 #[test]
1823 fn test_detect_desktop_from_proc_returns_option() {
1824 let result = detect_desktop_from_proc();
1826 if let Some(ref de) = result {
1827 assert!(!de.is_empty(), "desktop name should not be empty");
1828 }
1829 }
1830
1831 #[cfg(target_os = "linux")]
1832 #[test]
1833 fn test_detect_cpu_freq_range_returns_ordered_pair() {
1834 if std::path::Path::new("/sys/devices/system/cpu/cpufreq").exists() {
1835 if let Some((min, max)) = detect_cpu_freq_range() {
1836 assert!(
1837 min <= max,
1838 "min freq should be <= max freq: {} > {}",
1839 min,
1840 max
1841 );
1842 assert!(min > 0, "min freq should be positive");
1843 }
1844 }
1845 }
1846}