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 python_interpreters() -> Vec<PathBuf> {
128 let home = resolve_user_home();
129 python_interpreters_for_home(&home)
130}
131
132pub 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
152pub 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 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 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 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 push_if_exists!(home.join(".local/bin/python3"));
198 push_if_exists!(home.join(".local/bin/python"));
199
200 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 let priority = |v: (u32, u32)| -> (i32, u32) {
220 match v {
221 (3, 12) => (0, v.1), (3, 13) => (1, v.1),
223 (3, 11) => (2, v.1),
224 (3, 10) => (3, v.1),
225 _ => (4, v.1), }
227 };
228 priority(va).cmp(&priority(vb))
229 });
230 for p in uv_pythons {
231 push_if_exists!(p);
232 }
233 }
234
235 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 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 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 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 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
289fn extract_python_version(path: &Path) -> (u32, u32) {
291 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
292 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
307pub 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 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
357pub 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 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
412pub 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
431fn 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
443fn 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
510pub 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#[cfg(test)]
527mod tests {
528 use super::*;
529
530 #[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 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 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 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 #[test]
597 fn test_python_interpreters_returns_existing_paths() {
598 let interpreters = python_interpreters();
599 let valid: Vec<&PathBuf> = interpreters.iter().filter(|p| p.exists()).collect();
603 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 let saved = std::env::var("MLSTACK_PYTHON_BIN").ok();
625
626 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 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 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 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 #[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 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 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 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 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 #[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 assert!(result.contains("export ROCM_PATH=/opt/rocm"));
872 }
873}