1#[cfg(target_os = "linux")]
4use std::fs;
5use std::path::Path;
6use std::process::Command;
7
8pub struct GpuInfo {
19 pub index: u8,
20 pub name: String,
21 pub sm_major: u32,
22 pub sm_minor: u32,
23 pub total_memory_mb: u64,
24}
25
26impl GpuInfo {
27 pub fn sm_version(&self) -> String {
28 format!("sm_{}{}", self.sm_major, self.sm_minor)
29 }
30
31 pub fn vram_bytes(&self) -> u64 {
32 self.total_memory_mb * 1024 * 1024
33 }
34
35 pub fn short_name(&self) -> String {
36 self.name.replace("NVIDIA ", "").replace("GeForce ", "")
37 }
38}
39
40pub fn detect_gpus() -> Vec<GpuInfo> {
41 let output = match Command::new("nvidia-smi")
42 .args([
48 "--query-gpu=index,compute_cap,memory.total,name",
49 "--format=csv,noheader,nounits",
50 ])
51 .output()
52 {
53 Ok(o) if o.status.success() => o,
54 Ok(o) => {
60 eprintln!(
61 "flodl: nvidia-smi exited {} — treating as no GPUs (stderr: {})",
62 o.status,
63 String::from_utf8_lossy(&o.stderr).trim(),
64 );
65 return Vec::new();
66 }
67 Err(_) => return Vec::new(),
70 };
71
72 let stdout = String::from_utf8_lossy(&output.stdout);
73 stdout
74 .lines()
75 .filter(|line| !line.trim().is_empty())
76 .filter_map(|line| {
77 let parsed = parse_gpu_csv_row(line);
78 if parsed.is_none() {
79 eprintln!("flodl: could not parse an nvidia-smi GPU row, skipping: {line:?}");
82 }
83 parsed
84 })
85 .collect()
86}
87
88fn parse_gpu_csv_row(line: &str) -> Option<GpuInfo> {
92 let parts: Vec<&str> = line.splitn(4, ", ").collect();
95 if parts.len() < 4 {
96 return None;
97 }
98 let index: u8 = parts[0].trim().parse().ok()?;
99 let cap_parts: Vec<&str> = parts[1].trim().split('.').collect();
100 let sm_major: u32 = cap_parts.first()?.parse().ok()?;
101 let sm_minor: u32 = cap_parts.get(1)?.parse().ok()?;
102 let total_memory_mb: u64 = parts[2].trim().parse().ok()?;
103 let name = parts[3].trim().to_string();
104 Some(GpuInfo {
105 index,
106 name,
107 sm_major,
108 sm_minor,
109 total_memory_mb,
110 })
111}
112
113pub fn nvidia_driver_version() -> Option<String> {
114 let output = Command::new("nvidia-smi")
115 .args(["--query-gpu=driver_version", "--format=csv,noheader"])
116 .output()
117 .ok()?;
118 if !output.status.success() {
119 return None;
120 }
121 let s = String::from_utf8_lossy(&output.stdout);
122 Some(s.lines().next()?.trim().to_string())
123}
124
125#[cfg(target_os = "linux")]
130pub fn cpu_model() -> Option<String> {
131 let info = fs::read_to_string("/proc/cpuinfo").ok()?;
132 for line in info.lines() {
133 if let Some(rest) = line.strip_prefix("model name") {
134 if let Some(val) = rest.split(':').nth(1) {
135 return Some(val.trim().to_string());
136 }
137 }
138 }
139 None
140}
141
142#[cfg(target_os = "macos")]
143pub fn cpu_model() -> Option<String> {
144 let out = Command::new("sysctl")
145 .args(["-n", "machdep.cpu.brand_string"])
146 .output()
147 .ok()?;
148 if !out.status.success() {
149 return None;
150 }
151 let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
152 if s.is_empty() { None } else { Some(s) }
153}
154
155#[cfg(target_os = "windows")]
156pub fn cpu_model() -> Option<String> {
157 let out = Command::new("wmic")
158 .args(["cpu", "get", "Name", "/value"])
159 .output()
160 .ok()?;
161 let s = String::from_utf8_lossy(&out.stdout);
162 for line in s.lines() {
163 if let Some(val) = line.strip_prefix("Name=") {
164 let v = val.trim();
165 if !v.is_empty() {
166 return Some(v.to_string());
167 }
168 }
169 }
170 None
171}
172
173#[cfg(target_os = "linux")]
174pub fn cpu_threads() -> usize {
175 fs::read_to_string("/proc/cpuinfo")
176 .ok()
177 .map(|s| s.lines().filter(|l| l.starts_with("processor")).count())
178 .unwrap_or(1)
179}
180
181#[cfg(target_os = "macos")]
182pub fn cpu_threads() -> usize {
183 Command::new("sysctl")
184 .args(["-n", "hw.logicalcpu"])
185 .output()
186 .ok()
187 .and_then(|o| {
188 String::from_utf8_lossy(&o.stdout)
189 .trim()
190 .parse()
191 .ok()
192 })
193 .unwrap_or(1)
194}
195
196#[cfg(target_os = "windows")]
197pub fn cpu_threads() -> usize {
198 std::env::var("NUMBER_OF_PROCESSORS")
199 .ok()
200 .and_then(|v| v.parse().ok())
201 .unwrap_or(1)
202}
203
204#[cfg(target_os = "linux")]
209pub fn ram_total_gb() -> u64 {
210 fs::read_to_string("/proc/meminfo")
211 .ok()
212 .and_then(|s| {
213 for line in s.lines() {
214 if let Some(rest) = line.strip_prefix("MemTotal:") {
215 let kb: u64 = rest.split_whitespace().next()?.parse().ok()?;
216 return Some(kb / (1024 * 1024));
217 }
218 }
219 None
220 })
221 .unwrap_or(0)
222}
223
224#[cfg(target_os = "macos")]
225pub fn ram_total_gb() -> u64 {
226 Command::new("sysctl")
227 .args(["-n", "hw.memsize"])
228 .output()
229 .ok()
230 .and_then(|o| {
231 let bytes: u64 = String::from_utf8_lossy(&o.stdout)
232 .trim()
233 .parse()
234 .ok()?;
235 Some(bytes / (1024 * 1024 * 1024))
236 })
237 .unwrap_or(0)
238}
239
240#[cfg(target_os = "windows")]
241pub fn ram_total_gb() -> u64 {
242 Command::new("wmic")
243 .args(["os", "get", "TotalVisibleMemorySize", "/value"])
244 .output()
245 .ok()
246 .and_then(|o| {
247 let s = String::from_utf8_lossy(&o.stdout);
248 for line in s.lines() {
249 if let Some(val) = line.strip_prefix("TotalVisibleMemorySize=") {
250 let kb: u64 = val.trim().parse().ok()?;
251 return Some(kb / (1024 * 1024));
252 }
253 }
254 None
255 })
256 .unwrap_or(0)
257}
258
259#[cfg(target_os = "linux")]
264pub fn os_version() -> Option<String> {
265 let uname = Command::new("uname").arg("-r").output().ok()?;
266 let kernel = String::from_utf8_lossy(&uname.stdout).trim().to_string();
267 let wsl = if kernel.contains("WSL") || kernel.contains("microsoft") {
268 " (WSL2)"
269 } else {
270 ""
271 };
272 Some(format!("Linux {}{}", kernel, wsl))
273}
274
275#[cfg(target_os = "macos")]
276pub fn os_version() -> Option<String> {
277 let out = Command::new("sw_vers")
278 .args(["-productVersion"])
279 .output()
280 .ok()?;
281 let ver = String::from_utf8_lossy(&out.stdout).trim().to_string();
282 if ver.is_empty() {
283 return None;
284 }
285 let arch = Command::new("uname")
286 .arg("-m")
287 .output()
288 .ok()
289 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
290 .unwrap_or_default();
291 if arch.is_empty() {
292 Some(format!("macOS {}", ver))
293 } else {
294 Some(format!("macOS {} ({})", ver, arch))
295 }
296}
297
298#[cfg(target_os = "windows")]
299pub fn os_version() -> Option<String> {
300 let out = Command::new("cmd")
301 .args(["/C", "ver"])
302 .output()
303 .ok()?;
304 let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
305 if s.is_empty() { None } else { Some(s) }
306}
307
308pub fn is_inside_docker() -> bool {
313 Path::new("/.dockerenv").exists()
314}
315
316pub fn docker_version() -> Option<String> {
317 let out = Command::new("docker").arg("--version").output().ok()?;
318 if !out.status.success() {
319 return None;
320 }
321 let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
322 s.split("version ")
323 .nth(1)
324 .and_then(|v| v.split(',').next())
325 .map(|v| v.trim().to_string())
326}
327
328#[allow(dead_code)]
330pub fn has_cargo() -> bool {
331 Command::new("cargo")
332 .arg("--version")
333 .output()
334 .is_ok_and(|o| o.status.success())
335}
336
337#[allow(dead_code)]
343pub fn has_command(name: &str) -> bool {
344 Command::new(name)
345 .arg("--version")
346 .stdout(std::process::Stdio::null())
347 .stderr(std::process::Stdio::null())
348 .status()
349 .is_ok()
350}
351
352#[allow(dead_code)]
354pub fn platform_tag() -> Option<String> {
355 let os = std::env::consts::OS;
356 let arch = std::env::consts::ARCH;
357 match (os, arch) {
358 ("linux", "x86_64") => Some("linux-x86_64".into()),
359 ("macos", "aarch64") => Some("macos-arm64".into()),
360 ("windows", "x86_64") => Some("windows-x86_64".into()),
361 _ => None,
362 }
363}
364
365pub fn escape_json(s: &str) -> String {
371 let mut out = String::with_capacity(s.len());
372 for c in s.chars() {
373 match c {
374 '\\' => out.push_str("\\\\"),
375 '"' => out.push_str("\\\""),
376 '\n' => out.push_str("\\n"),
377 '\r' => out.push_str("\\r"),
378 '\t' => out.push_str("\\t"),
379 '\u{08}' => out.push_str("\\b"),
380 '\u{0C}' => out.push_str("\\f"),
381 c if (c as u32) < 0x20 => {
382 let _ = std::fmt::Write::write_fmt(
383 &mut out,
384 format_args!("\\u{:04x}", c as u32),
385 );
386 }
387 c => out.push(c),
388 }
389 }
390 out
391}
392
393pub fn arch_dir_name(archs: &str) -> String {
397 archs
398 .split(';')
399 .map(|cap| {
400 let clean = cap.replace('.', "");
401 format!("sm{}", clean)
402 })
403 .collect::<Vec<_>>()
404 .join("-")
405}
406
407#[cfg(test)]
408mod tests {
409 use super::{arch_dir_name, escape_json, parse_gpu_csv_row};
410
411 #[test]
412 fn escape_json_passes_through_plain_ascii() {
413 assert_eq!(escape_json("hello world 123"), "hello world 123");
414 }
415
416 #[test]
417 fn escape_json_escapes_quotes_and_backslashes() {
418 assert_eq!(escape_json(r#"C:\a\b"#), r#"C:\\a\\b"#);
421 assert_eq!(escape_json(r#"say "hi""#), r#"say \"hi\""#);
422 }
423
424 #[test]
425 fn escape_json_escapes_named_control_chars() {
426 assert_eq!(escape_json("a\nb\rc\td"), "a\\nb\\rc\\td");
427 assert_eq!(escape_json("\u{08}\u{0C}"), "\\b\\f");
428 }
429
430 #[test]
431 fn escape_json_uescapes_other_control_chars() {
432 assert_eq!(escape_json("\u{01}"), "\\u0001");
434 assert_eq!(escape_json("\u{1f}"), "\\u001f");
435 }
436
437 #[test]
438 fn escape_json_leaves_non_ascii_unescaped() {
439 assert_eq!(escape_json("café — 日本"), "café — 日本");
441 }
442
443 #[test]
444 fn arch_dir_name_single() {
445 assert_eq!(arch_dir_name("12.0"), "sm120");
446 }
447
448 #[test]
449 fn arch_dir_name_multi() {
450 assert_eq!(arch_dir_name("6.1;12.0"), "sm61-sm120");
451 }
452
453 #[test]
454 fn arch_dir_name_strips_all_dots() {
455 assert_eq!(arch_dir_name("7.5"), "sm75");
457 assert_eq!(arch_dir_name("8.0;8.6;9.0"), "sm80-sm86-sm90");
458 }
459
460 #[test]
461 fn parse_gpu_csv_row_parses_a_well_formed_row() {
462 let g = parse_gpu_csv_row("0, 8.9, 24564, NVIDIA GeForce RTX 4090").unwrap();
464 assert_eq!(g.index, 0);
465 assert_eq!(g.name, "NVIDIA GeForce RTX 4090");
466 assert_eq!(g.sm_major, 8);
467 assert_eq!(g.sm_minor, 9);
468 assert_eq!(g.total_memory_mb, 24564);
469 }
470
471 #[test]
472 fn parse_gpu_csv_row_rejects_malformed() {
473 assert!(parse_gpu_csv_row("0, 8.9, three").is_none());
475 assert!(parse_gpu_csv_row("x, 8.9, 24564, name").is_none());
476 assert!(parse_gpu_csv_row("0, notacap, 24564, name").is_none());
477 }
478
479 #[test]
480 fn parse_gpu_csv_row_keeps_comma_in_name() {
481 let g = parse_gpu_csv_row("0, 8.0, 81920, NVIDIA A100, 80GB").unwrap();
484 assert_eq!(g.name, "NVIDIA A100, 80GB");
485 assert_eq!(g.index, 0);
486 assert_eq!(g.sm_major, 8);
487 assert_eq!(g.sm_minor, 0);
488 assert_eq!(g.total_memory_mb, 81920);
489 }
490}