Skip to main content

flodl_cli/util/
system.rs

1//! Cross-platform system detection (CPU, RAM, OS, Docker, GPU).
2
3#[cfg(target_os = "linux")]
4use std::fs;
5use std::path::Path;
6use std::process::Command;
7
8// ---------------------------------------------------------------------------
9// GPU detection via nvidia-smi
10// ---------------------------------------------------------------------------
11
12pub struct GpuInfo {
13    pub index: u8,
14    pub name: String,
15    pub sm_major: u32,
16    pub sm_minor: u32,
17    pub total_memory_mb: u64,
18}
19
20impl GpuInfo {
21    pub fn sm_version(&self) -> String {
22        format!("sm_{}{}", self.sm_major, self.sm_minor)
23    }
24
25    pub fn vram_bytes(&self) -> u64 {
26        self.total_memory_mb * 1024 * 1024
27    }
28
29    pub fn short_name(&self) -> String {
30        self.name.replace("NVIDIA ", "").replace("GeForce ", "")
31    }
32}
33
34pub fn detect_gpus() -> Vec<GpuInfo> {
35    let output = match Command::new("nvidia-smi")
36        .args([
37            "--query-gpu=index,name,compute_cap,memory.total",
38            "--format=csv,noheader,nounits",
39        ])
40        .output()
41    {
42        Ok(o) if o.status.success() => o,
43        _ => return Vec::new(),
44    };
45
46    let stdout = String::from_utf8_lossy(&output.stdout);
47    stdout
48        .lines()
49        .filter_map(|line| {
50            let parts: Vec<&str> = line.splitn(4, ", ").collect();
51            if parts.len() < 4 {
52                return None;
53            }
54            let index: u8 = parts[0].trim().parse().ok()?;
55            let name = parts[1].trim().to_string();
56            let cap_parts: Vec<&str> = parts[2].trim().split('.').collect();
57            let sm_major: u32 = cap_parts.first()?.parse().ok()?;
58            let sm_minor: u32 = cap_parts.get(1)?.parse().ok()?;
59            let total_memory_mb: u64 = parts[3].trim().parse().ok()?;
60            Some(GpuInfo {
61                index,
62                name,
63                sm_major,
64                sm_minor,
65                total_memory_mb,
66            })
67        })
68        .collect()
69}
70
71pub fn nvidia_driver_version() -> Option<String> {
72    let output = Command::new("nvidia-smi")
73        .args(["--query-gpu=driver_version", "--format=csv,noheader"])
74        .output()
75        .ok()?;
76    if !output.status.success() {
77        return None;
78    }
79    let s = String::from_utf8_lossy(&output.stdout);
80    Some(s.lines().next()?.trim().to_string())
81}
82
83// ---------------------------------------------------------------------------
84// CPU
85// ---------------------------------------------------------------------------
86
87#[cfg(target_os = "linux")]
88pub fn cpu_model() -> Option<String> {
89    let info = fs::read_to_string("/proc/cpuinfo").ok()?;
90    for line in info.lines() {
91        if let Some(rest) = line.strip_prefix("model name") {
92            if let Some(val) = rest.split(':').nth(1) {
93                return Some(val.trim().to_string());
94            }
95        }
96    }
97    None
98}
99
100#[cfg(target_os = "macos")]
101pub fn cpu_model() -> Option<String> {
102    let out = Command::new("sysctl")
103        .args(["-n", "machdep.cpu.brand_string"])
104        .output()
105        .ok()?;
106    if !out.status.success() {
107        return None;
108    }
109    let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
110    if s.is_empty() { None } else { Some(s) }
111}
112
113#[cfg(target_os = "windows")]
114pub fn cpu_model() -> Option<String> {
115    let out = Command::new("wmic")
116        .args(["cpu", "get", "Name", "/value"])
117        .output()
118        .ok()?;
119    let s = String::from_utf8_lossy(&out.stdout);
120    for line in s.lines() {
121        if let Some(val) = line.strip_prefix("Name=") {
122            let v = val.trim();
123            if !v.is_empty() {
124                return Some(v.to_string());
125            }
126        }
127    }
128    None
129}
130
131#[cfg(target_os = "linux")]
132pub fn cpu_threads() -> usize {
133    fs::read_to_string("/proc/cpuinfo")
134        .ok()
135        .map(|s| s.lines().filter(|l| l.starts_with("processor")).count())
136        .unwrap_or(1)
137}
138
139#[cfg(target_os = "macos")]
140pub fn cpu_threads() -> usize {
141    Command::new("sysctl")
142        .args(["-n", "hw.logicalcpu"])
143        .output()
144        .ok()
145        .and_then(|o| {
146            String::from_utf8_lossy(&o.stdout)
147                .trim()
148                .parse()
149                .ok()
150        })
151        .unwrap_or(1)
152}
153
154#[cfg(target_os = "windows")]
155pub fn cpu_threads() -> usize {
156    std::env::var("NUMBER_OF_PROCESSORS")
157        .ok()
158        .and_then(|v| v.parse().ok())
159        .unwrap_or(1)
160}
161
162// ---------------------------------------------------------------------------
163// RAM
164// ---------------------------------------------------------------------------
165
166#[cfg(target_os = "linux")]
167pub fn ram_total_gb() -> u64 {
168    fs::read_to_string("/proc/meminfo")
169        .ok()
170        .and_then(|s| {
171            for line in s.lines() {
172                if let Some(rest) = line.strip_prefix("MemTotal:") {
173                    let kb: u64 = rest.split_whitespace().next()?.parse().ok()?;
174                    return Some(kb / (1024 * 1024));
175                }
176            }
177            None
178        })
179        .unwrap_or(0)
180}
181
182#[cfg(target_os = "macos")]
183pub fn ram_total_gb() -> u64 {
184    Command::new("sysctl")
185        .args(["-n", "hw.memsize"])
186        .output()
187        .ok()
188        .and_then(|o| {
189            let bytes: u64 = String::from_utf8_lossy(&o.stdout)
190                .trim()
191                .parse()
192                .ok()?;
193            Some(bytes / (1024 * 1024 * 1024))
194        })
195        .unwrap_or(0)
196}
197
198#[cfg(target_os = "windows")]
199pub fn ram_total_gb() -> u64 {
200    Command::new("wmic")
201        .args(["os", "get", "TotalVisibleMemorySize", "/value"])
202        .output()
203        .ok()
204        .and_then(|o| {
205            let s = String::from_utf8_lossy(&o.stdout);
206            for line in s.lines() {
207                if let Some(val) = line.strip_prefix("TotalVisibleMemorySize=") {
208                    let kb: u64 = val.trim().parse().ok()?;
209                    return Some(kb / (1024 * 1024));
210                }
211            }
212            None
213        })
214        .unwrap_or(0)
215}
216
217// ---------------------------------------------------------------------------
218// OS
219// ---------------------------------------------------------------------------
220
221#[cfg(target_os = "linux")]
222pub fn os_version() -> Option<String> {
223    let uname = Command::new("uname").arg("-r").output().ok()?;
224    let kernel = String::from_utf8_lossy(&uname.stdout).trim().to_string();
225    let wsl = if kernel.contains("WSL") || kernel.contains("microsoft") {
226        " (WSL2)"
227    } else {
228        ""
229    };
230    Some(format!("Linux {}{}", kernel, wsl))
231}
232
233#[cfg(target_os = "macos")]
234pub fn os_version() -> Option<String> {
235    let out = Command::new("sw_vers")
236        .args(["-productVersion"])
237        .output()
238        .ok()?;
239    let ver = String::from_utf8_lossy(&out.stdout).trim().to_string();
240    if ver.is_empty() {
241        return None;
242    }
243    let arch = Command::new("uname")
244        .arg("-m")
245        .output()
246        .ok()
247        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
248        .unwrap_or_default();
249    if arch.is_empty() {
250        Some(format!("macOS {}", ver))
251    } else {
252        Some(format!("macOS {} ({})", ver, arch))
253    }
254}
255
256#[cfg(target_os = "windows")]
257pub fn os_version() -> Option<String> {
258    let out = Command::new("cmd")
259        .args(["/C", "ver"])
260        .output()
261        .ok()?;
262    let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
263    if s.is_empty() { None } else { Some(s) }
264}
265
266// ---------------------------------------------------------------------------
267// Docker
268// ---------------------------------------------------------------------------
269
270pub fn is_inside_docker() -> bool {
271    Path::new("/.dockerenv").exists()
272}
273
274pub fn docker_version() -> Option<String> {
275    let out = Command::new("docker").arg("--version").output().ok()?;
276    if !out.status.success() {
277        return None;
278    }
279    let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
280    s.split("version ")
281        .nth(1)
282        .and_then(|v| v.split(',').next())
283        .map(|v| v.trim().to_string())
284}
285
286/// Check whether cargo is available on the host.
287#[allow(dead_code)]
288pub fn has_cargo() -> bool {
289    Command::new("cargo")
290        .arg("--version")
291        .output()
292        .is_ok_and(|o| o.status.success())
293}
294
295// ---------------------------------------------------------------------------
296// Helpers
297// ---------------------------------------------------------------------------
298
299/// Check whether a command exists on PATH.
300#[allow(dead_code)]
301pub fn has_command(name: &str) -> bool {
302    Command::new(name)
303        .arg("--version")
304        .stdout(std::process::Stdio::null())
305        .stderr(std::process::Stdio::null())
306        .status()
307        .is_ok()
308}
309
310/// Platform string for download URLs (e.g. "linux-x86_64", "macos-arm64").
311#[allow(dead_code)]
312pub fn platform_tag() -> Option<String> {
313    let os = std::env::consts::OS;
314    let arch = std::env::consts::ARCH;
315    match (os, arch) {
316        ("linux", "x86_64") => Some("linux-x86_64".into()),
317        ("macos", "aarch64") => Some("macos-arm64".into()),
318        ("windows", "x86_64") => Some("windows-x86_64".into()),
319        _ => None,
320    }
321}
322
323pub fn escape_json(s: &str) -> String {
324    s.replace('\\', "\\\\")
325        .replace('"', "\\\"")
326        .replace('\n', "\\n")
327}