1use crate::cli::Cli;
10use crate::config::Config;
11use crate::gpu;
12use chrono::TimeZone;
13use owo_colors::OwoColorize;
14
15use sysinfo::{Components, Disks, Networks, System, Users};
16
17#[derive(Debug)]
22pub struct SystemInfo {
23 pub os: String,
25 pub kernel: Option<String>,
27 pub hostname: Option<String>,
29 pub arch: String,
31 pub cpu: String,
33 pub cpu_cores: usize,
35 pub memory: String,
37 pub swap: String,
39 pub uptime: String,
41 pub processes: usize,
43 pub load_avg: Option<String>,
45 pub disks: Vec<String>,
47 pub temps: Vec<String>,
49 pub networks: Vec<String>,
51 pub boot_time: String,
53 pub battery: Option<String>,
55 pub shell: Option<String>,
57 pub terminal: Option<String>,
59 pub desktop: Option<String>,
61 pub cpu_freq: Option<String>,
63 pub users: usize,
65 pub gpu: Vec<String>,
67 pub packages: Option<usize>,
69 pub current_user: Option<String>,
71 pub local_ip: Option<String>,
73 pub public_ip: Option<String>,
75 pub active_interface: Option<String>,
77 pub motherboard: Option<String>,
79 pub bios: Option<String>,
81 pub displays: Vec<String>,
83}
84
85impl SystemInfo {
86 pub fn collect(_cli: &Cli, _config: &Config) -> anyhow::Result<Self> {
91 let mut sys = System::new_all();
92 sys.refresh_all();
93
94 let os = System::long_os_version()
95 .or_else(System::name)
96 .unwrap_or_else(|| "Unknown".to_string());
97
98 let kernel = System::kernel_version();
99 let hostname = System::host_name();
100
101 let cpu = sys
102 .cpus()
103 .first()
104 .map(|c| c.brand().to_string())
105 .unwrap_or_else(|| "Unknown CPU".to_string());
106
107 let cpu_cores = sys.cpus().len();
108
109 let total_mem = sys.total_memory() as f64 / 1024.0 / 1024.0;
110 let used_mem = sys.used_memory() as f64 / 1024.0 / 1024.0;
111 let memory = format!("{:.1} / {:.1} GB", used_mem, total_mem);
112
113 let total_swap = sys.total_swap() as f64 / 1024.0 / 1024.0;
114 let used_swap = sys.used_swap() as f64 / 1024.0 / 1024.0;
115 let swap = if total_swap > 0.0 {
116 format!("{:.1} / {:.1} GB", used_swap, total_swap)
117 } else {
118 "No swap".to_string()
119 };
120
121 let uptime = format!("{}s", System::uptime());
122
123 let disks_list = Disks::new_with_refreshed_list();
124 let disks: Vec<String> = if !_cli.long {
125 let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/"));
126 let mut best_match: Option<&sysinfo::Disk> = None;
127 for disk in disks_list.iter() {
128 if home.starts_with(disk.mount_point()) {
129 if let Some(best) = best_match {
130 if disk.mount_point().components().count()
131 > best.mount_point().components().count()
132 {
133 best_match = Some(disk);
134 }
135 } else {
136 best_match = Some(disk);
137 }
138 }
139 }
140 let selected_disks = if let Some(best) = best_match {
141 vec![best]
142 } else {
143 disks_list.iter().collect::<Vec<_>>()
144 };
145
146 selected_disks
147 .iter()
148 .map(|d| {
149 let total = d.total_space() as f64 / 1024.0 / 1024.0 / 1024.0;
150 let avail = d.available_space() as f64 / 1024.0 / 1024.0 / 1024.0;
151 let fs = d.file_system().to_string_lossy();
152 format!(
153 "{} ({}): {:.1} GB free / {:.1} GB",
154 d.mount_point().display(),
155 fs,
156 avail,
157 total
158 )
159 })
160 .collect()
161 } else {
162 disks_list
163 .iter()
164 .map(|d| {
165 let total = d.total_space() as f64 / 1024.0 / 1024.0 / 1024.0;
166 let avail = d.available_space() as f64 / 1024.0 / 1024.0 / 1024.0;
167 let fs = d.file_system().to_string_lossy();
168 format!(
169 "{} ({}): {:.1} GB free / {:.1} GB",
170 d.mount_point().display(),
171 fs,
172 avail,
173 total
174 )
175 })
176 .collect()
177 };
178
179 let battery = battery::Manager::new()
180 .ok()
181 .and_then(|manager| manager.batteries().ok())
182 .and_then(|mut batteries| batteries.next())
183 .and_then(|result| result.ok())
184 .map(|bat| {
185 let pct = bat.state_of_charge().value * 100.0;
186 let health = bat.state_of_health().value * 100.0;
187 let state = match bat.state() {
188 battery::State::Charging => "charging",
189 battery::State::Discharging => "discharging",
190 battery::State::Full => "full",
191 _ => "not charging",
192 };
193 let vendor = bat.vendor().map(|s| s.to_string());
194 let model = bat.model().map(|s| s.to_string());
195
196 let time_str = match bat.state() {
198 battery::State::Charging => bat.time_to_full().map(|d| {
199 let total_mins = (d.value / 60.0) as u32;
200 let hours = total_mins / 60;
201 let mins = total_mins % 60;
202 if hours >= 24 {
203 let days = hours / 24;
204 let rem_hours = hours % 24;
205 format!("{}d {}h until full", days, rem_hours)
206 } else if hours > 0 {
207 format!("{}h {}m until full", hours, mins)
208 } else {
209 format!("{}m until full", mins)
210 }
211 }),
212 battery::State::Discharging => bat.time_to_empty().map(|d| {
213 let total_mins = (d.value / 60.0) as u32;
214 let hours = total_mins / 60;
215 let mins = total_mins % 60;
216 if hours >= 24 {
217 let days = hours / 24;
218 let rem_hours = hours % 24;
219 format!("{}d {}h remaining", days, rem_hours)
220 } else if hours > 0 {
221 format!("{}h {}m remaining", hours, mins)
222 } else {
223 format!("{}m remaining", mins)
224 }
225 }),
226 _ => None,
227 };
228
229 let mut parts = vec![state.to_string()];
230 if let Some(t) = time_str {
231 parts.insert(0, t);
232 }
233 if health < 99.0 {
234 parts.push(format!("{:.0}% health", health));
235 }
236
237 let base = format!("{:.0}% ({})", pct, parts.join(", "));
238
239 match (vendor, model) {
240 (Some(v), Some(m)) => format!("{} [{} {}]", base, v, m),
241 (Some(v), None) => format!("{} [{}]", base, v),
242 _ => base,
243 }
244 });
245
246 let arch = System::cpu_arch();
247
248 let processes = sys.processes().len();
249
250 let load_avg = {
251 let avg = System::load_average();
252 if avg.one > 0.0 || avg.five > 0.0 {
253 Some(format!(
254 "{:.2}, {:.2}, {:.2}",
255 avg.one, avg.five, avg.fifteen
256 ))
257 } else {
258 None
259 }
260 };
261
262 let (gpu, packages, public_ip, (local_ip, active_interface), motherboard, bios, displays) =
264 std::thread::scope(|s| {
265 let gpu_handle = s.spawn(|| {
266 gpu::detect_gpus()
267 .into_iter()
268 .map(|g| g.format())
269 .collect::<Vec<String>>()
270 });
271 let packages_handle = s.spawn(detect_packages);
272 let public_ip_handle = s.spawn(|| {
273 std::process::Command::new("curl")
274 .args(["-s", "--max-time", "2", "https://api.ipify.org"])
275 .output()
276 .ok()
277 .and_then(|o| String::from_utf8(o.stdout).ok())
278 .map(|s| s.trim().to_string())
279 .filter(|s| !s.is_empty())
280 });
281 let network_ips_handle = s.spawn(|| {
282 let local_ip = std::net::UdpSocket::bind("0.0.0.0:0")
283 .ok()
284 .and_then(|socket| {
285 socket.connect("8.8.8.8:53").ok()?;
286 socket.local_addr().ok().map(|addr| addr.ip().to_string())
287 });
288
289 let active_interface = {
290 #[cfg(target_os = "linux")]
291 {
292 std::process::Command::new("ip")
293 .args(["route", "show", "default"])
294 .output()
295 .ok()
296 .and_then(|o| String::from_utf8(o.stdout).ok())
297 .and_then(|s| {
298 s.split_whitespace()
299 .position(|w| w == "dev")
300 .and_then(|i| s.split_whitespace().nth(i + 1))
301 .map(|s| s.to_string())
302 })
303 }
304 #[cfg(target_os = "macos")]
305 {
306 std::process::Command::new("route")
307 .args(["-n", "get", "default"])
308 .output()
309 .ok()
310 .and_then(|o| String::from_utf8(o.stdout).ok())
311 .and_then(|s| {
312 s.lines()
313 .find(|l| l.contains("interface:"))
314 .and_then(|l| l.split_whitespace().last())
315 .map(|s| s.to_string())
316 })
317 }
318 #[cfg(target_os = "windows")]
319 {
320 std::process::Command::new("powershell")
321 .args(["-Command", "Get-NetRoute -DestinationPrefix 0.0.0.0/0 | Select-Object -First 1 -ExpandProperty InterfaceAlias"])
322 .output()
323 .ok()
324 .and_then(|o| String::from_utf8(o.stdout).ok())
325 .map(|s| s.trim().to_string())
326 .filter(|s| !s.is_empty())
327 }
328 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
329 {
330 None
331 }
332 };
333
334 (local_ip, active_interface)
335 });
336 let motherboard_handle = s.spawn(detect_motherboard);
337 let bios_handle = s.spawn(detect_bios);
338 let displays_handle = s.spawn(detect_displays);
339
340 (
341 gpu_handle.join().unwrap_or_default(),
342 packages_handle.join().ok().flatten(),
343 public_ip_handle.join().ok().flatten(),
344 network_ips_handle.join().unwrap_or((None, None)),
345 motherboard_handle.join().ok().flatten(),
346 bios_handle.join().ok().flatten(),
347 displays_handle.join().unwrap_or_default(),
348 )
349 });
350
351 let mut temps: Vec<String> = Components::new_with_refreshed_list()
352 .iter()
353 .filter_map(|c| {
354 c.temperature().and_then(|t| {
355 if t > 0.0 {
356 Some(format!("{}: {:.0}°C", c.label(), t))
357 } else {
358 None
359 }
360 })
361 })
362 .collect();
363
364 temps.sort_by(|a, b| {
366 let a_cpu = a.to_lowercase().contains("cpu") || a.to_lowercase().contains("core");
367 let b_cpu = b.to_lowercase().contains("cpu") || b.to_lowercase().contains("core");
368 b_cpu.cmp(&a_cpu)
369 });
370
371 let networks = Networks::new_with_refreshed_list()
372 .iter()
373 .map(|(name, data)| {
374 let rx = format_bytes(data.total_received());
375 let tx = format_bytes(data.total_transmitted());
376 let is_up = data.operational_state() == sysinfo::InterfaceOperationalState::Up
377 || data.total_received() > 0
378 || data.total_transmitted() > 0;
379 let status = if is_up {
380 "Up".green().to_string()
381 } else {
382 "Down".red().to_string()
383 };
384
385 let mut ipv4_addresses = Vec::new();
386 let mut ipv6_addresses = Vec::new();
387
388 if is_up {
389 for ip_net in data.ip_networks() {
390 let ip = ip_net.addr;
391 let name_lower = name.to_lowercase();
392 let is_loopback_iface =
393 name_lower.starts_with("lo") || name_lower.contains("loopback");
394 if ip.is_loopback() && !is_loopback_iface {
395 continue;
396 }
397 match ip {
398 std::net::IpAddr::V4(v4) => {
399 ipv4_addresses.push(v4.to_string());
400 }
401 std::net::IpAddr::V6(v6) => {
402 if !v6.is_unicast_link_local() {
403 ipv6_addresses.push(v6.to_string());
404 }
405 }
406 }
407 }
408
409 if ipv4_addresses.is_empty() && ipv6_addresses.is_empty() {
411 if let (Some(ref active), Some(ref ip)) = (&active_interface, &local_ip) {
412 if name == active {
413 ipv4_addresses.push(ip.clone());
414 }
415 }
416 }
417 }
418
419 let ip_str = if !ipv4_addresses.is_empty() || !ipv6_addresses.is_empty() {
420 let mut combined = Vec::new();
421 if !ipv4_addresses.is_empty() {
422 combined.push(ipv4_addresses.join(", "));
423 }
424 if !ipv6_addresses.is_empty() {
425 combined.push(ipv6_addresses.join(", "));
426 }
427 format!(" ({})", combined.join(", "))
428 } else {
429 String::new()
430 };
431
432 format!("{}{} [{}] RX: {} TX: {}", name, ip_str, status, rx, tx)
433 })
434 .collect();
435
436 let boot_timestamp = System::boot_time();
437 let boot_dt = chrono::Local
438 .timestamp_opt(boot_timestamp as i64, 0)
439 .single()
440 .map(|dt| dt.format("%Y-%m-%dT%H:%M:%S%:z").to_string())
441 .unwrap_or_else(|| boot_timestamp.to_string());
442 let boot_time = boot_dt;
443
444 let shell = std::env::var("SHELL").ok();
446 let terminal = std::env::var("TERM").ok();
447 let desktop = std::env::var("XDG_CURRENT_DESKTOP")
448 .or_else(|_| std::env::var("DESKTOP_SESSION"))
449 .ok();
450
451 let cpu_freq = sys
453 .cpus()
454 .first()
455 .map(|c| format!("{:.2} GHz", c.frequency() as f64 / 1000.0));
456
457 let current_user = std::env::var("USER").ok();
459
460 let users = Users::new_with_refreshed_list()
462 .iter()
463 .filter(|user| {
464 let uid_str = user.id().to_string();
466 if let Ok(uid) = uid_str.parse::<u32>() {
467 uid >= 1000
468 } else {
469 false
470 }
471 })
472 .count();
473
474 Ok(Self {
475 os,
476 kernel,
477 hostname,
478 arch,
479 cpu,
480 cpu_cores,
481 memory,
482 swap,
483 uptime,
484 processes,
485 load_avg,
486 disks,
487 temps,
488 networks,
489 boot_time,
490 battery,
491 shell,
492 terminal,
493 desktop,
494 cpu_freq,
495 users,
496 gpu,
497 packages,
498 current_user,
499 local_ip,
500 public_ip,
501 active_interface,
502 motherboard,
503 bios,
504 displays,
505 })
506 }
507}
508
509fn detect_packages() -> Option<usize> {
514 #[cfg(target_os = "macos")]
515 {
516 let mut count = 0;
517
518 for cellar_path in &["/opt/homebrew/Cellar", "/usr/local/Cellar"] {
520 if let Ok(entries) = std::fs::read_dir(cellar_path) {
521 count += entries.filter_map(|e| e.ok()).count();
522 }
523 }
524
525 for cask_path in &["/opt/homebrew/Caskroom", "/usr/local/Caskroom"] {
527 if let Ok(entries) = std::fs::read_dir(cask_path) {
528 count += entries.filter_map(|e| e.ok()).count();
529 }
530 }
531
532 if let Ok(entries) = std::fs::read_dir("/opt/local/var/macports/software") {
534 count += entries.filter_map(|e| e.ok()).count();
535 }
536
537 if count > 0 {
538 Some(count)
539 } else {
540 None
541 }
542 }
543
544 #[cfg(target_os = "windows")]
545 {
546 let mut count = 0;
547
548 if let Some(home) = dirs::home_dir() {
550 let scoop_dir = std::env::var("SCOOP")
551 .map(std::path::PathBuf::from)
552 .unwrap_or_else(|_| home.join("scoop"));
553 let scoop_apps = scoop_dir.join("apps");
554 if let Ok(entries) = std::fs::read_dir(scoop_apps) {
555 count += entries.filter_map(|e| e.ok()).count();
556 }
557 }
558
559 let choco_install = std::env::var("ChocolateyInstall")
561 .unwrap_or_else(|_| "C:\\ProgramData\\chocolatey".to_string());
562 let choco_lib = std::path::Path::new(&choco_install).join("lib");
563 if let Ok(entries) = std::fs::read_dir(choco_lib) {
564 count += entries.filter_map(|e| e.ok()).count();
565 }
566
567 if count > 0 {
568 Some(count)
569 } else {
570 None
571 }
572 }
573
574 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
575 {
576 if let Ok(entries) = std::fs::read_dir("/var/lib/pacman/local") {
578 let count = entries.filter_map(|e| e.ok()).count();
579 if count > 0 {
580 return Some(count);
581 }
582 }
583
584 if let Ok(entries) = std::fs::read_dir("/var/lib/dpkg/info") {
586 let count = entries
587 .filter_map(|e| e.ok())
588 .filter(|e| e.path().extension().is_some_and(|ext| ext == "list"))
589 .count();
590 if count > 0 {
591 return Some(count);
592 }
593 }
594
595 if let Ok(entries) = std::fs::read_dir("/var/db/pkg") {
597 let count: usize = entries
598 .filter_map(|e| e.ok())
599 .map(|e| {
600 std::fs::read_dir(e.path())
601 .map(|d| d.filter(|_| true).count())
602 .unwrap_or(0)
603 })
604 .sum();
605 if count > 0 {
606 return Some(count);
607 }
608 }
609
610 if let Ok(entries) = std::fs::read_dir("/var/db/xbps") {
612 let count = entries
613 .filter_map(|e| e.ok())
614 .filter(|e| e.path().extension().is_some_and(|ext| ext == "plist"))
615 .count();
616 if count > 0 {
617 return Some(count);
618 }
619 }
620
621 let rpm_db = "/var/lib/rpm/rpmdb.sqlite";
623 if std::path::Path::new(rpm_db).exists() {
624 match rusqlite::Connection::open(rpm_db) {
625 Ok(conn) => {
626 if let Ok(count) = conn.query_row("SELECT COUNT(*) FROM Packages", [], |row| {
627 row.get::<_, i64>(0)
628 }) {
629 if count > 0 {
630 return Some(count as usize);
631 }
632 }
633 }
634 Err(e) => {
635 eprintln!("warning: failed to open RPM database at {}: {}", rpm_db, e);
636 }
637 }
638 }
639
640 None
641 }
642}
643
644fn detect_motherboard() -> Option<String> {
645 #[cfg(target_os = "macos")]
646 {
647 if let Ok(output) = std::process::Command::new("sysctl")
648 .args(["-n", "hw.model"])
649 .output()
650 {
651 if let Ok(model) = String::from_utf8(output.stdout) {
652 let trimmed = model.trim();
653 if !trimmed.is_empty() {
654 return Some(trimmed.to_string());
655 }
656 }
657 }
658 None
659 }
660
661 #[cfg(target_os = "windows")]
662 {
663 if let Ok(output) = std::process::Command::new("wmic")
664 .args(["baseboard", "get", "manufacturer,product", "/value"])
665 .output()
666 {
667 if let Ok(stdout) = String::from_utf8(output.stdout) {
668 let mut manufacturer = String::new();
669 let mut product = String::new();
670 for line in stdout.lines() {
671 let line = line.trim();
672 if line.starts_with("Manufacturer=") {
673 manufacturer = line
674 .strip_prefix("Manufacturer=")
675 .unwrap_or("")
676 .trim()
677 .to_string();
678 } else if line.starts_with("Product=") {
679 product = line
680 .strip_prefix("Product=")
681 .unwrap_or("")
682 .trim()
683 .to_string();
684 }
685 }
686 if !manufacturer.is_empty() && !product.is_empty() {
687 return Some(format!("{} {}", manufacturer, product));
688 } else if !product.is_empty() {
689 return Some(product);
690 } else if !manufacturer.is_empty() {
691 return Some(manufacturer);
692 }
693 }
694 }
695 None
696 }
697
698 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
699 {
700 let vendor = std::fs::read_to_string("/sys/class/dmi/id/board_vendor")
701 .map(|s| s.trim().to_string())
702 .ok();
703 let name = std::fs::read_to_string("/sys/class/dmi/id/board_name")
704 .map(|s| s.trim().to_string())
705 .ok();
706
707 match (vendor, name) {
708 (Some(v), Some(n)) if !v.is_empty() && !n.is_empty() => {
709 if n.starts_with(&v) {
710 Some(n)
711 } else {
712 Some(format!("{} {}", v, n))
713 }
714 }
715 (Some(v), _) if !v.is_empty() => Some(v),
716 (_, Some(n)) if !n.is_empty() => Some(n),
717 _ => None,
718 }
719 }
720}
721
722fn detect_bios() -> Option<String> {
723 #[cfg(target_os = "macos")]
724 {
725 if let Ok(output) = std::process::Command::new("system_profiler")
726 .arg("SPHardwareDataType")
727 .output()
728 {
729 if let Ok(stdout) = String::from_utf8(output.stdout) {
730 for line in stdout.lines() {
731 let line = line.trim();
732 if line.starts_with("System Firmware Version:")
733 || line.starts_with("Boot ROM Version:")
734 {
735 if let Some(val) = line.split(':').nth(1) {
736 return Some(val.trim().to_string());
737 }
738 }
739 }
740 }
741 }
742 None
743 }
744
745 #[cfg(target_os = "windows")]
746 {
747 if let Ok(output) = std::process::Command::new("wmic")
748 .args([
749 "bios",
750 "get",
751 "manufacturer,smbiosbiosversion,releasedate",
752 "/value",
753 ])
754 .output()
755 {
756 if let Ok(stdout) = String::from_utf8(output.stdout) {
757 let mut manufacturer = String::new();
758 let mut version = String::new();
759 let mut date = String::new();
760 for line in stdout.lines() {
761 let line = line.trim();
762 if line.starts_with("Manufacturer=") {
763 manufacturer = line
764 .strip_prefix("Manufacturer=")
765 .unwrap_or("")
766 .trim()
767 .to_string();
768 } else if line.starts_with("SMBIOSBIOSVersion=") {
769 version = line
770 .strip_prefix("SMBIOSBIOSVersion=")
771 .unwrap_or("")
772 .trim()
773 .to_string();
774 } else if line.starts_with("ReleaseDate=") {
775 let raw_date = line.strip_prefix("ReleaseDate=").unwrap_or("").trim();
776 if raw_date.len() >= 8 {
777 let year = &raw_date[0..4];
778 let month = &raw_date[4..6];
779 let day = &raw_date[6..8];
780 date = format!("{}/{}/{}", month, day, year);
781 } else {
782 date = raw_date.to_string();
783 }
784 }
785 }
786
787 let mut parts = Vec::new();
788 if !manufacturer.is_empty() {
789 parts.push(manufacturer);
790 }
791 if !version.is_empty() {
792 parts.push(version);
793 }
794 let mut res = parts.join(" ");
795 if !date.is_empty() {
796 if res.is_empty() {
797 res = date;
798 } else {
799 res = format!("{} ({})", res, date);
800 }
801 }
802 if !res.is_empty() {
803 return Some(res);
804 }
805 }
806 }
807 None
808 }
809
810 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
811 {
812 let vendor = std::fs::read_to_string("/sys/class/dmi/id/bios_vendor")
813 .map(|s| s.trim().to_string())
814 .ok();
815 let version = std::fs::read_to_string("/sys/class/dmi/id/bios_version")
816 .map(|s| s.trim().to_string())
817 .ok();
818 let date = std::fs::read_to_string("/sys/class/dmi/id/bios_date")
819 .map(|s| s.trim().to_string())
820 .ok();
821
822 let mut parts = Vec::new();
823 if let Some(v) = vendor {
824 if !v.is_empty() {
825 parts.push(v);
826 }
827 }
828 if let Some(ver) = version {
829 let mut ver_cleaned = ver;
830 while ver_cleaned.contains(" )") {
831 ver_cleaned = ver_cleaned.replace(" )", ")");
832 }
833 let ver_cleaned = ver_cleaned.trim().to_string();
834 if !ver_cleaned.is_empty() {
835 parts.push(ver_cleaned);
836 }
837 }
838 let mut res = parts.join(" ");
839 if let Some(d) = date {
840 if !d.is_empty() {
841 if res.is_empty() {
842 res = d;
843 } else {
844 res = format!("{} ({})", res, d);
845 }
846 }
847 }
848 if !res.is_empty() {
849 Some(res)
850 } else {
851 None
852 }
853 }
854}
855
856#[allow(dead_code)]
857fn parse_monitor_name_from_edid(edid: &[u8]) -> Option<String> {
858 if edid.len() < 128 {
859 return None;
860 }
861 let offsets = [54, 72, 90, 108];
862 for &offset in &offsets {
863 if offset + 18 <= edid.len() {
864 let block = &edid[offset..offset + 18];
865 if block[0] == 0x00 && block[1] == 0x00 && block[2] == 0x00 && block[3] == 0xFC {
866 let name_bytes = &block[4..17];
867 let name = String::from_utf8_lossy(name_bytes);
868 let cleaned = name.trim().replace('\0', "").to_string();
869 if !cleaned.is_empty() {
870 return Some(cleaned);
871 }
872 }
873 }
874 }
875 None
876}
877
878#[allow(dead_code)]
879fn parse_refresh_rate_from_edid(edid: &[u8]) -> Option<f64> {
880 if edid.len() < 72 {
881 return None;
882 }
883 let block = &edid[54..72];
884 let pixel_clock = ((block[1] as u32) << 8) | (block[0] as u32);
885 if pixel_clock == 0 {
886 return None;
887 }
888 let pixel_clock_hz = pixel_clock * 10_000;
889 let h_active = (block[2] as u32) | (((block[4] as u32) & 0xF0) << 4);
890 let h_blanking = (block[3] as u32) | (((block[4] as u32) & 0x0F) << 8);
891 let v_active = (block[5] as u32) | (((block[7] as u32) & 0xF0) << 4);
892 let v_blanking = (block[6] as u32) | (((block[7] as u32) & 0x0F) << 8);
893
894 let h_total = h_active + h_blanking;
895 let v_total = v_active + v_blanking;
896 if h_total == 0 || v_total == 0 {
897 return None;
898 }
899
900 let refresh = (pixel_clock_hz as f64) / ((h_total * v_total) as f64);
901 Some((refresh * 100.0).round() / 100.0)
902}
903
904#[allow(dead_code)]
905fn format_refresh_rate(refresh: f64) -> String {
906 if (refresh - refresh.round()).abs() < 0.01 {
907 format!("{:.0}", refresh)
908 } else {
909 format!("{:.2}", refresh)
910 }
911}
912
913#[allow(dead_code)]
914fn parse_serial_number_from_edid(edid: &[u8]) -> Option<String> {
915 if edid.len() < 128 {
916 return None;
917 }
918 let offsets = [54, 72, 90, 108];
920 for &offset in &offsets {
921 if offset + 18 <= edid.len() {
922 let block = &edid[offset..offset + 18];
923 if block[0] == 0x00 && block[1] == 0x00 && block[2] == 0x00 && block[3] == 0xFF {
924 let serial_bytes = &block[4..17];
925 let serial = String::from_utf8_lossy(serial_bytes);
926 let cleaned = serial.trim().replace('\0', "").to_string();
927 if !cleaned.is_empty() {
928 return Some(cleaned);
929 }
930 }
931 }
932 }
933
934 let serial_num = ((edid[15] as u32) << 24)
936 | ((edid[14] as u32) << 16)
937 | ((edid[13] as u32) << 8)
938 | (edid[12] as u32);
939 if serial_num != 0 && serial_num != 0xFFFFFFFF {
940 return Some(serial_num.to_string());
941 }
942
943 None
944}
945
946#[allow(dead_code)]
947fn get_monitor_name_for_port(port: &str) -> Option<String> {
948 if let Ok(entries) = std::fs::read_dir("/sys/class/drm") {
949 for entry in entries.filter_map(|e| e.ok()) {
950 let name = entry.file_name().to_string_lossy().to_string();
951 if name.ends_with(port) {
952 let edid_path = entry.path().join("edid");
953 if edid_path.exists() {
954 if let Ok(edid_bytes) = std::fs::read(&edid_path) {
955 if let Some(monitor_name) = parse_monitor_name_from_edid(&edid_bytes) {
956 return Some(monitor_name);
957 }
958 }
959 }
960 }
961 }
962 }
963 None
964}
965
966fn detect_displays() -> Vec<String> {
967 #[cfg(target_os = "macos")]
968 {
969 if let Ok(output) = std::process::Command::new("system_profiler")
970 .arg("SPDisplaysDataType")
971 .output()
972 {
973 if let Ok(stdout) = String::from_utf8(output.stdout) {
974 return parse_macos_displays(&stdout);
975 }
976 }
977 Vec::new()
978 }
979
980 #[cfg(target_os = "windows")]
981 {
982 let mut displays = Vec::new();
983 if let Ok(output) = std::process::Command::new("wmic")
984 .args([
985 "path",
986 "Win32_VideoController",
987 "get",
988 "Name,CurrentHorizontalResolution,CurrentVerticalResolution,CurrentRefreshRate",
989 "/value",
990 ])
991 .output()
992 {
993 if let Ok(stdout) = String::from_utf8(output.stdout) {
994 let mut name = String::new();
995 let mut width = String::new();
996 let mut height = String::new();
997 let mut refresh = String::new();
998 for line in stdout.lines() {
999 let line = line.trim();
1000 if line.starts_with("Name=") {
1001 name = line.strip_prefix("Name=").unwrap_or("").trim().to_string();
1002 } else if line.starts_with("CurrentHorizontalResolution=") {
1003 width = line
1004 .strip_prefix("CurrentHorizontalResolution=")
1005 .unwrap_or("")
1006 .trim()
1007 .to_string();
1008 } else if line.starts_with("CurrentVerticalResolution=") {
1009 height = line
1010 .strip_prefix("CurrentVerticalResolution=")
1011 .unwrap_or("")
1012 .trim()
1013 .to_string();
1014 } else if line.starts_with("CurrentRefreshRate=") {
1015 refresh = line
1016 .strip_prefix("CurrentRefreshRate=")
1017 .unwrap_or("")
1018 .trim()
1019 .to_string();
1020 }
1021
1022 if !name.is_empty()
1023 && !width.is_empty()
1024 && !height.is_empty()
1025 && !refresh.is_empty()
1026 {
1027 displays.push(format!("{} ({}x{} @ {}Hz)", name, width, height, refresh));
1028 name.clear();
1029 width.clear();
1030 height.clear();
1031 refresh.clear();
1032 }
1033 }
1034 if !name.is_empty() && !width.is_empty() && !height.is_empty() {
1035 displays.push(format!("{} ({}x{})", name, width, height));
1036 }
1037 }
1038 }
1039 displays
1040 }
1041
1042 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
1043 {
1044 let mut displays = Vec::new();
1045
1046 if let Ok(output) = std::process::Command::new("xrandr")
1047 .arg("--current")
1048 .output()
1049 {
1050 if let Ok(stdout) = String::from_utf8(output.stdout) {
1051 displays = parse_xrandr_displays(&stdout);
1052 }
1053 }
1054
1055 if displays.is_empty() {
1056 if let Ok(entries) = std::fs::read_dir("/sys/class/drm") {
1057 for entry in entries.filter_map(|e| e.ok()) {
1058 let path = entry.path();
1059 let status_path = path.join("status");
1060 let modes_path = path.join("modes");
1061 let edid_path = path.join("edid");
1062 if status_path.exists() && modes_path.exists() {
1063 if let Ok(status) = std::fs::read_to_string(&status_path) {
1064 if status.trim() == "connected" {
1065 if let Ok(modes) = std::fs::read_to_string(&modes_path) {
1066 if let Some(first_mode) = modes.lines().next() {
1067 let res = first_mode.trim().to_string();
1068 let port = entry.file_name().to_string_lossy().to_string();
1069 let clean_port = if let Some(idx) = port.find('-') {
1070 port[idx + 1..].to_string()
1071 } else {
1072 port
1073 };
1074
1075 let edid_bytes = if edid_path.exists() {
1076 std::fs::read(&edid_path).ok()
1077 } else {
1078 None
1079 };
1080
1081 let name = edid_bytes
1082 .as_ref()
1083 .and_then(|bytes| parse_monitor_name_from_edid(bytes))
1084 .unwrap_or(clean_port);
1085
1086 let refresh = edid_bytes
1087 .as_ref()
1088 .and_then(|bytes| parse_refresh_rate_from_edid(bytes));
1089
1090 let serial = edid_bytes
1091 .as_ref()
1092 .and_then(|bytes| parse_serial_number_from_edid(bytes));
1093
1094 let display_name = if let Some(ref s) = serial {
1095 format!("{} #{}", name, s)
1096 } else {
1097 name
1098 };
1099
1100 if let Some(r) = refresh {
1101 displays.push(format!(
1102 "{} ({} @ {}Hz)",
1103 display_name,
1104 res,
1105 format_refresh_rate(r)
1106 ));
1107 } else {
1108 displays.push(format!("{} ({})", display_name, res));
1109 }
1110 }
1111 }
1112 }
1113 }
1114 }
1115 }
1116 }
1117 }
1118
1119 displays
1120 }
1121}
1122
1123#[cfg(target_os = "macos")]
1124fn parse_macos_displays(stdout: &str) -> Vec<String> {
1125 let mut displays = Vec::new();
1126 let mut current_name = None;
1127 let mut current_res = None;
1128 let mut in_displays = false;
1129
1130 for line in stdout.lines() {
1131 let trimmed = line.trim();
1132 let indent = line.len() - line.trim_start().len();
1133
1134 if trimmed.starts_with("Displays:") {
1135 in_displays = true;
1136 continue;
1137 }
1138
1139 if in_displays {
1140 if indent < 8 && !trimmed.is_empty() && !trimmed.starts_with("Displays:") {
1141 in_displays = false;
1142 continue;
1143 }
1144
1145 if trimmed.ends_with(':') && !trimmed.starts_with("UI Looks like:") {
1146 let name = trimmed.trim_end_matches(':').trim().to_string();
1147 current_name = Some(name);
1148 } else if trimmed.starts_with("Resolution:") {
1149 let res = trimmed.strip_prefix("Resolution:").unwrap_or("").trim();
1150 let cleaned = res.replace(" ", "");
1151 current_res = Some(cleaned);
1152 } else if trimmed.starts_with("UI Looks like:") {
1153 if let Some(res) = current_res.take() {
1154 let name_str = current_name.take().unwrap_or_else(|| "Display".to_string());
1155 if let Some(idx) = trimmed.find('@') {
1156 let freq = trimmed[idx..].trim();
1157 let freq_clean = freq.replace(" ", "").replace(".00", "");
1158 displays.push(format!(
1159 "{} ({} @ {})",
1160 name_str,
1161 res,
1162 freq_clean.trim_start_matches('@')
1163 ));
1164 } else {
1165 displays.push(format!("{} ({})", name_str, res));
1166 }
1167 }
1168 }
1169 }
1170 }
1171 if let Some(res) = current_res {
1172 let name_str = current_name.unwrap_or_else(|| "Display".to_string());
1173 displays.push(format!("{} ({})", name_str, res));
1174 }
1175 displays
1176}
1177
1178#[cfg(not(any(target_os = "macos", target_os = "windows")))]
1179fn parse_xrandr_displays(stdout: &str) -> Vec<String> {
1180 let mut displays = Vec::new();
1181 let mut current_display = None;
1182 let mut current_port = None;
1183 for line in stdout.lines() {
1184 let line = line.trim();
1185 if line.contains(" connected ") {
1186 let parts: Vec<&str> = line.split_whitespace().collect();
1187 if let Some(&port) = parts.first() {
1188 current_port = Some(port.to_string());
1189 }
1190 for part in parts {
1191 if part.contains('x') && part.contains('+') {
1192 if let Some(res) = part.split('+').next() {
1193 current_display = Some(res.to_string());
1194 }
1195 }
1196 }
1197 } else if line.contains('*') {
1198 if let Some(res) = current_display.take() {
1199 let port = current_port.take().unwrap_or_default();
1200 let name = get_monitor_name_for_port(&port).unwrap_or_else(|| port.clone());
1201 let parts: Vec<&str> = line.split_whitespace().collect();
1202 let mut added = false;
1203 for part in parts {
1204 if part.contains('*') {
1205 let freq = part.trim_end_matches(['*', '+']);
1206 displays.push(format!("{} ({} @ {}Hz)", name, res, freq));
1207 added = true;
1208 break;
1209 }
1210 }
1211 if !added {
1212 displays.push(format!("{} ({})", name, res));
1213 }
1214 }
1215 }
1216 }
1217 displays
1218}
1219
1220fn format_bytes(bytes: u64) -> String {
1222 const KB: u64 = 1024;
1223 const MB: u64 = KB * 1024;
1224 const GB: u64 = MB * 1024;
1225
1226 if bytes >= GB {
1227 format!("{:.1} GB", bytes as f64 / GB as f64)
1228 } else if bytes >= MB {
1229 format!("{:.1} MB", bytes as f64 / MB as f64)
1230 } else if bytes >= KB {
1231 format!("{:.1} KB", bytes as f64 / KB as f64)
1232 } else {
1233 format!("{} B", bytes)
1234 }
1235}
1236
1237#[cfg(test)]
1238mod tests {
1239 use super::*;
1240
1241 #[test]
1242 fn test_format_bytes() {
1243 assert_eq!(format_bytes(500), "500 B");
1244 assert_eq!(format_bytes(1024), "1.0 KB");
1245 assert_eq!(format_bytes(1024 * 1024), "1.0 MB");
1246 assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB");
1247 assert_eq!(format_bytes(1536), "1.5 KB");
1248 }
1249
1250 #[test]
1251 fn test_system_info_collect() {
1252 use clap::Parser;
1253 let cli = Cli::try_parse_from(["retch"]).unwrap();
1254 let config = Config::default();
1255 let res = SystemInfo::collect(&cli, &config);
1256 assert!(res.is_ok());
1257 let info = res.unwrap();
1258 assert!(!info.os.is_empty());
1259 assert!(info.cpu_cores > 0);
1260 }
1261
1262 #[cfg(target_os = "macos")]
1263 #[test]
1264 fn test_parse_macos_displays() {
1265 let sample = "Graphics/Displays:\n\n Apple M2:\n\n Chipset Model: Apple M2\n Displays:\n Color LCD:\n Resolution: 3024 x 1964\n UI Looks like: 1512 x 982 @ 60.00Hz\n";
1266 let parsed = parse_macos_displays(sample);
1267 assert_eq!(parsed, vec!["Color LCD (3024x1964 @ 60Hz)".to_string()]);
1268 }
1269
1270 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
1271 #[test]
1272 fn test_parse_xrandr_displays() {
1273 let sample = "Screen 0: minimum 320 x 200, current 2560 x 1440\nDP-1 connected primary 2560x1440+0+0\n 2560x1440 143.97*+\n 1920x1080 60.00\n";
1274 let parsed = parse_xrandr_displays(sample);
1275 assert_eq!(parsed, vec!["DP-1 (2560x1440 @ 143.97Hz)".to_string()]);
1276 }
1277
1278 #[test]
1279 fn test_parse_refresh_rate_from_edid() {
1280 let mut edid = vec![0u8; 128];
1282 edid[54] = 0x02;
1284 edid[55] = 0x3A;
1285 edid[56] = 0x80; edid[57] = 0x18; edid[58] = 0x71; edid[59] = 0x38; edid[60] = 0x2D; edid[61] = 0x40; let refresh = parse_refresh_rate_from_edid(&edid);
1295 assert!(refresh.is_some());
1296 assert_eq!(refresh.unwrap(), 60.0);
1298 }
1299
1300 #[test]
1301 fn test_format_refresh_rate() {
1302 assert_eq!(format_refresh_rate(60.0), "60");
1303 assert_eq!(format_refresh_rate(59.94), "59.94");
1304 assert_eq!(format_refresh_rate(143.971), "143.97");
1305 }
1306
1307 #[test]
1308 fn test_parse_serial_number_from_edid() {
1309 let mut edid = vec![0u8; 128];
1310 edid[12] = 0x78;
1312 edid[13] = 0x56;
1313 edid[14] = 0x34;
1314 edid[15] = 0x12; assert_eq!(
1316 parse_serial_number_from_edid(&edid),
1317 Some("305419896".to_string())
1318 );
1319
1320 edid[72] = 0x00;
1322 edid[73] = 0x00;
1323 edid[74] = 0x00;
1324 edid[75] = 0xFF; let serial_str = b"CN0123456789\n";
1326 for i in 0..serial_str.len() {
1327 edid[76 + i] = serial_str[i];
1328 }
1329 assert_eq!(
1330 parse_serial_number_from_edid(&edid),
1331 Some("CN0123456789".to_string())
1332 );
1333 }
1334}