1use std::fmt;
2use std::path::Path;
3use std::process::Command;
4
5use sysinfo::{Disks, System};
6
7use crate::cache::CacheLayout;
8use crate::manifest::CudaVersion;
9
10#[derive(Debug, Clone)]
13pub struct GpuInfo {
14 pub name: String,
15 pub vram_mb: u64,
16 pub driver_version: Option<String>,
17 pub cuda_version: Option<CudaVersion>,
18}
19
20#[derive(Debug, Clone)]
21pub struct DetectedHardware {
22 pub cpu_cores: u32,
23 pub ram_mb: u64,
24 pub disk_free_mb: u64,
25 pub gpus: Vec<GpuInfo>,
26}
27
28impl DetectedHardware {
29 pub fn detect() -> Self {
30 let mut sys = System::new();
31 sys.refresh_cpu_all();
32 sys.refresh_memory();
33
34 let cpu_cores = sys.cpus().len() as u32;
35 let ram_mb = sys.total_memory() / (1024 * 1024);
36
37 let disk_free_mb = disk_free_for(&CacheLayout::new().root().clone());
43
44 let gpus = detect_gpus();
45
46 Self {
47 cpu_cores,
48 ram_mb,
49 disk_free_mb,
50 gpus,
51 }
52 }
53
54 pub fn ram_gb(&self) -> f64 {
55 self.ram_mb as f64 / 1024.0
56 }
57
58 pub fn disk_free_gb(&self) -> f64 {
59 self.disk_free_mb as f64 / 1024.0
60 }
61}
62
63#[derive(Debug, Clone)]
66pub enum HardwareMismatch {
67 NoGpu,
68 InsufficientVram {
69 required_gb: u32,
70 available_gb: u32,
71 },
72 CudaTooOld {
73 required: CudaVersion,
74 available: CudaVersion,
75 },
76 NoCuda {
77 required: CudaVersion,
78 },
79 InsufficientRam {
80 required_gb: f64,
81 available_gb: f64,
82 },
83 InsufficientDisk {
84 required_gb: f64,
85 available_gb: f64,
86 },
87}
88
89impl fmt::Display for HardwareMismatch {
90 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91 match self {
92 HardwareMismatch::NoGpu => {
93 write!(f, "NVIDIA GPU required but none detected")
94 }
95 HardwareMismatch::InsufficientVram {
96 required_gb,
97 available_gb,
98 } => {
99 write!(
100 f,
101 "GPU requires ≥{required_gb} GB VRAM, but best available is {available_gb} GB"
102 )
103 }
104 HardwareMismatch::CudaTooOld {
105 required,
106 available,
107 } => {
108 write!(f, "CUDA ≥{required} required, driver supports {available}")
109 }
110 HardwareMismatch::NoCuda { required } => {
111 write!(f, "CUDA ≥{required} required but no CUDA driver detected")
112 }
113 HardwareMismatch::InsufficientRam {
114 required_gb,
115 available_gb,
116 } => {
117 write!(
118 f,
119 "{required_gb:.0} GB RAM required, only {available_gb:.1} GB available"
120 )
121 }
122 HardwareMismatch::InsufficientDisk {
123 required_gb,
124 available_gb,
125 } => {
126 write!(
127 f,
128 "{required_gb:.0} GB free disk required, only {available_gb:.1} GB available"
129 )
130 }
131 }
132 }
133}
134
135fn detect_gpus() -> Vec<GpuInfo> {
138 let output = match Command::new("nvidia-smi")
139 .args([
140 "--query-gpu=name,memory.total,driver_version",
141 "--format=csv,noheader,nounits",
142 ])
143 .output()
144 {
145 Ok(o) if o.status.success() => o,
146 _ => return vec![],
147 };
148
149 let cuda_ver = detect_cuda_version();
150 let stdout = String::from_utf8_lossy(&output.stdout);
151
152 stdout
153 .lines()
154 .filter_map(|line| parse_gpu_csv(line, cuda_ver.clone()))
155 .collect()
156}
157
158fn parse_gpu_csv(line: &str, cuda_version: Option<CudaVersion>) -> Option<GpuInfo> {
159 let parts: Vec<&str> = line.splitn(3, ',').map(str::trim).collect();
160 if parts.len() < 2 {
161 return None;
162 }
163 let name = parts[0].to_string();
164 let vram_mb = parts[1].parse::<u64>().ok()?;
165 let driver_version = parts.get(2).map(|s| s.to_string());
166
167 Some(GpuInfo {
168 name,
169 vram_mb,
170 driver_version,
171 cuda_version,
172 })
173}
174
175fn detect_cuda_version() -> Option<CudaVersion> {
177 let output = Command::new("nvidia-smi").output().ok()?;
178 if !output.status.success() {
179 return None;
180 }
181 let stdout = String::from_utf8_lossy(&output.stdout);
182 for line in stdout.lines() {
183 if let Some(rest) = line.find("CUDA Version:").map(|i| &line[i + 13..])
184 && let Some(ver_str) = rest.split_whitespace().next()
185 {
186 return ver_str.parse().ok();
187 }
188 }
189 None
190}
191
192fn disk_free_for(target: &Path) -> u64 {
197 let disks = Disks::new_with_refreshed_list();
198 if disks.is_empty() {
199 return 0;
200 }
201
202 let canonical = ancestor_canonical(target);
206
207 let pick = canonical
208 .as_ref()
209 .and_then(|t| {
210 disks
211 .iter()
212 .filter(|d| t.starts_with(d.mount_point()))
213 .max_by_key(|d| d.mount_point().as_os_str().len())
214 });
215
216 let bytes = match pick {
217 Some(d) => d.available_space(),
218 None => disks
219 .iter()
220 .map(|d| d.available_space())
221 .max()
222 .unwrap_or(0),
223 };
224 bytes / (1024 * 1024)
225}
226
227fn ancestor_canonical(path: &Path) -> Option<std::path::PathBuf> {
228 let mut p = path;
229 loop {
230 if let Ok(c) = p.canonicalize() {
231 return Some(c);
232 }
233 p = p.parent()?;
234 }
235}