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