nd / nd-core/src/specs.rs

raw file 5768 bytes
//! 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::<f64>().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<String> {
    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<String> {
    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<String> {
    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<String> {
    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");
    }
}