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// ML Stack paths — the SINGLE consolidated root (~/.mlstack/)
117// ===========================================================================
118
119/// The single ML Stack root directory: `~/.mlstack/`.
120///
121/// All Rusty-managed state lives under here (Stage 0 consolidation): the
122/// global venv (`global/`), named envs (`envs/<name>/`), logs, cache, triton,
123/// the installed-component registry (`installed.json`), and config.
124pub fn mlstack_root() -> PathBuf {
125    resolve_user_home().join(".mlstack")
126}
127
128/// The managed **global** default environment: `~/.mlstack/global/`.
129pub fn mlstack_global_dir() -> PathBuf {
130    mlstack_root().join("global")
131}
132
133/// The Python binary of the managed global env: `~/.mlstack/global/bin/python`.
134pub fn mlstack_global_python() -> PathBuf {
135    mlstack_global_dir().join("bin").join("python")
136}
137
138/// The named-envs directory: `~/.mlstack/envs/`.
139pub fn mlstack_envs_dir() -> PathBuf {
140    mlstack_root().join("envs")
141}
142
143/// A specific named env: `~/.mlstack/envs/<name>/`.
144pub fn mlstack_env_dir(name: &str) -> PathBuf {
145    mlstack_envs_dir().join(name)
146}
147
148/// The cache directory: `~/.mlstack/cache/`.
149pub fn mlstack_cache_dir() -> PathBuf {
150    mlstack_root().join("cache")
151}
152
153/// The logs directory: `~/.mlstack/logs/`.
154pub fn mlstack_logs_dir() -> PathBuf {
155    mlstack_root().join("logs")
156}
157
158/// The triton home directory: `~/.mlstack/triton/`.
159pub fn mlstack_triton_dir() -> PathBuf {
160    mlstack_root().join("triton")
161}
162
163/// Ensure the `~/.mlstack/` root and its standard subdirs exist.
164pub fn ensure_mlstack_dirs() -> std::io::Result<()> {
165    for dir in [
166        mlstack_root(),
167        mlstack_global_dir(),
168        mlstack_envs_dir(),
169        mlstack_cache_dir(),
170        mlstack_logs_dir(),
171        mlstack_triton_dir(),
172    ] {
173        std::fs::create_dir_all(&dir)?;
174    }
175    Ok(())
176}
177
178/// Create the managed global venv at `~/.mlstack/global/` if it does not yet
179/// exist, using `bootstrap_python` (a discovered system/uv interpreter) as the
180/// base. Returns the path to the global venv's `python`.
181///
182/// Prefers `uv venv` when available (fast, deterministic); falls back to
183/// `python -m venv`. This is the deterministic anchor that replaces the old
184/// non-deterministic interpreter scan for global installs.
185pub fn ensure_global_venv(bootstrap_python: &str) -> anyhow::Result<PathBuf> {
186    let global_python = mlstack_global_python();
187    if global_python.exists() {
188        return Ok(global_python);
189    }
190    ensure_mlstack_dirs()?;
191    let global_dir = mlstack_global_dir();
192    create_venv(&global_dir, bootstrap_python)?;
193    if !global_python.exists() {
194        anyhow::bail!(
195            "global venv creation reported success but {} is missing",
196            global_python.display()
197        );
198    }
199    Ok(global_python)
200}
201
202/// Create the managed NAMED env at `~/.mlstack/envs/<name>/` if absent
203/// (Tenet 1: install-to-env isolation). Returns the path to its `python`.
204///
205/// `name` is validated (non-empty, no path separators / traversal) since it is
206/// interpolated into a filesystem path.
207pub fn ensure_named_venv(name: &str, bootstrap_python: &str) -> anyhow::Result<PathBuf> {
208    let name = name.trim();
209    if name.is_empty() {
210        anyhow::bail!("named env name must not be empty");
211    }
212    if name.contains(std::path::MAIN_SEPARATOR)
213        || name.contains('/')
214        || name.contains('\\')
215        || name.contains("..")
216    {
217        anyhow::bail!("invalid env name '{name}'");
218    }
219    ensure_mlstack_dirs()?;
220    let env_dir = mlstack_env_dir(name);
221    let env_python = env_dir.join("bin").join("python");
222    if env_python.exists() {
223        return Ok(env_python);
224    }
225    create_venv(&env_dir, bootstrap_python)?;
226    if !env_python.exists() {
227        anyhow::bail!(
228            "named venv creation reported success but {} is missing",
229            env_python.display()
230        );
231    }
232    Ok(env_python)
233}
234
235/// Create a venv at `dir` using `uv` when available, else `python -m venv`.
236fn create_venv(dir: &Path, bootstrap_python: &str) -> anyhow::Result<()> {
237    if command_on_path("uv") {
238        // Treat a uv SPAWN failure (e.g. uv on PATH but not executable / missing
239        // runtime) the same as a non-successful run: fall through to the
240        // `python -m venv` fallback rather than bailing with a spawn error.
241        let uv_status = std::process::Command::new("uv")
242            .arg("venv")
243            .arg("--python")
244            .arg(bootstrap_python)
245            .arg(dir)
246            .status();
247        if let Ok(status) = uv_status {
248            if status.success() {
249                return Ok(());
250            }
251        }
252    }
253    let status = std::process::Command::new(bootstrap_python)
254        .arg("-m")
255        .arg("venv")
256        .arg(dir)
257        .status()?;
258    if !status.success() {
259        anyhow::bail!(
260            "failed to create venv at {} via uv/venv (bootstrap={})",
261            dir.display(),
262            bootstrap_python
263        );
264    }
265    Ok(())
266}
267
268// ===========================================================================
269// Python Interpreter Discovery (VAL-PLAT-020)
270// ===========================================================================
271
272/// Discover Python interpreter paths in priority order, deduplicated.
273///
274/// Returns only paths that exist on the filesystem. The order is:
275/// 0. The managed global env (`~/.mlstack/global/bin/python`) — deterministic anchor
276/// 1. `MLSTACK_PYTHON_BIN` / `UV_PYTHON` env vars
277/// 2. Active virtualenv (`VIRTUAL_ENV`/bin/python)
278/// 3. Conda environment (`CONDA_PREFIX`/bin/python)
279/// 4. Component venvs relative to user home (rocm_venv, etc.)
280/// 5. System Pythons (/usr/bin/python3.*, /usr/local/bin/python3)
281pub fn python_interpreters() -> Vec<PathBuf> {
282    let home = resolve_user_home();
283    python_interpreters_for_home(&home)
284}
285
286/// Resolve the canonical Python binary path — the single interpreter ALL ML
287/// components install into.
288///
289/// Resolution order:
290/// 1. `MLSTACK_PYTHON_BIN` / `UV_PYTHON` — an **explicit user override** always
291///    wins (respects user intent; e.g. pinning a specific interpreter).
292/// 2. The managed global env (`~/.mlstack/global/bin/python`) if it exists —
293///    the deterministic single source (Stage 0). Installers create it on first
294///    global install via [`ensure_global_venv`].
295/// 3. Active virtualenv
296/// 4. uv-managed Python (preferred for ML workloads)
297/// 5. System Python
298///
299/// Falls back to `"python3"` if no interpreter is found.
300pub fn resolve_canonical_python_bin() -> String {
301    // 0. Named-env isolation (Tenet 1): MLSTACK_ENV_NAME pins the target env.
302    if let Ok(name) = env::var("MLSTACK_ENV_NAME") {
303        let name = name.trim();
304        if !name.is_empty() {
305            let p = mlstack_env_dir(name).join("bin").join("python");
306            if p.exists() {
307                return p.to_string_lossy().to_string();
308            }
309        }
310    }
311    // 1. Explicit user override wins.
312    for key in ["MLSTACK_PYTHON_BIN", "UV_PYTHON"] {
313        if let Ok(val) = env::var(key) {
314            let val = val.trim();
315            if !val.is_empty() && Path::new(val).exists() {
316                return val.to_string();
317            }
318        }
319    }
320    // 2. Deterministic managed global env (single source).
321    let global = mlstack_global_python();
322    if global.exists() {
323        return global.to_string_lossy().to_string();
324    }
325    // 3. Discovery fallback.
326    let interpreters = python_interpreters();
327    if let Some(first) = interpreters.first() {
328        return first.to_string_lossy().to_string();
329    }
330    "python3".to_string()
331}
332
333/// Discover Python interpreters for a given home directory (testable).
334pub fn python_interpreters_for_home(home: &Path) -> Vec<PathBuf> {
335    let mut paths = Vec::new();
336    let mut seen = HashSet::new();
337
338    macro_rules! push_if_exists {
339        ($path:expr) => {
340            let p = $path;
341            if p.exists() && seen.insert(p.clone()) {
342                paths.push(p);
343            }
344        };
345    }
346
347    // Priority 1: Environment variable overrides — an explicit override wins
348    // (kept first so discovery is consistent with `resolve_canonical_python_bin`).
349    for key in ["MLSTACK_PYTHON_BIN", "UV_PYTHON"] {
350        if let Ok(val) = env::var(key) {
351            let val = val.trim().to_string();
352            if !val.is_empty() {
353                push_if_exists!(PathBuf::from(&val));
354            }
355        }
356    }
357
358    // Priority 2: The managed global env (~/.mlstack/global/bin/python) —
359    // the deterministic single source (Stage 0). Derived from `home` so the
360    // override is consistent with `resolve_user_home()`.
361    push_if_exists!(home
362        .join(".mlstack")
363        .join("global")
364        .join("bin")
365        .join("python"));
366    push_if_exists!(home
367        .join(".mlstack")
368        .join("global")
369        .join("bin")
370        .join("python3"));
371
372    // Priority 2: Active virtualenv
373    if let Ok(venv) = env::var("VIRTUAL_ENV") {
374        let venv = venv.trim().to_string();
375        if !venv.is_empty() {
376            push_if_exists!(PathBuf::from(&venv).join("bin/python"));
377            push_if_exists!(PathBuf::from(&venv).join("bin/python3"));
378        }
379    }
380
381    // Priority 3: Conda environment
382    if let Ok(conda_prefix) = env::var("CONDA_PREFIX") {
383        let conda_prefix = conda_prefix.trim().to_string();
384        if !conda_prefix.is_empty() {
385            push_if_exists!(PathBuf::from(&conda_prefix).join("bin/python"));
386            push_if_exists!(PathBuf::from(&conda_prefix).join("bin/python3"));
387        }
388    }
389
390    // Priority 4: uv-managed Python installations
391    // uv installs Pythons under ~/.local/share/uv/python/ and symlinks to ~/.local/bin/
392    // These are PREFERRED over system Pythons because they are the user's ML environment.
393    push_if_exists!(home.join(".local/bin/python3"));
394    push_if_exists!(home.join(".local/bin/python"));
395
396    // Scan uv python installations (newest first)
397    let uv_python_dir = home.join(".local/share/uv/python");
398    if let Ok(entries) = fs::read_dir(&uv_python_dir) {
399        let mut uv_pythons: Vec<PathBuf> = entries
400            .filter_map(|e| e.ok())
401            .filter_map(|e| {
402                let bin = e.path().join("bin/python3");
403                if bin.exists() {
404                    Some(bin)
405                } else {
406                    None
407                }
408            })
409            .collect();
410        uv_pythons.sort_by(|a, b| {
411            let va = extract_python_version(a);
412            let vb = extract_python_version(b);
413            // Prefer 3.12 > 3.13 > 3.14 (3.12 is the ML stable target)
414            // Within each, sort by version descending
415            let priority = |v: (u32, u32)| -> (i32, u32) {
416                match v {
417                    (3, 12) => (0, v.1), // highest priority
418                    (3, 13) => (1, v.1),
419                    (3, 11) => (2, v.1),
420                    (3, 10) => (3, v.1),
421                    _ => (4, v.1), // 3.14+ is lowest priority
422                }
423            };
424            priority(va).cmp(&priority(vb))
425        });
426        for p in uv_pythons {
427            push_if_exists!(p);
428        }
429    }
430
431    // Priority 5: Component venvs relative to home
432    let component_venvs = ["rocm_venv", "pytorch", "ml_stack"];
433    for venv_name in &component_venvs {
434        push_if_exists!(home.join(venv_name).join("bin/python"));
435        push_if_exists!(home.join(venv_name).join("bin/python3"));
436    }
437
438    // Priority 6: System Pythons (lowest priority — fallback only)
439    let system_pythons = [
440        "/usr/local/bin/python3",
441        "/usr/bin/python3",
442        "/usr/bin/python",
443    ];
444    for sys_path in &system_pythons {
445        push_if_exists!(PathBuf::from(sys_path));
446    }
447
448    // Also discover versioned system Pythons
449    if let Ok(entries) = fs::read_dir("/usr/bin") {
450        let mut versioned: Vec<PathBuf> = entries
451            .filter_map(|e| e.ok())
452            .filter_map(|e| {
453                let name = e.file_name().to_string_lossy().to_string();
454                if name.starts_with("python3.") && e.path().exists() {
455                    Some(e.path())
456                } else {
457                    None
458                }
459            })
460            .collect();
461        versioned.sort_by(|a, b| {
462            // Sort newest first by version number
463            let va = extract_python_version(a);
464            let vb = extract_python_version(b);
465            vb.cmp(&va)
466        });
467        for p in versioned {
468            push_if_exists!(p);
469        }
470    }
471
472    // Fallback: Always check common user-local Python installations
473    // This handles cases where uv installs Python to ~/.local/bin but the directory
474    // scanning above might have failed or the canonicalize check earlier didn't work.
475    let local_python3 = home.join(".local/bin/python3");
476    if local_python3.exists() && seen.insert(local_python3.clone()) {
477        paths.push(local_python3);
478    }
479
480    paths
481}
482
483/// Extract a version tuple from a python binary path like `/usr/bin/python3.13`.
484fn extract_python_version(path: &Path) -> (u32, u32) {
485    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
486    // Try to parse "python3.13" → (3, 13)
487    if let Some(rest) = name.strip_prefix("python") {
488        let parts: Vec<&str> = rest.split('.').collect();
489        if parts.len() >= 2 {
490            let major = parts[0].parse::<u32>().unwrap_or(0);
491            let minor = parts[1].parse::<u32>().unwrap_or(0);
492            return (major, minor);
493        }
494        if let Ok(major) = rest.parse::<u32>() {
495            return (major, 0);
496        }
497    }
498    (0, 0)
499}
500
501// ===========================================================================
502// .mlstack_env Normalization (VAL-PLAT-018)
503// ===========================================================================
504
505/// Normalize the `.mlstack_env` file in the user's home directory.
506///
507/// Ensures that the file contains all required environment variables with
508/// correct values. The operation is idempotent: running it twice produces
509/// the same result as running it once.
510///
511/// Required variables set by this function:
512/// - `MLSTACK_PYTHON_BIN` — path to the resolved Python binary
513/// - `ROCM_PATH` — path to ROCm installation
514/// - `PYTHONPATH` — includes ROCm lib directory
515/// - `PATH` — includes ROCm bin directories
516/// - `LD_LIBRARY_PATH` — includes ROCm lib directories
517pub fn normalize_mlstack_env(
518    user_home: &Path,
519    python_bin: &str,
520    rocm_path: &Path,
521) -> anyhow::Result<EnvUpdateResult> {
522    let env_path = user_home.join(".mlstack_env");
523
524    let rocm_str = rocm_path.to_string_lossy();
525    let rocm_lib_path = rocm_path.join("lib");
526    let rocm_lib = rocm_lib_path.to_string_lossy();
527
528    if env_path.exists() {
529        let contents = fs::read_to_string(&env_path)?;
530
531        let (normalized, changed) =
532            normalize_env_contents(&contents, python_bin, &rocm_str, &rocm_lib);
533
534        if changed {
535            fs::write(&env_path, normalized)?;
536            return Ok(EnvUpdateResult::Updated);
537        }
538        return Ok(EnvUpdateResult::Unchanged);
539    }
540
541    // Create new file
542    if let Some(parent) = env_path.parent() {
543        fs::create_dir_all(parent)?;
544    }
545
546    let content = generate_env_file(python_bin, &rocm_str, &rocm_lib);
547    fs::write(&env_path, content)?;
548    Ok(EnvUpdateResult::Created)
549}
550
551/// Normalize the contents of a `.mlstack_env` file.
552///
553/// Returns `(normalized_contents, changed)`.
554pub fn normalize_env_contents(
555    contents: &str,
556    python_bin: &str,
557    rocm_home: &str,
558    rocm_lib: &str,
559) -> (String, bool) {
560    let mut changed = false;
561    let mut lines: Vec<String> = Vec::new();
562    let mut seen_keys: HashSet<String> = HashSet::new();
563
564    for line in contents.lines() {
565        let trimmed = line.trim();
566        if trimmed.is_empty() || trimmed.starts_with('#') {
567            lines.push(line.to_string());
568            continue;
569        }
570
571        if let Some(key) = extract_export_key(trimmed) {
572            seen_keys.insert(key.clone());
573
574            let (normalized_line, line_changed) =
575                normalize_env_line(line, &key, python_bin, rocm_home, rocm_lib);
576            changed |= line_changed;
577            lines.push(normalized_line);
578        } else {
579            lines.push(line.to_string());
580        }
581    }
582
583    // Ensure required keys are present
584    let required = [
585        (
586            "MLSTACK_PYTHON_BIN",
587            format!("export MLSTACK_PYTHON_BIN={}", python_bin),
588        ),
589        ("ROCM_PATH", format!("export ROCM_PATH={}", rocm_home)),
590        (
591            "ORT_MIGRAPHX_FP16_ENABLE",
592            "export ORT_MIGRAPHX_FP16_ENABLE=0".to_string(),
593        ),
594    ];
595
596    for (key, line) in &required {
597        if !seen_keys.contains(*key) {
598            lines.push(line.clone());
599            changed = true;
600        }
601    }
602
603    (lines.join("\n"), changed)
604}
605
606/// Generate a new `.mlstack_env` file from scratch.
607pub fn generate_env_file(python_bin: &str, rocm_home: &str, rocm_lib: &str) -> String {
608    let home = dirs::home_dir()
609        .map(|h| h.to_string_lossy().to_string())
610        .unwrap_or_else(|| "/root".to_string());
611    format!(
612        "# ML Stack Environment File (generated by Rusty-Stack)\n\
613export MLSTACK_PYTHON_BIN={python_bin}\n\
614export UV_PYTHON={python_bin}\n\
615export ROCM_HOME={rocm_home}\n\
616export ROCM_PATH={rocm_home}\n\
617export HIP_PATH={rocm_home}\n\
618export PYTHONPATH={rocm_lib}:$PYTHONPATH\n\
619export PATH=\"{rocm_home}/bin:{rocm_home}/hip/bin:$PATH\"\n\
620export LD_LIBRARY_PATH=\"{rocm_home}/lib:{rocm_home}/hip/lib:{rocm_home}/opencl/lib:{home}/.mlstack/lib-compat:$LD_LIBRARY_PATH\"\n\
621export ORT_MIGRAPHX_FP16_ENABLE=0\n"
622    )
623}
624
625// ===========================================================================
626// Private Helpers
627// ===========================================================================
628
629/// Extract the variable key from an `export KEY=VALUE` line.
630fn extract_export_key(line: &str) -> Option<String> {
631    let line = line.trim();
632    let after_export = line.strip_prefix("export ")?;
633    let (key, _) = after_export.split_once('=')?;
634    Some(key.trim().to_string())
635}
636
637/// Normalize a single env line, returning `(normalized_line, changed)`.
638fn normalize_env_line(
639    line: &str,
640    key: &str,
641    python_bin: &str,
642    rocm_home: &str,
643    rocm_lib: &str,
644) -> (String, bool) {
645    match key {
646        "MLSTACK_PYTHON_BIN" | "UV_PYTHON" => {
647            let desired = format!("export {}={}", key, python_bin);
648            let trimmed = line.trim();
649            if trimmed != desired {
650                (desired, true)
651            } else {
652                (line.to_string(), false)
653            }
654        }
655        "ROCM_PATH" | "ROCM_HOME" | "HIP_PATH" => {
656            let desired = format!("export {}={}", key, rocm_home);
657            let trimmed = line.trim();
658            if trimmed != desired {
659                (desired, true)
660            } else {
661                (line.to_string(), false)
662            }
663        }
664        "PYTHONPATH" => {
665            let desired = format!("export PYTHONPATH={}:$PYTHONPATH", rocm_lib);
666            let trimmed = line.trim();
667            if trimmed != desired {
668                (desired, true)
669            } else {
670                (line.to_string(), false)
671            }
672        }
673        "PATH" => {
674            let desired = format!(
675                "export PATH=\"{}/bin:{}/hip/bin:$PATH\"",
676                rocm_home, rocm_home
677            );
678            let trimmed = line.trim();
679            if trimmed != desired {
680                (desired, true)
681            } else {
682                (line.to_string(), false)
683            }
684        }
685        "LD_LIBRARY_PATH" => {
686            let home = dirs::home_dir()
687                .map(|h| h.to_string_lossy().to_string())
688                .unwrap_or_else(|| "/root".to_string());
689            let desired = format!(
690                "export LD_LIBRARY_PATH=\"{}/lib:{}/hip/lib:{}/opencl/lib:{}/.mlstack/lib-compat:$LD_LIBRARY_PATH\"",
691                rocm_home, rocm_home, rocm_home, home
692            );
693            let trimmed = line.trim();
694            if trimmed != desired {
695                (desired, true)
696            } else {
697                (line.to_string(), false)
698            }
699        }
700        _ => (line.to_string(), false),
701    }
702}
703
704/// Deduplicate colon-separated path entries.
705pub fn dedup_path_var(path_var: &str) -> String {
706    let mut seen = HashSet::new();
707    let mut result = Vec::new();
708    for entry in path_var.split(':') {
709        if !entry.is_empty() && seen.insert(entry.to_string()) {
710            result.push(entry.to_string());
711        }
712    }
713    result.join(":")
714}
715
716/// Is `cmd` executable found on `PATH`? (Dependency-free; does not spawn.)
717pub fn command_on_path(cmd: &str) -> bool {
718    let path = match env::var_os("PATH") {
719        Some(p) => p,
720        None => return false,
721    };
722    for dir in env::split_paths(&path) {
723        let candidate = dir.join(cmd);
724        if candidate.is_file() {
725            return true;
726        }
727    }
728    false
729}
730
731// ===========================================================================
732// Tests
733// ===========================================================================
734
735#[cfg(test)]
736mod tests {
737    use super::*;
738
739    // -----------------------------------------------------------------------
740    // VAL-PLAT-017: Home directory resolution handles sudo context
741    // -----------------------------------------------------------------------
742
743    #[test]
744    fn test_resolve_user_home_returns_valid_path() {
745        let home = resolve_user_home();
746        assert!(
747            !home.as_os_str().is_empty(),
748            "Home directory should not be empty"
749        );
750    }
751
752    #[test]
753    fn test_resolve_user_home_env_override() {
754        let _env = crate::test_support::lock_env();
755        let saved = std::env::var("MLSTACK_USER_HOME").ok();
756        let dir = tempfile::tempdir().unwrap();
757        let dir_path = dir.path().to_path_buf();
758
759        std::env::set_var("MLSTACK_USER_HOME", dir_path.to_string_lossy().to_string());
760        let result = resolve_user_home();
761        assert_eq!(result, dir_path);
762
763        // Restore original state
764        match saved {
765            Some(v) => std::env::set_var("MLSTACK_USER_HOME", v),
766            None => std::env::remove_var("MLSTACK_USER_HOME"),
767        }
768    }
769
770    #[test]
771    fn test_resolve_user_home_from_passwd() {
772        // Look up current user from /etc/passwd
773        if let Ok(user) = env::var("USER") {
774            if let Some(home) = lookup_home_from_passwd(&user) {
775                assert!(
776                    home.is_dir(),
777                    "Home directory from passwd should exist: {:?}",
778                    home
779                );
780                assert!(
781                    !home.as_os_str().is_empty(),
782                    "Home directory should not be empty"
783                );
784            }
785        }
786    }
787
788    #[test]
789    fn test_lookup_home_from_passwd_root() {
790        // Root should always be in /etc/passwd
791        let home = lookup_home_from_passwd("root");
792        assert!(home.is_some(), "root should be in /etc/passwd");
793        assert_eq!(home.unwrap(), PathBuf::from("/root"));
794    }
795
796    #[test]
797    fn test_lookup_home_from_passwd_nonexistent() {
798        let home = lookup_home_from_passwd("nonexistent_user_xyz_12345");
799        assert!(home.is_none());
800    }
801
802    // -----------------------------------------------------------------------
803    // VAL-PLAT-020: Python interpreter discovery
804    // -----------------------------------------------------------------------
805
806    #[test]
807    fn test_python_interpreters_returns_existing_paths() {
808        let interpreters = python_interpreters();
809        // All returned paths should exist. Note: tests that set MLSTACK_PYTHON_BIN
810        // run in parallel and may leave stale env vars pointing to deleted temp dirs.
811        // We skip paths from temp directories that no longer exist.
812        let valid: Vec<&PathBuf> = interpreters.iter().filter(|p| p.exists()).collect();
813        // At least the system python should be found
814        assert!(
815            !valid.is_empty(),
816            "Should find at least one existing Python interpreter"
817        );
818    }
819
820    #[test]
821    fn test_python_interpreters_deduplication() {
822        let interpreters = python_interpreters();
823        let unique: HashSet<PathBuf> = interpreters.clone().into_iter().collect();
824        assert_eq!(
825            interpreters.len(),
826            unique.len(),
827            "Python interpreters should be deduplicated"
828        );
829    }
830
831    #[test]
832    fn test_python_interpreters_env_var_override() {
833        let _env = crate::test_support::lock_env();
834        // Save current state (hermetic: pin home so a real ~/.mlstack/global
835        // on the dev machine can't shadow the explicit override).
836        let saved_py = std::env::var("MLSTACK_PYTHON_BIN").ok();
837        let saved_home = std::env::var("MLSTACK_USER_HOME").ok();
838
839        // Create a temp file to act as "python"
840        let dir = tempfile::tempdir().unwrap();
841        let fake_python = dir.path().join("bin/python3");
842        fs::create_dir_all(dir.path().join("bin")).unwrap();
843        fs::write(&fake_python, "#!/bin/sh").unwrap();
844
845        std::env::set_var(
846            "MLSTACK_USER_HOME",
847            dir.path().to_string_lossy().to_string(),
848        );
849        std::env::set_var(
850            "MLSTACK_PYTHON_BIN",
851            fake_python.to_string_lossy().to_string(),
852        );
853        let interpreters = python_interpreters();
854        assert!(
855            interpreters[0] == fake_python,
856            "First interpreter should be the env var override: {:?} vs {:?}",
857            interpreters[0],
858            fake_python
859        );
860
861        // Restore original state
862        match saved_py {
863            Some(v) => std::env::set_var("MLSTACK_PYTHON_BIN", v),
864            None => std::env::remove_var("MLSTACK_PYTHON_BIN"),
865        }
866        match saved_home {
867            Some(v) => std::env::set_var("MLSTACK_USER_HOME", v),
868            None => std::env::remove_var("MLSTACK_USER_HOME"),
869        }
870    }
871
872    #[test]
873    fn test_python_interpreters_for_home_with_component_venvs() {
874        let dir = tempfile::tempdir().unwrap();
875        let home = dir.path();
876
877        // Create component venvs
878        for venv_name in &["rocm_venv", "pytorch"] {
879            let bin = home.join(venv_name).join("bin");
880            fs::create_dir_all(&bin).unwrap();
881            fs::write(bin.join("python3"), "#!/bin/sh").unwrap();
882        }
883
884        let interpreters = python_interpreters_for_home(home);
885        assert!(
886            interpreters.len() >= 2,
887            "Should find at least 2 component venv interpreters, found {}",
888            interpreters.len()
889        );
890
891        // rocm_venv should come before system pythons
892        let rocm_venv_pos = interpreters
893            .iter()
894            .position(|p| p.to_string_lossy().contains("rocm_venv"));
895        let system_pos = interpreters
896            .iter()
897            .position(|p| p.to_string_lossy().starts_with("/usr/bin"));
898        if let (Some(rv), Some(sp)) = (rocm_venv_pos, system_pos) {
899            assert!(rv < sp, "Component venvs should come before system Pythons");
900        }
901    }
902
903    #[test]
904    fn test_extract_python_version() {
905        assert_eq!(
906            extract_python_version(Path::new("/usr/bin/python3.13")),
907            (3, 13)
908        );
909        assert_eq!(
910            extract_python_version(Path::new("/usr/bin/python3.12")),
911            (3, 12)
912        );
913        assert_eq!(
914            extract_python_version(Path::new("/usr/bin/python3")),
915            (3, 0)
916        );
917        assert_eq!(extract_python_version(Path::new("/usr/bin/python")), (0, 0));
918    }
919
920    // -----------------------------------------------------------------------
921    // VAL-PLAT-018: .mlstack_env normalization is idempotent
922    // -----------------------------------------------------------------------
923
924    #[test]
925    fn test_normalize_mlstack_env_creates_new_file() {
926        let dir = tempfile::tempdir().unwrap();
927        let python_bin = "/usr/bin/python3";
928        let rocm_path = Path::new("/opt/rocm");
929
930        let result = normalize_mlstack_env(dir.path(), python_bin, rocm_path).unwrap();
931        assert_eq!(result, EnvUpdateResult::Created);
932
933        let env_path = dir.path().join(".mlstack_env");
934        assert!(env_path.exists());
935
936        let contents = fs::read_to_string(&env_path).unwrap();
937        assert!(contents.contains("export MLSTACK_PYTHON_BIN=/usr/bin/python3"));
938        assert!(contents.contains("export ROCM_PATH=/opt/rocm"));
939        assert!(contents.contains("export PYTHONPATH=/opt/rocm/lib:$PYTHONPATH"));
940    }
941
942    #[test]
943    fn test_normalize_mlstack_env_idempotent() {
944        let dir = tempfile::tempdir().unwrap();
945        let python_bin = "/usr/bin/python3";
946        let rocm_path = Path::new("/opt/rocm");
947
948        // First run: creates
949        normalize_mlstack_env(dir.path(), python_bin, rocm_path).unwrap();
950        let first_contents = fs::read_to_string(dir.path().join(".mlstack_env")).unwrap();
951
952        // Second run: should be unchanged
953        let result = normalize_mlstack_env(dir.path(), python_bin, rocm_path).unwrap();
954        assert_eq!(result, EnvUpdateResult::Unchanged);
955
956        let second_contents = fs::read_to_string(dir.path().join(".mlstack_env")).unwrap();
957        assert_eq!(
958            first_contents, second_contents,
959            "Idempotent: second run should produce identical file"
960        );
961    }
962
963    #[test]
964    fn test_normalize_mlstack_env_updates_stale_values() {
965        let dir = tempfile::tempdir().unwrap();
966        let env_path = dir.path().join(".mlstack_env");
967
968        // Write stale content
969        fs::write(
970            &env_path,
971            "# ML Stack Environment File\n\
972export MLSTACK_PYTHON_BIN=/usr/bin/python3.11\n\
973export ROCM_PATH=/opt/rocm-6.0\n\
974export PYTHONPATH=/opt/rocm-6.0/lib:$PYTHONPATH\n\
975export PATH=\"/opt/rocm-6.0/bin:/opt/rocm-6.0/hip/bin:$PATH\"\n\
976export LD_LIBRARY_PATH=\"/opt/rocm-6.0/lib:/opt/rocm-6.0/hip/lib:/opt/rocm-6.0/opencl/lib:$LD_LIBRARY_PATH\"\n",
977        )
978        .unwrap();
979
980        let result =
981            normalize_mlstack_env(dir.path(), "/usr/bin/python3.13", Path::new("/opt/rocm"))
982                .unwrap();
983
984        assert_eq!(result, EnvUpdateResult::Updated);
985
986        let contents = fs::read_to_string(&env_path).unwrap();
987        assert!(
988            contents.contains("export MLSTACK_PYTHON_BIN=/usr/bin/python3.13"),
989            "Should update python bin"
990        );
991        assert!(
992            contents.contains("export ROCM_PATH=/opt/rocm\n")
993                || contents.contains("export ROCM_PATH=/opt/rocm\r"),
994            "Should update ROCM_PATH"
995        );
996    }
997
998    #[test]
999    fn test_normalize_mlstack_env_adds_missing_keys() {
1000        let dir = tempfile::tempdir().unwrap();
1001        let env_path = dir.path().join(".mlstack_env");
1002
1003        // Write content missing required keys
1004        fs::write(&env_path, "# Minimal env\nexport PATH=/usr/bin:$PATH\n").unwrap();
1005
1006        let result =
1007            normalize_mlstack_env(dir.path(), "/usr/bin/python3", Path::new("/opt/rocm")).unwrap();
1008
1009        assert_eq!(result, EnvUpdateResult::Updated);
1010
1011        let contents = fs::read_to_string(&env_path).unwrap();
1012        assert!(contents.contains("export MLSTACK_PYTHON_BIN=/usr/bin/python3"));
1013        assert!(contents.contains("export ROCM_PATH=/opt/rocm"));
1014    }
1015
1016    // -----------------------------------------------------------------------
1017    // Helper function tests
1018    // -----------------------------------------------------------------------
1019
1020    #[test]
1021    fn test_extract_export_key() {
1022        assert_eq!(
1023            extract_export_key("export MLSTACK_PYTHON_BIN=/usr/bin/python3"),
1024            Some("MLSTACK_PYTHON_BIN".to_string())
1025        );
1026        assert_eq!(
1027            extract_export_key("export ROCM_PATH=/opt/rocm"),
1028            Some("ROCM_PATH".to_string())
1029        );
1030        assert_eq!(extract_export_key("# comment"), None);
1031        assert_eq!(extract_export_key(""), None);
1032        assert_eq!(extract_export_key("not an export"), None);
1033    }
1034
1035    #[test]
1036    fn test_dedup_path_var() {
1037        assert_eq!(
1038            dedup_path_var("/usr/bin:/usr/local/bin:/usr/bin"),
1039            "/usr/bin:/usr/local/bin"
1040        );
1041        assert_eq!(dedup_path_var("/usr/bin"), "/usr/bin");
1042        assert_eq!(dedup_path_var(""), "");
1043        assert_eq!(
1044            dedup_path_var("/usr/bin::/usr/local/bin"),
1045            "/usr/bin:/usr/local/bin"
1046        );
1047    }
1048
1049    #[test]
1050    fn test_generate_env_file() {
1051        let content = generate_env_file("/usr/bin/python3", "/opt/rocm", "/opt/rocm/lib");
1052        assert!(content.contains("export MLSTACK_PYTHON_BIN=/usr/bin/python3"));
1053        assert!(content.contains("export UV_PYTHON=/usr/bin/python3"));
1054        assert!(content.contains("export ROCM_HOME=/opt/rocm"));
1055        assert!(content.contains("export ROCM_PATH=/opt/rocm"));
1056        assert!(content.contains("export HIP_PATH=/opt/rocm"));
1057        assert!(content.contains("export PYTHONPATH=/opt/rocm/lib:$PYTHONPATH"));
1058        assert!(content.contains("/opt/rocm/bin"));
1059        assert!(content.contains(".mlstack/lib-compat"));
1060        assert!(content.contains("export ORT_MIGRAPHX_FP16_ENABLE=0"));
1061    }
1062
1063    #[test]
1064    fn test_normalize_env_contents_no_change() {
1065        let contents =
1066            "# ML Stack Environment File\nexport MLSTACK_PYTHON_BIN=/usr/bin/python3\nexport ROCM_PATH=/opt/rocm\nexport ORT_MIGRAPHX_FP16_ENABLE=0\n";
1067        let (result, changed) =
1068            normalize_env_contents(contents, "/usr/bin/python3", "/opt/rocm", "/opt/rocm/lib");
1069        assert!(
1070            !changed,
1071            "Should not report changes when content is already normalized"
1072        );
1073        assert!(result.contains("export MLSTACK_PYTHON_BIN=/usr/bin/python3"));
1074        assert!(result.contains("export ROCM_PATH=/opt/rocm"));
1075        assert!(result.contains("export ORT_MIGRAPHX_FP16_ENABLE=0"));
1076    }
1077
1078    #[test]
1079    fn test_normalize_env_contents_with_change() {
1080        let contents =
1081            "# ML Stack Environment File\nexport MLSTACK_PYTHON_BIN=/usr/bin/python3.11\n"
1082                .to_string();
1083        let (result, changed) = normalize_env_contents(
1084            &contents,
1085            "/usr/bin/python3.13",
1086            "/opt/rocm",
1087            "/opt/rocm/lib",
1088        );
1089        assert!(changed);
1090        assert!(result.contains("export MLSTACK_PYTHON_BIN=/usr/bin/python3.13"));
1091        // Should also add missing ROCM_PATH
1092        assert!(result.contains("export ROCM_PATH=/opt/rocm"));
1093    }
1094}