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}
78
79impl SystemInfo {
80 pub fn collect(_cli: &Cli, _config: &Config) -> anyhow::Result<Self> {
85 let mut sys = System::new_all();
86 sys.refresh_all();
87
88 let os = System::long_os_version()
89 .or_else(System::name)
90 .unwrap_or_else(|| "Unknown".to_string());
91
92 let kernel = System::kernel_version();
93 let hostname = System::host_name();
94
95 let cpu = sys
96 .cpus()
97 .first()
98 .map(|c| c.brand().to_string())
99 .unwrap_or_else(|| "Unknown CPU".to_string());
100
101 let cpu_cores = sys.cpus().len();
102
103 let total_mem = sys.total_memory() as f64 / 1024.0 / 1024.0;
104 let used_mem = sys.used_memory() as f64 / 1024.0 / 1024.0;
105 let memory = format!("{:.1} / {:.1} GB", used_mem, total_mem);
106
107 let total_swap = sys.total_swap() as f64 / 1024.0 / 1024.0;
108 let used_swap = sys.used_swap() as f64 / 1024.0 / 1024.0;
109 let swap = if total_swap > 0.0 {
110 format!("{:.1} / {:.1} GB", used_swap, total_swap)
111 } else {
112 "No swap".to_string()
113 };
114
115 let uptime = format!("{}s", System::uptime());
116
117 let disks = Disks::new_with_refreshed_list()
118 .iter()
119 .map(|d| {
120 let total = d.total_space() as f64 / 1024.0 / 1024.0 / 1024.0;
121 let avail = d.available_space() as f64 / 1024.0 / 1024.0 / 1024.0;
122 let fs = d.file_system().to_string_lossy();
123 format!(
124 "{} ({}): {:.1} GB free / {:.1} GB",
125 d.mount_point().display(),
126 fs,
127 avail,
128 total
129 )
130 })
131 .collect();
132
133 let battery = battery::Manager::new()
134 .ok()
135 .and_then(|manager| manager.batteries().ok())
136 .and_then(|mut batteries| batteries.next())
137 .and_then(|result| result.ok())
138 .map(|bat| {
139 let pct = bat.state_of_charge().value * 100.0;
140 let health = bat.state_of_health().value * 100.0;
141 let state = match bat.state() {
142 battery::State::Charging => "charging",
143 battery::State::Discharging => "discharging",
144 battery::State::Full => "full",
145 _ => "not charging",
146 };
147 let vendor = bat.vendor().map(|s| s.to_string());
148 let model = bat.model().map(|s| s.to_string());
149
150 let time_str = match bat.state() {
152 battery::State::Charging => bat.time_to_full().map(|d| {
153 let total_mins = (d.value / 60.0) as u32;
154 let hours = total_mins / 60;
155 let mins = total_mins % 60;
156 if hours >= 24 {
157 let days = hours / 24;
158 let rem_hours = hours % 24;
159 format!("{}d {}h until full", days, rem_hours)
160 } else if hours > 0 {
161 format!("{}h {}m until full", hours, mins)
162 } else {
163 format!("{}m until full", mins)
164 }
165 }),
166 battery::State::Discharging => bat.time_to_empty().map(|d| {
167 let total_mins = (d.value / 60.0) as u32;
168 let hours = total_mins / 60;
169 let mins = total_mins % 60;
170 if hours >= 24 {
171 let days = hours / 24;
172 let rem_hours = hours % 24;
173 format!("{}d {}h remaining", days, rem_hours)
174 } else if hours > 0 {
175 format!("{}h {}m remaining", hours, mins)
176 } else {
177 format!("{}m remaining", mins)
178 }
179 }),
180 _ => None,
181 };
182
183 let mut parts = vec![state.to_string()];
184 if let Some(t) = time_str {
185 parts.insert(0, t);
186 }
187 if health < 99.0 {
188 parts.push(format!("{:.0}% health", health));
189 }
190
191 let base = format!("{:.0}% ({})", pct, parts.join(", "));
192
193 match (vendor, model) {
194 (Some(v), Some(m)) => format!("{} [{} {}]", base, v, m),
195 (Some(v), None) => format!("{} [{}]", base, v),
196 _ => base,
197 }
198 });
199
200 let arch = System::cpu_arch();
201
202 let processes = sys.processes().len();
203
204 let load_avg = {
205 let avg = System::load_average();
206 if avg.one > 0.0 || avg.five > 0.0 {
207 Some(format!(
208 "{:.2}, {:.2}, {:.2}",
209 avg.one, avg.five, avg.fifteen
210 ))
211 } else {
212 None
213 }
214 };
215
216 let local_ip: Option<String> =
218 std::net::UdpSocket::bind("0.0.0.0:0")
219 .ok()
220 .and_then(|socket| {
221 socket.connect("8.8.8.8:53").ok()?;
222 socket.local_addr().ok().map(|addr| addr.ip().to_string())
223 });
224
225 let public_ip: Option<String> = std::process::Command::new("curl")
226 .args(["-s", "--max-time", "2", "https://api.ipify.org"])
227 .output()
228 .ok()
229 .and_then(|o| String::from_utf8(o.stdout).ok())
230 .map(|s| s.trim().to_string())
231 .filter(|s| !s.is_empty());
232
233 let active_interface: Option<String> = {
234 #[cfg(target_os = "linux")]
235 {
236 std::process::Command::new("ip")
237 .args(["route", "show", "default"])
238 .output()
239 .ok()
240 .and_then(|o| String::from_utf8(o.stdout).ok())
241 .and_then(|s| {
242 s.split_whitespace()
243 .position(|w| w == "dev")
244 .and_then(|i| s.split_whitespace().nth(i + 1))
245 .map(|s| s.to_string())
246 })
247 }
248 #[cfg(target_os = "macos")]
249 {
250 std::process::Command::new("route")
251 .args(["-n", "get", "default"])
252 .output()
253 .ok()
254 .and_then(|o| String::from_utf8(o.stdout).ok())
255 .and_then(|s| {
256 s.lines()
257 .find(|l| l.contains("interface:"))
258 .and_then(|l| l.split_whitespace().last())
259 .map(|s| s.to_string())
260 })
261 }
262 #[cfg(target_os = "windows")]
263 {
264 std::process::Command::new("powershell")
265 .args(["-Command", "Get-NetRoute -DestinationPrefix 0.0.0.0/0 | Select-Object -First 1 -ExpandProperty InterfaceAlias"])
266 .output()
267 .ok()
268 .and_then(|o| String::from_utf8(o.stdout).ok())
269 .map(|s| s.trim().to_string())
270 .filter(|s| !s.is_empty())
271 }
272 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
273 {
274 None
275 }
276 };
277
278 let mut temps: Vec<String> = Components::new_with_refreshed_list()
279 .iter()
280 .filter_map(|c| {
281 c.temperature().and_then(|t| {
282 if t > 0.0 {
283 Some(format!("{}: {:.0}°C", c.label(), t))
284 } else {
285 None
286 }
287 })
288 })
289 .collect();
290
291 temps.sort_by(|a, b| {
293 let a_cpu = a.to_lowercase().contains("cpu") || a.to_lowercase().contains("core");
294 let b_cpu = b.to_lowercase().contains("cpu") || b.to_lowercase().contains("core");
295 b_cpu.cmp(&a_cpu)
296 });
297
298 let networks = Networks::new_with_refreshed_list()
299 .iter()
300 .map(|(name, data)| {
301 let rx = format_bytes(data.total_received());
302 let tx = format_bytes(data.total_transmitted());
303 let is_up = data.operational_state() == sysinfo::InterfaceOperationalState::Up
304 || data.total_received() > 0
305 || data.total_transmitted() > 0;
306 let status = if is_up {
307 "Up".green().to_string()
308 } else {
309 "Down".red().to_string()
310 };
311
312 let mut ipv4_addresses = Vec::new();
313 let mut ipv6_addresses = Vec::new();
314
315 if is_up {
316 for ip_net in data.ip_networks() {
317 let ip = ip_net.addr;
318 let name_lower = name.to_lowercase();
319 let is_loopback_iface =
320 name_lower.starts_with("lo") || name_lower.contains("loopback");
321 if ip.is_loopback() && !is_loopback_iface {
322 continue;
323 }
324 match ip {
325 std::net::IpAddr::V4(v4) => {
326 ipv4_addresses.push(v4.to_string());
327 }
328 std::net::IpAddr::V6(v6) => {
329 if !v6.is_unicast_link_local() {
330 ipv6_addresses.push(v6.to_string());
331 }
332 }
333 }
334 }
335
336 if ipv4_addresses.is_empty() && ipv6_addresses.is_empty() {
338 if let (Some(ref active), Some(ref ip)) = (&active_interface, &local_ip) {
339 if name == active {
340 ipv4_addresses.push(ip.clone());
341 }
342 }
343 }
344 }
345
346 let ip_str = if !ipv4_addresses.is_empty() || !ipv6_addresses.is_empty() {
347 let mut combined = Vec::new();
348 if !ipv4_addresses.is_empty() {
349 combined.push(ipv4_addresses.join(", "));
350 }
351 if !ipv6_addresses.is_empty() {
352 combined.push(ipv6_addresses.join(", "));
353 }
354 format!(" ({})", combined.join(", "))
355 } else {
356 String::new()
357 };
358
359 format!("{}{} [{}] RX: {} TX: {}", name, ip_str, status, rx, tx)
360 })
361 .collect();
362
363 let boot_timestamp = System::boot_time();
364 let boot_dt = chrono::Local
365 .timestamp_opt(boot_timestamp as i64, 0)
366 .single()
367 .map(|dt| dt.format("%Y-%m-%dT%H:%M:%S%:z").to_string())
368 .unwrap_or_else(|| boot_timestamp.to_string());
369 let boot_time = boot_dt;
370
371 let shell = std::env::var("SHELL").ok();
373 let terminal = std::env::var("TERM").ok();
374 let desktop = std::env::var("XDG_CURRENT_DESKTOP")
375 .or_else(|_| std::env::var("DESKTOP_SESSION"))
376 .ok();
377
378 let cpu_freq = sys
380 .cpus()
381 .first()
382 .map(|c| format!("{:.2} GHz", c.frequency() as f64 / 1000.0));
383
384 let current_user = std::env::var("USER").ok();
386
387 let users = Users::new_with_refreshed_list()
389 .iter()
390 .filter(|user| {
391 let uid_str = user.id().to_string();
393 if let Ok(uid) = uid_str.parse::<u32>() {
394 uid >= 1000
395 } else {
396 false
397 }
398 })
399 .count();
400
401 Ok(Self {
402 os,
403 kernel,
404 hostname,
405 arch,
406 cpu,
407 cpu_cores,
408 memory,
409 swap,
410 uptime,
411 processes,
412 load_avg,
413 disks,
414 temps,
415 networks,
416 boot_time,
417 battery,
418 shell,
419 terminal,
420 desktop,
421 cpu_freq,
422 users,
423 gpu: gpu::detect_gpus().into_iter().map(|g| g.format()).collect(),
424 packages: detect_packages(),
425 current_user,
426 local_ip,
427 public_ip,
428 active_interface,
429 })
430 }
431}
432
433fn detect_packages() -> Option<usize> {
438 #[cfg(target_os = "macos")]
439 {
440 let mut count = 0;
441
442 for cellar_path in &["/opt/homebrew/Cellar", "/usr/local/Cellar"] {
444 if let Ok(entries) = std::fs::read_dir(cellar_path) {
445 count += entries.filter_map(|e| e.ok()).count();
446 }
447 }
448
449 for cask_path in &["/opt/homebrew/Caskroom", "/usr/local/Caskroom"] {
451 if let Ok(entries) = std::fs::read_dir(cask_path) {
452 count += entries.filter_map(|e| e.ok()).count();
453 }
454 }
455
456 if let Ok(entries) = std::fs::read_dir("/opt/local/var/macports/software") {
458 count += entries.filter_map(|e| e.ok()).count();
459 }
460
461 if count > 0 {
462 Some(count)
463 } else {
464 None
465 }
466 }
467
468 #[cfg(target_os = "windows")]
469 {
470 let mut count = 0;
471
472 if let Some(home) = dirs::home_dir() {
474 let scoop_dir = std::env::var("SCOOP")
475 .map(std::path::PathBuf::from)
476 .unwrap_or_else(|_| home.join("scoop"));
477 let scoop_apps = scoop_dir.join("apps");
478 if let Ok(entries) = std::fs::read_dir(scoop_apps) {
479 count += entries.filter_map(|e| e.ok()).count();
480 }
481 }
482
483 let choco_install = std::env::var("ChocolateyInstall")
485 .unwrap_or_else(|_| "C:\\ProgramData\\chocolatey".to_string());
486 let choco_lib = std::path::Path::new(&choco_install).join("lib");
487 if let Ok(entries) = std::fs::read_dir(choco_lib) {
488 count += entries.filter_map(|e| e.ok()).count();
489 }
490
491 if count > 0 {
492 Some(count)
493 } else {
494 None
495 }
496 }
497
498 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
499 {
500 if let Ok(entries) = std::fs::read_dir("/var/lib/pacman/local") {
502 let count = entries.filter_map(|e| e.ok()).count();
503 if count > 0 {
504 return Some(count);
505 }
506 }
507
508 if let Ok(entries) = std::fs::read_dir("/var/lib/dpkg/info") {
510 let count = entries
511 .filter_map(|e| e.ok())
512 .filter(|e| e.path().extension().is_some_and(|ext| ext == "list"))
513 .count();
514 if count > 0 {
515 return Some(count);
516 }
517 }
518
519 if let Ok(entries) = std::fs::read_dir("/var/db/pkg") {
521 let count: usize = entries
522 .filter_map(|e| e.ok())
523 .map(|e| {
524 std::fs::read_dir(e.path())
525 .map(|d| d.filter(|_| true).count())
526 .unwrap_or(0)
527 })
528 .sum();
529 if count > 0 {
530 return Some(count);
531 }
532 }
533
534 if let Ok(entries) = std::fs::read_dir("/var/db/xbps") {
536 let count = entries
537 .filter_map(|e| e.ok())
538 .filter(|e| e.path().extension().is_some_and(|ext| ext == "plist"))
539 .count();
540 if count > 0 {
541 return Some(count);
542 }
543 }
544
545 let rpm_db = "/var/lib/rpm/rpmdb.sqlite";
547 if std::path::Path::new(rpm_db).exists() {
548 match rusqlite::Connection::open(rpm_db) {
549 Ok(conn) => {
550 if let Ok(count) = conn.query_row("SELECT COUNT(*) FROM Packages", [], |row| {
551 row.get::<_, i64>(0)
552 }) {
553 if count > 0 {
554 return Some(count as usize);
555 }
556 }
557 }
558 Err(e) => {
559 eprintln!("warning: failed to open RPM database at {}: {}", rpm_db, e);
560 }
561 }
562 }
563
564 None
565 }
566}
567
568fn format_bytes(bytes: u64) -> String {
570 const KB: u64 = 1024;
571 const MB: u64 = KB * 1024;
572 const GB: u64 = MB * 1024;
573
574 if bytes >= GB {
575 format!("{:.1} GB", bytes as f64 / GB as f64)
576 } else if bytes >= MB {
577 format!("{:.1} MB", bytes as f64 / MB as f64)
578 } else if bytes >= KB {
579 format!("{:.1} KB", bytes as f64 / KB as f64)
580 } else {
581 format!("{} B", bytes)
582 }
583}
584
585#[cfg(test)]
586mod tests {
587 use super::*;
588
589 #[test]
590 fn test_format_bytes() {
591 assert_eq!(format_bytes(500), "500 B");
592 assert_eq!(format_bytes(1024), "1.0 KB");
593 assert_eq!(format_bytes(1024 * 1024), "1.0 MB");
594 assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB");
595 assert_eq!(format_bytes(1536), "1.5 KB");
596 }
597}