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