Skip to main content

rusty_stack/installers/common/
env_validation.rs

1//! Environment validation — `.mlstack_env` checking with sensible defaults.
2//!
3//! Ports functionality from `scripts/env_validation_utils.sh`:
4//! - Validates `.mlstack_env` file exists and contains required variables
5//! - Provides sensible defaults for missing values
6//! - Validates ROCM_CHANNEL value
7//! - Auto-detects GPU_ARCH and ROCM_VERSION when possible
8//!
9//! # Validation Assertions
10//!
11//! - **VAL-INFRA-017**: .mlstack_env validation with sensible defaults
12//! - **VAL-INFRA-022**: Graceful handling of missing dependencies (no panics)
13
14use crate::installers::common::utils::command_exists;
15use std::collections::HashMap;
16use std::fs;
17use std::path::{Path, PathBuf};
18
19// ===========================================================================
20// Constants
21// ===========================================================================
22
23/// Valid ROCm channel names.
24const VALID_CHANNELS: &[&str] = &["legacy", "stable", "latest", "preview"];
25
26/// Default ROCm version if not detected.
27const DEFAULT_ROCM_VERSION: &str = "7.2.0";
28
29/// Default ROCm channel.
30const DEFAULT_ROCM_CHANNEL: &str = "latest";
31
32/// Default GPU architecture.
33const DEFAULT_GPU_ARCH: &str = "gfx1100";
34
35/// Default venv base directory (relative to home).
36const DEFAULT_VENV_BASE_SUBDIR: &str = ".mlstack/venvs";
37
38// ===========================================================================
39// Types
40// ===========================================================================
41
42/// Result of validating the `.mlstack_env` file.
43#[derive(Debug, Clone)]
44pub struct EnvValidationResult {
45    /// Whether the env file existed.
46    pub file_existed: bool,
47    /// Whether any defaults were applied.
48    pub defaults_applied: bool,
49    /// The resolved environment variables.
50    pub vars: EnvVars,
51    /// Warnings generated during validation.
52    pub warnings: Vec<String>,
53}
54
55/// Resolved environment variables from `.mlstack_env`.
56#[derive(Debug, Clone)]
57pub struct EnvVars {
58    /// ROCm version string (e.g., "7.2.0").
59    pub rocm_version: String,
60    /// ROCm channel (legacy, stable, latest, preview).
61    pub rocm_channel: String,
62    /// GPU architecture (e.g., "gfx1100").
63    pub gpu_arch: String,
64    /// ML Stack venv base directory.
65    pub venv_base: String,
66    /// Python binary path (if set).
67    pub python_bin: Option<String>,
68}
69
70impl Default for EnvVars {
71    fn default() -> Self {
72        Self {
73            rocm_version: DEFAULT_ROCM_VERSION.to_string(),
74            rocm_channel: DEFAULT_ROCM_CHANNEL.to_string(),
75            gpu_arch: DEFAULT_GPU_ARCH.to_string(),
76            venv_base: String::new(),
77            python_bin: None,
78        }
79    }
80}
81
82// ===========================================================================
83// Validation Functions
84// ===========================================================================
85
86/// Get the default path for `.mlstack_env`.
87pub fn default_env_path() -> PathBuf {
88    dirs::home_dir()
89        .unwrap_or_else(|| PathBuf::from("/tmp"))
90        .join(".mlstack_env")
91}
92
93/// Get the default venv base directory.
94pub fn default_venv_base() -> String {
95    let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/tmp"));
96    home.join(DEFAULT_VENV_BASE_SUBDIR)
97        .to_string_lossy()
98        .to_string()
99}
100
101/// Validate the `.mlstack_env` file and return resolved variables.
102///
103/// If the file does not exist, auto-detects values from the system.
104/// Missing values are filled with sensible defaults.
105/// Invalid channel values are corrected to "latest".
106///
107/// # Validation
108///
109/// - **VAL-INFRA-017**: .mlstack_env validation with sensible defaults
110/// - **VAL-INFRA-022**: Graceful handling of missing dependencies
111///
112/// This function never panics. If detection fails, defaults are used.
113pub fn validate_mlstack_env(script_name: Option<&str>) -> EnvValidationResult {
114    let env_path = default_env_path();
115    let mut warnings = Vec::new();
116    let mut defaults_applied = false;
117
118    if !env_path.exists() {
119        warnings.push(format!(
120            "Environment file not found: {} - using auto-detection",
121            env_path.display()
122        ));
123        let vars = auto_detect_vars(&mut defaults_applied);
124        return EnvValidationResult {
125            file_existed: false,
126            defaults_applied: true,
127            vars,
128            warnings,
129        };
130    }
131
132    // Parse the env file
133    let contents = match fs::read_to_string(&env_path) {
134        Ok(c) => c,
135        Err(e) => {
136            warnings.push(format!("Failed to read {}: {e}", env_path.display()));
137            let vars = auto_detect_vars(&mut defaults_applied);
138            return EnvValidationResult {
139                file_existed: true,
140                defaults_applied: true,
141                vars,
142                warnings,
143            };
144        }
145    };
146
147    let parsed = parse_env_file(&contents);
148
149    // Build EnvVars from parsed values with defaults
150    let mut vars = EnvVars {
151        rocm_version: String::new(),
152        rocm_channel: String::new(),
153        gpu_arch: String::new(),
154        venv_base: String::new(),
155        python_bin: None,
156    };
157    let _ = script_name; // used for warning context
158
159    // ROCM_VERSION
160    if let Some(v) = parsed.get("ROCM_VERSION") {
161        vars.rocm_version = v.clone();
162    } else {
163        vars.rocm_version = detect_rocm_version_or_default();
164        defaults_applied = true;
165        warnings.push("ROCM_VERSION not set, using auto-detection".to_string());
166    }
167
168    // ROCM_CHANNEL
169    if let Some(v) = parsed.get("ROCM_CHANNEL") {
170        let lower = v.to_lowercase();
171        if VALID_CHANNELS.contains(&lower.as_str()) {
172            vars.rocm_channel = lower;
173        } else {
174            warnings.push(format!(
175                "Unknown ROCM_CHANNEL '{v}', defaulting to 'latest'"
176            ));
177            vars.rocm_channel = DEFAULT_ROCM_CHANNEL.to_string();
178            defaults_applied = true;
179        }
180    } else {
181        vars.rocm_channel = DEFAULT_ROCM_CHANNEL.to_string();
182        defaults_applied = true;
183        warnings.push("ROCM_CHANNEL not set, defaulting to 'latest'".to_string());
184    }
185
186    // GPU_ARCH
187    if let Some(v) = parsed.get("GPU_ARCH") {
188        let arch = v.trim();
189        if arch == "gfx000" {
190            // Substitute detected arch or flag invalid
191            if let Some(detected) = crate::gpu::detect_discrete_amd_gpus()
192                .first()
193                .and_then(|g| g.gfx_arch.clone())
194            {
195                vars.gpu_arch = detected;
196                warnings
197                    .push("GPU_ARCH was gfx000 (invalid), substituted detected arch".to_string());
198            } else {
199                warnings.push(
200                    "GPU_ARCH is gfx000 and no GPU detected — env file may be invalid".to_string(),
201                );
202                vars.gpu_arch = "gfx1100".to_string();
203            }
204        } else {
205            vars.gpu_arch = v.clone();
206        }
207    } else {
208        vars.gpu_arch = detect_gpu_arch_or_default();
209        defaults_applied = true;
210        warnings.push("GPU_ARCH not set, using auto-detection".to_string());
211    }
212
213    // MLSTACK_VENV_BASE
214    vars.venv_base = parsed
215        .get("MLSTACK_VENV_BASE")
216        .cloned()
217        .unwrap_or_else(default_venv_base);
218
219    // MLSTACK_PYTHON_BIN
220    vars.python_bin = parsed.get("MLSTACK_PYTHON_BIN").cloned();
221
222    EnvValidationResult {
223        file_existed: true,
224        defaults_applied,
225        vars,
226        warnings,
227    }
228}
229
230/// Require `.mlstack_env` validation — always succeeds with defaults.
231///
232/// This is the equivalent of `require_mlstack_env` in the shell scripts.
233/// It never fails; missing values are always filled with defaults.
234pub fn require_mlstack_env(script_name: Option<&str>) -> EnvValidationResult {
235    validate_mlstack_env(script_name)
236}
237
238/// Load the `.mlstack_env` file into the current process environment.
239///
240/// Equivalent to `source ~/.mlstack_env` in the shell scripts.
241/// Returns `true` if the file was loaded successfully.
242///
243/// Handles both direct exports (`export VAR=value`) and if-guarded exports
244/// (`if [ -z "${VAR:-}" ]; then export VAR=value; fi`). Shell suffixes are
245/// stripped to prevent `; fi` from appearing in environment variable values.
246pub fn load_mlstack_env() -> bool {
247    let env_path = default_env_path();
248    if !env_path.exists() {
249        return false;
250    }
251
252    let contents = match fs::read_to_string(&env_path) {
253        Ok(c) => c,
254        Err(_) => return false,
255    };
256
257    for line in contents.lines() {
258        let trimmed = line.trim();
259        if trimmed.is_empty() || trimmed.starts_with('#') {
260            continue;
261        }
262
263        if let Some((key, raw_value)) = extract_export_assignment(trimmed) {
264            let value = strip_shell_suffixes(&raw_value);
265            // Remove surrounding quotes if present
266            let value = value
267                .strip_prefix('"')
268                .and_then(|v| v.strip_suffix('"'))
269                .unwrap_or(&value);
270            // Skip values with shell variable expansions ($PATH, etc.)
271            if !value.contains('$') {
272                std::env::set_var(&key, value);
273            }
274        }
275    }
276
277    true
278}
279
280// ===========================================================================
281// Helpers
282// ===========================================================================
283
284/// Parse an env file into a HashMap of key-value pairs.
285///
286/// Handles two formats:
287/// 1. Direct exports: `export VAR=value`
288/// 2. If-guarded exports: `if [ -z "${VAR:-}" ]; then export VAR=value; fi`
289///
290/// Shell suffixes (`; fi`, `&&`, etc.) are stripped from values to prevent
291/// shell script remnants from appearing in parsed environment variable values.
292fn parse_env_file(contents: &str) -> HashMap<String, String> {
293    let mut map = HashMap::new();
294
295    for line in contents.lines() {
296        let trimmed = line.trim();
297        if trimmed.is_empty() || trimmed.starts_with('#') {
298            continue;
299        }
300
301        // Extract any "export KEY=VALUE" assignments from the line.
302        // This handles both direct exports and if-guarded exports.
303        if let Some(key_value) = extract_export_assignment(trimmed) {
304            let (key, value) = key_value;
305            let value = strip_shell_suffixes(&value);
306            let value = value
307                .strip_prefix('"')
308                .and_then(|v| v.strip_suffix('"'))
309                .unwrap_or(&value)
310                .to_string();
311            map.insert(key, value);
312        }
313    }
314    map
315}
316
317/// Extract a `KEY=VALUE` pair from a line containing `export KEY=VALUE`.
318///
319/// Handles:
320/// - `export KEY=VALUE`
321/// - `if [ -z "${KEY:-}" ]; then export KEY=VALUE; fi`
322/// - `    export KEY=VALUE` (indented inside if block)
323fn extract_export_assignment(line: &str) -> Option<(String, String)> {
324    // Find the last occurrence of "export " in the line (handles indented exports)
325    let export_idx = line.rfind("export ")?;
326    let rest = &line[export_idx + 7..]; // skip "export "
327
328    if let Some((key, value)) = rest.split_once('=') {
329        let key = key.trim().to_string();
330        if key.is_empty() || key.contains(' ') || key.contains('$') {
331            return None;
332        }
333        let value = value.trim().to_string();
334        Some((key, value))
335    } else {
336        None
337    }
338}
339
340/// Strip shell command suffixes from a value string.
341///
342/// Removes trailing:
343/// - `; fi` (from if-guard lines)
344/// - `&& ...` (from chained commands)
345/// - Any remaining `;` followed by shell keywords
346fn strip_shell_suffixes(value: &str) -> String {
347    let mut value = value.to_string();
348
349    // Strip trailing "; fi" (possibly with whitespace)
350    if let Some(idx) = value.find("; fi") {
351        value.truncate(idx);
352    }
353    if let Some(idx) = value.find(";& fi") {
354        value.truncate(idx);
355    }
356
357    // Strip trailing "&& ..." (chained commands)
358    if let Some(idx) = value.find("&& ") {
359        value.truncate(idx);
360    }
361
362    // Strip trailing "; then" (shouldn't normally appear in values but safety net)
363    if let Some(idx) = value.find("; then") {
364        value.truncate(idx);
365    }
366
367    value.trim().to_string()
368}
369
370/// Auto-detect environment variables from the system.
371fn auto_detect_vars(defaults_applied: &mut bool) -> EnvVars {
372    *defaults_applied = true;
373    EnvVars {
374        rocm_version: detect_rocm_version_or_default(),
375        rocm_channel: DEFAULT_ROCM_CHANNEL.to_string(),
376        gpu_arch: detect_gpu_arch_or_default(),
377        venv_base: default_venv_base(),
378        python_bin: None,
379    }
380}
381
382/// Detect ROCm version or return default.
383fn detect_rocm_version_or_default() -> String {
384    // Try platform::linux first
385    #[cfg(unix)]
386    {
387        let version = crate::platform::linux::get_rocm_version();
388        if !version.is_empty() {
389            return version;
390        }
391    }
392
393    // Fallback: read version file directly
394    let version_file = Path::new("/opt/rocm/.info/version");
395    if let Ok(contents) = fs::read_to_string(version_file) {
396        let v = contents.trim().to_string();
397        if !v.is_empty() {
398            return v;
399        }
400    }
401
402    DEFAULT_ROCM_VERSION.to_string()
403}
404
405/// Detect GPU architecture or return default.
406fn detect_gpu_arch_or_default() -> String {
407    // Try rocminfo with resolved full path (avoids "command not found" under sudo)
408    let rocminfo_path = crate::installers::common::utils::resolve_rocminfo_path();
409    if command_exists(&rocminfo_path) || command_exists("rocminfo") {
410        if let Ok(output) = std::process::Command::new(&rocminfo_path).output() {
411            if output.status.success() {
412                let stdout = String::from_utf8_lossy(&output.stdout);
413                for line in stdout.lines() {
414                    let trimmed = line.trim();
415                    if trimmed.starts_with("Name:") || trimmed.contains("gfx") {
416                        // Extract gfx version
417                        if let Some(idx) = trimmed.find("gfx") {
418                            let rest = &trimmed[idx..];
419                            let gfx: String = rest
420                                .chars()
421                                .take_while(|c| c.is_ascii_alphanumeric())
422                                .collect();
423                            if gfx.starts_with("gfx") && gfx.len() >= 5 {
424                                return gfx;
425                            }
426                        }
427                    }
428                }
429            }
430        }
431    }
432
433    DEFAULT_GPU_ARCH.to_string()
434}
435
436// ===========================================================================
437// Tests
438// ===========================================================================
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    // --- parse_env_file tests ---
445
446    #[test]
447    fn test_parse_env_file_basic() {
448        let contents = r#"
449# Comment
450export ROCM_VERSION=7.2.0
451export ROCM_CHANNEL=latest
452export GPU_ARCH=gfx1100
453"#;
454        let map = parse_env_file(contents);
455        assert_eq!(map.get("ROCM_VERSION").unwrap(), "7.2.0");
456        assert_eq!(map.get("ROCM_CHANNEL").unwrap(), "latest");
457        assert_eq!(map.get("GPU_ARCH").unwrap(), "gfx1100");
458    }
459
460    #[test]
461    fn test_parse_env_file_quoted_values() {
462        let contents = r#"
463export PATH="/opt/rocm/bin:$PATH"
464export ROCM_PATH=/opt/rocm
465"#;
466        let map = parse_env_file(contents);
467        assert_eq!(map.get("PATH").unwrap(), "/opt/rocm/bin:$PATH");
468        assert_eq!(map.get("ROCM_PATH").unwrap(), "/opt/rocm");
469    }
470
471    #[test]
472    fn test_parse_env_file_empty() {
473        let map = parse_env_file("");
474        assert!(map.is_empty());
475    }
476
477    #[test]
478    fn test_parse_env_file_comments_only() {
479        let contents = "# just comments\n# another comment\n";
480        let map = parse_env_file(contents);
481        assert!(map.is_empty());
482    }
483
484    // --- validate_mlstack_env tests (VAL-INFRA-017) ---
485
486    #[test]
487    fn test_validate_missing_env_file() {
488        // Use a temp dir to ensure no .mlstack_env exists
489        let _temp_dir = tempfile::tempdir().unwrap();
490        // We can't easily override the home dir, but we can test the auto-detect path
491        let result = validate_mlstack_env(Some("test_script"));
492        // Should always succeed (never panic)
493        assert!(
494            result.vars.rocm_channel == "latest"
495                || result.vars.rocm_channel == "stable"
496                || result.vars.rocm_channel == "legacy"
497                || result.vars.rocm_channel == "preview"
498        );
499    }
500
501    #[test]
502    fn test_validate_always_returns_defaults() {
503        let result = validate_mlstack_env(None);
504        // Should always have non-empty values
505        assert!(!result.vars.rocm_version.is_empty());
506        assert!(!result.vars.rocm_channel.is_empty());
507        assert!(!result.vars.gpu_arch.is_empty());
508    }
509
510    #[test]
511    fn test_require_mlstack_env_always_succeeds() {
512        let result = require_mlstack_env(None);
513        // Should always return a result with valid defaults
514        assert!(!result.vars.rocm_version.is_empty());
515    }
516
517    #[test]
518    fn test_valid_channels() {
519        for ch in VALID_CHANNELS {
520            assert!(VALID_CHANNELS.contains(ch));
521        }
522    }
523
524    // --- EnvVars default ---
525
526    #[test]
527    fn test_env_vars_default() {
528        let vars = EnvVars::default();
529        assert_eq!(vars.rocm_version, "7.2.0");
530        assert_eq!(vars.rocm_channel, "latest");
531        assert_eq!(vars.gpu_arch, "gfx1100");
532        assert!(vars.python_bin.is_none());
533    }
534
535    // --- default_venv_base ---
536
537    #[test]
538    fn test_default_venv_base() {
539        let base = default_venv_base();
540        assert!(base.contains(".mlstack/venvs"));
541    }
542
543    // --- load_mlstack_env ---
544
545    #[test]
546    fn test_load_mlstack_env_nonexistent() {
547        // Should return false without panicking
548        // This tests the actual default path which may or may not exist
549        let _ = load_mlstack_env();
550    }
551
552    // --- detect_rocm_version_or_default ---
553
554    #[test]
555    fn test_detect_rocm_version_no_panic() {
556        let version = detect_rocm_version_or_default();
557        assert!(!version.is_empty());
558    }
559
560    // --- detect_gpu_arch_or_default ---
561
562    #[test]
563    fn test_detect_gpu_arch_no_panic() {
564        let arch = detect_gpu_arch_or_default();
565        assert!(!arch.is_empty());
566        assert!(arch.starts_with("gfx"));
567    }
568
569    // --- Integration: parse + validate with known content ---
570
571    #[test]
572    fn test_validate_with_valid_channel() {
573        // This tests the channel validation logic indirectly
574        let vars = EnvVars::default();
575        assert!(VALID_CHANNELS.contains(&vars.rocm_channel.as_str()));
576    }
577
578    #[test]
579    fn test_gpu_arch_format() {
580        let arch = detect_gpu_arch_or_default();
581        // Should be gfx followed by digits
582        assert!(arch.starts_with("gfx"));
583        assert!(arch.len() >= 5); // gfx + at least 2 digits
584    }
585
586    // --- Shell suffix stripping tests (fix-persistent-env-detection) ---
587
588    #[test]
589    fn test_parse_env_file_if_guard_single_line() {
590        // The env_setup.rs generates lines like:
591        // if [ -z "${HIP_VISIBLE_DEVICES:-}" ]; then export HIP_VISIBLE_DEVICES=0,1,2; fi
592        let contents =
593            r#"if [ -z "${HIP_VISIBLE_DEVICES:-}" ]; then export HIP_VISIBLE_DEVICES=0,1,2; fi"#;
594        let map = parse_env_file(contents);
595        let value = map.get("HIP_VISIBLE_DEVICES").unwrap();
596        assert_eq!(value, "0,1,2", "Should strip '; fi' suffix, got: {value}");
597        assert!(
598            !value.contains("; fi"),
599            "Value must not contain shell suffixes"
600        );
601    }
602
603    #[test]
604    fn test_parse_env_file_rocm_version_if_guard() {
605        let contents = r#"if [ -z "${ROCM_VERSION:-}" ]; then export ROCM_VERSION=7.2.2; fi"#;
606        let map = parse_env_file(contents);
607        let value = map.get("ROCM_VERSION").unwrap();
608        assert_eq!(value, "7.2.2", "Should strip '; fi' suffix, got: {value}");
609    }
610
611    #[test]
612    fn test_parse_env_file_rocm_home_if_guard() {
613        let contents = r#"if [ -z "${ROCM_HOME:-}" ]; then export ROCM_HOME=/opt/rocm; fi"#;
614        let map = parse_env_file(contents);
615        let value = map.get("ROCM_HOME").unwrap();
616        assert_eq!(
617            value, "/opt/rocm",
618            "Should strip '; fi' suffix, got: {value}"
619        );
620    }
621
622    #[test]
623    fn test_parse_env_file_mixed_formats() {
624        // Mix of if-guard and direct export lines, like the real .mlstack_env
625        let contents = r#"
626# ML Stack Environment File
627if [ -z "${HIP_VISIBLE_DEVICES:-}" ]; then export HIP_VISIBLE_DEVICES=0,1,2; fi
628if [ -z "${ROCM_VERSION:-}" ]; then export ROCM_VERSION=7.2.2; fi
629export GPU_ARCH=gfx1100
630export HSA_OVERRIDE_GFX_VERSION=11.0.0
631if [ -z "${HSA_ENABLE_SDMA:-}" ]; then export HSA_ENABLE_SDMA=0; fi
632"#;
633        let map = parse_env_file(contents);
634
635        assert_eq!(map.get("HIP_VISIBLE_DEVICES").unwrap(), "0,1,2");
636        assert_eq!(map.get("ROCM_VERSION").unwrap(), "7.2.2");
637        assert_eq!(map.get("GPU_ARCH").unwrap(), "gfx1100");
638        assert_eq!(map.get("HSA_OVERRIDE_GFX_VERSION").unwrap(), "11.0.0");
639        assert_eq!(map.get("HSA_ENABLE_SDMA").unwrap(), "0");
640
641        // Verify no shell suffixes in any value
642        for (key, value) in &map {
643            assert!(
644                !value.contains("; fi"),
645                "{key}: Value contains '; fi': {value}"
646            );
647            assert!(!value.contains("&&"), "{key}: Value contains '&&': {value}");
648        }
649    }
650
651    #[test]
652    fn test_parse_env_file_multiline_if_guard() {
653        // Some if-guards span multiple lines
654        let contents = r#"if [ -z "${HSA_TOOLS_LIB:-}" ]; then
655    if [ -f "/opt/rocm/lib/rocprofiler-sdk/librocprofiler-sdk-tool.so" ]; then
656        export HSA_TOOLS_LIB="/opt/rocm/lib/rocprofiler-sdk/librocprofiler-sdk-tool.so"
657    else
658        export HSA_TOOLS_LIB=0
659    fi
660fi"#;
661        let map = parse_env_file(contents);
662        // Should extract the last export in the if block
663        let value = map.get("HSA_TOOLS_LIB");
664        assert!(
665            value.is_some(),
666            "Should find HSA_TOOLS_LIB in multiline if-guard"
667        );
668        let val = value.unwrap();
669        assert!(!val.contains("fi"), "Value should not contain 'fi': {val}");
670    }
671
672    #[test]
673    fn test_parse_env_file_no_shell_suffixes_in_values() {
674        let contents = r#"
675if [ -z "${CUDA_VISIBLE_DEVICES:-}" ]; then export CUDA_VISIBLE_DEVICES=0,1,2; fi
676if [ -z "${PYTORCH_ROCM_DEVICE:-}" ]; then export PYTORCH_ROCM_DEVICE=0,1,2; fi
677if [ -z "${ROCM_CHANNEL:-}" ]; then export ROCM_CHANNEL=latest; fi
678"#;
679        let map = parse_env_file(contents);
680        for (key, value) in &map {
681            assert!(!value.contains(';'), "{key}: Value contains ';': {value}");
682            assert!(!value.contains("fi"), "{key}: Value contains 'fi': {value}");
683        }
684    }
685
686    #[test]
687    fn test_parse_env_file_direct_export_unmodified() {
688        // Direct exports (no if-guard) should still work correctly
689        let contents = r#"
690export ROCM_VERSION=7.2.2
691export GPU_ARCH=gfx1100
692export ROCM_HOME=/opt/rocm
693"#;
694        let map = parse_env_file(contents);
695        assert_eq!(map.get("ROCM_VERSION").unwrap(), "7.2.2");
696        assert_eq!(map.get("GPU_ARCH").unwrap(), "gfx1100");
697        assert_eq!(map.get("ROCM_HOME").unwrap(), "/opt/rocm");
698    }
699
700    #[test]
701    fn test_parse_env_file_permanent_env_format() {
702        // Content generated by permanent_env.rs (direct export, no if-guards)
703        let contents = r#"# Permanent ROCm Environment Setup (Generated by rusty-stack)
704export ROCM_VERSION=7.2.0
705export ROCM_CHANNEL=latest
706export GPU_ARCH=gfx1100
707export PYTORCH_ROCM_ARCH=gfx1100
708export ROCM_HOME=/opt/rocm
709export ROCM_PATH=/opt/rocm
710export HIP_VISIBLE_DEVICES=0
711export CUDA_VISIBLE_DEVICES=0
712export MLSTACK_PYTHON_BIN=python3
713export HSA_OVERRIDE_GFX_VERSION=11.0.0
714"#;
715        let map = parse_env_file(contents);
716        assert_eq!(map.get("ROCM_VERSION").unwrap(), "7.2.0");
717        assert_eq!(map.get("HIP_VISIBLE_DEVICES").unwrap(), "0");
718        assert_eq!(map.get("HSA_OVERRIDE_GFX_VERSION").unwrap(), "11.0.0");
719    }
720}