Skip to main content

rusty_stack/
hardware.rs

1use crate::state::{
2    GPUInfo, HardwareState, PreflightCheck, PreflightResult, PreflightStatus, PreflightType,
3    SystemInfo,
4};
5use anyhow::Result;
6use std::fs;
7use std::io::Write;
8use std::path::{Path, PathBuf};
9use std::process::{Command, Stdio};
10use sysinfo::{Disks, System};
11
12pub fn detect_hardware() -> Result<HardwareState> {
13    let mut state = HardwareState {
14        status: "Detecting system info".into(),
15        progress: 0.2,
16        ..Default::default()
17    };
18
19    let mut sys = System::new_all();
20    sys.refresh_all();
21
22    state.system.os = System::name().unwrap_or_else(|| "Unknown".into());
23    state.system.kernel = System::kernel_version().unwrap_or_else(|| "Unknown".into());
24    state.system.distribution = System::long_os_version().unwrap_or_else(|| "Unknown".into());
25    if state.system.distribution.trim().is_empty() || state.system.distribution == "Unknown" {
26        if let Some(dist) = fallback_distribution() {
27            state.system.distribution = dist;
28        }
29    }
30    state.system.cpu_model = sys
31        .cpus()
32        .first()
33        .map(|c| c.brand().to_string())
34        .unwrap_or_default();
35    if state.system.cpu_model.trim().is_empty() {
36        if let Some(model) = read_cpu_model_name() {
37            state.system.cpu_model = model;
38        }
39    }
40    state.system.memory_gb = (sys.total_memory() as f32) / 1024.0 / 1024.0;
41    if state.system.memory_gb < 0.1 {
42        if let Some(mem) = fallback_memory_gb() {
43            state.system.memory_gb = mem;
44        }
45    }
46
47    let disks = Disks::new_with_refreshed_list();
48    let total_storage: u64 = disks.iter().map(|d| d.total_space()).sum();
49    state.system.storage_gb = total_storage as f32 / 1024.0 / 1024.0 / 1024.0;
50    let total_available: u64 = disks.iter().map(|d| d.available_space()).sum();
51    state.system.storage_available_gb = total_available as f32 / 1024.0 / 1024.0 / 1024.0;
52    if state.system.storage_gb < 0.1 || state.system.storage_available_gb < 0.1 {
53        if let Some((available, total)) = fallback_disk_space_gb() {
54            state.system.storage_available_gb = available;
55            state.system.storage_gb = total;
56        }
57    }
58
59    state.status = "Detecting GPU info".into();
60    state.progress = 0.6;
61    state.gpu = detect_gpu();
62
63    state.status = "Hardware detection complete".into();
64    state.progress = 1.0;
65
66    Ok(state)
67}
68
69pub fn run_preflight_checks(
70    system: &SystemInfo,
71    gpu: &GPUInfo,
72    sudo_password: Option<&str>,
73) -> PreflightResult {
74    let mut result = PreflightResult {
75        can_continue: true,
76        ..Default::default()
77    };
78
79    let checks = vec![
80        check_root_privileges(sudo_password),
81        check_disk_space(system),
82        check_network_connectivity(),
83        check_gpu_detection(gpu),
84        check_kernel_driver(gpu),
85        check_rocm_userspace(gpu),
86        check_cpu_compatibility(system),
87        check_memory_requirements(system),
88        check_package_manager(),
89        check_python_availability(),
90        check_system_dependencies(),
91        check_distribution_compatibility(system),
92    ];
93
94    for check in checks {
95        match check.status {
96            PreflightStatus::Passed => result.passed_count += 1,
97            PreflightStatus::Warning => result.warning_count += 1,
98            PreflightStatus::Failed => {
99                result.failed_count += 1;
100                if check.check_type == PreflightType::Critical {
101                    result.can_continue = false;
102                }
103            }
104        }
105        result.total_score += check.score;
106        result.checks.push(check);
107    }
108
109    result.passed = result.failed_count == 0;
110    result.summary = if result.passed {
111        format!("Preflight checks passed (score: {})", result.total_score)
112    } else {
113        format!(
114            "Preflight checks reported issues ({} failed)",
115            result.failed_count
116        )
117    };
118
119    result
120}
121
122fn check_root_privileges(sudo_password: Option<&str>) -> PreflightCheck {
123    #[cfg(unix)]
124    let is_root = unsafe { libc::geteuid() == 0 };
125    #[cfg(not(unix))]
126    let is_root = false;
127
128    if is_root {
129        return PreflightCheck {
130            name: "Root privileges".into(),
131            status: PreflightStatus::Passed,
132            check_type: PreflightType::Critical,
133            message: "Running as root".into(),
134            details: "Installer has direct root access".into(),
135            score: 10,
136        };
137    }
138
139    let (status, message, details, score) = if let Some(password) = sudo_password {
140        match validate_sudo_password(password) {
141            Ok(true) => (
142                PreflightStatus::Passed,
143                "Sudo privileges validated".into(),
144                "Sudo password validated".into(),
145                10,
146            ),
147            Ok(false) => (
148                PreflightStatus::Failed,
149                "Sudo validation failed".into(),
150                "Check the sudo password".into(),
151                0,
152            ),
153            Err(err) => (
154                PreflightStatus::Warning,
155                "Sudo validation unavailable".into(),
156                format!("Sudo validation skipped: {}", err),
157                5,
158            ),
159        }
160    } else if check_cached_sudo() {
161        (
162            PreflightStatus::Passed,
163            "Sudo privileges validated".into(),
164            "Cached sudo credentials available".into(),
165            10,
166        )
167    } else {
168        (
169            PreflightStatus::Failed,
170            "Sudo privileges unavailable".into(),
171            "Enter sudo password on the welcome screen".into(),
172            0,
173        )
174    };
175
176    PreflightCheck {
177        name: "Root privileges".into(),
178        status,
179        check_type: PreflightType::Critical,
180        message,
181        details,
182        score,
183    }
184}
185
186fn validate_sudo_password(password: &str) -> Result<bool> {
187    let mut command = Command::new("sudo");
188    command
189        .arg("-S")
190        .arg("-p")
191        .arg("")
192        .arg("-k")
193        .arg("-v")
194        .stdin(Stdio::piped())
195        .stdout(Stdio::piped())
196        .stderr(Stdio::piped());
197
198    let mut child = command.spawn()?;
199    if let Some(mut stdin) = child.stdin.take() {
200        stdin.write_all(password.as_bytes())?;
201        stdin.write_all(b"\n")?;
202    }
203
204    let output = child.wait_with_output()?;
205    Ok(output.status.success())
206}
207
208fn check_cached_sudo() -> bool {
209    Command::new("sudo")
210        .arg("-n")
211        .arg("-v")
212        .stdout(Stdio::null())
213        .stderr(Stdio::null())
214        .status()
215        .map(|status| status.success())
216        .unwrap_or(false)
217}
218
219fn check_disk_space(system: &SystemInfo) -> PreflightCheck {
220    let free_gb = system.storage_available_gb;
221    let total_gb = system.storage_gb;
222
223    if total_gb < 0.1 {
224        return PreflightCheck {
225            name: "Disk space".into(),
226            status: PreflightStatus::Warning,
227            check_type: PreflightType::Critical,
228            message: "Disk space unavailable".into(),
229            details: "Unable to detect disk capacity".into(),
230            score: 2,
231        };
232    }
233
234    let (status, score, details) = if free_gb >= 100.0 {
235        (
236            PreflightStatus::Passed,
237            10,
238            "Sufficient disk space for full ML stack".into(),
239        )
240    } else if free_gb >= 50.0 {
241        (
242            PreflightStatus::Warning,
243            5,
244            "Limited disk space (100GB recommended)".into(),
245        )
246    } else {
247        (
248            PreflightStatus::Failed,
249            0,
250            "Insufficient disk space (minimum 50GB required)".into(),
251        )
252    };
253
254    PreflightCheck {
255        name: "Disk space".into(),
256        status,
257        check_type: PreflightType::Critical,
258        message: format!("{:.1} GB free of {:.1} GB", free_gb, total_gb),
259        details,
260        score,
261    }
262}
263
264fn check_network_connectivity() -> PreflightCheck {
265    let network_ok = Command::new("bash")
266        .arg("-c")
267        .arg("ping -c 1 -W 2 8.8.8.8 >/dev/null 2>&1")
268        .status()
269        .map(|s| s.success())
270        .unwrap_or(false);
271
272    PreflightCheck {
273        name: "Network connectivity".into(),
274        status: if network_ok {
275            PreflightStatus::Passed
276        } else {
277            PreflightStatus::Failed
278        },
279        check_type: PreflightType::Critical,
280        message: if network_ok {
281            "Network reachable".into()
282        } else {
283            "Network connectivity failed".into()
284        },
285        details: "Connectivity required for package downloads".into(),
286        score: if network_ok { 10 } else { 0 },
287    }
288}
289
290fn check_gpu_detection(gpu: &GPUInfo) -> PreflightCheck {
291    let ok = gpu.gpu_count > 0;
292    let score = if ok { 15 } else { 0 };
293    PreflightCheck {
294        name: "GPU detection".into(),
295        status: if ok {
296            PreflightStatus::Passed
297        } else {
298            PreflightStatus::Failed
299        },
300        check_type: PreflightType::Critical,
301        message: if ok {
302            format!("Detected {} AMD GPU(s)", gpu.gpu_count)
303        } else {
304            "No AMD GPU detected".into()
305        },
306        details: format!("Primary GPU: {}", gpu.model),
307        score,
308    }
309}
310
311fn check_kernel_driver(gpu: &GPUInfo) -> PreflightCheck {
312    // The amdgpu KERNEL driver — the thing that makes the hardware usable at all.
313    // /sys/module/amdgpu exists iff the module is loaded; sysfs GPU detection
314    // (gpu_count > 0) also implies it, since the drm device only appears once the
315    // driver binds. This is DISTINCT from the ROCm userspace (see check_rocm_userspace).
316    let loaded = std::path::Path::new("/sys/module/amdgpu").exists() || gpu.gpu_count > 0;
317    PreflightCheck {
318        name: "Kernel driver".into(),
319        status: if loaded {
320            PreflightStatus::Passed
321        } else {
322            PreflightStatus::Warning
323        },
324        check_type: PreflightType::Warning,
325        message: if loaded {
326            "AMDGPU kernel driver loaded".into()
327        } else {
328            "AMDGPU kernel driver not loaded".into()
329        },
330        details: if loaded {
331            format!("{} AMD GPU(s) visible via sysfs", gpu.gpu_count)
332        } else {
333            "Load amdgpu (`modprobe amdgpu`) or install the amdgpu kernel drivers".into()
334        },
335        score: if loaded { 10 } else { 2 },
336    }
337}
338
339fn check_rocm_userspace(gpu: &GPUInfo) -> PreflightCheck {
340    // Require a REAL ROCm binary, not just the version file that `rocm-core` drops
341    // at /opt/rocm/.info/version. rocm-core can be present (pulled in as a dep by
342    // rocm-smi-lib etc.) while the actual compute stack (rocminfo/hipcc) is absent —
343    // keying "ROCm detected" off the version file alone reports a pass for a box that
344    // can't run ROCm. This is exactly the partial-install trap the old check hid.
345    const ROCM_BINARIES: &[&str] = &[
346        "/opt/rocm/bin/rocminfo",
347        "/opt/rocm/bin/hipcc",
348        "/usr/bin/rocminfo",
349        "/usr/bin/hipcc",
350    ];
351    let has_real_binary = ROCM_BINARIES
352        .iter()
353        .any(|p| std::path::Path::new(p).exists());
354    let has_version = !gpu.rocm_version.is_empty();
355
356    let (status, message, details, score) = if has_real_binary {
357        (
358            PreflightStatus::Passed,
359            "ROCm userspace detected".into(),
360            format!("ROCm {} (rocminfo/hipcc present)", gpu.rocm_version),
361            10,
362        )
363    } else if has_version {
364        (
365            PreflightStatus::Warning,
366            "Partial ROCm install".into(),
367            format!(
368                "rocm-core {} version file present but rocminfo/hipcc missing — complete \
369                 the ROCm install (rusty-stack: install the `rocm` component)",
370                gpu.rocm_version
371            ),
372            5,
373        )
374    } else {
375        (
376            PreflightStatus::Warning,
377            "ROCm userspace not installed".into(),
378            "Install ROCm for GPU compute (rusty-stack: install the `rocm` component)".into(),
379            2,
380        )
381    };
382
383    PreflightCheck {
384        name: "ROCm userspace".into(),
385        status,
386        check_type: PreflightType::Warning,
387        message,
388        details,
389        score,
390    }
391}
392
393fn check_cpu_compatibility(system: &SystemInfo) -> PreflightCheck {
394    let is_amd = is_amd_cpu(system);
395    PreflightCheck {
396        name: "CPU compatibility".into(),
397        status: if is_amd {
398            PreflightStatus::Passed
399        } else {
400            PreflightStatus::Warning
401        },
402        check_type: PreflightType::Warning,
403        message: if is_amd {
404            "AMD CPU detected".into()
405        } else {
406            "Non-AMD CPU detected".into()
407        },
408        details: if system.cpu_model.trim().is_empty() {
409            "CPU model unavailable".into()
410        } else {
411            system.cpu_model.clone()
412        },
413        score: if is_amd { 10 } else { 5 },
414    }
415}
416
417fn is_amd_cpu(system: &SystemInfo) -> bool {
418    let model = system.cpu_model.to_lowercase();
419    if model.contains("amd") || model.contains("ryzen") || model.contains("epyc") {
420        return true;
421    }
422
423    if let Ok(cpuinfo) = fs::read_to_string("/proc/cpuinfo") {
424        for line in cpuinfo.lines() {
425            let lower = line.to_lowercase();
426            if lower.starts_with("vendor_id") && lower.contains("authenticamd") {
427                return true;
428            }
429            if lower.starts_with("model name") && lower.contains("amd") {
430                return true;
431            }
432        }
433    }
434
435    false
436}
437
438fn read_cpu_model_name() -> Option<String> {
439    let cpuinfo = fs::read_to_string("/proc/cpuinfo").ok()?;
440    for line in cpuinfo.lines() {
441        let lower = line.to_lowercase();
442        if lower.starts_with("model name") {
443            if let Some(model) = line.split(':').nth(1) {
444                let trimmed = model.trim();
445                if !trimmed.is_empty() {
446                    return Some(trimmed.to_string());
447                }
448            }
449        }
450    }
451    None
452}
453
454fn fallback_memory_gb() -> Option<f32> {
455    let meminfo = fs::read_to_string("/proc/meminfo").ok()?;
456    for line in meminfo.lines() {
457        if line.starts_with("MemTotal") {
458            let parts: Vec<&str> = line.split_whitespace().collect();
459            if parts.len() >= 2 {
460                if let Ok(kb) = parts[1].parse::<f32>() {
461                    return Some(kb / 1024.0 / 1024.0);
462                }
463            }
464        }
465    }
466    None
467}
468
469fn fallback_disk_space_gb() -> Option<(f32, f32)> {
470    let output = Command::new("df")
471        .args(["-k", "--output=avail,size", "/"])
472        .output()
473        .ok()?;
474    if !output.status.success() {
475        return None;
476    }
477    let stdout = String::from_utf8_lossy(&output.stdout);
478    let last_line = stdout.lines().last()?;
479    let parts: Vec<&str> = last_line.split_whitespace().collect();
480    if parts.len() < 2 {
481        return None;
482    }
483    let avail_kb = parts[0].parse::<f32>().ok()?;
484    let total_kb = parts[1].parse::<f32>().ok()?;
485    Some((avail_kb / 1024.0 / 1024.0, total_kb / 1024.0 / 1024.0))
486}
487
488fn fallback_distribution() -> Option<String> {
489    let contents = fs::read_to_string("/etc/os-release").ok()?;
490    for line in contents.lines() {
491        if let Some(value) = line.strip_prefix("PRETTY_NAME=") {
492            let cleaned = value.trim().trim_matches('"');
493            if !cleaned.is_empty() {
494                return Some(cleaned.to_string());
495            }
496        }
497    }
498    None
499}
500
501fn detect_gpu_from_sysfs() -> Option<(usize, String)> {
502    let mut count = 0usize;
503    let mut model = String::new();
504    let entries = fs::read_dir("/sys/class/drm").ok()?;
505    for entry in entries.flatten() {
506        let name = entry.file_name();
507        let name = name.to_string_lossy();
508        if !name.starts_with("card") || name.contains("render") {
509            continue;
510        }
511        let vendor_path = entry.path().join("device/vendor");
512        let vendor = match fs::read_to_string(&vendor_path) {
513            Ok(value) => value,
514            Err(_) => continue,
515        };
516        if vendor.trim().eq_ignore_ascii_case("0x1002") {
517            count += 1;
518            if model.is_empty() {
519                let device_path = entry.path().join("device");
520                let uevent_path = device_path.join("uevent");
521                if let Ok(uevent) = fs::read_to_string(&uevent_path) {
522                    for line in uevent.lines() {
523                        if let Some(driver) = line.strip_prefix("DRIVER=") {
524                            model = format!("AMD GPU ({})", driver);
525                            break;
526                        }
527                    }
528                }
529                if model.is_empty() {
530                    model = "AMD GPU".to_string();
531                }
532            }
533        }
534    }
535    if count > 0 {
536        Some((count, model))
537    } else {
538        None
539    }
540}
541
542fn check_memory_requirements(system: &SystemInfo) -> PreflightCheck {
543    let mem = system.memory_gb;
544
545    if mem < 0.1 {
546        return PreflightCheck {
547            name: "Memory requirements".into(),
548            status: PreflightStatus::Warning,
549            check_type: PreflightType::Warning,
550            message: "Memory unavailable".into(),
551            details: "Unable to detect system memory".into(),
552            score: 2,
553        };
554    }
555
556    let (status, score, details) = if mem >= 32.0 {
557        (
558            PreflightStatus::Passed,
559            10,
560            "Sufficient memory for large models".into(),
561        )
562    } else if mem >= 16.0 {
563        (
564            PreflightStatus::Warning,
565            7,
566            "Moderate memory (consider 32GB+)".into(),
567        )
568    } else {
569        (
570            PreflightStatus::Failed,
571            0,
572            "Insufficient memory (16GB minimum)".into(),
573        )
574    };
575
576    PreflightCheck {
577        name: "Memory requirements".into(),
578        status,
579        check_type: PreflightType::Warning,
580        message: format!("{:.1} GB RAM", mem),
581        details,
582        score,
583    }
584}
585
586fn check_package_manager() -> PreflightCheck {
587    let managers = ["apt", "dnf", "yum", "zypper", "pacman"];
588    let found = managers
589        .iter()
590        .find(|m| {
591            Command::new("bash")
592                .arg("-c")
593                .arg(format!("command -v {}", m))
594                .stdout(Stdio::null())
595                .stderr(Stdio::null())
596                .status()
597                .map(|s| s.success())
598                .unwrap_or(false)
599        })
600        .cloned();
601
602    PreflightCheck {
603        name: "Package manager".into(),
604        status: if found.is_some() {
605            PreflightStatus::Passed
606        } else {
607            PreflightStatus::Failed
608        },
609        check_type: PreflightType::Critical,
610        message: match found {
611            Some(manager) => format!("{} detected", manager),
612            None => "No supported package manager detected".into(),
613        },
614        details: "Required for dependency installation".into(),
615        score: if found.is_some() { 10 } else { 0 },
616    }
617}
618
619fn is_version_at_least(version: &str, min_version: &str) -> bool {
620    let v_parts: Vec<i32> = version
621        .split('.')
622        .filter_map(|s| s.parse::<i32>().ok())
623        .collect();
624    let m_parts: Vec<i32> = min_version
625        .split('.')
626        .filter_map(|s| s.parse::<i32>().ok())
627        .collect();
628
629    for i in 0..std::cmp::max(v_parts.len(), m_parts.len()) {
630        let v = *v_parts.get(i).unwrap_or(&0);
631        let m = *m_parts.get(i).unwrap_or(&0);
632        if v > m {
633            return true;
634        }
635        if v < m {
636            return false;
637        }
638    }
639    true
640}
641
642fn check_python_availability() -> PreflightCheck {
643    let output = Command::new("python3").arg("--version").output();
644    let (status, message, details, score) = if let Ok(output) = output {
645        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
646        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
647        let version_str = if stdout.is_empty() { stderr } else { stdout };
648        let version = version_str
649            .split_whitespace()
650            .nth(1)
651            .unwrap_or("")
652            .to_string();
653        if is_version_at_least(&version, "3.9") {
654            (
655                PreflightStatus::Passed,
656                "Python 3.9+ available".into(),
657                version,
658                10,
659            )
660        } else {
661            (
662                PreflightStatus::Warning,
663                "Python version too old".into(),
664                version,
665                5,
666            )
667        }
668    } else {
669        (
670            PreflightStatus::Failed,
671            "Python not available".into(),
672            "python3 missing".into(),
673            0,
674        )
675    };
676
677    PreflightCheck {
678        name: "Python availability".into(),
679        status,
680        check_type: PreflightType::Critical,
681        message,
682        details,
683        score,
684    }
685}
686
687fn check_system_dependencies() -> PreflightCheck {
688    let deps = [
689        "curl", "wget", "git", "cmake", "unzip", "gcc", "g++", "make",
690    ];
691    let mut missing = Vec::new();
692    for dep in deps {
693        let ok = Command::new("bash")
694            .arg("-c")
695            .arg(format!("command -v {}", dep))
696            .stdout(Stdio::null())
697            .stderr(Stdio::null())
698            .status()
699            .map(|s| s.success())
700            .unwrap_or(false);
701        if !ok {
702            missing.push(dep);
703        }
704    }
705
706    PreflightCheck {
707        name: "System dependencies".into(),
708        status: if missing.is_empty() {
709            PreflightStatus::Passed
710        } else {
711            PreflightStatus::Failed
712        },
713        check_type: PreflightType::Critical,
714        message: if missing.is_empty() {
715            "All dependencies available".into()
716        } else {
717            "Missing dependencies".into()
718        },
719        details: if missing.is_empty() {
720            "Required tools are installed".into()
721        } else {
722            format!("Missing: {}", missing.join(", "))
723        },
724        score: if missing.is_empty() { 10 } else { 0 },
725    }
726}
727
728fn check_distribution_compatibility(system: &SystemInfo) -> PreflightCheck {
729    // Supported distributions include major distros and Arch-based derivatives
730    let supported = [
731        "ubuntu",
732        "debian",
733        "linux mint",
734        "pop!_os",
735        "fedora",
736        "arch",
737        "cachyos",
738        "manjaro",
739        "endeavouros",
740        "garuda",
741    ];
742    let dist = system.distribution.to_lowercase();
743    let compatible = supported.iter().any(|name| dist.contains(name));
744
745    PreflightCheck {
746        name: "Distribution compatibility".into(),
747        status: if compatible {
748            PreflightStatus::Passed
749        } else {
750            PreflightStatus::Warning
751        },
752        check_type: PreflightType::Info,
753        message: if compatible {
754            format!("Compatible distribution: {}", system.distribution)
755        } else {
756            format!("Unofficial distribution: {}", system.distribution)
757        },
758        details: "Supported distributions offer best compatibility".into(),
759        score: if compatible { 5 } else { 2 },
760    }
761}
762
763/// Detect ROCm installation path by searching common locations
764/// Returns the first valid ROCm path found, or None if not found
765fn detect_rocm_path() -> Option<PathBuf> {
766    // Build search paths in priority order
767    let mut search_paths: Vec<PathBuf> = Vec::new();
768
769    // 1. Environment variable override (highest priority)
770    if let Ok(env_path) = std::env::var("ROCm_PATH") {
771        search_paths.push(PathBuf::from(env_path));
772    }
773
774    // 2. Standard location
775    search_paths.push(PathBuf::from("/opt/rocm"));
776
777    // First check explicit paths
778    for path in &search_paths {
779        if validate_rocm_path(path) {
780            return Some(path.clone());
781        }
782    }
783
784    // 3. Check for versioned ROCm directories in /opt (sorted by version, newest first)
785    if let Ok(entries) = fs::read_dir("/opt") {
786        let mut versioned_paths: Vec<(String, PathBuf)> = entries
787            .filter_map(|e| e.ok())
788            .filter_map(|e| {
789                let name = e.file_name().to_string_lossy().to_string();
790                if name.starts_with("rocm-") {
791                    let version = name.trim_start_matches("rocm-").to_string();
792                    Some((version, e.path()))
793                } else {
794                    None
795                }
796            })
797            .collect();
798
799        // Sort by version (descending)
800        versioned_paths.sort_by(|a, b| {
801            let a_parts: Vec<i32> =
802                a.0.split('.')
803                    .filter_map(|s: &str| s.parse().ok())
804                    .collect();
805            let b_parts: Vec<i32> =
806                b.0.split('.')
807                    .filter_map(|s: &str| s.parse().ok())
808                    .collect();
809            for i in 0..std::cmp::max(a_parts.len(), b_parts.len()) {
810                let a_val = *a_parts.get(i).unwrap_or(&0);
811                let b_val = *b_parts.get(i).unwrap_or(&0);
812                if a_val != b_val {
813                    return b_val.cmp(&a_val); // Descending order
814                }
815            }
816            std::cmp::Ordering::Equal
817        });
818
819        for (_version, path) in versioned_paths {
820            if validate_rocm_path(&path) {
821                return Some(path);
822            }
823        }
824    }
825
826    // 4. Check for Arch AUR package locations
827    let arch_paths = ["/usr/lib/rocm", "/usr/local/rocm"];
828    for path_str in &arch_paths {
829        let path = PathBuf::from(path_str);
830        if validate_rocm_path(&path) {
831            return Some(path);
832        }
833    }
834
835    // 5. Check for versioned paths in /usr/lib (Arch AUR)
836    if let Ok(entries) = fs::read_dir("/usr/lib") {
837        for entry in entries.filter_map(|e| e.ok()) {
838            let name = entry.file_name().to_string_lossy().to_string();
839            if name.starts_with("rocm-") && validate_rocm_path(&entry.path()) {
840                return Some(entry.path());
841            }
842        }
843    }
844
845    // 6. Try to derive from rocminfo in PATH
846    if let Ok(output) = Command::new("which").arg("rocminfo").output() {
847        if output.status.success() {
848            let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
849            if !path.is_empty() {
850                if let Some(parent) = Path::new(&path).parent() {
851                    if let Some(rocm_root) = parent.parent() {
852                        if validate_rocm_path(rocm_root) {
853                            return Some(rocm_root.to_path_buf());
854                        }
855                    }
856                }
857            }
858        }
859    }
860
861    None
862}
863
864/// Validate that a path is a valid ROCm installation
865fn validate_rocm_path(path: &Path) -> bool {
866    if !path.exists() || !path.is_dir() {
867        return false;
868    }
869
870    // Should have at least bin or lib directory
871    path.join("bin").exists() || path.join("lib").exists()
872        // Or contain ROCm tools directly
873        || path.join("rocminfo").exists() || path.join("rocm-smi").exists()
874}
875
876/// Map GPU marketing name to correct gfx architecture.
877/// This corrects rocminfo bugs where RDNA3 cards report gfx1030 instead of gfx1100/gfx1101.
878fn get_correct_gfx_from_marketing_name(marketing_name: &str, rocminfo_gfx: &str) -> String {
879    crate::gpu::gfx_from_marketing_name(marketing_name)
880        .map(|s| s.to_string())
881        .unwrap_or_else(|| format!("gfx{}", rocminfo_gfx))
882}
883
884fn detect_gpu() -> GPUInfo {
885    let mut info = GPUInfo::default();
886
887    // Detect ROCm path first
888    let rocm_path = detect_rocm_path();
889
890    // Determine rocminfo path
891    let rocminfo_cmd = if let Some(ref path) = rocm_path {
892        let rocminfo_path = path.join("bin/rocminfo");
893        if rocminfo_path.exists() {
894            rocminfo_path.to_string_lossy().to_string()
895        } else {
896            "rocminfo".to_string()
897        }
898    } else if Path::new("/opt/rocm/bin/rocminfo").exists() {
899        "/opt/rocm/bin/rocminfo".to_string()
900    } else {
901        "rocminfo".to_string()
902    };
903
904    if let Ok(output) = Command::new(rocminfo_cmd).output() {
905        if output.status.success() {
906            let stdout = String::from_utf8_lossy(&output.stdout);
907            let mut gpu_count = 0usize;
908            // Track marketing name and device type to detect iGPUs
909            let mut current_marketing_name = String::new();
910            let mut current_device_type = String::new();
911            for line in stdout.lines() {
912                let trimmed = line.trim();
913                // Track marketing name to detect iGPUs (Agent section)
914                if trimmed.starts_with("Marketing Name:") {
915                    current_marketing_name = trimmed
916                        .strip_prefix("Marketing Name:")
917                        .unwrap_or("")
918                        .trim()
919                        .to_string();
920                }
921                // Track device type to distinguish GPU from CPU
922                if trimmed.starts_with("Device Type:") {
923                    current_device_type = trimmed
924                        .strip_prefix("Device Type:")
925                        .unwrap_or("")
926                        .trim()
927                        .to_string();
928                }
929                if line.contains("Name:") {
930                    let name = line.replace("Name:", "").trim().to_string();
931                    if !name.is_empty() && !name.to_lowercase().contains("cpu") {
932                        gpu_count += 1;
933                        if info.model.is_empty() {
934                            info.model = name;
935                        }
936                    }
937                }
938                // Detect GPU architecture from Name field
939                if trimmed.starts_with("Name:") && trimmed.contains("gfx") {
940                    if let Some(name_value) = trimmed.strip_prefix("Name:") {
941                        let name_value = name_value.trim();
942                        // Only accept pure gfx architectures (e.g., "gfx1100", "gfx1030")
943                        if let Some(after_gfx) = name_value.strip_prefix("gfx") {
944                            let gfx_num: String = after_gfx
945                                .chars()
946                                .take_while(|c| c.is_ascii_digit())
947                                .collect();
948                            if !gfx_num.is_empty() && gfx_num.len() >= 3 {
949                                // Detect iGPU via the canonical classifier (Stage 1).
950                                // Use the combined classifier so a nameless agent
951                                // (empty marketing name) with a gfx1036/gfx1103 arch
952                                // is still caught via the gfx-arch signal.
953                                let gfx_arch = format!("gfx{gfx_num}");
954                                let is_igpu = crate::gpu::device_is_integrated(
955                                    Some(&current_marketing_name),
956                                    None,
957                                    Some(&gfx_arch),
958                                    None,
959                                );
960                                // Only set architecture for dGPUs (not iGPUs, not CPUs)
961                                if !is_igpu
962                                    && current_device_type == "GPU"
963                                    && info.architecture.is_empty()
964                                {
965                                    // Use marketing name to get correct architecture
966                                    // rocminfo may report wrong gfx on some ROCm versions
967                                    let corrected_arch = get_correct_gfx_from_marketing_name(
968                                        &current_marketing_name,
969                                        &gfx_num,
970                                    );
971                                    info.architecture = corrected_arch;
972                                }
973                            }
974                        }
975                    }
976                }
977            }
978            // Stage 1: gpu_count must EXCLUDE the iGPU. Reuse the canonical
979            // discrete detector (no "0" fallback) so an iGPU-only / no-dGPU host
980            // reports 0 rather than falsely counting the fallback. Fall back to
981            // the raw rocminfo agent count only when the detector returns nothing.
982            let discrete_count = crate::installer::detect_discrete_gpus().len();
983            if discrete_count > 0 {
984                info.gpu_count = discrete_count;
985            } else if gpu_count > 0 {
986                info.gpu_count = gpu_count;
987            }
988        }
989    }
990
991    if info.rocm_version.is_empty() {
992        // Try detected ROCm path first
993        let version_path = if let Some(ref path) = rocm_path {
994            path.join(".info/version")
995        } else {
996            PathBuf::from("/opt/rocm/.info/version")
997        };
998
999        if let Ok(output) = Command::new("bash")
1000            .arg("-c")
1001            .arg(format!("cat {} 2>/dev/null", version_path.display()))
1002            .output()
1003        {
1004            if output.status.success() {
1005                info.rocm_version = String::from_utf8_lossy(&output.stdout).trim().to_string();
1006            }
1007        }
1008    }
1009
1010    if info.model.is_empty() {
1011        let lspci_cmd = if Path::new("/usr/bin/lspci").exists() {
1012            "/usr/bin/lspci"
1013        } else {
1014            "lspci"
1015        };
1016        if let Ok(output) = Command::new(lspci_cmd).output() {
1017            let stdout = String::from_utf8_lossy(&output.stdout);
1018            for line in stdout.lines() {
1019                if line.to_lowercase().contains("amd") && line.to_lowercase().contains("vga") {
1020                    if info.model.is_empty() {
1021                        info.model = line.trim().to_string();
1022                    }
1023                    info.gpu_count += 1;
1024                }
1025            }
1026        }
1027    }
1028
1029    if info.gpu_count == 0 {
1030        if let Some((count, model)) = detect_gpu_from_sysfs() {
1031            info.gpu_count = count;
1032            if info.model.is_empty() {
1033                info.model = model;
1034            }
1035        }
1036    }
1037
1038    if info.gpu_count == 0 && !info.model.is_empty() {
1039        info.gpu_count = 1;
1040    }
1041
1042    // Determine rocm-smi path using detected ROCm path
1043    let rocm_smi_cmd = if let Some(ref path) = rocm_path {
1044        let rocm_smi_path = path.join("bin/rocm-smi");
1045        if rocm_smi_path.exists() {
1046            rocm_smi_path.to_string_lossy().to_string()
1047        } else {
1048            "rocm-smi".to_string()
1049        }
1050    } else if Path::new("/opt/rocm/bin/rocm-smi").exists() {
1051        "/opt/rocm/bin/rocm-smi".to_string()
1052    } else {
1053        "rocm-smi".to_string()
1054    };
1055
1056    if let Ok(output) = Command::new(&rocm_smi_cmd)
1057        .arg("--showmeminfo")
1058        .arg("vram")
1059        .arg("--csv")
1060        .output()
1061    {
1062        if output.status.success() {
1063            let stdout = String::from_utf8_lossy(&output.stdout);
1064            for line in stdout.lines().skip(1) {
1065                let parts: Vec<&str> = line.split(',').collect();
1066                if parts.len() >= 2 {
1067                    if let Ok(value) = parts[1].trim().parse::<f32>() {
1068                        info.memory_gb = value / 1024.0;
1069                        break;
1070                    }
1071                }
1072            }
1073        }
1074    }
1075
1076    if let Ok(output) = Command::new(&rocm_smi_cmd)
1077        .arg("--showtemp")
1078        .arg("--csv")
1079        .output()
1080    {
1081        if output.status.success() {
1082            let stdout = String::from_utf8_lossy(&output.stdout);
1083            for line in stdout.lines().skip(1) {
1084                let parts: Vec<&str> = line.split(',').collect();
1085                if parts.len() >= 2 {
1086                    if let Ok(value) = parts[1].trim().parse::<f32>() {
1087                        info.temperature_c = Some(value);
1088                        break;
1089                    }
1090                }
1091            }
1092        }
1093    }
1094
1095    if let Ok(output) = Command::new(&rocm_smi_cmd)
1096        .arg("--showpower")
1097        .arg("--csv")
1098        .output()
1099    {
1100        if output.status.success() {
1101            let stdout = String::from_utf8_lossy(&output.stdout);
1102            for line in stdout.lines().skip(1) {
1103                let parts: Vec<&str> = line.split(',').collect();
1104                if parts.len() >= 2 {
1105                    if let Ok(value) = parts[1].trim().parse::<f32>() {
1106                        info.power_watts = Some(value);
1107                        break;
1108                    }
1109                }
1110            }
1111        }
1112    }
1113
1114    info
1115}