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