1use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15use std::process::Command;
16
17use serde::{Deserialize, Serialize};
18
19use super::environment::resolve_user_home;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
27pub enum DetectionMethod {
28 PathBased,
30 PythonModule,
32 GitBased,
34 CommandBased,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
40pub struct ComponentInfo {
41 pub id: String,
43 pub display_name: String,
45 pub detection_method: DetectionMethod,
47 pub installer_script: String,
49 pub python_import: Option<String>,
51 pub clone_dir: Option<String>,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
57pub struct VersionInfo {
58 pub component_id: String,
60 pub version: String,
62}
63
64pub fn known_components() -> &'static [ComponentInfo] {
70 static COMPONENTS: std::sync::OnceLock<Vec<ComponentInfo>> = std::sync::OnceLock::new();
72 COMPONENTS.get_or_init(|| {
73 vec![
74 ComponentInfo {
76 id: "rocm".into(),
77 display_name: "ROCm".into(),
78 detection_method: DetectionMethod::PathBased,
79 installer_script: "install_rocm.sh".into(),
80 python_import: None,
81 clone_dir: None,
82 },
83 ComponentInfo {
84 id: "rocm-smi".into(),
85 display_name: "ROCm SMI".into(),
86 detection_method: DetectionMethod::CommandBased,
87 installer_script: String::new(), python_import: None,
89 clone_dir: None,
90 },
91 ComponentInfo {
93 id: "pytorch".into(),
94 display_name: "PyTorch".into(),
95 detection_method: DetectionMethod::PythonModule,
96 installer_script: "install_pytorch_rocm.sh".into(),
97 python_import: Some("torch".into()),
98 clone_dir: None,
99 },
100 ComponentInfo {
101 id: "triton".into(),
102 display_name: "Triton".into(),
103 detection_method: DetectionMethod::PythonModule,
104 installer_script: "install_triton_multi.sh".into(),
105 python_import: Some("triton".into()),
106 clone_dir: None,
107 },
108 ComponentInfo {
109 id: "onnx".into(),
110 display_name: "ONNX Runtime".into(),
111 detection_method: DetectionMethod::PythonModule,
112 installer_script: "build_onnxruntime_multi.sh".into(),
113 python_import: Some("onnxruntime".into()),
114 clone_dir: None,
115 },
116 ComponentInfo {
117 id: "migraphx".into(),
118 display_name: "MIGraphX".into(),
119 detection_method: DetectionMethod::PythonModule,
120 installer_script: "install_migraphx_multi.sh".into(),
121 python_import: Some("migraphx".into()),
122 clone_dir: None,
123 },
124 ComponentInfo {
125 id: "migraphx-python".into(),
131 display_name: "MIGraphX Python".into(),
132 detection_method: DetectionMethod::PythonModule,
133 installer_script: "install_migraphx_python.sh".into(),
134 python_import: Some("migraphx".into()),
135 clone_dir: None,
136 },
137 ComponentInfo {
138 id: "flash-attn".into(),
139 display_name: "Flash Attention".into(),
140 detection_method: DetectionMethod::PythonModule,
141 installer_script: "install_flash_attention_ck.sh".into(),
142 python_import: Some("flash_attn".into()),
143 clone_dir: None,
144 },
145 ComponentInfo {
146 id: "flash-attn-triton".into(),
147 display_name: "Flash Attention (Triton)".into(),
148 detection_method: DetectionMethod::PythonModule,
149 installer_script: "install_flash_attention_triton.sh".into(),
150 python_import: Some("flash_attn".into()),
151 clone_dir: None,
152 },
153 ComponentInfo {
154 id: "flash-attn-ck".into(),
155 display_name: "Flash Attention (CK)".into(),
156 detection_method: DetectionMethod::PythonModule,
157 installer_script: "install_flash_attention_ck.sh".into(),
158 python_import: Some("flash_attn".into()),
159 clone_dir: None,
160 },
161 ComponentInfo {
162 id: "mpi4py".into(),
163 display_name: "MPI4Py".into(),
164 detection_method: DetectionMethod::PythonModule,
165 installer_script: "install_mpi4py.sh".into(),
166 python_import: Some("mpi4py".into()),
167 clone_dir: None,
168 },
169 ComponentInfo {
170 id: "megatron".into(),
171 display_name: "Megatron-LM".into(),
172 detection_method: DetectionMethod::GitBased,
173 installer_script: "install_megatron.sh".into(),
174 python_import: None,
175 clone_dir: Some("Megatron-LM".into()),
176 },
177 ComponentInfo {
178 id: "aiter".into(),
179 display_name: "AITER".into(),
180 detection_method: DetectionMethod::PythonModule,
181 installer_script: "install_aiter.sh".into(),
182 python_import: Some("aiter".into()),
183 clone_dir: None,
184 },
185 ComponentInfo {
187 id: "deepspeed".into(),
188 display_name: "DeepSpeed".into(),
189 detection_method: DetectionMethod::PythonModule,
190 installer_script: "install_deepspeed.sh".into(),
191 python_import: Some("deepspeed".into()),
192 clone_dir: None,
193 },
194 ComponentInfo {
195 id: "vllm".into(),
196 display_name: "vLLM".into(),
197 detection_method: DetectionMethod::PythonModule,
198 installer_script: "install_vllm_multi.sh".into(),
199 python_import: Some("vllm".into()),
200 clone_dir: None,
201 },
202 ComponentInfo {
203 id: "bitsandbytes".into(),
204 display_name: "bitsandbytes".into(),
205 detection_method: DetectionMethod::PythonModule,
206 installer_script: "install_bitsandbytes_multi.sh".into(),
207 python_import: Some("bitsandbytes".into()),
208 clone_dir: None,
209 },
210 ComponentInfo {
211 id: "wandb".into(),
212 display_name: "Weights & Biases".into(),
213 detection_method: DetectionMethod::PythonModule,
214 installer_script: "install_wandb.sh".into(),
215 python_import: Some("wandb".into()),
216 clone_dir: None,
217 },
218 ComponentInfo {
220 id: "llama-cpp".into(),
221 display_name: "llama.cpp (HIP)".into(),
222 detection_method: DetectionMethod::CommandBased,
223 installer_script: String::new(), python_import: None,
225 clone_dir: None,
226 },
227 ComponentInfo {
229 id: "comfyui".into(),
230 display_name: "ComfyUI".into(),
231 detection_method: DetectionMethod::GitBased,
232 installer_script: "install_comfyui.sh".into(),
233 python_import: None,
234 clone_dir: Some("ComfyUI".into()),
235 },
236 ComponentInfo {
237 id: "vllm-studio".into(),
238 display_name: "vLLM Studio".into(),
239 detection_method: DetectionMethod::GitBased,
240 installer_script: "install_vllm_studio.sh".into(),
241 python_import: None,
242 clone_dir: Some("vllm-studio".into()),
243 },
244 ComponentInfo {
245 id: "textgen".into(),
246 display_name: "text-generation-webui".into(),
247 detection_method: DetectionMethod::GitBased,
248 installer_script: "install_textgen.sh".into(),
249 python_import: None,
250 clone_dir: Some("text-generation-webui".into()),
251 },
252 ComponentInfo {
254 id: "permanent-env".into(),
255 display_name: "ML Stack Environment".into(),
256 detection_method: DetectionMethod::PathBased,
257 installer_script: String::new(), python_import: None,
259 clone_dir: None,
260 },
261 ]
262 })
263}
264
265pub fn is_known_component(id: &str) -> bool {
267 known_components().iter().any(|c| c.id == id)
268}
269
270pub fn get_component(id: &str) -> Option<&'static ComponentInfo> {
272 known_components().iter().find(|c| c.id == id)
273}
274
275pub fn display_name(id: &str) -> String {
280 get_component(id)
281 .map(|c| c.display_name.clone())
282 .unwrap_or_else(|| id.to_string())
283}
284
285pub fn display_name_to_id_map() -> HashMap<String, String> {
289 known_components()
290 .iter()
291 .map(|c| (c.display_name.clone(), c.id.clone()))
292 .collect()
293}
294
295pub fn is_component_installed(id: &str) -> bool {
309 is_component_installed_with_home(id, &resolve_user_home())
310}
311
312pub fn is_component_installed_with_home(id: &str, home: &Path) -> bool {
316 let Some(info) = get_component(id) else {
317 return false;
318 };
319
320 let detected = match info.detection_method {
321 DetectionMethod::PathBased => detect_path_based(info, home),
322 DetectionMethod::CommandBased => detect_command_based(info, home),
323 DetectionMethod::PythonModule => detect_python_module_single(info),
324 DetectionMethod::GitBased => detect_git_based(info, home),
325 };
326 if !detected && id == "migraphx" {
333 return crate::installers::components::migraphx_multi::migraphx_driver_functional();
334 }
335 detected
336}
337
338fn detect_path_based(info: &ComponentInfo, home: &Path) -> bool {
344 match info.id.as_str() {
345 "rocm" => {
346 let version_file_exists = Path::new("/opt/rocm/.info/version").exists();
348 let rocminfo_works = Command::new("rocminfo")
349 .arg("--version")
350 .output()
351 .map(|o| o.status.success())
352 .unwrap_or(false);
353 version_file_exists && rocminfo_works
354 }
355 "permanent-env" => {
356 let env_file = home.join(".mlstack_env");
358 if !env_file.exists() {
359 return false;
360 }
361 std::fs::read_to_string(&env_file)
362 .map(|content| content.contains("MLSTACK_PYTHON_BIN"))
363 .unwrap_or(false)
364 }
365 _ => false,
366 }
367}
368
369fn detect_command_based(info: &ComponentInfo, home: &Path) -> bool {
375 match info.id.as_str() {
376 "rocm-smi" => Command::new("rocm-smi")
377 .arg("--version")
378 .output()
379 .map(|o| o.status.success())
380 .unwrap_or(false),
381 "llama-cpp" => {
382 let home_str = home.to_string_lossy();
385 crate::installers::components::llama_cpp::is_llama_cli_functional(&home_str)
386 || Command::new("llama-cli")
387 .arg("--help")
388 .output()
389 .map(|o| o.status.success())
390 .unwrap_or(false)
391 }
392 _ => false,
393 }
394}
395
396fn detect_python_module_single(info: &ComponentInfo) -> bool {
402 let Some(ref import_name) = info.python_import else {
403 return false;
404 };
405
406 let interpreters = super::environment::python_interpreters();
407 for python in &interpreters {
408 if try_python_import(python, import_name) {
409 return true;
410 }
411 }
412 false
413}
414
415fn try_python_import(python: &Path, module: &str) -> bool {
417 let script = format!(
418 "import importlib.util; spec = importlib.util.find_spec('{module}'); exit(0 if spec else 1)"
419 );
420 Command::new(python)
421 .arg("-c")
422 .arg(&script)
423 .output()
424 .map(|o| o.status.success())
425 .unwrap_or(false)
426}
427
428fn detect_git_based(info: &ComponentInfo, home: &Path) -> bool {
434 let Some(ref clone_dir) = info.clone_dir else {
435 return false;
436 };
437
438 let git_dir = home.join(clone_dir).join(".git");
439 git_dir.exists()
440}
441
442pub fn get_version(id: &str) -> String {
454 get_version_with_home(id, &resolve_user_home())
455}
456
457pub fn get_version_with_home(id: &str, home: &Path) -> String {
459 let Some(info) = get_component(id) else {
460 return "unknown".to_string();
461 };
462
463 let v = match info.detection_method {
464 DetectionMethod::PathBased => get_version_path_based(info, home),
465 DetectionMethod::CommandBased => get_version_command_based(info, home),
466 DetectionMethod::PythonModule => get_version_python_single(info),
467 DetectionMethod::GitBased => get_version_git(info, home),
468 };
469 if id == "migraphx" && (v == "not installed" || v == "unknown") {
475 if let Some(driver_version) =
476 crate::installers::components::migraphx_multi::migraphx_driver_version()
477 {
478 return driver_version;
479 }
480 }
481 v
482}
483
484fn get_version_path_based(info: &ComponentInfo, home: &Path) -> String {
486 match info.id.as_str() {
487 "rocm" => {
488 if let Ok(content) = std::fs::read_to_string("/opt/rocm/.info/version") {
490 let v = content.trim().to_string();
491 if !v.is_empty() {
492 return v;
493 }
494 }
495 if let Ok(output) = Command::new("rocminfo").arg("--version").output() {
496 if output.status.success() {
497 let stdout = String::from_utf8_lossy(&output.stdout);
498 if let Some(line) = stdout.lines().next() {
499 return line.trim().to_string();
500 }
501 }
502 }
503 "unknown".to_string()
504 }
505 "permanent-env" => {
506 if is_component_installed_with_home("permanent-env", home) {
507 "installed".to_string()
508 } else {
509 "not installed".to_string()
510 }
511 }
512 _ => "unknown".to_string(),
513 }
514}
515
516fn get_version_command_based(info: &ComponentInfo, home: &Path) -> String {
518 match info.id.as_str() {
519 "rocm-smi" => {
520 if let Ok(output) = Command::new("rocm-smi").arg("--version").output() {
521 if output.status.success() {
522 let stdout = String::from_utf8_lossy(&output.stdout);
523 if let Some(line) = stdout.lines().next() {
524 return extract_semver(line.trim());
525 }
526 }
527 }
528 "not installed".to_string()
529 }
530 "llama-cpp" => {
531 if let Ok(output) = Command::new("llama-cli").arg("--version").output() {
532 if output.status.success() {
533 let stdout = String::from_utf8_lossy(&output.stdout);
534 let stderr = String::from_utf8_lossy(&output.stderr);
535 let combined = format!("{stdout}\n{stderr}");
536 let parsed = extract_semver(combined.trim());
537 if parsed != "unknown" {
538 return parsed;
539 }
540 return "installed".to_string();
541 }
542 }
543 if detect_command_based(info, home) {
544 return "installed".to_string();
545 }
546 "not installed".to_string()
547 }
548 _ => "unknown".to_string(),
549 }
550}
551
552fn get_version_python_single(info: &ComponentInfo) -> String {
554 let Some(ref import_name) = info.python_import else {
555 return "unknown".to_string();
556 };
557
558 let interpreters = super::environment::python_interpreters();
559 for python in &interpreters {
560 let script = format!(
561 "try:\n import {import_name}\n ver = getattr({import_name}, '__version__', None)\n if ver is None:\n import importlib.metadata as im\n for _n in ['{import_name}', '{import_name}'.replace('-', '_'), 'amd_' + '{import_name}'.replace('-', '_'), 'amd-' + '{import_name}']:\n try:\n ver = im.version(_n)\n break\n except Exception:\n pass\n if ver is not None:\n print(ver)\nexcept (ImportError, AttributeError):\n pass",
562 import_name = import_name
563 );
564 if let Ok(output) = Command::new(python).arg("-c").arg(&script).output() {
565 if output.status.success() {
566 let stdout = String::from_utf8_lossy(&output.stdout);
567 let version = stdout
571 .lines()
572 .map(|l| l.trim())
573 .rfind(|l| !l.is_empty() && !l.contains("[WARNING]") && !l.contains("[ERROR]"))
574 .unwrap_or("")
575 .to_string();
576 if !version.is_empty() {
577 return version;
578 }
579 }
580 }
581 }
582 "not installed".to_string()
583}
584
585fn get_version_git(info: &ComponentInfo, home: &Path) -> String {
588 let Some(ref clone_dir) = info.clone_dir else {
589 return "unknown".to_string();
590 };
591
592 let repo_path = home.join(clone_dir);
593
594 if !repo_path.join(".git").exists() {
595 return "not installed".to_string();
596 }
597
598 if let Ok(output) = Command::new("git")
600 .args(["-C", repo_path.to_str().unwrap_or(".")])
601 .args(["describe", "--tags", "--abbrev=0"])
602 .output()
603 {
604 if output.status.success() {
605 let stdout = String::from_utf8_lossy(&output.stdout);
606 let tag = stdout.trim().to_string();
607 if !tag.is_empty() {
608 return tag;
609 }
610 }
611 }
612
613 if let Ok(output) = Command::new("git")
615 .args(["-C", repo_path.to_str().unwrap_or(".")])
616 .args(["log", "-1", "--format=%h %s"])
617 .output()
618 {
619 if output.status.success() {
620 let stdout = String::from_utf8_lossy(&output.stdout);
621 let line = stdout.trim().to_string();
622 if !line.is_empty() {
623 return line;
624 }
625 }
626 }
627
628 "installed".to_string()
629}
630
631fn extract_semver(raw: &str) -> String {
643 let s = raw.trim().trim_start_matches('v').to_string();
645
646 let re = regex::Regex::new(r"(\d+\.\d+(?:\.\d+)?)").unwrap();
648 if let Some(caps) = re.captures(&s) {
649 return caps[1].to_string();
650 }
651
652 s
654}
655
656pub fn git_component_has_updates(id: &str) -> bool {
661 let Some(info) = get_component(id) else {
662 return false;
663 };
664 let Some(ref clone_dir) = info.clone_dir else {
665 return false;
666 };
667 let home = resolve_user_home();
668 let repo_path = home.join(clone_dir);
669
670 if !repo_path.join(".git").exists() {
671 return false;
672 }
673
674 let _ = Command::new("git")
676 .args(["-C", repo_path.to_str().unwrap_or(".")])
677 .args(["fetch", "--quiet"])
678 .output();
679
680 if let Ok(output) = Command::new("git")
682 .args(["-C", repo_path.to_str().unwrap_or(".")])
683 .args(["log", "HEAD..@{u}", "--oneline"])
684 .output()
685 {
686 if output.status.success() {
687 let stdout = String::from_utf8_lossy(&output.stdout);
688 return !stdout.trim().is_empty();
689 }
690 }
691
692 false
693}
694
695fn python_module_mappings() -> &'static [(String, String)] {
697 static MAPPINGS: std::sync::OnceLock<Vec<(String, String)>> = std::sync::OnceLock::new();
698 MAPPINGS.get_or_init(|| {
699 known_components()
700 .iter()
701 .filter(|c| c.detection_method == DetectionMethod::PythonModule)
702 .filter_map(|c| {
703 c.python_import
704 .as_ref()
705 .map(|imp| (c.id.clone(), imp.clone()))
706 })
707 .collect()
708 })
709}
710
711pub fn detect_python_modules() -> Vec<String> {
716 let interpreters = super::environment::python_interpreters();
717 detect_python_modules_with_interpreters(&interpreters)
718}
719
720pub fn detect_python_modules_with_interpreters(interpreters: &[PathBuf]) -> Vec<String> {
722 let mappings = python_module_mappings();
723 if mappings.is_empty() || interpreters.is_empty() {
724 return Vec::new();
725 }
726
727 let mut script = String::from("mods = [\n");
729 for (comp_id, import_name) in mappings {
730 script.push_str(&format!(" ({comp_id:?}, {import_name:?}),\n"));
731 }
732 script.push_str(
733 "]\nfor comp_id, import_name in mods:\n try:\n __import__(import_name)\n print(comp_id)\n except ImportError:\n pass\n",
734 );
735
736 for python in interpreters {
737 if let Ok(output) = Command::new(python).arg("-c").arg(&script).output() {
738 tracing::debug!(
739 "Python detection: Interpreter {} returned status: {}",
740 python.display(),
741 output.status
742 );
743 if output.status.success() {
744 let stdout = String::from_utf8_lossy(&output.stdout);
745 let detected: Vec<String> = stdout
746 .lines()
747 .map(|l| l.trim().to_string())
748 .filter(|l| !l.is_empty() && is_known_component(l))
749 .collect();
750 if !detected.is_empty() {
751 return detected;
752 }
753 }
754 }
755 }
756
757 Vec::new()
758}
759
760pub fn get_versions_batch(component_ids: &[&str]) -> Vec<VersionInfo> {
765 let interpreters = super::environment::python_interpreters();
766 get_versions_batch_with_interpreters(component_ids, &interpreters)
767}
768
769pub fn get_versions_batch_with_interpreters(
771 component_ids: &[&str],
772 interpreters: &[PathBuf],
773) -> Vec<VersionInfo> {
774 let mappings = python_module_mappings();
775
776 if interpreters.is_empty() {
777 return component_ids
778 .iter()
779 .map(|id| VersionInfo {
780 component_id: (*id).to_string(),
781 version: "unknown".to_string(),
782 })
783 .collect();
784 }
785
786 let mut script = String::from("import sys\nmapping = {\n");
788 for (comp_id, import_name) in mappings {
789 script.push_str(&format!(
790 " {:?}: ({:?}, '__version__'),\n",
791 comp_id, import_name
792 ));
793 }
794 script.push_str(
795 "}\nfor comp_id in sys.argv[1:]:\n if comp_id not in mapping:\n print(f'{comp_id}=unknown')\n continue\n mod_name, ver_attr = mapping[comp_id]\n try:\n mod = __import__(mod_name)\n ver = getattr(mod, ver_attr, 'unknown')\n if ver == 'unknown':\n try:\n import importlib.metadata as im\n for _n in [mod_name, mod_name.replace('-', '_'), 'amd_' + mod_name.replace('-', '_'), 'amd-' + mod_name]:\n try:\n ver = im.version(_n)\n break\n except Exception:\n pass\n except Exception:\n pass\n print(f'{comp_id}={ver}')\n except ImportError:\n print(f'{comp_id}=not installed')\n",
796 );
797
798 for python in interpreters {
799 if let Ok(output) = Command::new(python)
800 .arg("-c")
801 .arg(&script)
802 .args(component_ids)
803 .output()
804 {
805 if output.status.success() {
806 let stdout = String::from_utf8_lossy(&output.stdout);
807 return stdout
808 .lines()
809 .filter_map(|line| {
810 let line = line.trim();
811 if line.is_empty() {
812 return None;
813 }
814 let (id, version) = line.split_once('=')?;
815 if id.contains('[') || id.contains(' ') || id.contains(']') {
819 return None;
820 }
821 Some(VersionInfo {
822 component_id: id.to_string(),
823 version: version.to_string(),
824 })
825 })
826 .collect();
827 }
828 }
829 }
830
831 component_ids
833 .iter()
834 .map(|id| VersionInfo {
835 component_id: (*id).to_string(),
836 version: "unknown".to_string(),
837 })
838 .collect()
839}
840
841pub fn detect_all_installed() -> Vec<String> {
846 let home = resolve_user_home();
847 detect_all_installed_with_home(&home)
848}
849
850pub fn detect_all_installed_with_home(home: &Path) -> Vec<String> {
852 let mut installed = Vec::new();
853
854 if is_component_installed_with_home("rocm", home) {
856 installed.push("rocm".to_string());
857 }
858
859 for info in known_components() {
861 if info.detection_method == DetectionMethod::CommandBased
862 && is_component_installed(&info.id)
863 {
864 installed.push(info.id.clone());
865 }
866 }
867
868 let python_installed = detect_python_modules();
870 installed.extend(python_installed);
871
872 for info in known_components() {
874 if info.detection_method == DetectionMethod::GitBased && detect_git_based(info, home) {
875 installed.push(info.id.clone());
876 }
877 }
878
879 if is_component_installed_with_home("permanent-env", home) {
881 installed.push("permanent-env".to_string());
882 }
883
884 if !installed.iter().any(|id| id == "migraphx")
890 && crate::installers::components::migraphx_multi::migraphx_driver_functional()
891 {
892 installed.push("migraphx".to_string());
893 }
894
895 installed.sort();
896 installed.dedup();
897 installed
898}
899
900#[cfg(test)]
905mod tests {
906 use super::*;
907
908 #[test]
913 fn test_registry_has_exactly_21_components() {
914 let components = known_components();
915 assert_eq!(
920 components.len(),
921 22,
922 "Registry must contain exactly 22 known components, found {}",
923 components.len()
924 );
925 }
926
927 #[test]
928 fn test_registry_contains_all_expected_ids() {
929 let expected_ids = [
930 "rocm",
931 "pytorch",
932 "triton",
933 "megatron",
934 "deepspeed",
935 "vllm",
936 "aiter",
937 "onnx",
938 "bitsandbytes",
939 "migraphx",
940 "migraphx-python",
941 "flash-attn",
942 "flash-attn-triton",
943 "flash-attn-ck",
944 "mpi4py",
945 "wandb",
946 "comfyui",
947 "vllm-studio",
948 "textgen",
949 "rocm-smi",
950 "permanent-env",
951 "llama-cpp",
952 ];
953 let components = known_components();
954 for expected in &expected_ids {
955 assert!(
956 components.iter().any(|c| c.id == *expected),
957 "Missing component: {expected}"
958 );
959 }
960 }
961
962 #[test]
963 fn test_is_known_component_accepts_all_21() {
964 let ids = [
965 "rocm",
966 "pytorch",
967 "triton",
968 "megatron",
969 "deepspeed",
970 "vllm",
971 "aiter",
972 "onnx",
973 "bitsandbytes",
974 "migraphx",
975 "migraphx-python",
976 "flash-attn",
977 "flash-attn-triton",
978 "flash-attn-ck",
979 "mpi4py",
980 "wandb",
981 "comfyui",
982 "vllm-studio",
983 "textgen",
984 "rocm-smi",
985 "permanent-env",
986 "llama-cpp",
987 ];
988 for id in &ids {
989 assert!(
990 is_known_component(id),
991 "is_known_component should accept '{id}'"
992 );
993 }
994 }
995
996 #[test]
997 fn test_is_known_component_rejects_unknown() {
998 assert!(!is_known_component("nonexistent"));
999 assert!(!is_known_component("cuda"));
1000 assert!(!is_known_component("tensorflow"));
1001 assert!(!is_known_component(""));
1002 assert!(!is_known_component("PyTorch")); }
1004
1005 #[test]
1006 fn test_get_component_returns_info_for_known() {
1007 let info = get_component("pytorch").unwrap();
1008 assert_eq!(info.id, "pytorch");
1009 assert_eq!(info.display_name, "PyTorch");
1010 assert_eq!(info.detection_method, DetectionMethod::PythonModule);
1011 assert_eq!(info.python_import.as_deref(), Some("torch"));
1012 }
1013
1014 #[test]
1015 fn test_get_component_returns_none_for_unknown() {
1016 assert!(get_component("nonexistent").is_none());
1017 }
1018
1019 #[test]
1020 fn test_all_components_have_non_empty_ids() {
1021 for c in known_components() {
1022 assert!(!c.id.is_empty(), "Component has empty id");
1023 }
1024 }
1025
1026 #[test]
1027 fn test_all_component_ids_are_unique() {
1028 let ids: Vec<&str> = known_components().iter().map(|c| c.id.as_str()).collect();
1029 let unique: std::collections::HashSet<&str> = ids.iter().copied().collect();
1030 assert_eq!(ids.len(), unique.len(), "Component IDs must be unique");
1031 }
1032
1033 #[test]
1038 fn test_all_display_names_non_empty() {
1039 for c in known_components() {
1040 assert!(
1041 !c.display_name.is_empty(),
1042 "Display name for '{}' is empty",
1043 c.id
1044 );
1045 }
1046 }
1047
1048 #[test]
1049 fn test_display_names_are_distinct() {
1050 let names: Vec<&str> = known_components()
1051 .iter()
1052 .map(|c| c.display_name.as_str())
1053 .collect();
1054 let unique: std::collections::HashSet<&str> = names.iter().copied().collect();
1055 assert_eq!(
1056 names.len(),
1057 unique.len(),
1058 "Display names must all be distinct (bijective)"
1059 );
1060 }
1061
1062 #[test]
1063 fn test_display_name_to_id_is_bijective() {
1064 let map = display_name_to_id_map();
1065 assert_eq!(
1066 map.len(),
1067 known_components().len(),
1068 "Display name to ID map must cover every known component"
1069 );
1070
1071 for c in known_components() {
1073 let name = display_name(&c.id);
1074 let back_id = map.get(&name);
1075 assert_eq!(
1076 back_id,
1077 Some(&c.id),
1078 "Round-trip failed for '{}': display_name='{}'",
1079 c.id,
1080 name
1081 );
1082 }
1083 }
1084
1085 #[test]
1086 fn test_display_name_unknown_passthrough() {
1087 assert_eq!(display_name("nonexistent"), "nonexistent");
1088 assert_eq!(display_name("custom-tool"), "custom-tool");
1089 }
1090
1091 #[test]
1092 fn test_display_name_known_values() {
1093 assert_eq!(display_name("rocm"), "ROCm");
1094 assert_eq!(display_name("pytorch"), "PyTorch");
1095 assert_eq!(display_name("flash-attn"), "Flash Attention");
1096 assert_eq!(display_name("wandb"), "Weights & Biases");
1097 assert_eq!(display_name("comfyui"), "ComfyUI");
1098 assert_eq!(display_name("permanent-env"), "ML Stack Environment");
1099 }
1100
1101 #[test]
1106 fn test_rocm_detection_requires_both_version_file_and_rocminfo() {
1107 let result = is_component_installed("rocm");
1111 let _ = result;
1113 }
1114
1115 #[test]
1116 fn test_permanent_env_detection_checks_marker() {
1117 let dir = tempfile::tempdir().unwrap();
1118
1119 std::fs::write(dir.path().join(".mlstack_env"), "# empty\n").unwrap();
1121 assert!(!is_component_installed_with_home(
1122 "permanent-env",
1123 dir.path()
1124 ));
1125
1126 std::fs::write(
1128 dir.path().join(".mlstack_env"),
1129 "export MLSTACK_PYTHON_BIN=/usr/bin/python3\n",
1130 )
1131 .unwrap();
1132 assert!(is_component_installed_with_home(
1133 "permanent-env",
1134 dir.path()
1135 ));
1136 }
1137
1138 #[test]
1139 fn test_permanent_env_not_installed_when_file_missing() {
1140 let dir = tempfile::tempdir().unwrap();
1141 assert!(!is_component_installed_with_home(
1142 "permanent-env",
1143 dir.path()
1144 ));
1145 }
1146
1147 #[test]
1152 fn test_python_module_detection_for_pytorch() {
1153 let _ = is_component_installed("pytorch");
1157 }
1158
1159 #[test]
1160 fn test_detect_python_modules_with_interpreters_empty() {
1161 let result = detect_python_modules_with_interpreters(&[]);
1162 assert!(result.is_empty());
1163 }
1164
1165 #[test]
1166 fn test_detect_python_modules_uses_single_subprocess() {
1167 let result = detect_python_modules();
1170 for id in &result {
1172 assert!(is_known_component(id), "Unknown component ID: {id}");
1173 }
1174 }
1175
1176 #[test]
1181 fn test_git_based_detection_comfyui_with_git_dir() {
1182 let dir = tempfile::tempdir().unwrap();
1183
1184 std::fs::create_dir_all(dir.path().join("ComfyUI/.git")).unwrap();
1186 assert!(is_component_installed_with_home("comfyui", dir.path()));
1187 }
1188
1189 #[test]
1190 fn test_git_based_detection_comfyui_without_git_dir() {
1191 let dir = tempfile::tempdir().unwrap();
1192
1193 std::fs::create_dir_all(dir.path().join("ComfyUI")).unwrap();
1195 assert!(!is_component_installed_with_home("comfyui", dir.path()));
1196 }
1197
1198 #[test]
1199 fn test_git_based_detection_vllm_studio() {
1200 let dir = tempfile::tempdir().unwrap();
1201
1202 std::fs::create_dir_all(dir.path().join("vllm-studio/.git")).unwrap();
1203 assert!(is_component_installed_with_home("vllm-studio", dir.path()));
1204 }
1205
1206 #[test]
1207 fn test_git_based_detection_textgen() {
1208 let dir = tempfile::tempdir().unwrap();
1209
1210 std::fs::create_dir_all(dir.path().join("text-generation-webui/.git")).unwrap();
1211 assert!(is_component_installed_with_home("textgen", dir.path()));
1212 }
1213
1214 #[test]
1219 fn test_version_query_rocm_format() {
1220 let version = get_version("rocm");
1221 assert!(!version.is_empty());
1223 }
1224
1225 #[test]
1226 fn test_version_query_permanent_env() {
1227 let dir = tempfile::tempdir().unwrap();
1228
1229 let version = get_version_with_home("permanent-env", dir.path());
1231 assert_eq!(version, "not installed");
1232
1233 std::fs::write(
1235 dir.path().join(".mlstack_env"),
1236 "export MLSTACK_PYTHON_BIN=/usr/bin/python3\n",
1237 )
1238 .unwrap();
1239 let version = get_version_with_home("permanent-env", dir.path());
1240 assert_eq!(version, "installed");
1241 }
1242
1243 #[test]
1244 fn test_version_query_unknown_component() {
1245 assert_eq!(get_version("nonexistent"), "unknown");
1246 }
1247
1248 #[test]
1249 fn test_version_query_git_based_not_installed() {
1250 let dir = tempfile::tempdir().unwrap();
1251 let version = get_version_with_home("comfyui", dir.path());
1252 assert_eq!(version, "not installed");
1253 }
1254
1255 #[test]
1260 fn test_get_versions_batch_returns_results() {
1261 let ids = vec!["pytorch", "triton", "nonexistent-mod"];
1262 let results = get_versions_batch(&ids);
1263
1264 assert_eq!(results.len(), 3);
1265
1266 assert_eq!(results[0].component_id, "pytorch");
1268 assert_eq!(results[1].component_id, "triton");
1269 assert_eq!(results[2].component_id, "nonexistent-mod");
1270
1271 assert_eq!(results[2].version, "unknown");
1273 }
1274
1275 #[test]
1276 fn test_get_versions_batch_with_interpreters_empty() {
1277 let ids = vec!["pytorch"];
1278 let results = get_versions_batch_with_interpreters(&ids, &[]);
1279 assert_eq!(results.len(), 1);
1280 assert_eq!(results[0].version, "unknown");
1281 }
1282
1283 #[test]
1284 fn test_get_versions_batch_empty_ids() {
1285 let results = get_versions_batch(&[]);
1286 assert!(results.is_empty());
1287 }
1288
1289 #[test]
1294 fn test_detect_all_installed_returns_valid_ids() {
1295 let installed = detect_all_installed();
1296 for id in &installed {
1297 assert!(
1298 is_known_component(id),
1299 "detect_all_installed returned unknown component: {id}"
1300 );
1301 }
1302 }
1303
1304 #[test]
1305 fn test_detect_all_installed_no_duplicates() {
1306 let installed = detect_all_installed();
1307 let unique: std::collections::HashSet<&String> = installed.iter().collect();
1308 assert_eq!(
1309 installed.len(),
1310 unique.len(),
1311 "detect_all_installed should not return duplicates"
1312 );
1313 }
1314
1315 #[test]
1320 fn test_component_info_serde_roundtrip() {
1321 let info = get_component("pytorch").unwrap();
1322 let json = serde_json::to_string(info).unwrap();
1323 let back: ComponentInfo = serde_json::from_str(&json).unwrap();
1324 assert_eq!(info, &back);
1325 }
1326
1327 #[test]
1328 fn test_detection_method_serde_roundtrip() {
1329 let methods = [
1330 DetectionMethod::PathBased,
1331 DetectionMethod::PythonModule,
1332 DetectionMethod::GitBased,
1333 DetectionMethod::CommandBased,
1334 ];
1335 for method in &methods {
1336 let json = serde_json::to_string(method).unwrap();
1337 let back: DetectionMethod = serde_json::from_str(&json).unwrap();
1338 assert_eq!(*method, back);
1339 }
1340 }
1341
1342 #[test]
1343 fn test_version_info_serde_roundtrip() {
1344 let info = VersionInfo {
1345 component_id: "pytorch".to_string(),
1346 version: "2.4.0".to_string(),
1347 };
1348 let json = serde_json::to_string(&info).unwrap();
1349 let back: VersionInfo = serde_json::from_str(&json).unwrap();
1350 assert_eq!(info, back);
1351 }
1352
1353 #[test]
1358 fn test_python_components_have_import_names() {
1359 for c in known_components() {
1360 if c.detection_method == DetectionMethod::PythonModule {
1361 assert!(
1362 c.python_import.is_some(),
1363 "Python module component '{}' must have python_import",
1364 c.id
1365 );
1366 assert!(
1367 !c.python_import.as_ref().unwrap().is_empty(),
1368 "python_import for '{}' must be non-empty",
1369 c.id
1370 );
1371 }
1372 }
1373 }
1374
1375 #[test]
1376 fn test_git_components_have_clone_dirs() {
1377 for c in known_components() {
1378 if c.detection_method == DetectionMethod::GitBased {
1379 assert!(
1380 c.clone_dir.is_some(),
1381 "Git-based component '{}' must have clone_dir",
1382 c.id
1383 );
1384 assert!(
1385 !c.clone_dir.as_ref().unwrap().is_empty(),
1386 "clone_dir for '{}' must be non-empty",
1387 c.id
1388 );
1389 }
1390 }
1391 }
1392
1393 #[test]
1394 fn test_installer_scripts_for_installable_components() {
1395 let no_script = ["rocm-smi", "permanent-env", "llama-cpp"];
1397 for c in known_components() {
1398 if !no_script.contains(&c.id.as_str()) {
1399 assert!(
1400 !c.installer_script.is_empty(),
1401 "Component '{}' must have an installer script",
1402 c.id
1403 );
1404 }
1405 }
1406 }
1407
1408 #[test]
1413 fn test_real_system_detect_all() {
1414 let installed = detect_all_installed();
1415 println!("Installed components: {:?}", installed);
1418 }
1420
1421 #[test]
1422 fn test_real_system_version_queries() {
1423 for c in known_components() {
1425 let version = get_version(&c.id);
1426 println!("{}: {}", display_name(&c.id), version);
1427 }
1428 }
1430
1431 #[test]
1432 fn test_extract_semver_parses_migraphx_driver_output() {
1433 assert_eq!(
1439 extract_semver("MIGraphX Version: 2.15.0.20250912-17-220-g07ef4ba4f9-dirty"),
1440 "2.15.0"
1441 );
1442 assert_eq!(extract_semver("MIGraphX Version: 2.15"), "2.15");
1444 }
1445}