Skip to main content

rusty_stack/installers/common/
rocm_env.rs

1//! ROCm environment facade — thin wrapper over `platform::linux` for ROCm paths/versions.
2//!
3//! This module delegates all ROCm detection to `platform::linux` and does NOT
4//! duplicate any detection logic. It provides a convenient struct-based API
5//! for installer use.
6//!
7//! # Validation Assertions
8//!
9//! - **VAL-INFRA-007**: ROCm environment delegates to platform modules
10//! - **VAL-INFRA-008**: ROCm version detection returns semantic version
11//! - **VAL-INFRA-019**: No code duplication with platform modules
12
13#[cfg(unix)]
14use crate::platform::linux;
15use std::path::PathBuf;
16
17// ===========================================================================
18// RocmEnv
19// ===========================================================================
20
21/// ROCm environment information, lazily detected from the system.
22///
23/// All detection delegates to `platform::linux`. This struct provides
24/// a convenient cached view of the ROCm environment for installer use.
25///
26/// # Validation
27///
28/// - **VAL-INFRA-007**: ROCm env uses platform::linux (no duplicate detection)
29/// - **VAL-INFRA-008**: ROCm version detection returns semantic version
30#[derive(Debug, Clone)]
31pub struct RocmEnv {
32    /// Path to the ROCm installation (e.g., `/opt/rocm`).
33    path: Option<PathBuf>,
34    /// ROCm version string (e.g., "6.4.3").
35    version: String,
36}
37
38impl RocmEnv {
39    /// Detect the ROCm environment from the system.
40    ///
41    /// This calls `platform::linux::detect_rocm_path()` and
42    /// `platform::linux::get_rocm_version()` — no duplicate detection.
43    pub fn detect() -> Self {
44        #[cfg(unix)]
45        {
46            let path = linux::detect_rocm_path();
47            let version = linux::get_rocm_version();
48            Self { path, version }
49        }
50        #[cfg(not(unix))]
51        {
52            Self {
53                path: None,
54                version: String::new(),
55            }
56        }
57    }
58
59    /// Create a RocmEnv with known values (useful for testing).
60    pub fn from_known(path: Option<PathBuf>, version: String) -> Self {
61        Self { path, version }
62    }
63
64    /// Create a RocmEnv representing no ROCm installation.
65    pub fn none() -> Self {
66        Self {
67            path: None,
68            version: String::new(),
69        }
70    }
71
72    /// Get the ROCm installation path.
73    pub fn path(&self) -> Option<&PathBuf> {
74        self.path.as_ref()
75    }
76
77    /// Get the ROCm version string.
78    ///
79    /// Returns an empty string if ROCm is not installed or version
80    /// cannot be determined.
81    pub fn version(&self) -> &str {
82        &self.version
83    }
84
85    /// Check if a ROCm installation was found.
86    pub fn is_detected(&self) -> bool {
87        self.path.is_some()
88    }
89
90    /// Get the ROCm bin directory path.
91    ///
92    /// Returns `None` if ROCm is not detected or the bin directory
93    /// does not exist.
94    pub fn bin_path(&self) -> Option<PathBuf> {
95        self.path
96            .as_ref()
97            .map(|p| p.join("bin"))
98            .filter(|p| p.is_dir())
99    }
100
101    /// Get the ROCm lib directory path.
102    ///
103    /// Checks both `lib` and `lib64` subdirectories.
104    pub fn lib_path(&self) -> Option<PathBuf> {
105        self.path.as_ref().and_then(|p| {
106            let lib = p.join("lib");
107            if lib.is_dir() {
108                return Some(lib);
109            }
110            let lib64 = p.join("lib64");
111            if lib64.is_dir() {
112                return Some(lib64);
113            }
114            None
115        })
116    }
117
118    /// Get the ROCm include directory path.
119    pub fn include_path(&self) -> Option<PathBuf> {
120        self.path
121            .as_ref()
122            .map(|p| p.join("include"))
123            .filter(|p| p.is_dir())
124    }
125
126    /// Check if a specific ROCm tool exists.
127    ///
128    /// Checks both in the ROCm bin directory and in the system PATH.
129    pub fn tool_exists(&self, tool_name: &str) -> bool {
130        // Check in ROCm bin directory
131        if let Some(bin) = self.bin_path() {
132            let tool_path = bin.join(tool_name);
133            if tool_path.exists() {
134                return true;
135            }
136        }
137
138        // Check in system PATH
139        which_exists(tool_name)
140    }
141
142    /// Get the full path to a ROCm tool.
143    ///
144    /// Returns `None` if the tool is not found.
145    pub fn tool_path(&self, tool_name: &str) -> Option<PathBuf> {
146        // Check in ROCm bin directory first
147        if let Some(bin) = self.bin_path() {
148            let tool_path = bin.join(tool_name);
149            if tool_path.exists() {
150                return Some(tool_path);
151            }
152        }
153
154        // Check in system PATH
155        if let Ok(output) = std::process::Command::new("which").arg(tool_name).output() {
156            if output.status.success() {
157                let path_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
158                if !path_str.is_empty() {
159                    return Some(PathBuf::from(path_str));
160                }
161            }
162        }
163
164        None
165    }
166
167    /// Get the ROCm major.minor version (e.g., "7.2" from "7.2.0").
168    ///
169    /// Returns an empty string if version cannot be parsed.
170    pub fn version_major_minor(&self) -> String {
171        extract_major_minor(&self.version)
172    }
173
174    /// Get the ROCm series candidates for wheel index probing.
175    ///
176    /// Returns the detected version first, then common fallback versions.
177    pub fn series_candidates(&self) -> Vec<String> {
178        let detected = self.version_major_minor();
179        let mut candidates = Vec::new();
180        if !detected.is_empty() {
181            candidates.push(detected);
182        }
183        for fallback in &[
184            "7.2", "7.1", "7.0", "6.4", "6.3", "6.2", "6.1", "6.0", "5.7",
185        ] {
186            let fb = fallback.to_string();
187            if !candidates.contains(&fb) {
188                candidates.push(fb);
189            }
190        }
191        candidates
192    }
193
194    /// Get the PyTorch index URL for a given channel and ROCm series.
195    pub fn torch_index_url(&self, channel: &str, series: &str) -> String {
196        match channel {
197            "nightly" => format!("https://download.pytorch.org/whl/nightly/rocm{series}"),
198            _ => format!("https://download.pytorch.org/whl/rocm{series}"),
199        }
200    }
201
202    /// Get the Radeon PyTorch index URL for a given ROCm series.
203    pub fn torch_radeon_index_url(&self, series: &str) -> String {
204        format!("https://repo.radeon.com/rocm/manylinux/rocm-rel-{series}/")
205    }
206}
207
208impl Default for RocmEnv {
209    fn default() -> Self {
210        Self::detect()
211    }
212}
213
214// ===========================================================================
215// Helpers
216// ===========================================================================
217
218/// Extract major.minor from a version string.
219fn extract_major_minor(version: &str) -> String {
220    let version = version.trim();
221    if version.is_empty() {
222        return String::new();
223    }
224
225    // Find the first N.N pattern
226    let chars: Vec<char> = version.chars().collect();
227    let mut result = String::new();
228    let mut dot_count = 0;
229
230    for &ch in &chars {
231        if ch == '.' {
232            dot_count += 1;
233            if dot_count > 1 {
234                break;
235            }
236            result.push(ch);
237        } else if ch.is_ascii_digit() {
238            result.push(ch);
239        } else {
240            break;
241        }
242    }
243
244    // Validate it looks like N.N
245    if result.contains('.') && result.len() >= 3 {
246        result
247    } else {
248        String::new()
249    }
250}
251
252/// Check if a command exists in PATH.
253fn which_exists(cmd: &str) -> bool {
254    std::process::Command::new("which")
255        .arg(cmd)
256        .output()
257        .map(|o| o.status.success())
258        .unwrap_or(false)
259}
260
261// ===========================================================================
262// Tests
263// ===========================================================================
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    // --- extract_major_minor ---
270
271    #[test]
272    fn test_extract_major_minor_standard() {
273        assert_eq!(extract_major_minor("7.2.0"), "7.2");
274        assert_eq!(extract_major_minor("6.4.3"), "6.4");
275    }
276
277    #[test]
278    fn test_extract_major_minor_two_part() {
279        assert_eq!(extract_major_minor("7.2"), "7.2");
280    }
281
282    #[test]
283    fn test_extract_major_minor_empty() {
284        assert_eq!(extract_major_minor(""), "");
285    }
286
287    #[test]
288    fn test_extract_major_minor_invalid() {
289        assert_eq!(extract_major_minor("abc"), "");
290    }
291
292    // --- RocmEnv (VAL-INFRA-007, VAL-INFRA-008) ---
293
294    #[test]
295    fn test_rocm_env_detect_no_panic() {
296        // Should not panic even if ROCm is not installed
297        let env = RocmEnv::detect();
298        // version may be empty if no ROCm
299        let _ = env.version();
300    }
301
302    #[test]
303    fn test_rocm_env_from_known() {
304        let env = RocmEnv::from_known(Some(PathBuf::from("/opt/rocm")), "7.2.0".to_string());
305        assert!(env.is_detected());
306        assert_eq!(env.version(), "7.2.0");
307        assert_eq!(env.path(), Some(&PathBuf::from("/opt/rocm")));
308    }
309
310    #[test]
311    fn test_rocm_env_none() {
312        let env = RocmEnv::none();
313        assert!(!env.is_detected());
314        assert!(env.version().is_empty());
315        assert!(env.path().is_none());
316    }
317
318    #[test]
319    fn test_rocm_env_version_major_minor() {
320        let env = RocmEnv::from_known(Some(PathBuf::from("/opt/rocm")), "7.2.0".to_string());
321        assert_eq!(env.version_major_minor(), "7.2");
322    }
323
324    #[test]
325    fn test_rocm_env_version_major_minor_empty() {
326        let env = RocmEnv::none();
327        assert!(env.version_major_minor().is_empty());
328    }
329
330    #[test]
331    fn test_rocm_env_series_candidates() {
332        let env = RocmEnv::from_known(Some(PathBuf::from("/opt/rocm")), "7.2.0".to_string());
333        let candidates = env.series_candidates();
334        assert!(!candidates.is_empty());
335        assert_eq!(candidates[0], "7.2");
336        // Should contain fallbacks
337        assert!(candidates.contains(&"7.1".to_string()));
338        assert!(candidates.contains(&"6.4".to_string()));
339    }
340
341    #[test]
342    fn test_rocm_env_series_candidates_no_duplicates() {
343        let env = RocmEnv::from_known(Some(PathBuf::from("/opt/rocm")), "7.2.0".to_string());
344        let candidates = env.series_candidates();
345        let mut seen = std::collections::HashSet::new();
346        for c in &candidates {
347            assert!(seen.insert(c.clone()), "Duplicate series candidate: {c}");
348        }
349    }
350
351    #[test]
352    fn test_rocm_env_torch_index_url() {
353        let env = RocmEnv::from_known(Some(PathBuf::from("/opt/rocm")), "7.2.0".to_string());
354        assert_eq!(
355            env.torch_index_url("stable", "7.2"),
356            "https://download.pytorch.org/whl/rocm7.2"
357        );
358        assert_eq!(
359            env.torch_index_url("nightly", "7.2"),
360            "https://download.pytorch.org/whl/nightly/rocm7.2"
361        );
362    }
363
364    #[test]
365    fn test_rocm_env_torch_radeon_index_url() {
366        let env = RocmEnv::from_known(Some(PathBuf::from("/opt/rocm")), "7.2.0".to_string());
367        assert_eq!(
368            env.torch_radeon_index_url("7.2"),
369            "https://repo.radeon.com/rocm/manylinux/rocm-rel-7.2/"
370        );
371    }
372
373    #[test]
374    fn test_rocm_env_default() {
375        let env = RocmEnv::default();
376        // Should not panic
377        let _ = env.version();
378    }
379
380    #[test]
381    fn test_rocm_env_bin_path_with_mock() {
382        // Use /usr as a directory that has bin/
383        let env = RocmEnv::from_known(Some(PathBuf::from("/usr")), "1.0.0".to_string());
384        assert!(env.bin_path().is_some());
385    }
386
387    #[test]
388    fn test_rocm_env_lib_path_with_mock() {
389        // Use /usr as a directory that has lib/
390        let env = RocmEnv::from_known(Some(PathBuf::from("/usr")), "1.0.0".to_string());
391        assert!(env.lib_path().is_some());
392    }
393
394    #[test]
395    fn test_rocm_env_include_path_with_mock() {
396        // Use /usr as a directory that has include/
397        let env = RocmEnv::from_known(Some(PathBuf::from("/usr")), "1.0.0".to_string());
398        assert!(env.include_path().is_some());
399    }
400
401    #[test]
402    fn test_rocm_env_bin_path_none() {
403        let env = RocmEnv::none();
404        assert!(env.bin_path().is_none());
405    }
406
407    #[test]
408    fn test_rocm_env_tool_exists_bash() {
409        let env = RocmEnv::from_known(Some(PathBuf::from("/usr")), "1.0.0".to_string());
410        // bash should exist in /usr/bin
411        assert!(env.tool_exists("bash"));
412    }
413
414    #[test]
415    fn test_rocm_env_tool_exists_nonexistent() {
416        let env = RocmEnv::none();
417        assert!(!env.tool_exists("nonexistent_tool_xyz_12345"));
418    }
419
420    #[test]
421    fn test_rocm_env_tool_path_bash() {
422        let env = RocmEnv::from_known(Some(PathBuf::from("/usr")), "1.0.0".to_string());
423        assert!(env.tool_path("bash").is_some());
424    }
425
426    #[test]
427    fn test_rocm_env_tool_path_nonexistent() {
428        let env = RocmEnv::none();
429        assert!(env.tool_path("nonexistent_tool_xyz_12345").is_none());
430    }
431
432    // --- VAL-INFRA-007: Delegation verification ---
433
434    #[test]
435    fn test_rocm_env_delegates_to_platform_linux() {
436        // Verify that detect() returns the same result as calling platform::linux directly
437        let env = RocmEnv::detect();
438        let platform_path = linux::detect_rocm_path();
439        let platform_version = linux::get_rocm_version();
440
441        assert_eq!(env.path(), platform_path.as_ref());
442        assert_eq!(env.version(), platform_version);
443    }
444
445    // --- VAL-INFRA-008: Version returns semantic version or empty ---
446
447    #[test]
448    fn test_rocm_version_no_panic_on_malformed() {
449        let env = RocmEnv::from_known(None, "not-a-version".to_string());
450        // Should not panic
451        let mm = env.version_major_minor();
452        assert!(mm.is_empty()); // malformed version returns empty
453    }
454
455    #[test]
456    fn test_rocm_version_empty_on_not_installed() {
457        let env = RocmEnv::none();
458        assert!(env.version().is_empty());
459        assert!(env.version_major_minor().is_empty());
460    }
461}