1use std::collections::HashSet;
33use std::env;
34use std::fs;
35use std::path::{Path, PathBuf};
36
37#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum EnvUpdateResult {
44 Created,
46 Updated,
48 Unchanged,
50}
51
52pub fn resolve_user_home() -> PathBuf {
64 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 if let Ok(home) = env::var("HOME") {
74 let home = home.trim().to_string();
75 if !home.is_empty() {
76 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 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 PathBuf::from("/tmp")
98}
99
100pub 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
115pub fn mlstack_root() -> PathBuf {
125 resolve_user_home().join(".mlstack")
126}
127
128pub fn mlstack_global_dir() -> PathBuf {
130 mlstack_root().join("global")
131}
132
133pub fn mlstack_global_python() -> PathBuf {
135 mlstack_global_dir().join("bin").join("python")
136}
137
138pub fn mlstack_envs_dir() -> PathBuf {
140 mlstack_root().join("envs")
141}
142
143pub fn mlstack_env_dir(name: &str) -> PathBuf {
145 mlstack_envs_dir().join(name)
146}
147
148pub fn mlstack_cache_dir() -> PathBuf {
150 mlstack_root().join("cache")
151}
152
153pub fn mlstack_logs_dir() -> PathBuf {
155 mlstack_root().join("logs")
156}
157
158pub fn mlstack_triton_dir() -> PathBuf {
160 mlstack_root().join("triton")
161}
162
163pub 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
178pub 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
202pub 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
235fn create_venv(dir: &Path, bootstrap_python: &str) -> anyhow::Result<()> {
237 if command_on_path("uv") {
238 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
268pub fn python_interpreters() -> Vec<PathBuf> {
282 let home = resolve_user_home();
283 python_interpreters_for_home(&home)
284}
285
286pub fn resolve_canonical_python_bin() -> String {
301 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 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 let global = mlstack_global_python();
322 if global.exists() {
323 return global.to_string_lossy().to_string();
324 }
325 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
333pub 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 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 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 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 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 push_if_exists!(home.join(".local/bin/python3"));
394 push_if_exists!(home.join(".local/bin/python"));
395
396 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 let priority = |v: (u32, u32)| -> (i32, u32) {
416 match v {
417 (3, 12) => (0, v.1), (3, 13) => (1, v.1),
419 (3, 11) => (2, v.1),
420 (3, 10) => (3, v.1),
421 _ => (4, v.1), }
423 };
424 priority(va).cmp(&priority(vb))
425 });
426 for p in uv_pythons {
427 push_if_exists!(p);
428 }
429 }
430
431 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 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 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 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 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
483fn extract_python_version(path: &Path) -> (u32, u32) {
485 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
486 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
501pub 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 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
551pub 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 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
606pub 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
625fn 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
637fn 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
704pub 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
716pub 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#[cfg(test)]
736mod tests {
737 use super::*;
738
739 #[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 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 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 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 #[test]
807 fn test_python_interpreters_returns_existing_paths() {
808 let interpreters = python_interpreters();
809 let valid: Vec<&PathBuf> = interpreters.iter().filter(|p| p.exists()).collect();
813 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 let saved_py = std::env::var("MLSTACK_PYTHON_BIN").ok();
837 let saved_home = std::env::var("MLSTACK_USER_HOME").ok();
838
839 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 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 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 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 #[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 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 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 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 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 #[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 assert!(result.contains("export ROCM_PATH=/opt/rocm"));
1093 }
1094}