//! Machine specs, gathered at upload time (best effort, linux-first). //! Opt-in: the cli asks before including them (`--specs` / `--no-specs`). use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Specs { pub os: String, pub arch: String, pub cpu: String, pub cpu_cores: usize, pub memory_gb: f64, pub gpu: String, } impl Specs { pub fn is_empty(&self) -> bool { self.os.is_empty() && self.cpu.is_empty() && self.gpu.is_empty() && self.memory_gb == 0.0 } /// One-line "os · arch · cpu (n cores) · ram · gpu: x" pub fn summary(&self) -> String { let mut parts = Vec::new(); if !self.os.is_empty() { parts.push(self.os.clone()); } if !self.arch.is_empty() { parts.push(self.arch.clone()); } if !self.cpu.is_empty() { if self.cpu_cores > 0 { parts.push(format!("{} ({} cores)", self.cpu, self.cpu_cores)); } else { parts.push(self.cpu.clone()); } } if self.memory_gb > 0.0 { parts.push(format!("{:.1} GB ram", self.memory_gb)); } if !self.gpu.is_empty() { parts.push(format!("gpu: {}", self.gpu)); } parts.join(" · ") } } pub fn gather() -> Specs { Specs { os: detect_os(), arch: std::env::consts::ARCH.to_string(), cpu: read_cpu_model(), cpu_cores: std::thread::available_parallelism() .map(|n| n.get()) .unwrap_or(0), memory_gb: read_mem_gb(), gpu: detect_gpu(), } } fn detect_os() -> String { if let Ok(raw) = std::fs::read_to_string("/etc/os-release") { for line in raw.lines() { if let Some(v) = line.strip_prefix("PRETTY_NAME=") { return v.trim_matches('"').trim().to_string(); } } } std::env::consts::OS.to_string() } fn read_cpu_model() -> String { std::fs::read_to_string("/proc/cpuinfo") .ok() .and_then(|raw| { raw.lines() .find(|l| l.starts_with("model name")) .and_then(|l| l.split(':').nth(1)) .map(|s| s.trim().to_string()) }) .unwrap_or_default() } fn read_mem_gb() -> f64 { std::fs::read_to_string("/proc/meminfo") .ok() .and_then(|raw| { raw.lines() .find(|l| l.starts_with("MemTotal:")) .and_then(|l| l.split_whitespace().nth(1)) .and_then(|kb| kb.parse::().ok()) }) .map(|kb| (kb / 1024.0 / 1024.0 * 10.0).round() / 10.0) .unwrap_or(0.0) } /// nvidia-smi -> rocm-smi -> lspci, first hit wins. fn detect_gpu() -> String { gpu_nvidia() .or_else(gpu_amd) .or_else(gpu_lspci) .unwrap_or_default() } fn gpu_nvidia() -> Option { let out = std::process::Command::new("nvidia-smi") .args(["--query-gpu=name", "--format=csv,noheader"]) .output() .ok()?; if !out.status.success() { return None; } first_nonempty_line(&String::from_utf8_lossy(&out.stdout)) } fn gpu_amd() -> Option { let out = std::process::Command::new("rocm-smi") .args(["--showproductname"]) .output() .ok()?; if !out.status.success() { return None; } let s = String::from_utf8_lossy(&out.stdout); // "GPU[0] : Product Name: AMD Radeon RX 7900 XTX" for line in s.lines() { if let Some(idx) = line.find("Product Name:") { let v = line[idx + "Product Name:".len()..].trim(); if !v.is_empty() { return Some(v.to_string()); } } } // some rocm versions print "Card series: ..." instead for line in s.lines() { let l = line.to_lowercase(); if (l.contains("radeon") || l.contains("instinct")) && !line.trim().is_empty() { return Some(line.trim().to_string()); } } None } fn gpu_lspci() -> Option { let out = std::process::Command::new("lspci").output().ok()?; if !out.status.success() { return None; } let s = String::from_utf8_lossy(&out.stdout); for line in s.lines() { let l = line.to_lowercase(); if l.contains("vga") || l.contains("3d controller") || l.contains("display controller") { // "01:00.0 VGA compatible controller: NVIDIA Corporation GA104 ..." if let Some(v) = line.splitn(3, ": ").nth(2) { let v = v.trim(); if !v.is_empty() { return Some(v.to_string()); } } } } None } fn first_nonempty_line(s: &str) -> Option { s.lines().map(|l| l.trim()).find(|l| !l.is_empty()).map(|l| l.to_string()) } #[cfg(test)] mod tests { use super::*; #[test] fn summary_and_empty() { let s = Specs::default(); assert!(s.is_empty()); assert_eq!(s.summary(), ""); let s = Specs { os: "Ubuntu 24.04".into(), arch: "x86_64".into(), cpu: "AMD EPYC".into(), cpu_cores: 8, memory_gb: 31.2, gpu: "NVIDIA RTX".into(), }; assert!(!s.is_empty()); assert_eq!(s.summary(), "Ubuntu 24.04 · x86_64 · AMD EPYC (8 cores) · 31.2 GB ram · gpu: NVIDIA RTX"); } #[test] fn serde_roundtrip() { let s = Specs { os: "t".into(), ..Default::default() }; let v = serde_json::to_value(&s).unwrap(); let back: Specs = serde_json::from_value(v).unwrap(); assert_eq!(back.os, "t"); } }