Skip to main content

rusty_stack/platform/
linux.rs

1//! Linux-specific platform detection: GPU, ROCm, and hardware introspection.
2//!
3//! Provides GPU detection with a three-tier fallback chain (rocminfo → lspci → sysfs),
4//! GPU architecture correction from marketing names, ROCm version detection from
5//! `/opt/rocm/.info/version`, and ROCm path search with priority ordering.
6//!
7//! # Fallback Chain
8//!
9//! 1. **rocminfo** — parses agent entries for AMD GPU devices
10//! 2. **lspci** — scans PCI bus for AMD VGA devices
11//! 3. **sysfs** (`/sys/class/drm`) — reads vendor IDs from DRM card entries
12//!
13//! # ROCm Path Priority
14//!
15//! 1. `ROCm_PATH` environment variable
16//! 2. `/opt/rocm` (standard location)
17//! 3. `/opt/rocm-*` versioned directories (sorted newest-first)
18//! 4. `/usr/lib/rocm`, `/usr/local/rocm` (Arch AUR paths)
19//! 5. `/usr/lib/rocm-*` versioned paths (Arch AUR)
20//! 6. Derived from `which rocminfo`
21
22use crate::core::types::GPUInfo;
23use std::fs;
24use std::path::{Path, PathBuf};
25use std::process::Command;
26
27// ===========================================================================
28// Public API
29// ===========================================================================
30
31/// Detect AMD GPUs using the three-tier fallback chain (rocminfo → lspci → sysfs).
32///
33/// Returns a [`GPUInfo`] with detected GPU details. If no AMD GPU is found,
34/// returns a default `GPUInfo` with `gpu_count = 0`.
35///
36/// # Hardware Test
37///
38/// On systems with real AMD GPUs, run with `cargo test -- --ignored` to execute
39/// hardware-dependent tests.
40pub fn detect_gpu() -> GPUInfo {
41    let mut info = GPUInfo::default();
42
43    // Detect ROCm path first
44    let rocm_path = detect_rocm_path();
45
46    // Determine rocminfo path
47    let rocminfo_cmd = resolve_rocminfo_path(&rocm_path);
48
49    // Tier 1: rocminfo
50    let rocminfo_ok = detect_gpu_from_rocminfo(&rocminfo_cmd, &mut info);
51
52    // Detect ROCm version
53    if info.rocm_version.is_empty() {
54        info.rocm_version = detect_rocm_version_from_file(&rocm_path);
55    }
56
57    // Tier 2: lspci (only if rocminfo didn't find a model)
58    if !rocminfo_ok || info.model.is_empty() {
59        detect_gpu_from_lspci(&mut info);
60    }
61
62    // Tier 3: sysfs (only if still no GPU found)
63    if info.gpu_count == 0 {
64        detect_gpu_from_sysfs(&mut info);
65    }
66
67    // Ensure at least 1 GPU if a model was found but count is 0
68    if info.gpu_count == 0 && !info.model.is_empty() {
69        info.gpu_count = 1;
70    }
71
72    // Enrich with runtime metrics from rocm-smi
73    if info.gpu_count > 0 {
74        let rocm_smi_cmd = resolve_rocm_smi_path(&rocm_path);
75        enrich_gpu_metrics(&rocm_smi_cmd, &mut info);
76    }
77
78    info
79}
80
81/// Map GPU marketing name to correct gfx architecture.
82///
83/// This corrects rocminfo bugs where RDNA3 cards report `gfx1030` instead of
84/// `gfx1100`/`gfx1101`. When the marketing name matches a known GPU, the correct
85/// architecture is returned. For unknown names, the `fallback_gfx` value is used.
86///
87/// # Examples
88///
89/// ```
90/// use rusty_stack::platform::get_correct_gfx_from_marketing_name;
91///
92/// assert_eq!(get_correct_gfx_from_marketing_name("AMD Radeon RX 7900 XTX", "1030"), "gfx1100");
93/// assert_eq!(get_correct_gfx_from_marketing_name("AMD Radeon RX 7800 XT", "1030"), "gfx1101");
94/// assert_eq!(get_correct_gfx_from_marketing_name("Unknown GPU", "999"), "gfx999");
95/// ```
96pub fn get_correct_gfx_from_marketing_name(marketing_name: &str, fallback_gfx: &str) -> String {
97    crate::gpu::gfx_from_marketing_name(marketing_name)
98        .map(|s| s.to_string())
99        .unwrap_or_else(|| format!("gfx{}", fallback_gfx))
100}
101
102/// Detect ROCm installation path by searching common locations in priority order.
103///
104/// # Priority Order
105///
106/// 1. `ROCm_PATH` environment variable
107/// 2. `/opt/rocm` (standard location)
108/// 3. `/opt/rocm-*` versioned directories (sorted newest-first)
109/// 4. `/usr/lib/rocm`, `/usr/local/rocm` (Arch AUR paths)
110/// 5. `/usr/lib/rocm-*` versioned paths (Arch AUR)
111/// 6. Derived from `which rocminfo`
112///
113/// Returns `None` if no valid ROCm installation is found.
114pub fn detect_rocm_path() -> Option<PathBuf> {
115    let mut search_paths: Vec<PathBuf> = Vec::new();
116
117    // Priority 1: Environment variable override
118    if let Ok(env_path) = std::env::var("ROCm_PATH") {
119        search_paths.push(PathBuf::from(env_path));
120    }
121
122    // Priority 2: Standard location
123    search_paths.push(PathBuf::from("/opt/rocm"));
124
125    // Check explicit paths first
126    for path in &search_paths {
127        if validate_rocm_path(path) {
128            return Some(path.clone());
129        }
130    }
131
132    // Priority 3: Versioned ROCm directories in /opt (sorted newest-first)
133    if let Some(path) = find_versioned_rocm_in_dir("/opt") {
134        return Some(path);
135    }
136
137    // Priority 4: Arch AUR package locations
138    let arch_paths = ["/usr/lib/rocm", "/usr/local/rocm"];
139    for path_str in &arch_paths {
140        let path = PathBuf::from(path_str);
141        if validate_rocm_path(&path) {
142            return Some(path);
143        }
144    }
145
146    // Priority 5: Versioned paths in /usr/lib (Arch AUR)
147    if let Some(path) = find_versioned_rocm_in_dir("/usr/lib") {
148        return Some(path);
149    }
150
151    // Priority 6: Try to derive from rocminfo in PATH
152    if let Ok(output) = Command::new("which").arg("rocminfo").output() {
153        if output.status.success() {
154            let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
155            if !path.is_empty() {
156                if let Some(parent) = Path::new(&path).parent() {
157                    if let Some(rocm_root) = parent.parent() {
158                        if validate_rocm_path(rocm_root) {
159                            return Some(rocm_root.to_path_buf());
160                        }
161                    }
162                }
163            }
164        }
165    }
166
167    None
168}
169
170/// Detect ROCm version using multiple fallback methods.
171///
172/// Tries in order:
173/// 1. `$ROCm_PATH/.info/version`
174/// 2. `$ROCm_PATH/version`
175/// 3. `rocminfo --version`
176/// 4. `rocm-smi --version`
177/// 5. Version extracted from ROCm path
178///
179/// Returns an empty string if no method succeeds.
180pub fn get_rocm_version() -> String {
181    let rocm_path = detect_rocm_path();
182
183    // Method 1: $ROCm_PATH/.info/version
184    if let Some(ref path) = rocm_path {
185        let version_file = path.join(".info/version");
186        if let Ok(version) = read_version_file(&version_file) {
187            return version;
188        }
189    }
190
191    // Method 2: $ROCm_PATH/version
192    if let Some(ref path) = rocm_path {
193        let version_file = path.join("version");
194        if let Ok(version) = read_version_file(&version_file) {
195            return version;
196        }
197    }
198
199    // Method 3: rocminfo --version
200    if let Ok(output) = Command::new("rocminfo").arg("--version").output() {
201        if output.status.success() {
202            let stdout = String::from_utf8_lossy(&output.stdout);
203            if let Some(version) = parse_version_from_output(&stdout) {
204                return version;
205            }
206        }
207    }
208
209    // Method 4: rocm-smi --version
210    if let Ok(output) = Command::new("rocm-smi").arg("--version").output() {
211        if output.status.success() {
212            let stdout = String::from_utf8_lossy(&output.stdout);
213            if let Some(version) = parse_version_from_output(&stdout) {
214                return version;
215            }
216        }
217    }
218
219    // Method 5: Version from path
220    if let Some(ref path) = rocm_path {
221        if let Some(version) = extract_version_from_path(path) {
222            return version;
223        }
224    }
225
226    String::new()
227}
228
229// ===========================================================================
230// Private Implementation
231// ===========================================================================
232
233/// Resolve the rocminfo binary path from the ROCm installation.
234fn resolve_rocminfo_path(rocm_path: &Option<PathBuf>) -> String {
235    if let Some(ref path) = rocm_path {
236        let rocminfo_path = path.join("bin/rocminfo");
237        if rocminfo_path.exists() {
238            return rocminfo_path.to_string_lossy().to_string();
239        }
240    }
241    if Path::new("/opt/rocm/bin/rocminfo").exists() {
242        "/opt/rocm/bin/rocminfo".to_string()
243    } else {
244        "rocminfo".to_string()
245    }
246}
247
248/// Resolve the rocm-smi binary path from the ROCm installation.
249fn resolve_rocm_smi_path(rocm_path: &Option<PathBuf>) -> String {
250    if let Some(ref path) = rocm_path {
251        let rocm_smi_path = path.join("bin/rocm-smi");
252        if rocm_smi_path.exists() {
253            return rocm_smi_path.to_string_lossy().to_string();
254        }
255    }
256    if Path::new("/opt/rocm/bin/rocm-smi").exists() {
257        "/opt/rocm/bin/rocm-smi".to_string()
258    } else {
259        "rocm-smi".to_string()
260    }
261}
262
263/// Tier 1: Detect GPU from rocminfo output.
264///
265/// Returns `true` if rocminfo ran successfully and found at least one GPU.
266fn detect_gpu_from_rocminfo(rocminfo_cmd: &str, info: &mut GPUInfo) -> bool {
267    let output = match Command::new(rocminfo_cmd).output() {
268        Ok(o) if o.status.success() => o,
269        _ => return false,
270    };
271
272    let stdout = String::from_utf8_lossy(&output.stdout);
273    let mut gpu_count = 0usize;
274    let mut current_marketing_name = String::new();
275    let mut current_device_type = String::new();
276
277    for line in stdout.lines() {
278        let trimmed = line.trim();
279
280        // Track marketing name (within Agent section)
281        if trimmed.starts_with("Marketing Name:") {
282            current_marketing_name = trimmed
283                .strip_prefix("Marketing Name:")
284                .unwrap_or("")
285                .trim()
286                .to_string();
287        }
288
289        // Track device type to distinguish GPU from CPU
290        if trimmed.starts_with("Device Type:") {
291            current_device_type = trimmed
292                .strip_prefix("Device Type:")
293                .unwrap_or("")
294                .trim()
295                .to_string();
296        }
297
298        // Count GPU agents (Name: lines that aren't CPU)
299        if line.contains("Name:") {
300            let name = line.replace("Name:", "").trim().to_string();
301            if !name.is_empty() && !name.to_lowercase().contains("cpu") {
302                gpu_count += 1;
303                if info.model.is_empty() {
304                    info.model = name;
305                }
306            }
307        }
308
309        // Detect GPU architecture from Name field
310        if trimmed.starts_with("Name:") && trimmed.contains("gfx") {
311            if let Some(name_value) = trimmed.strip_prefix("Name:") {
312                let name_value = name_value.trim();
313                if let Some(after_gfx) = name_value.strip_prefix("gfx") {
314                    let gfx_num: String = after_gfx
315                        .chars()
316                        .take_while(|c| c.is_ascii_digit())
317                        .collect();
318                    if !gfx_num.is_empty() && gfx_num.len() >= 3 {
319                        // Only set architecture for dGPUs (not iGPUs, not CPUs).
320                        // Use the combined classifier so a nameless agent
321                        // (empty marketing name) with gfx1036/gfx1103 is caught
322                        // via the gfx-arch signal rather than slipping into the
323                        // dGPU architecture path.
324                        let gfx_arch = format!("gfx{gfx_num}");
325                        let is_igpu = crate::gpu::device_is_integrated(
326                            Some(&current_marketing_name),
327                            None,
328                            Some(&gfx_arch),
329                            None,
330                        );
331                        if !is_igpu && current_device_type == "GPU" && info.architecture.is_empty()
332                        {
333                            info.architecture = get_correct_gfx_from_marketing_name(
334                                &current_marketing_name,
335                                &gfx_num,
336                            );
337                        }
338                    }
339                }
340            }
341        }
342    }
343
344    // Stage 1: gpu_count must exclude the iGPU. Reuse the canonical discrete
345    // detector (no "0" fallback) so an iGPU-only / no-dGPU host reports 0 rather
346    // than falsely counting the fallback. Fall back to the raw count only when
347    // the detector returns nothing.
348    let discrete_count = crate::installer::detect_discrete_gpus().len();
349    if discrete_count > 0 {
350        info.gpu_count = discrete_count;
351        true
352    } else if gpu_count > 0 {
353        info.gpu_count = gpu_count;
354        true
355    } else {
356        false
357    }
358}
359
360/// Tier 2: Detect GPU from lspci output.
361///
362/// Scans for AMD VGA devices on the PCI bus.
363fn detect_gpu_from_lspci(info: &mut GPUInfo) {
364    let lspci_cmd = if Path::new("/usr/bin/lspci").exists() {
365        "/usr/bin/lspci"
366    } else {
367        "lspci"
368    };
369
370    if let Ok(output) = Command::new(lspci_cmd).output() {
371        let stdout = String::from_utf8_lossy(&output.stdout);
372        for line in stdout.lines() {
373            if line.to_lowercase().contains("amd") && line.to_lowercase().contains("vga") {
374                if info.model.is_empty() {
375                    info.model = line.trim().to_string();
376                }
377                info.gpu_count += 1;
378            }
379        }
380    }
381}
382
383/// Tier 3: Detect GPU from sysfs (`/sys/class/drm`).
384///
385/// Reads vendor IDs from DRM card entries to find AMD GPU devices.
386fn detect_gpu_from_sysfs(info: &mut GPUInfo) {
387    let mut count = 0usize;
388    let mut model = String::new();
389
390    let entries = match fs::read_dir("/sys/class/drm") {
391        Ok(e) => e,
392        Err(_) => return,
393    };
394
395    for entry in entries.flatten() {
396        let name = entry.file_name();
397        let name = name.to_string_lossy();
398        if !name.starts_with("card") || name.contains("render") {
399            continue;
400        }
401
402        let vendor_path = entry.path().join("device/vendor");
403        let vendor = match fs::read_to_string(&vendor_path) {
404            Ok(value) => value,
405            Err(_) => continue,
406        };
407
408        // AMD vendor ID: 0x1002
409        if vendor.trim().eq_ignore_ascii_case("0x1002") {
410            count += 1;
411            if model.is_empty() {
412                let device_path = entry.path().join("device");
413                let uevent_path = device_path.join("uevent");
414                if let Ok(uevent) = fs::read_to_string(&uevent_path) {
415                    for line in uevent.lines() {
416                        if let Some(driver) = line.strip_prefix("DRIVER=") {
417                            model = format!("AMD GPU ({})", driver);
418                            break;
419                        }
420                    }
421                }
422                if model.is_empty() {
423                    model = "AMD GPU".to_string();
424                }
425            }
426        }
427    }
428
429    if count > 0 {
430        info.gpu_count = count;
431        if info.model.is_empty() {
432            info.model = model;
433        }
434    }
435}
436
437/// Detect ROCm version from the `.info/version` file.
438fn detect_rocm_version_from_file(rocm_path: &Option<PathBuf>) -> String {
439    let version_path = if let Some(ref path) = rocm_path {
440        path.join(".info/version")
441    } else {
442        PathBuf::from("/opt/rocm/.info/version")
443    };
444
445    read_version_file(&version_path).unwrap_or_default()
446}
447
448/// Read and trim a version file.
449fn read_version_file(path: &Path) -> Result<String, std::io::Error> {
450    let content = fs::read_to_string(path)?;
451    Ok(content.trim().to_string())
452}
453
454/// Parse a version number from command output (e.g., "ROCm version 7.2.1").
455fn parse_version_from_output(output: &str) -> Option<String> {
456    // Look for version-like patterns: X.Y.Z
457    for part in output.split_whitespace() {
458        let parts: Vec<&str> = part.split('.').collect();
459        if parts.len() >= 2 && parts.iter().all(|p| p.parse::<u32>().is_ok()) {
460            return Some(part.to_string());
461        }
462    }
463    None
464}
465
466/// Extract version from a path like `/opt/rocm-7.2.1`.
467fn extract_version_from_path(path: &Path) -> Option<String> {
468    let name = path.file_name()?.to_string_lossy();
469    if let Some(version) = name.strip_prefix("rocm-") {
470        let version = version.trim().to_string();
471        if !version.is_empty() {
472            return Some(version);
473        }
474    }
475    None
476}
477
478/// Validate that a path is a valid ROCm installation directory.
479fn validate_rocm_path(path: &Path) -> bool {
480    if !path.exists() || !path.is_dir() {
481        return false;
482    }
483    // Should have at least bin or lib directory, or contain ROCm tools directly
484    path.join("bin").exists()
485        || path.join("lib").exists()
486        || path.join("rocminfo").exists()
487        || path.join("rocm-smi").exists()
488}
489
490/// Find versioned ROCm directories in a given parent directory, sorted newest-first.
491fn find_versioned_rocm_in_dir(parent_dir: &str) -> Option<PathBuf> {
492    let entries = fs::read_dir(parent_dir).ok()?;
493    let mut versioned_paths: Vec<(Vec<i32>, PathBuf)> = entries
494        .filter_map(|e| e.ok())
495        .filter_map(|e| {
496            let name = e.file_name().to_string_lossy().to_string();
497            if name.starts_with("rocm-") {
498                let version = name.trim_start_matches("rocm-").to_string();
499                let parts: Vec<i32> = version.split('.').filter_map(|s| s.parse().ok()).collect();
500                if !parts.is_empty() {
501                    Some((parts, e.path()))
502                } else {
503                    None
504                }
505            } else {
506                None
507            }
508        })
509        .collect();
510
511    // Sort by version descending (newest first)
512    versioned_paths.sort_by(|a, b| {
513        for i in 0..std::cmp::max(a.0.len(), b.0.len()) {
514            let a_val = *a.0.get(i).unwrap_or(&0);
515            let b_val = *b.0.get(i).unwrap_or(&0);
516            if a_val != b_val {
517                return b_val.cmp(&a_val);
518            }
519        }
520        std::cmp::Ordering::Equal
521    });
522
523    for (_version_parts, path) in versioned_paths {
524        if validate_rocm_path(&path) {
525            return Some(path);
526        }
527    }
528
529    None
530}
531
532/// Enrich GPU info with runtime metrics from rocm-smi (VRAM, temperature, power).
533fn enrich_gpu_metrics(rocm_smi_cmd: &str, info: &mut GPUInfo) {
534    // VRAM usage
535    if let Ok(output) = Command::new(rocm_smi_cmd)
536        .args(["--showmeminfo", "vram", "--csv"])
537        .output()
538    {
539        if output.status.success() {
540            let stdout = String::from_utf8_lossy(&output.stdout);
541            for line in stdout.lines().skip(1) {
542                let parts: Vec<&str> = line.split(',').collect();
543                if parts.len() >= 2 {
544                    if let Ok(value) = parts[1].trim().parse::<f32>() {
545                        info.memory_gb = value / 1024.0;
546                        break;
547                    }
548                }
549            }
550        }
551    }
552
553    // Temperature
554    if let Ok(output) = Command::new(rocm_smi_cmd)
555        .args(["--showtemp", "--csv"])
556        .output()
557    {
558        if output.status.success() {
559            let stdout = String::from_utf8_lossy(&output.stdout);
560            for line in stdout.lines().skip(1) {
561                let parts: Vec<&str> = line.split(',').collect();
562                if parts.len() >= 2 {
563                    if let Ok(value) = parts[1].trim().parse::<f32>() {
564                        info.temperature_c = Some(value);
565                        break;
566                    }
567                }
568            }
569        }
570    }
571
572    // Power draw
573    if let Ok(output) = Command::new(rocm_smi_cmd)
574        .args(["--showpower", "--csv"])
575        .output()
576    {
577        if output.status.success() {
578            let stdout = String::from_utf8_lossy(&output.stdout);
579            for line in stdout.lines().skip(1) {
580                let parts: Vec<&str> = line.split(',').collect();
581                if parts.len() >= 2 {
582                    if let Ok(value) = parts[1].trim().parse::<f32>() {
583                        info.power_watts = Some(value);
584                        break;
585                    }
586                }
587            }
588        }
589    }
590}
591
592// ===========================================================================
593// Tests
594// ===========================================================================
595
596#[cfg(test)]
597mod tests {
598    use super::*;
599
600    // -----------------------------------------------------------------------
601    // VAL-PLAT-002: GPU architecture correction from marketing name
602    // -----------------------------------------------------------------------
603
604    #[test]
605    fn test_gfx_correction_rdna3_7900_xtx() {
606        assert_eq!(
607            get_correct_gfx_from_marketing_name("AMD Radeon RX 7900 XTX", "1030"),
608            "gfx1100"
609        );
610    }
611
612    #[test]
613    fn test_gfx_correction_rdna3_7900_xtx_no_space() {
614        assert_eq!(
615            get_correct_gfx_from_marketing_name("AMD Radeon RX 7900XTX", "1030"),
616            "gfx1100"
617        );
618    }
619
620    #[test]
621    fn test_gfx_correction_rdna3_7900_xt() {
622        assert_eq!(
623            get_correct_gfx_from_marketing_name("AMD Radeon RX 7900 XT", "1030"),
624            "gfx1100"
625        );
626    }
627
628    #[test]
629    fn test_gfx_correction_rdna3_7900_gre() {
630        assert_eq!(
631            get_correct_gfx_from_marketing_name("AMD Radeon RX 7900 GRE", "1030"),
632            "gfx1100"
633        );
634    }
635
636    #[test]
637    fn test_gfx_correction_rdna3_7800_xt() {
638        assert_eq!(
639            get_correct_gfx_from_marketing_name("AMD Radeon RX 7800 XT", "1030"),
640            "gfx1101"
641        );
642    }
643
644    #[test]
645    fn test_gfx_correction_rdna3_7700_xt() {
646        assert_eq!(
647            get_correct_gfx_from_marketing_name("AMD Radeon RX 7700 XT", "1030"),
648            "gfx1101"
649        );
650    }
651
652    #[test]
653    fn test_gfx_correction_rdna3_7600() {
654        assert_eq!(
655            get_correct_gfx_from_marketing_name("AMD Radeon RX 7600", "1030"),
656            "gfx1102"
657        );
658    }
659
660    #[test]
661    fn test_gfx_correction_rdna4_9070_xt() {
662        assert_eq!(
663            get_correct_gfx_from_marketing_name("AMD Radeon RX 9070 XT", "1200"),
664            "gfx1201"
665        );
666    }
667
668    #[test]
669    fn test_gfx_correction_rdna4_9060() {
670        assert_eq!(
671            get_correct_gfx_from_marketing_name("AMD Radeon RX 9060", "1201"),
672            "gfx1200"
673        );
674    }
675
676    #[test]
677    fn test_gfx_correction_rdna2_6900_xt() {
678        assert_eq!(
679            get_correct_gfx_from_marketing_name("AMD Radeon RX 6900 XT", "1030"),
680            "gfx1030"
681        );
682    }
683
684    #[test]
685    fn test_gfx_correction_rdna2_6800() {
686        assert_eq!(
687            get_correct_gfx_from_marketing_name("AMD Radeon RX 6800", "1030"),
688            "gfx1030"
689        );
690    }
691
692    #[test]
693    fn test_gfx_correction_rdna2_6700_xt() {
694        assert_eq!(
695            get_correct_gfx_from_marketing_name("AMD Radeon RX 6700 XT", "1030"),
696            "gfx1031"
697        );
698    }
699
700    #[test]
701    fn test_gfx_correction_rdna2_6600() {
702        assert_eq!(
703            get_correct_gfx_from_marketing_name("AMD Radeon RX 6600", "1030"),
704            "gfx1032"
705        );
706    }
707
708    #[test]
709    fn test_gfx_correction_rdna2_6500_xt() {
710        assert_eq!(
711            get_correct_gfx_from_marketing_name("AMD Radeon RX 6500 XT", "1030"),
712            "gfx1034"
713        );
714    }
715
716    #[test]
717    fn test_gfx_correction_cdna_instinct() {
718        // CDNA accelerators trust rocminfo
719        assert_eq!(
720            get_correct_gfx_from_marketing_name("AMD Instinct MI250X", "90a0"),
721            "gfx90a0"
722        );
723    }
724
725    #[test]
726    fn test_gfx_correction_unknown_passthrough() {
727        // Unknown GPU names pass through the fallback gfx
728        assert_eq!(
729            get_correct_gfx_from_marketing_name("Unknown GPU Model", "999"),
730            "gfx999"
731        );
732    }
733
734    // -----------------------------------------------------------------------
735    // VAL-PLAT-003: GPU detection fallback chain
736    // -----------------------------------------------------------------------
737
738    #[test]
739    fn test_detect_gpu_from_sysfs_no_drm_dir() {
740        // When /sys/class/drm doesn't exist, no GPU detected via sysfs
741        let mut info = GPUInfo::default();
742        detect_gpu_from_sysfs(&mut info);
743        // On this system, DRM may or may not have AMD entries,
744        // but the function should not panic
745        // Test passes if we get here without panicking
746    }
747
748    #[test]
749    fn test_detect_gpu_from_lspci_no_panic() {
750        // lspci may or may not find AMD GPUs, but should never panic
751        let mut info = GPUInfo::default();
752        detect_gpu_from_lspci(&mut info);
753        // No assertion on count — just ensuring no panic
754    }
755
756    #[test]
757    fn test_detect_gpu_from_rocminfo_bad_command() {
758        // Non-existent rocminfo command should return false cleanly
759        let mut info = GPUInfo::default();
760        let result = detect_gpu_from_rocminfo("/nonexistent/rocminfo", &mut info);
761        assert!(!result);
762        assert_eq!(info.gpu_count, 0);
763    }
764
765    // -----------------------------------------------------------------------
766    // VAL-PLAT-004: ROCm version detection from .info/version
767    // -----------------------------------------------------------------------
768
769    #[test]
770    fn test_detect_rocm_version_from_file_with_real_install() {
771        // On this system, ROCm should be installed at /opt/rocm
772        let rocm_path = Some(PathBuf::from("/opt/rocm"));
773        let version = detect_rocm_version_from_file(&rocm_path);
774        if Path::new("/opt/rocm/.info/version").exists() {
775            assert!(
776                !version.is_empty(),
777                "ROCm version should be non-empty when file exists"
778            );
779            // Version should look like X.Y.Z
780            let parts: Vec<&str> = version.split('.').collect();
781            assert!(
782                parts.len() >= 2,
783                "Version should have at least major.minor: got '{}'",
784                version
785            );
786        }
787    }
788
789    #[test]
790    fn test_detect_rocm_version_from_file_missing() {
791        let rocm_path = Some(PathBuf::from("/nonexistent/rocm"));
792        let version = detect_rocm_version_from_file(&rocm_path);
793        assert!(
794            version.is_empty(),
795            "Version should be empty for nonexistent path"
796        );
797    }
798
799    // -----------------------------------------------------------------------
800    // VAL-PLAT-005: ROCm path search priority
801    // -----------------------------------------------------------------------
802
803    #[test]
804    fn test_detect_rocm_path_finds_standard_location() {
805        // On this system, /opt/rocm should exist
806        let path = detect_rocm_path();
807        if Path::new("/opt/rocm").is_dir() {
808            assert!(path.is_some(), "Should detect ROCm at /opt/rocm");
809            let p = path.unwrap();
810            assert!(
811                p.as_os_str() == "/opt/rocm" || p.to_string_lossy().contains("rocm"),
812                "Path should contain 'rocm': got {:?}",
813                p
814            );
815        }
816    }
817
818    #[test]
819    fn test_detect_rocm_path_env_override() {
820        let _env = crate::test_support::lock_env();
821        // Set ROCm_PATH env var — should take priority
822        // We test this by setting to a known valid path
823        if Path::new("/opt/rocm").is_dir() {
824            std::env::set_var("ROCm_PATH", "/opt/rocm");
825            let path = detect_rocm_path();
826            assert_eq!(path, Some(PathBuf::from("/opt/rocm")));
827            std::env::remove_var("ROCm_PATH");
828        }
829    }
830
831    #[test]
832    fn test_validate_rocm_path_rejects_nonexistent() {
833        assert!(!validate_rocm_path(Path::new("/nonexistent/path")));
834    }
835
836    #[test]
837    fn test_validate_rocm_path_accepts_standard() {
838        if Path::new("/opt/rocm/bin").is_dir() {
839            assert!(validate_rocm_path(Path::new("/opt/rocm")));
840        }
841    }
842
843    // -----------------------------------------------------------------------
844    // VAL-PLAT-006: ROCm version detection multi-method fallback
845    // -----------------------------------------------------------------------
846
847    #[test]
848    fn test_get_rocm_version_returns_nonempty_on_installed_system() {
849        if Path::new("/opt/rocm/.info/version").exists() {
850            let version = get_rocm_version();
851            assert!(
852                !version.is_empty(),
853                "Should detect ROCm version on installed system"
854            );
855        }
856    }
857
858    // -----------------------------------------------------------------------
859    // Helper function tests
860    // -----------------------------------------------------------------------
861
862    #[test]
863    fn test_parse_version_from_output() {
864        assert_eq!(
865            parse_version_from_output("ROCm version 7.2.1"),
866            Some("7.2.1".to_string())
867        );
868        assert_eq!(
869            parse_version_from_output("rocm-smi version 4.0.0"),
870            Some("4.0.0".to_string())
871        );
872        assert_eq!(parse_version_from_output("no version here"), None);
873    }
874
875    #[test]
876    fn test_extract_version_from_path() {
877        assert_eq!(
878            extract_version_from_path(Path::new("/opt/rocm-7.2.1")),
879            Some("7.2.1".to_string())
880        );
881        assert_eq!(extract_version_from_path(Path::new("/opt/rocm")), None);
882    }
883
884    #[test]
885    fn test_read_version_file() {
886        let dir = tempfile::tempdir().unwrap();
887        let version_file = dir.path().join("version");
888        fs::write(&version_file, "7.2.2\n").unwrap();
889        let version = read_version_file(&version_file).unwrap();
890        assert_eq!(version, "7.2.2");
891    }
892
893    #[test]
894    fn test_read_version_file_trims_whitespace() {
895        let dir = tempfile::tempdir().unwrap();
896        let version_file = dir.path().join("version");
897        fs::write(&version_file, "  7.2.2  \n").unwrap();
898        let version = read_version_file(&version_file).unwrap();
899        assert_eq!(version, "7.2.2");
900    }
901
902    #[test]
903    fn test_find_versioned_rocm_no_dir() {
904        let result = find_versioned_rocm_in_dir("/nonexistent/dir");
905        assert!(result.is_none());
906    }
907
908    // -----------------------------------------------------------------------
909    // Hardware tests (require real AMD GPU + ROCm)
910    // Run with: cargo test -- --ignored
911    // -----------------------------------------------------------------------
912
913    /// VAL-PLAT-001: GPU detection identifies AMD GPUs via rocminfo
914    #[test]
915    #[ignore]
916    fn test_detect_gpu_real_hardware() {
917        let info = detect_gpu();
918        assert!(
919            info.gpu_count >= 1,
920            "Expected at least 1 AMD GPU, got gpu_count = {}",
921            info.gpu_count
922        );
923        assert!(
924            !info.model.is_empty(),
925            "GPU model should be non-empty on real hardware"
926        );
927    }
928
929    /// VAL-PLAT-001: GPU detection on real hardware has correct architecture
930    #[test]
931    #[ignore]
932    fn test_detect_gpu_real_hardware_architecture() {
933        let info = detect_gpu();
934        if info.gpu_count > 0 {
935            assert!(
936                !info.architecture.is_empty(),
937                "Architecture should be detected on real AMD GPU"
938            );
939            assert!(
940                info.architecture.starts_with("gfx"),
941                "Architecture should start with 'gfx': got '{}'",
942                info.architecture
943            );
944        }
945    }
946
947    /// VAL-PLAT-004: ROCm version detected on real system
948    #[test]
949    #[ignore]
950    fn test_detect_rocm_version_real_system() {
951        let info = detect_gpu();
952        assert!(
953            !info.rocm_version.is_empty(),
954            "ROCm version should be detected on installed system"
955        );
956    }
957
958    /// VAL-PLAT-005: ROCm path found on real system
959    #[test]
960    #[ignore]
961    fn test_detect_rocm_path_real_system() {
962        let path = detect_rocm_path();
963        assert!(
964            path.is_some(),
965            "ROCm path should be detected on installed system"
966        );
967        let p = path.unwrap();
968        assert!(
969            p.exists() && p.is_dir(),
970            "ROCm path should be an existing directory"
971        );
972    }
973
974    /// VAL-PLAT-006: Multi-method ROCm version on real system
975    #[test]
976    #[ignore]
977    fn test_get_rocm_version_real_system() {
978        let version = get_rocm_version();
979        assert!(
980            !version.is_empty(),
981            "Should detect ROCm version via multi-method fallback"
982        );
983        // Version should be parseable as X.Y.Z
984        let parts: Vec<&str> = version.split('.').collect();
985        assert!(
986            parts.len() >= 2,
987            "Version should have at least major.minor: got '{}'",
988            version
989        );
990    }
991
992    /// GPU detection returns GPUInfo with runtime metrics on real hardware
993    #[test]
994    #[ignore]
995    fn test_detect_gpu_real_hardware_metrics() {
996        let info = detect_gpu();
997        if info.gpu_count > 0 {
998            // Memory should be positive for real GPUs
999            assert!(
1000                info.memory_gb > 0.0,
1001                "GPU memory should be positive: got {} GB",
1002                info.memory_gb
1003            );
1004        }
1005    }
1006
1007    /// Verify GPU detection matches rocm-smi output
1008    #[test]
1009    #[ignore]
1010    fn test_detect_gpu_matches_rocm_smi() {
1011        let info = detect_gpu();
1012
1013        // Cross-check with rocm-smi
1014        let output = Command::new("/opt/rocm/bin/rocm-smi")
1015            .args(["--showproductinfo", "--csv"])
1016            .output()
1017            .expect("rocm-smi should be available");
1018
1019        if output.status.success() {
1020            let stdout = String::from_utf8_lossy(&output.stdout);
1021            // If rocm-smi reports a card, our detection should also find one
1022            if stdout.contains("card") {
1023                assert!(
1024                    info.gpu_count >= 1,
1025                    "GPU count should be >= 1 if rocm-smi reports cards"
1026                );
1027            }
1028        }
1029    }
1030}