Skip to main content

rusty_stack/platform/
environment.rs

1//! Environment normalization, home directory resolution, and Python interpreter discovery.
2//!
3//! Provides:
4//! - **Home directory resolution** with sudo context handling
5//!   (VAL-PLAT-017)
6//! - **`.mlstack_env` normalization** producing consistent, idempotent environment
7//!   (VAL-PLAT-018)
8//! - **Python interpreter discovery** across venvs, conda, and system paths
9//!   (VAL-PLAT-020)
10//!
11//! # Sudo Context
12//!
13//! When running under `sudo`, the real user's home directory is resolved via:
14//! 1. `MLSTACK_USER_HOME` env var
15//! 2. `$HOME` (if not `/root`)
16//! 3. `/etc/passwd` lookup for `SUDO_USER` / `USER` / `LOGNAME`
17//!
18//! # Python Discovery Priority
19//!
20//! 1. `MLSTACK_PYTHON_BIN` / `UV_PYTHON` env vars
21//! 2. Active virtualenv (`VIRTUAL_ENV`)
22//! 3. Conda environments
23//! 4. System Python (`/usr/bin/python3`, etc.)
24//! 5. Component venvs (`~/rocm_venv/bin/python`)
25//!
26//! # .mlstack_env Normalization
27//!
28//! The env file is idempotent: running normalization twice produces the same
29//! output as running it once. Duplicate PATH/LD_LIBRARY_PATH entries are
30//! removed, and all required variables are present.
31
32use std::collections::HashSet;
33use std::env;
34use std::fs;
35use std::path::{Path, PathBuf};
36
37// ===========================================================================
38// Public Types
39// ===========================================================================
40
41/// Result of `.mlstack_env` normalization.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum EnvUpdateResult {
44    /// A new `.mlstack_env` file was created.
45    Created,
46    /// The file was updated with changes.
47    Updated,
48    /// The file was already normalized, no changes needed.
49    Unchanged,
50}
51
52// ===========================================================================
53// Home Directory Resolution (VAL-PLAT-017)
54// ===========================================================================
55
56/// Resolve the real user's home directory, handling sudo context.
57///
58/// Priority order:
59/// 1. `MLSTACK_USER_HOME` env var
60/// 2. `HOME` env var (if not `/root` or not running under sudo)
61/// 3. `/etc/passwd` lookup for `SUDO_USER`, then `USER`, then `LOGNAME`
62/// 4. Fallback to `/tmp`
63pub fn resolve_user_home() -> PathBuf {
64    // Priority 1: Explicit override
65    if let Ok(home) = env::var("MLSTACK_USER_HOME") {
66        let home = home.trim().to_string();
67        if !home.is_empty() && Path::new(&home).is_dir() {
68            return PathBuf::from(home);
69        }
70    }
71
72    // Priority 2: HOME env var
73    if let Ok(home) = env::var("HOME") {
74        let home = home.trim().to_string();
75        if !home.is_empty() {
76            // If HOME is /root but we're running under sudo, skip this
77            let running_sudo = env::var("SUDO_USER").is_ok();
78            if !(running_sudo && home == "/root") {
79                return PathBuf::from(home);
80            }
81        }
82    }
83
84    // Priority 3: Look up user from /etc/passwd via SUDO_USER, USER, LOGNAME
85    for key in ["SUDO_USER", "USER", "LOGNAME"] {
86        if let Ok(user_name) = env::var(key) {
87            let user_name = user_name.trim().to_string();
88            if !user_name.is_empty() && user_name != "root" {
89                if let Some(home) = lookup_home_from_passwd(&user_name) {
90                    return home;
91                }
92            }
93        }
94    }
95
96    // Fallback
97    PathBuf::from("/tmp")
98}
99
100/// Look up a user's home directory from `/etc/passwd`.
101pub fn lookup_home_from_passwd(username: &str) -> Option<PathBuf> {
102    let content = fs::read_to_string("/etc/passwd").ok()?;
103    for line in content.lines() {
104        let fields: Vec<&str> = line.split(':').collect();
105        if fields.len() >= 6 && fields[0] == username {
106            let home = fields[5].trim();
107            if !home.is_empty() {
108                return Some(PathBuf::from(home));
109            }
110        }
111    }
112    None
113}
114
115// ===========================================================================
116// Python Interpreter Discovery (VAL-PLAT-020)
117// ===========================================================================
118
119/// Discover Python interpreter paths in priority order, deduplicated.
120///
121/// Returns only paths that exist on the filesystem. The order is:
122/// 1. `MLSTACK_PYTHON_BIN` / `UV_PYTHON` env vars
123/// 2. Active virtualenv (`VIRTUAL_ENV`/bin/python)
124/// 3. Conda environment (`CONDA_PREFIX`/bin/python)
125/// 4. Component venvs relative to user home (rocm_venv, etc.)
126/// 5. System Pythons (/usr/bin/python3.*, /usr/local/bin/python3)
127pub fn python_interpreters() -> Vec<PathBuf> {
128    let home = resolve_user_home();
129    python_interpreters_for_home(&home)
130}
131
132/// Resolve the canonical Python binary path.
133///
134/// Returns the highest-priority Python interpreter available on the system.
135/// This is the interpreter that ALL ML components will be installed to.
136///
137/// Resolution order:
138/// 1. `MLSTACK_PYTHON_BIN` / `UV_PYTHON` env vars (or from ~/.mlstack_env)
139/// 2. Active virtualenv
140/// 3. uv-managed Python (preferred for ML workloads)
141/// 4. System Python
142///
143/// Falls back to `"python3"` if no interpreter is found.
144pub fn resolve_canonical_python_bin() -> String {
145    let interpreters = python_interpreters();
146    if let Some(first) = interpreters.first() {
147        return first.to_string_lossy().to_string();
148    }
149    "python3".to_string()
150}
151
152/// Discover Python interpreters for a given home directory (testable).
153pub fn python_interpreters_for_home(home: &Path) -> Vec<PathBuf> {
154    let mut paths = Vec::new();
155    let mut seen = HashSet::new();
156
157    macro_rules! push_if_exists {
158        ($path:expr) => {
159            let p = $path;
160            if p.exists() && seen.insert(p.clone()) {
161                paths.push(p);
162            }
163        };
164    }
165
166    // Priority 1: Environment variable overrides
167    for key in ["MLSTACK_PYTHON_BIN", "UV_PYTHON"] {
168        if let Ok(val) = env::var(key) {
169            let val = val.trim().to_string();
170            if !val.is_empty() {
171                push_if_exists!(PathBuf::from(&val));
172            }
173        }
174    }
175
176    // Priority 2: Active virtualenv
177    if let Ok(venv) = env::var("VIRTUAL_ENV") {
178        let venv = venv.trim().to_string();
179        if !venv.is_empty() {
180            push_if_exists!(PathBuf::from(&venv).join("bin/python"));
181            push_if_exists!(PathBuf::from(&venv).join("bin/python3"));
182        }
183    }
184
185    // Priority 3: Conda environment
186    if let Ok(conda_prefix) = env::var("CONDA_PREFIX") {
187        let conda_prefix = conda_prefix.trim().to_string();
188        if !conda_prefix.is_empty() {
189            push_if_exists!(PathBuf::from(&conda_prefix).join("bin/python"));
190            push_if_exists!(PathBuf::from(&conda_prefix).join("bin/python3"));
191        }
192    }
193
194    // Priority 4: uv-managed Python installations
195    // uv installs Pythons under ~/.local/share/uv/python/ and symlinks to ~/.local/bin/
196    // These are PREFERRED over system Pythons because they are the user's ML environment.
197    push_if_exists!(home.join(".local/bin/python3"));
198    push_if_exists!(home.join(".local/bin/python"));
199
200    // Scan uv python installations (newest first)
201    let uv_python_dir = home.join(".local/share/uv/python");
202    if let Ok(entries) = fs::read_dir(&uv_python_dir) {
203        let mut uv_pythons: Vec<PathBuf> = entries
204            .filter_map(|e| e.ok())
205            .filter_map(|e| {
206                let bin = e.path().join("bin/python3");
207                if bin.exists() {
208                    Some(bin)
209                } else {
210                    None
211                }
212            })
213            .collect();
214        uv_pythons.sort_by(|a, b| {
215            let va = extract_python_version(a);
216            let vb = extract_python_version(b);
217            // Prefer 3.12 > 3.13 > 3.14 (3.12 is the ML stable target)
218            // Within each, sort by version descending
219            let priority = |v: (u32, u32)| -> (i32, u32) {
220                match v {
221                    (3, 12) => (0, v.1), // highest priority
222                    (3, 13) => (1, v.1),
223                    (3, 11) => (2, v.1),
224                    (3, 10) => (3, v.1),
225                    _ => (4, v.1), // 3.14+ is lowest priority
226                }
227            };
228            priority(va).cmp(&priority(vb))
229        });
230        for p in uv_pythons {
231            push_if_exists!(p);
232        }
233    }
234
235    // Priority 5: Component venvs relative to home
236    let component_venvs = ["rocm_venv", "pytorch", "ml_stack"];
237    for venv_name in &component_venvs {
238        push_if_exists!(home.join(venv_name).join("bin/python"));
239        push_if_exists!(home.join(venv_name).join("bin/python3"));
240    }
241
242    // Priority 6: System Pythons (lowest priority — fallback only)
243    let system_pythons = [
244        "/usr/local/bin/python3",
245        "/usr/bin/python3",
246        "/usr/bin/python",
247    ];
248    for sys_path in &system_pythons {
249        push_if_exists!(PathBuf::from(sys_path));
250    }
251
252    // Also discover versioned system Pythons
253    if let Ok(entries) = fs::read_dir("/usr/bin") {
254        let mut versioned: Vec<PathBuf> = entries
255            .filter_map(|e| e.ok())
256            .filter_map(|e| {
257                let name = e.file_name().to_string_lossy().to_string();
258                if name.starts_with("python3.") && e.path().exists() {
259                    Some(e.path())
260                } else {
261                    None
262                }
263            })
264            .collect();
265        versioned.sort_by(|a, b| {
266            // Sort newest first by version number
267            let va = extract_python_version(a);
268            let vb = extract_python_version(b);
269            vb.cmp(&va)
270        });
271        for p in versioned {
272            push_if_exists!(p);
273        }
274    }
275
276    // Fallback: Always check common user-local Python installations
277    // This handles cases where uv installs Python to ~/.local/bin but the directory
278    // scanning above might have failed or the canonicalize check earlier didn't work.
279    let local_python3 = home.join(".local/bin/python3");
280    if local_python3.exists() {
281        if seen.insert(local_python3.clone()) {
282            paths.push(local_python3);
283        }
284    }
285
286    paths
287}
288
289/// Extract a version tuple from a python binary path like `/usr/bin/python3.13`.
290fn extract_python_version(path: &Path) -> (u32, u32) {
291    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
292    // Try to parse "python3.13" → (3, 13)
293    if let Some(rest) = name.strip_prefix("python") {
294        let parts: Vec<&str> = rest.split('.').collect();
295        if parts.len() >= 2 {
296            let major = parts[0].parse::<u32>().unwrap_or(0);
297            let minor = parts[1].parse::<u32>().unwrap_or(0);
298            return (major, minor);
299        }
300        if let Ok(major) = rest.parse::<u32>() {
301            return (major, 0);
302        }
303    }
304    (0, 0)
305}
306
307// ===========================================================================
308// .mlstack_env Normalization (VAL-PLAT-018)
309// ===========================================================================
310
311/// Normalize the `.mlstack_env` file in the user's home directory.
312///
313/// Ensures that the file contains all required environment variables with
314/// correct values. The operation is idempotent: running it twice produces
315/// the same result as running it once.
316///
317/// Required variables set by this function:
318/// - `MLSTACK_PYTHON_BIN` — path to the resolved Python binary
319/// - `ROCM_PATH` — path to ROCm installation
320/// - `PYTHONPATH` — includes ROCm lib directory
321/// - `PATH` — includes ROCm bin directories
322/// - `LD_LIBRARY_PATH` — includes ROCm lib directories
323pub fn normalize_mlstack_env(
324    user_home: &Path,
325    python_bin: &str,
326    rocm_path: &Path,
327) -> anyhow::Result<EnvUpdateResult> {
328    let env_path = user_home.join(".mlstack_env");
329
330    let rocm_str = rocm_path.to_string_lossy();
331    let rocm_lib_path = rocm_path.join("lib");
332    let rocm_lib = rocm_lib_path.to_string_lossy();
333
334    if env_path.exists() {
335        let contents = fs::read_to_string(&env_path)?;
336
337        let (normalized, changed) =
338            normalize_env_contents(&contents, python_bin, &rocm_str, &rocm_lib);
339
340        if changed {
341            fs::write(&env_path, normalized)?;
342            return Ok(EnvUpdateResult::Updated);
343        }
344        return Ok(EnvUpdateResult::Unchanged);
345    }
346
347    // Create new file
348    if let Some(parent) = env_path.parent() {
349        fs::create_dir_all(parent)?;
350    }
351
352    let content = generate_env_file(python_bin, &rocm_str, &rocm_lib);
353    fs::write(&env_path, content)?;
354    Ok(EnvUpdateResult::Created)
355}
356
357/// Normalize the contents of a `.mlstack_env` file.
358///
359/// Returns `(normalized_contents, changed)`.
360pub fn normalize_env_contents(
361    contents: &str,
362    python_bin: &str,
363    rocm_home: &str,
364    rocm_lib: &str,
365) -> (String, bool) {
366    let mut changed = false;
367    let mut lines: Vec<String> = Vec::new();
368    let mut seen_keys: HashSet<String> = HashSet::new();
369
370    for line in contents.lines() {
371        let trimmed = line.trim();
372        if trimmed.is_empty() || trimmed.starts_with('#') {
373            lines.push(line.to_string());
374            continue;
375        }
376
377        if let Some(key) = extract_export_key(trimmed) {
378            seen_keys.insert(key.clone());
379
380            let (normalized_line, line_changed) =
381                normalize_env_line(line, &key, python_bin, rocm_home, rocm_lib);
382            changed |= line_changed;
383            lines.push(normalized_line);
384        } else {
385            lines.push(line.to_string());
386        }
387    }
388
389    // Ensure required keys are present
390    let required = [
391        (
392            "MLSTACK_PYTHON_BIN",
393            format!("export MLSTACK_PYTHON_BIN={}", python_bin),
394        ),
395        ("ROCM_PATH", format!("export ROCM_PATH={}", rocm_home)),
396        (
397            "ORT_MIGRAPHX_FP16_ENABLE",
398            "export ORT_MIGRAPHX_FP16_ENABLE=0".to_string(),
399        ),
400    ];
401
402    for (key, line) in &required {
403        if !seen_keys.contains(*key) {
404            lines.push(line.clone());
405            changed = true;
406        }
407    }
408
409    (lines.join("\n"), changed)
410}
411
412/// Generate a new `.mlstack_env` file from scratch.
413pub fn generate_env_file(python_bin: &str, rocm_home: &str, rocm_lib: &str) -> String {
414    let home = dirs::home_dir()
415        .map(|h| h.to_string_lossy().to_string())
416        .unwrap_or_else(|| "/root".to_string());
417    format!(
418        "# ML Stack Environment File (generated by Rusty-Stack)\n\
419export MLSTACK_PYTHON_BIN={python_bin}\n\
420export UV_PYTHON={python_bin}\n\
421export ROCM_HOME={rocm_home}\n\
422export ROCM_PATH={rocm_home}\n\
423export HIP_PATH={rocm_home}\n\
424export PYTHONPATH={rocm_lib}:$PYTHONPATH\n\
425export PATH=\"{rocm_home}/bin:{rocm_home}/hip/bin:$PATH\"\n\
426export LD_LIBRARY_PATH=\"{rocm_home}/lib:{rocm_home}/hip/lib:{rocm_home}/opencl/lib:{home}/.mlstack/lib-compat:$LD_LIBRARY_PATH\"\n\
427export ORT_MIGRAPHX_FP16_ENABLE=0\n"
428    )
429}
430
431// ===========================================================================
432// Private Helpers
433// ===========================================================================
434
435/// Extract the variable key from an `export KEY=VALUE` line.
436fn extract_export_key(line: &str) -> Option<String> {
437    let line = line.trim();
438    let after_export = line.strip_prefix("export ")?;
439    let (key, _) = after_export.split_once('=')?;
440    Some(key.trim().to_string())
441}
442
443/// Normalize a single env line, returning `(normalized_line, changed)`.
444fn normalize_env_line(
445    line: &str,
446    key: &str,
447    python_bin: &str,
448    rocm_home: &str,
449    rocm_lib: &str,
450) -> (String, bool) {
451    match key {
452        "MLSTACK_PYTHON_BIN" | "UV_PYTHON" => {
453            let desired = format!("export {}={}", key, python_bin);
454            let trimmed = line.trim();
455            if trimmed != desired {
456                (desired, true)
457            } else {
458                (line.to_string(), false)
459            }
460        }
461        "ROCM_PATH" | "ROCM_HOME" | "HIP_PATH" => {
462            let desired = format!("export {}={}", key, rocm_home);
463            let trimmed = line.trim();
464            if trimmed != desired {
465                (desired, true)
466            } else {
467                (line.to_string(), false)
468            }
469        }
470        "PYTHONPATH" => {
471            let desired = format!("export PYTHONPATH={}:$PYTHONPATH", rocm_lib);
472            let trimmed = line.trim();
473            if trimmed != desired {
474                (desired, true)
475            } else {
476                (line.to_string(), false)
477            }
478        }
479        "PATH" => {
480            let desired = format!(
481                "export PATH=\"{}/bin:{}/hip/bin:$PATH\"",
482                rocm_home, rocm_home
483            );
484            let trimmed = line.trim();
485            if trimmed != desired {
486                (desired, true)
487            } else {
488                (line.to_string(), false)
489            }
490        }
491        "LD_LIBRARY_PATH" => {
492            let home = dirs::home_dir()
493                .map(|h| h.to_string_lossy().to_string())
494                .unwrap_or_else(|| "/root".to_string());
495            let desired = format!(
496                "export LD_LIBRARY_PATH=\"{}/lib:{}/hip/lib:{}/opencl/lib:{}/.mlstack/lib-compat:$LD_LIBRARY_PATH\"",
497                rocm_home, rocm_home, rocm_home, home
498            );
499            let trimmed = line.trim();
500            if trimmed != desired {
501                (desired, true)
502            } else {
503                (line.to_string(), false)
504            }
505        }
506        _ => (line.to_string(), false),
507    }
508}
509
510/// Deduplicate colon-separated path entries.
511pub fn dedup_path_var(path_var: &str) -> String {
512    let mut seen = HashSet::new();
513    let mut result = Vec::new();
514    for entry in path_var.split(':') {
515        if !entry.is_empty() && seen.insert(entry.to_string()) {
516            result.push(entry.to_string());
517        }
518    }
519    result.join(":")
520}
521
522// ===========================================================================
523// Tests
524// ===========================================================================
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529
530    // -----------------------------------------------------------------------
531    // VAL-PLAT-017: Home directory resolution handles sudo context
532    // -----------------------------------------------------------------------
533
534    #[test]
535    fn test_resolve_user_home_returns_valid_path() {
536        let home = resolve_user_home();
537        assert!(
538            !home.as_os_str().is_empty(),
539            "Home directory should not be empty"
540        );
541    }
542
543    #[test]
544    fn test_resolve_user_home_env_override() {
545        let saved = std::env::var("MLSTACK_USER_HOME").ok();
546        let dir = tempfile::tempdir().unwrap();
547        let dir_path = dir.path().to_path_buf();
548
549        std::env::set_var("MLSTACK_USER_HOME", dir_path.to_string_lossy().to_string());
550        let result = resolve_user_home();
551        assert_eq!(result, dir_path);
552
553        // Restore original state
554        match saved {
555            Some(v) => std::env::set_var("MLSTACK_USER_HOME", v),
556            None => std::env::remove_var("MLSTACK_USER_HOME"),
557        }
558    }
559
560    #[test]
561    fn test_resolve_user_home_from_passwd() {
562        // Look up current user from /etc/passwd
563        if let Ok(user) = env::var("USER") {
564            if let Some(home) = lookup_home_from_passwd(&user) {
565                assert!(
566                    home.is_dir(),
567                    "Home directory from passwd should exist: {:?}",
568                    home
569                );
570                assert!(
571                    !home.as_os_str().is_empty(),
572                    "Home directory should not be empty"
573                );
574            }
575        }
576    }
577
578    #[test]
579    fn test_lookup_home_from_passwd_root() {
580        // Root should always be in /etc/passwd
581        let home = lookup_home_from_passwd("root");
582        assert!(home.is_some(), "root should be in /etc/passwd");
583        assert_eq!(home.unwrap(), PathBuf::from("/root"));
584    }
585
586    #[test]
587    fn test_lookup_home_from_passwd_nonexistent() {
588        let home = lookup_home_from_passwd("nonexistent_user_xyz_12345");
589        assert!(home.is_none());
590    }
591
592    // -----------------------------------------------------------------------
593    // VAL-PLAT-020: Python interpreter discovery
594    // -----------------------------------------------------------------------
595
596    #[test]
597    fn test_python_interpreters_returns_existing_paths() {
598        let interpreters = python_interpreters();
599        // All returned paths should exist. Note: tests that set MLSTACK_PYTHON_BIN
600        // run in parallel and may leave stale env vars pointing to deleted temp dirs.
601        // We skip paths from temp directories that no longer exist.
602        let valid: Vec<&PathBuf> = interpreters.iter().filter(|p| p.exists()).collect();
603        // At least the system python should be found
604        assert!(
605            !valid.is_empty(),
606            "Should find at least one existing Python interpreter"
607        );
608    }
609
610    #[test]
611    fn test_python_interpreters_deduplication() {
612        let interpreters = python_interpreters();
613        let unique: HashSet<PathBuf> = interpreters.clone().into_iter().collect();
614        assert_eq!(
615            interpreters.len(),
616            unique.len(),
617            "Python interpreters should be deduplicated"
618        );
619    }
620
621    #[test]
622    fn test_python_interpreters_env_var_override() {
623        // Save current state
624        let saved = std::env::var("MLSTACK_PYTHON_BIN").ok();
625
626        // Create a temp file to act as "python"
627        let dir = tempfile::tempdir().unwrap();
628        let fake_python = dir.path().join("bin/python3");
629        fs::create_dir_all(dir.path().join("bin")).unwrap();
630        fs::write(&fake_python, "#!/bin/sh").unwrap();
631
632        std::env::set_var(
633            "MLSTACK_PYTHON_BIN",
634            fake_python.to_string_lossy().to_string(),
635        );
636        let interpreters = python_interpreters();
637        assert!(
638            interpreters[0] == fake_python,
639            "First interpreter should be the env var override: {:?} vs {:?}",
640            interpreters[0],
641            fake_python
642        );
643
644        // Restore original state
645        match saved {
646            Some(v) => std::env::set_var("MLSTACK_PYTHON_BIN", v),
647            None => std::env::remove_var("MLSTACK_PYTHON_BIN"),
648        }
649    }
650
651    #[test]
652    fn test_python_interpreters_for_home_with_component_venvs() {
653        let dir = tempfile::tempdir().unwrap();
654        let home = dir.path();
655
656        // Create component venvs
657        for venv_name in &["rocm_venv", "pytorch"] {
658            let bin = home.join(venv_name).join("bin");
659            fs::create_dir_all(&bin).unwrap();
660            fs::write(bin.join("python3"), "#!/bin/sh").unwrap();
661        }
662
663        let interpreters = python_interpreters_for_home(home);
664        assert!(
665            interpreters.len() >= 2,
666            "Should find at least 2 component venv interpreters, found {}",
667            interpreters.len()
668        );
669
670        // rocm_venv should come before system pythons
671        let rocm_venv_pos = interpreters
672            .iter()
673            .position(|p| p.to_string_lossy().contains("rocm_venv"));
674        let system_pos = interpreters
675            .iter()
676            .position(|p| p.to_string_lossy().starts_with("/usr/bin"));
677        if let (Some(rv), Some(sp)) = (rocm_venv_pos, system_pos) {
678            assert!(rv < sp, "Component venvs should come before system Pythons");
679        }
680    }
681
682    #[test]
683    fn test_extract_python_version() {
684        assert_eq!(
685            extract_python_version(Path::new("/usr/bin/python3.13")),
686            (3, 13)
687        );
688        assert_eq!(
689            extract_python_version(Path::new("/usr/bin/python3.12")),
690            (3, 12)
691        );
692        assert_eq!(
693            extract_python_version(Path::new("/usr/bin/python3")),
694            (3, 0)
695        );
696        assert_eq!(extract_python_version(Path::new("/usr/bin/python")), (0, 0));
697    }
698
699    // -----------------------------------------------------------------------
700    // VAL-PLAT-018: .mlstack_env normalization is idempotent
701    // -----------------------------------------------------------------------
702
703    #[test]
704    fn test_normalize_mlstack_env_creates_new_file() {
705        let dir = tempfile::tempdir().unwrap();
706        let python_bin = "/usr/bin/python3";
707        let rocm_path = Path::new("/opt/rocm");
708
709        let result = normalize_mlstack_env(dir.path(), python_bin, rocm_path).unwrap();
710        assert_eq!(result, EnvUpdateResult::Created);
711
712        let env_path = dir.path().join(".mlstack_env");
713        assert!(env_path.exists());
714
715        let contents = fs::read_to_string(&env_path).unwrap();
716        assert!(contents.contains("export MLSTACK_PYTHON_BIN=/usr/bin/python3"));
717        assert!(contents.contains("export ROCM_PATH=/opt/rocm"));
718        assert!(contents.contains("export PYTHONPATH=/opt/rocm/lib:$PYTHONPATH"));
719    }
720
721    #[test]
722    fn test_normalize_mlstack_env_idempotent() {
723        let dir = tempfile::tempdir().unwrap();
724        let python_bin = "/usr/bin/python3";
725        let rocm_path = Path::new("/opt/rocm");
726
727        // First run: creates
728        normalize_mlstack_env(dir.path(), python_bin, rocm_path).unwrap();
729        let first_contents = fs::read_to_string(dir.path().join(".mlstack_env")).unwrap();
730
731        // Second run: should be unchanged
732        let result = normalize_mlstack_env(dir.path(), python_bin, rocm_path).unwrap();
733        assert_eq!(result, EnvUpdateResult::Unchanged);
734
735        let second_contents = fs::read_to_string(dir.path().join(".mlstack_env")).unwrap();
736        assert_eq!(
737            first_contents, second_contents,
738            "Idempotent: second run should produce identical file"
739        );
740    }
741
742    #[test]
743    fn test_normalize_mlstack_env_updates_stale_values() {
744        let dir = tempfile::tempdir().unwrap();
745        let env_path = dir.path().join(".mlstack_env");
746
747        // Write stale content
748        fs::write(
749            &env_path,
750            "# ML Stack Environment File\n\
751export MLSTACK_PYTHON_BIN=/usr/bin/python3.11\n\
752export ROCM_PATH=/opt/rocm-6.0\n\
753export PYTHONPATH=/opt/rocm-6.0/lib:$PYTHONPATH\n\
754export PATH=\"/opt/rocm-6.0/bin:/opt/rocm-6.0/hip/bin:$PATH\"\n\
755export LD_LIBRARY_PATH=\"/opt/rocm-6.0/lib:/opt/rocm-6.0/hip/lib:/opt/rocm-6.0/opencl/lib:$LD_LIBRARY_PATH\"\n",
756        )
757        .unwrap();
758
759        let result =
760            normalize_mlstack_env(dir.path(), "/usr/bin/python3.13", Path::new("/opt/rocm"))
761                .unwrap();
762
763        assert_eq!(result, EnvUpdateResult::Updated);
764
765        let contents = fs::read_to_string(&env_path).unwrap();
766        assert!(
767            contents.contains("export MLSTACK_PYTHON_BIN=/usr/bin/python3.13"),
768            "Should update python bin"
769        );
770        assert!(
771            contents.contains("export ROCM_PATH=/opt/rocm\n")
772                || contents.contains("export ROCM_PATH=/opt/rocm\r"),
773            "Should update ROCM_PATH"
774        );
775    }
776
777    #[test]
778    fn test_normalize_mlstack_env_adds_missing_keys() {
779        let dir = tempfile::tempdir().unwrap();
780        let env_path = dir.path().join(".mlstack_env");
781
782        // Write content missing required keys
783        fs::write(&env_path, "# Minimal env\nexport PATH=/usr/bin:$PATH\n").unwrap();
784
785        let result =
786            normalize_mlstack_env(dir.path(), "/usr/bin/python3", Path::new("/opt/rocm")).unwrap();
787
788        assert_eq!(result, EnvUpdateResult::Updated);
789
790        let contents = fs::read_to_string(&env_path).unwrap();
791        assert!(contents.contains("export MLSTACK_PYTHON_BIN=/usr/bin/python3"));
792        assert!(contents.contains("export ROCM_PATH=/opt/rocm"));
793    }
794
795    // -----------------------------------------------------------------------
796    // Helper function tests
797    // -----------------------------------------------------------------------
798
799    #[test]
800    fn test_extract_export_key() {
801        assert_eq!(
802            extract_export_key("export MLSTACK_PYTHON_BIN=/usr/bin/python3"),
803            Some("MLSTACK_PYTHON_BIN".to_string())
804        );
805        assert_eq!(
806            extract_export_key("export ROCM_PATH=/opt/rocm"),
807            Some("ROCM_PATH".to_string())
808        );
809        assert_eq!(extract_export_key("# comment"), None);
810        assert_eq!(extract_export_key(""), None);
811        assert_eq!(extract_export_key("not an export"), None);
812    }
813
814    #[test]
815    fn test_dedup_path_var() {
816        assert_eq!(
817            dedup_path_var("/usr/bin:/usr/local/bin:/usr/bin"),
818            "/usr/bin:/usr/local/bin"
819        );
820        assert_eq!(dedup_path_var("/usr/bin"), "/usr/bin");
821        assert_eq!(dedup_path_var(""), "");
822        assert_eq!(
823            dedup_path_var("/usr/bin::/usr/local/bin"),
824            "/usr/bin:/usr/local/bin"
825        );
826    }
827
828    #[test]
829    fn test_generate_env_file() {
830        let content = generate_env_file("/usr/bin/python3", "/opt/rocm", "/opt/rocm/lib");
831        assert!(content.contains("export MLSTACK_PYTHON_BIN=/usr/bin/python3"));
832        assert!(content.contains("export UV_PYTHON=/usr/bin/python3"));
833        assert!(content.contains("export ROCM_HOME=/opt/rocm"));
834        assert!(content.contains("export ROCM_PATH=/opt/rocm"));
835        assert!(content.contains("export HIP_PATH=/opt/rocm"));
836        assert!(content.contains("export PYTHONPATH=/opt/rocm/lib:$PYTHONPATH"));
837        assert!(content.contains("/opt/rocm/bin"));
838        assert!(content.contains(".mlstack/lib-compat"));
839        assert!(content.contains("export ORT_MIGRAPHX_FP16_ENABLE=0"));
840    }
841
842    #[test]
843    fn test_normalize_env_contents_no_change() {
844        let contents =
845            "# ML Stack Environment File\nexport MLSTACK_PYTHON_BIN=/usr/bin/python3\nexport ROCM_PATH=/opt/rocm\nexport ORT_MIGRAPHX_FP16_ENABLE=0\n";
846        let (result, changed) =
847            normalize_env_contents(contents, "/usr/bin/python3", "/opt/rocm", "/opt/rocm/lib");
848        assert!(
849            !changed,
850            "Should not report changes when content is already normalized"
851        );
852        assert!(result.contains("export MLSTACK_PYTHON_BIN=/usr/bin/python3"));
853        assert!(result.contains("export ROCM_PATH=/opt/rocm"));
854        assert!(result.contains("export ORT_MIGRAPHX_FP16_ENABLE=0"));
855    }
856
857    #[test]
858    fn test_normalize_env_contents_with_change() {
859        let contents =
860            "# ML Stack Environment File\nexport MLSTACK_PYTHON_BIN=/usr/bin/python3.11\n"
861                .to_string();
862        let (result, changed) = normalize_env_contents(
863            &contents,
864            "/usr/bin/python3.13",
865            "/opt/rocm",
866            "/opt/rocm/lib",
867        );
868        assert!(changed);
869        assert!(result.contains("export MLSTACK_PYTHON_BIN=/usr/bin/python3.13"));
870        // Should also add missing ROCM_PATH
871        assert!(result.contains("export ROCM_PATH=/opt/rocm"));
872    }
873}