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