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                        // Use the combined classifier so a nameless agent
405                        // (empty marketing name) with gfx1036/gfx1103 is caught
406                        // via the gfx-arch signal rather than slipping into the
407                        // dGPU architecture path.
408                        let gfx_arch = format!("gfx{gfx_num}");
409                        let is_igpu = crate::gpu::device_is_integrated(
410                            Some(&current_marketing_name),
411                            None,
412                            Some(&gfx_arch),
413                            None,
414                        );
415                        if !is_igpu && current_device_type == "GPU" && info.architecture.is_empty()
416                        {
417                            info.architecture = get_correct_gfx_from_marketing_name(
418                                &current_marketing_name,
419                                &gfx_num,
420                            );
421                        }
422                    }
423                }
424            }
425        }
426    }
427
428    // Stage 1: gpu_count must exclude the iGPU. Reuse the canonical discrete
429    // detector (no "0" fallback) so an iGPU-only / no-dGPU host reports 0 rather
430    // than falsely counting the fallback. Fall back to the raw count only when
431    // the detector returns nothing.
432    let discrete_count = crate::installer::detect_discrete_gpus().len();
433    if discrete_count > 0 {
434        info.gpu_count = discrete_count;
435        true
436    } else if gpu_count > 0 {
437        info.gpu_count = gpu_count;
438        true
439    } else {
440        false
441    }
442}
443
444/// Tier 2: Detect GPU from lspci output.
445///
446/// Scans for AMD VGA devices on the PCI bus.
447fn detect_gpu_from_lspci(info: &mut GPUInfo) {
448    let lspci_cmd = if Path::new("/usr/bin/lspci").exists() {
449        "/usr/bin/lspci"
450    } else {
451        "lspci"
452    };
453
454    if let Ok(output) = Command::new(lspci_cmd).output() {
455        let stdout = String::from_utf8_lossy(&output.stdout);
456        for line in stdout.lines() {
457            if line.to_lowercase().contains("amd") && line.to_lowercase().contains("vga") {
458                if info.model.is_empty() {
459                    info.model = line.trim().to_string();
460                }
461                info.gpu_count += 1;
462            }
463        }
464    }
465}
466
467/// Tier 3: Detect GPU from sysfs (`/sys/class/drm`).
468///
469/// Reads vendor IDs from DRM card entries to find AMD GPU devices.
470fn detect_gpu_from_sysfs(info: &mut GPUInfo) {
471    let mut count = 0usize;
472    let mut model = String::new();
473
474    let entries = match fs::read_dir("/sys/class/drm") {
475        Ok(e) => e,
476        Err(_) => return,
477    };
478
479    for entry in entries.flatten() {
480        let name = entry.file_name();
481        let name = name.to_string_lossy();
482        if !name.starts_with("card") || name.contains("render") {
483            continue;
484        }
485
486        let vendor_path = entry.path().join("device/vendor");
487        let vendor = match fs::read_to_string(&vendor_path) {
488            Ok(value) => value,
489            Err(_) => continue,
490        };
491
492        // AMD vendor ID: 0x1002
493        if vendor.trim().eq_ignore_ascii_case("0x1002") {
494            count += 1;
495            if model.is_empty() {
496                let device_path = entry.path().join("device");
497                let uevent_path = device_path.join("uevent");
498                if let Ok(uevent) = fs::read_to_string(&uevent_path) {
499                    for line in uevent.lines() {
500                        if let Some(driver) = line.strip_prefix("DRIVER=") {
501                            model = format!("AMD GPU ({})", driver);
502                            break;
503                        }
504                    }
505                }
506                if model.is_empty() {
507                    model = "AMD GPU".to_string();
508                }
509            }
510        }
511    }
512
513    if count > 0 {
514        info.gpu_count = count;
515        if info.model.is_empty() {
516            info.model = model;
517        }
518    }
519}
520
521/// Detect ROCm version from the `.info/version` file.
522fn detect_rocm_version_from_file(rocm_path: &Option<PathBuf>) -> String {
523    let version_path = if let Some(ref path) = rocm_path {
524        path.join(".info/version")
525    } else {
526        PathBuf::from("/opt/rocm/.info/version")
527    };
528
529    read_version_file(&version_path).unwrap_or_default()
530}
531
532/// Read and trim a version file.
533fn read_version_file(path: &Path) -> Result<String, std::io::Error> {
534    let content = fs::read_to_string(path)?;
535    Ok(content.trim().to_string())
536}
537
538/// Parse a version number from command output (e.g., "ROCm version 7.2.1").
539fn parse_version_from_output(output: &str) -> Option<String> {
540    // Look for version-like patterns: X.Y.Z
541    for part in output.split_whitespace() {
542        let parts: Vec<&str> = part.split('.').collect();
543        if parts.len() >= 2 && parts.iter().all(|p| p.parse::<u32>().is_ok()) {
544            return Some(part.to_string());
545        }
546    }
547    None
548}
549
550/// Extract version from a path like `/opt/rocm-7.2.1`.
551fn extract_version_from_path(path: &Path) -> Option<String> {
552    let name = path.file_name()?.to_string_lossy();
553    if let Some(version) = name.strip_prefix("rocm-") {
554        let version = version.trim().to_string();
555        if !version.is_empty() {
556            return Some(version);
557        }
558    }
559    None
560}
561
562/// Validate that a path is a valid ROCm installation directory.
563fn validate_rocm_path(path: &Path) -> bool {
564    if !path.exists() || !path.is_dir() {
565        return false;
566    }
567    // Should have at least bin or lib directory, or contain ROCm tools directly
568    path.join("bin").exists()
569        || path.join("lib").exists()
570        || path.join("rocminfo").exists()
571        || path.join("rocm-smi").exists()
572}
573
574/// Find versioned ROCm directories in a given parent directory, sorted newest-first.
575fn find_versioned_rocm_in_dir(parent_dir: &str) -> Option<PathBuf> {
576    let entries = fs::read_dir(parent_dir).ok()?;
577    let mut versioned_paths: Vec<(Vec<i32>, PathBuf)> = entries
578        .filter_map(|e| e.ok())
579        .filter_map(|e| {
580            let name = e.file_name().to_string_lossy().to_string();
581            if name.starts_with("rocm-") {
582                let version = name.trim_start_matches("rocm-").to_string();
583                let parts: Vec<i32> = version.split('.').filter_map(|s| s.parse().ok()).collect();
584                if !parts.is_empty() {
585                    Some((parts, e.path()))
586                } else {
587                    None
588                }
589            } else {
590                None
591            }
592        })
593        .collect();
594
595    // Sort by version descending (newest first)
596    versioned_paths.sort_by(|a, b| {
597        for i in 0..std::cmp::max(a.0.len(), b.0.len()) {
598            let a_val = *a.0.get(i).unwrap_or(&0);
599            let b_val = *b.0.get(i).unwrap_or(&0);
600            if a_val != b_val {
601                return b_val.cmp(&a_val);
602            }
603        }
604        std::cmp::Ordering::Equal
605    });
606
607    for (_version_parts, path) in versioned_paths {
608        if validate_rocm_path(&path) {
609            return Some(path);
610        }
611    }
612
613    None
614}
615
616/// Enrich GPU info with runtime metrics from rocm-smi (VRAM, temperature, power).
617fn enrich_gpu_metrics(rocm_smi_cmd: &str, info: &mut GPUInfo) {
618    // VRAM usage
619    if let Ok(output) = Command::new(rocm_smi_cmd)
620        .args(["--showmeminfo", "vram", "--csv"])
621        .output()
622    {
623        if output.status.success() {
624            let stdout = String::from_utf8_lossy(&output.stdout);
625            for line in stdout.lines().skip(1) {
626                let parts: Vec<&str> = line.split(',').collect();
627                if parts.len() >= 2 {
628                    if let Ok(value) = parts[1].trim().parse::<f32>() {
629                        info.memory_gb = value / 1024.0;
630                        break;
631                    }
632                }
633            }
634        }
635    }
636
637    // Temperature
638    if let Ok(output) = Command::new(rocm_smi_cmd)
639        .args(["--showtemp", "--csv"])
640        .output()
641    {
642        if output.status.success() {
643            let stdout = String::from_utf8_lossy(&output.stdout);
644            for line in stdout.lines().skip(1) {
645                let parts: Vec<&str> = line.split(',').collect();
646                if parts.len() >= 2 {
647                    if let Ok(value) = parts[1].trim().parse::<f32>() {
648                        info.temperature_c = Some(value);
649                        break;
650                    }
651                }
652            }
653        }
654    }
655
656    // Power draw
657    if let Ok(output) = Command::new(rocm_smi_cmd)
658        .args(["--showpower", "--csv"])
659        .output()
660    {
661        if output.status.success() {
662            let stdout = String::from_utf8_lossy(&output.stdout);
663            for line in stdout.lines().skip(1) {
664                let parts: Vec<&str> = line.split(',').collect();
665                if parts.len() >= 2 {
666                    if let Ok(value) = parts[1].trim().parse::<f32>() {
667                        info.power_watts = Some(value);
668                        break;
669                    }
670                }
671            }
672        }
673    }
674}
675
676// ===========================================================================
677// Tests
678// ===========================================================================
679
680#[cfg(test)]
681mod tests {
682    use super::*;
683
684    // -----------------------------------------------------------------------
685    // VAL-PLAT-002: GPU architecture correction from marketing name
686    // -----------------------------------------------------------------------
687
688    #[test]
689    fn test_gfx_correction_rdna3_7900_xtx() {
690        assert_eq!(
691            get_correct_gfx_from_marketing_name("AMD Radeon RX 7900 XTX", "1030"),
692            "gfx1100"
693        );
694    }
695
696    #[test]
697    fn test_gfx_correction_rdna3_7900_xtx_no_space() {
698        assert_eq!(
699            get_correct_gfx_from_marketing_name("AMD Radeon RX 7900XTX", "1030"),
700            "gfx1100"
701        );
702    }
703
704    #[test]
705    fn test_gfx_correction_rdna3_7900_xt() {
706        assert_eq!(
707            get_correct_gfx_from_marketing_name("AMD Radeon RX 7900 XT", "1030"),
708            "gfx1100"
709        );
710    }
711
712    #[test]
713    fn test_gfx_correction_rdna3_7900_gre() {
714        assert_eq!(
715            get_correct_gfx_from_marketing_name("AMD Radeon RX 7900 GRE", "1030"),
716            "gfx1100"
717        );
718    }
719
720    #[test]
721    fn test_gfx_correction_rdna3_7800_xt() {
722        assert_eq!(
723            get_correct_gfx_from_marketing_name("AMD Radeon RX 7800 XT", "1030"),
724            "gfx1101"
725        );
726    }
727
728    #[test]
729    fn test_gfx_correction_rdna3_7700_xt() {
730        assert_eq!(
731            get_correct_gfx_from_marketing_name("AMD Radeon RX 7700 XT", "1030"),
732            "gfx1101"
733        );
734    }
735
736    #[test]
737    fn test_gfx_correction_rdna3_7600() {
738        assert_eq!(
739            get_correct_gfx_from_marketing_name("AMD Radeon RX 7600", "1030"),
740            "gfx1102"
741        );
742    }
743
744    #[test]
745    fn test_gfx_correction_rdna4_9070_xt() {
746        assert_eq!(
747            get_correct_gfx_from_marketing_name("AMD Radeon RX 9070 XT", "1200"),
748            "gfx1200"
749        );
750    }
751
752    #[test]
753    fn test_gfx_correction_rdna4_9060() {
754        assert_eq!(
755            get_correct_gfx_from_marketing_name("AMD Radeon RX 9060", "1201"),
756            "gfx1201"
757        );
758    }
759
760    #[test]
761    fn test_gfx_correction_rdna2_6900_xt() {
762        assert_eq!(
763            get_correct_gfx_from_marketing_name("AMD Radeon RX 6900 XT", "1030"),
764            "gfx1030"
765        );
766    }
767
768    #[test]
769    fn test_gfx_correction_rdna2_6800() {
770        assert_eq!(
771            get_correct_gfx_from_marketing_name("AMD Radeon RX 6800", "1030"),
772            "gfx1030"
773        );
774    }
775
776    #[test]
777    fn test_gfx_correction_rdna2_6700_xt() {
778        assert_eq!(
779            get_correct_gfx_from_marketing_name("AMD Radeon RX 6700 XT", "1030"),
780            "gfx1031"
781        );
782    }
783
784    #[test]
785    fn test_gfx_correction_rdna2_6600() {
786        assert_eq!(
787            get_correct_gfx_from_marketing_name("AMD Radeon RX 6600", "1030"),
788            "gfx1032"
789        );
790    }
791
792    #[test]
793    fn test_gfx_correction_rdna2_6500_xt() {
794        assert_eq!(
795            get_correct_gfx_from_marketing_name("AMD Radeon RX 6500 XT", "1030"),
796            "gfx1034"
797        );
798    }
799
800    #[test]
801    fn test_gfx_correction_cdna_instinct() {
802        // CDNA accelerators trust rocminfo
803        assert_eq!(
804            get_correct_gfx_from_marketing_name("AMD Instinct MI250X", "90a0"),
805            "gfx90a0"
806        );
807    }
808
809    #[test]
810    fn test_gfx_correction_unknown_passthrough() {
811        // Unknown GPU names pass through the fallback gfx
812        assert_eq!(
813            get_correct_gfx_from_marketing_name("Unknown GPU Model", "999"),
814            "gfx999"
815        );
816    }
817
818    // -----------------------------------------------------------------------
819    // VAL-PLAT-003: GPU detection fallback chain
820    // -----------------------------------------------------------------------
821
822    #[test]
823    fn test_detect_gpu_from_sysfs_no_drm_dir() {
824        // When /sys/class/drm doesn't exist, no GPU detected via sysfs
825        let mut info = GPUInfo::default();
826        detect_gpu_from_sysfs(&mut info);
827        // On this system, DRM may or may not have AMD entries,
828        // but the function should not panic
829        // Test passes if we get here without panicking
830    }
831
832    #[test]
833    fn test_detect_gpu_from_lspci_no_panic() {
834        // lspci may or may not find AMD GPUs, but should never panic
835        let mut info = GPUInfo::default();
836        detect_gpu_from_lspci(&mut info);
837        // No assertion on count — just ensuring no panic
838    }
839
840    #[test]
841    fn test_detect_gpu_from_rocminfo_bad_command() {
842        // Non-existent rocminfo command should return false cleanly
843        let mut info = GPUInfo::default();
844        let result = detect_gpu_from_rocminfo("/nonexistent/rocminfo", &mut info);
845        assert!(!result);
846        assert_eq!(info.gpu_count, 0);
847    }
848
849    // -----------------------------------------------------------------------
850    // VAL-PLAT-004: ROCm version detection from .info/version
851    // -----------------------------------------------------------------------
852
853    #[test]
854    fn test_detect_rocm_version_from_file_with_real_install() {
855        // On this system, ROCm should be installed at /opt/rocm
856        let rocm_path = Some(PathBuf::from("/opt/rocm"));
857        let version = detect_rocm_version_from_file(&rocm_path);
858        if Path::new("/opt/rocm/.info/version").exists() {
859            assert!(
860                !version.is_empty(),
861                "ROCm version should be non-empty when file exists"
862            );
863            // Version should look like X.Y.Z
864            let parts: Vec<&str> = version.split('.').collect();
865            assert!(
866                parts.len() >= 2,
867                "Version should have at least major.minor: got '{}'",
868                version
869            );
870        }
871    }
872
873    #[test]
874    fn test_detect_rocm_version_from_file_missing() {
875        let rocm_path = Some(PathBuf::from("/nonexistent/rocm"));
876        let version = detect_rocm_version_from_file(&rocm_path);
877        assert!(
878            version.is_empty(),
879            "Version should be empty for nonexistent path"
880        );
881    }
882
883    // -----------------------------------------------------------------------
884    // VAL-PLAT-005: ROCm path search priority
885    // -----------------------------------------------------------------------
886
887    #[test]
888    fn test_detect_rocm_path_finds_standard_location() {
889        // On this system, /opt/rocm should exist
890        let path = detect_rocm_path();
891        if Path::new("/opt/rocm").is_dir() {
892            assert!(path.is_some(), "Should detect ROCm at /opt/rocm");
893            let p = path.unwrap();
894            assert!(
895                p.as_os_str() == "/opt/rocm" || p.to_string_lossy().contains("rocm"),
896                "Path should contain 'rocm': got {:?}",
897                p
898            );
899        }
900    }
901
902    #[test]
903    fn test_detect_rocm_path_env_override() {
904        let _env = crate::test_support::lock_env();
905        // Set ROCm_PATH env var — should take priority
906        // We test this by setting to a known valid path
907        if Path::new("/opt/rocm").is_dir() {
908            std::env::set_var("ROCm_PATH", "/opt/rocm");
909            let path = detect_rocm_path();
910            assert_eq!(path, Some(PathBuf::from("/opt/rocm")));
911            std::env::remove_var("ROCm_PATH");
912        }
913    }
914
915    #[test]
916    fn test_validate_rocm_path_rejects_nonexistent() {
917        assert!(!validate_rocm_path(Path::new("/nonexistent/path")));
918    }
919
920    #[test]
921    fn test_validate_rocm_path_accepts_standard() {
922        if Path::new("/opt/rocm/bin").is_dir() {
923            assert!(validate_rocm_path(Path::new("/opt/rocm")));
924        }
925    }
926
927    // -----------------------------------------------------------------------
928    // VAL-PLAT-006: ROCm version detection multi-method fallback
929    // -----------------------------------------------------------------------
930
931    #[test]
932    fn test_get_rocm_version_returns_nonempty_on_installed_system() {
933        if Path::new("/opt/rocm/.info/version").exists() {
934            let version = get_rocm_version();
935            assert!(
936                !version.is_empty(),
937                "Should detect ROCm version on installed system"
938            );
939        }
940    }
941
942    // -----------------------------------------------------------------------
943    // Helper function tests
944    // -----------------------------------------------------------------------
945
946    #[test]
947    fn test_parse_version_from_output() {
948        assert_eq!(
949            parse_version_from_output("ROCm version 7.2.1"),
950            Some("7.2.1".to_string())
951        );
952        assert_eq!(
953            parse_version_from_output("rocm-smi version 4.0.0"),
954            Some("4.0.0".to_string())
955        );
956        assert_eq!(parse_version_from_output("no version here"), None);
957    }
958
959    #[test]
960    fn test_extract_version_from_path() {
961        assert_eq!(
962            extract_version_from_path(Path::new("/opt/rocm-7.2.1")),
963            Some("7.2.1".to_string())
964        );
965        assert_eq!(extract_version_from_path(Path::new("/opt/rocm")), None);
966    }
967
968    #[test]
969    fn test_read_version_file() {
970        let dir = tempfile::tempdir().unwrap();
971        let version_file = dir.path().join("version");
972        fs::write(&version_file, "7.2.2\n").unwrap();
973        let version = read_version_file(&version_file).unwrap();
974        assert_eq!(version, "7.2.2");
975    }
976
977    #[test]
978    fn test_read_version_file_trims_whitespace() {
979        let dir = tempfile::tempdir().unwrap();
980        let version_file = dir.path().join("version");
981        fs::write(&version_file, "  7.2.2  \n").unwrap();
982        let version = read_version_file(&version_file).unwrap();
983        assert_eq!(version, "7.2.2");
984    }
985
986    #[test]
987    fn test_find_versioned_rocm_no_dir() {
988        let result = find_versioned_rocm_in_dir("/nonexistent/dir");
989        assert!(result.is_none());
990    }
991
992    // -----------------------------------------------------------------------
993    // Hardware tests (require real AMD GPU + ROCm)
994    // Run with: cargo test -- --ignored
995    // -----------------------------------------------------------------------
996
997    /// VAL-PLAT-001: GPU detection identifies AMD GPUs via rocminfo
998    #[test]
999    #[ignore]
1000    fn test_detect_gpu_real_hardware() {
1001        let info = detect_gpu();
1002        assert!(
1003            info.gpu_count >= 1,
1004            "Expected at least 1 AMD GPU, got gpu_count = {}",
1005            info.gpu_count
1006        );
1007        assert!(
1008            !info.model.is_empty(),
1009            "GPU model should be non-empty on real hardware"
1010        );
1011    }
1012
1013    /// VAL-PLAT-001: GPU detection on real hardware has correct architecture
1014    #[test]
1015    #[ignore]
1016    fn test_detect_gpu_real_hardware_architecture() {
1017        let info = detect_gpu();
1018        if info.gpu_count > 0 {
1019            assert!(
1020                !info.architecture.is_empty(),
1021                "Architecture should be detected on real AMD GPU"
1022            );
1023            assert!(
1024                info.architecture.starts_with("gfx"),
1025                "Architecture should start with 'gfx': got '{}'",
1026                info.architecture
1027            );
1028        }
1029    }
1030
1031    /// VAL-PLAT-004: ROCm version detected on real system
1032    #[test]
1033    #[ignore]
1034    fn test_detect_rocm_version_real_system() {
1035        let info = detect_gpu();
1036        assert!(
1037            !info.rocm_version.is_empty(),
1038            "ROCm version should be detected on installed system"
1039        );
1040    }
1041
1042    /// VAL-PLAT-005: ROCm path found on real system
1043    #[test]
1044    #[ignore]
1045    fn test_detect_rocm_path_real_system() {
1046        let path = detect_rocm_path();
1047        assert!(
1048            path.is_some(),
1049            "ROCm path should be detected on installed system"
1050        );
1051        let p = path.unwrap();
1052        assert!(
1053            p.exists() && p.is_dir(),
1054            "ROCm path should be an existing directory"
1055        );
1056    }
1057
1058    /// VAL-PLAT-006: Multi-method ROCm version on real system
1059    #[test]
1060    #[ignore]
1061    fn test_get_rocm_version_real_system() {
1062        let version = get_rocm_version();
1063        assert!(
1064            !version.is_empty(),
1065            "Should detect ROCm version via multi-method fallback"
1066        );
1067        // Version should be parseable as X.Y.Z
1068        let parts: Vec<&str> = version.split('.').collect();
1069        assert!(
1070            parts.len() >= 2,
1071            "Version should have at least major.minor: got '{}'",
1072            version
1073        );
1074    }
1075
1076    /// GPU detection returns GPUInfo with runtime metrics on real hardware
1077    #[test]
1078    #[ignore]
1079    fn test_detect_gpu_real_hardware_metrics() {
1080        let info = detect_gpu();
1081        if info.gpu_count > 0 {
1082            // Memory should be positive for real GPUs
1083            assert!(
1084                info.memory_gb > 0.0,
1085                "GPU memory should be positive: got {} GB",
1086                info.memory_gb
1087            );
1088        }
1089    }
1090
1091    /// Verify GPU detection matches rocm-smi output
1092    #[test]
1093    #[ignore]
1094    fn test_detect_gpu_matches_rocm_smi() {
1095        let info = detect_gpu();
1096
1097        // Cross-check with rocm-smi
1098        let output = Command::new("/opt/rocm/bin/rocm-smi")
1099            .args(["--showproductinfo", "--csv"])
1100            .output()
1101            .expect("rocm-smi should be available");
1102
1103        if output.status.success() {
1104            let stdout = String::from_utf8_lossy(&output.stdout);
1105            // If rocm-smi reports a card, our detection should also find one
1106            if stdout.contains("card") {
1107                assert!(
1108                    info.gpu_count >= 1,
1109                    "GPU count should be >= 1 if rocm-smi reports cards"
1110                );
1111            }
1112        }
1113    }
1114}