Skip to main content

rusty_stack/platform/
registry.rs

1//! Component registry and installed component detection.
2//!
3//! Provides:
4//! - **Component registry** — 21 known components with display names and installer mappings
5//!   (VAL-PLAT-009, VAL-PLAT-010)
6//! - **Installed component detection** — path-based, Python module, and git-based strategies
7//!   (VAL-PLAT-011, VAL-PLAT-012, VAL-PLAT-013)
8//! - **Version querying** — per-component-type version retrieval
9//!   (VAL-PLAT-014)
10//! - **Batch Python operations** — single-subprocess module detection and version queries
11//!   (VAL-PLAT-015, VAL-PLAT-016)
12
13use 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// ===========================================================================
22// Public Types
23// ===========================================================================
24
25/// How a component's installation status is detected.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
27pub enum DetectionMethod {
28    /// Check for files/paths on the filesystem.
29    PathBased,
30    /// Try importing a Python module via `importlib`.
31    PythonModule,
32    /// Check for a `.git` directory under an expected clone path.
33    GitBased,
34    /// Check for a command on `PATH`.
35    CommandBased,
36}
37
38/// Metadata for a known component in the registry.
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
40pub struct ComponentInfo {
41    /// Canonical component identifier (e.g., `"pytorch"`, `"flash-attn"`).
42    pub id: String,
43    /// Human-readable display name (e.g., `"PyTorch"`, `"Flash Attention"`).
44    pub display_name: String,
45    /// How to detect whether this component is installed.
46    pub detection_method: DetectionMethod,
47    /// Installer script name (relative to scripts directory).
48    pub installer_script: String,
49    /// Python import name, if this is a Python module.
50    pub python_import: Option<String>,
51    /// Expected clone directory name (relative to user home), for git-based components.
52    pub clone_dir: Option<String>,
53}
54
55/// Result of querying a component's version.
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
57pub struct VersionInfo {
58    /// The component identifier.
59    pub component_id: String,
60    /// Version string, or `"not installed"` / `"unknown"`.
61    pub version: String,
62}
63
64// ===========================================================================
65// Component Registry (VAL-PLAT-009, VAL-PLAT-010)
66// ===========================================================================
67
68/// Returns the canonical list of all 21 known components.
69pub fn known_components() -> &'static [ComponentInfo] {
70    // Built once, reused across calls.
71    static COMPONENTS: std::sync::OnceLock<Vec<ComponentInfo>> = std::sync::OnceLock::new();
72    COMPONENTS.get_or_init(|| {
73        vec![
74            // Foundation
75            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(), // managed by ROCm
88                python_import: None,
89                clone_dir: None,
90            },
91            // Core
92            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                // The standalone Python bindings (AMDMIGraphX). Detection maps
126                // onto the SAME `migraphx` import as the C++ core — the python
127                // package re-exports it — so is_component_installed/get_version
128                // are not blind to this component (and the post-install honesty
129                // guard can verify it). Keep in sync with baseline_manifest.json.
130                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            // Extensions
186            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            // Extensions
219            ComponentInfo {
220                id: "llama-cpp".into(),
221                display_name: "llama.cpp (HIP)".into(),
222                detection_method: DetectionMethod::CommandBased,
223                installer_script: String::new(), // Native Rust installer
224                python_import: None,
225                clone_dir: None,
226            },
227            // UI/UX
228            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            // Environment
253            ComponentInfo {
254                id: "permanent-env".into(),
255                display_name: "ML Stack Environment".into(),
256                detection_method: DetectionMethod::PathBased,
257                installer_script: String::new(), // configured by installer
258                python_import: None,
259                clone_dir: None,
260            },
261        ]
262    })
263}
264
265/// Check whether a component ID is recognized (VAL-PLAT-009).
266pub fn is_known_component(id: &str) -> bool {
267    known_components().iter().any(|c| c.id == id)
268}
269
270/// Look up a component by ID.
271pub fn get_component(id: &str) -> Option<&'static ComponentInfo> {
272    known_components().iter().find(|c| c.id == id)
273}
274
275/// Return the human-readable display name for a component ID.
276///
277/// Unknown IDs are returned as-is (passthrough).
278/// VAL-PLAT-010: The mapping must be bijective for all 21 known components.
279pub 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
285/// Build a lookup map from display name back to component ID.
286///
287/// Used to verify bijectivity of the display name mapping.
288pub 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
295// ===========================================================================
296// Installed Component Detection
297// ===========================================================================
298
299/// Check whether a specific component is installed.
300///
301/// Dispatches to the appropriate detection strategy based on the component's
302/// `detection_method`:
303///
304/// - **PathBased**: ROCm checks version file + `rocminfo`; `permanent-env` checks `~/.mlstack_env`.
305/// - **CommandBased**: Checks that the binary exists and executes.
306/// - **PythonModule**: Uses `importlib.util.find_spec` in candidate interpreters.
307/// - **GitBased**: Checks for `.git` under expected clone path.
308pub fn is_component_installed(id: &str) -> bool {
309    is_component_installed_with_home(id, &resolve_user_home())
310}
311
312/// Check whether a component is installed using an explicit home directory.
313///
314/// This is the testable version that doesn't depend on global state.
315pub 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    // migraphx: the PythonModule check (`import migraphx`) is insufficient on
327    // Arch/CachyOS, where the `migraphx` pip wheel is unavailable — the C++
328    // driver (from the `migraphx` system package) is the install target. Fall
329    // back to a functional driver, mirroring component_status's migraphx arm.
330    // Without this, `rusty-stack update` reports migraphx as not-installed and
331    // proposes a redundant install despite the driver working.
332    if !detected && id == "migraphx" {
333        return crate::installers::components::migraphx_multi::migraphx_driver_functional();
334    }
335    detected
336}
337
338// -----------------------------------------------------------------------
339// Path-based detection (VAL-PLAT-011)
340// -----------------------------------------------------------------------
341
342/// Path-based detection for ROCm and permanent-env.
343fn detect_path_based(info: &ComponentInfo, home: &Path) -> bool {
344    match info.id.as_str() {
345        "rocm" => {
346            // ROCm: version file must exist AND rocminfo must be runnable
347            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            // Check for marker string in ~/.mlstack_env
357            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
369// -----------------------------------------------------------------------
370// Command-based detection
371// -----------------------------------------------------------------------
372
373/// Command-based detection for tools like rocm-smi.
374fn 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            // Prefer installer contract: binary under ~/.mlstack/components/llama-cpp/bin
383            // must be functional. Fall back to PATH lookup for manual/system installs.
384            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
396// -----------------------------------------------------------------------
397// Python module detection (VAL-PLAT-012)
398// -----------------------------------------------------------------------
399
400/// Detect a single Python module component by trying importlib in candidate interpreters.
401fn 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
415/// Try importing a Python module using the given interpreter.
416fn 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
428// -----------------------------------------------------------------------
429// Git-based detection (VAL-PLAT-013)
430// -----------------------------------------------------------------------
431
432/// Git-based detection for ComfyUI, vllm-studio, textgen.
433fn 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
442// ===========================================================================
443// Version Querying (VAL-PLAT-014)
444// ===========================================================================
445
446/// Query the version of a single installed component.
447///
448/// Returns:
449/// - Semver string for Python modules
450/// - Git short-hash + subject for git-based components
451/// - `"installed"` for command-only tools
452/// - `"not installed"` when absent
453pub fn get_version(id: &str) -> String {
454    get_version_with_home(id, &resolve_user_home())
455}
456
457/// Query version with explicit home directory (testable).
458pub 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    // migraphx: the python-import version fails on Arch/CachyOS (no pip wheel).
470    // Fall back to the C++ driver version so the planner sees a real version
471    // (and doesn't propose a downgrade of a newer driver build). Delegated to
472    // the migraphx_multi owner module — verification logic (and the
473    // migraphx-driver subprocess) belongs in installers/, not platform/.
474    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
484/// Get version for path-based components.
485fn get_version_path_based(info: &ComponentInfo, home: &Path) -> String {
486    match info.id.as_str() {
487        "rocm" => {
488            // Try version file first, then rocminfo fallback
489            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
516/// Get version for command-based components.
517fn 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
552/// Get version for a single Python module component.
553fn 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                // Take the last non-empty line that doesn't look like a Python
568                // library warning (some libs like accelerate write warnings to
569                // stdout instead of stderr).
570                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
585/// Get version for git-based components (short hash + subject).
586/// Also stores the short hash for update comparison.
587fn 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    // Try to get a tag-based version first (git describe)
599    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    // Fallback to short hash + subject
614    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
631// ===========================================================================
632// Batch Python Operations (VAL-PLAT-015, VAL-PLAT-016)
633// ===========================================================================
634
635/// Extract a semantic version substring from a version output string.
636///
637/// Handles strings like:
638/// - `"ROCM-SMI version: 4.0.0+unknown"` → `"4.0.0"`
639/// - `"4.0.0+unknown"` → `"4.0.0"`
640/// - `"v2.6.0"` → `"2.6.0"`
641/// - `"2.6.0"` → `"2.6.0"`
642fn extract_semver(raw: &str) -> String {
643    // Strip common prefixes
644    let s = raw.trim().trim_start_matches('v').to_string();
645
646    // Try to find a semver pattern (X.Y.Z or X.Y)
647    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    // No match — return cleaned string
653    s
654}
655
656/// Check if a git-based component has updates available.
657///
658/// Performs `git fetch` then compares local HEAD with remote HEAD.
659/// Returns `true` if remote has new commits.
660pub 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    // Fetch without output
675    let _ = Command::new("git")
676        .args(["-C", repo_path.to_str().unwrap_or(".")])
677        .args(["fetch", "--quiet"])
678        .output();
679
680    // Compare local HEAD with remote HEAD
681    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
695/// Python module component IDs and their import names.
696fn 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
711/// Detect all installed Python ML modules using a single subprocess.
712///
713/// VAL-PLAT-016: Must use a single Python process invocation.
714/// Returns a set of component IDs that are installed.
715pub fn detect_python_modules() -> Vec<String> {
716    let interpreters = super::environment::python_interpreters();
717    detect_python_modules_with_interpreters(&interpreters)
718}
719
720/// Detect Python modules using the provided interpreter list (testable).
721pub 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    // Build a single Python script that checks all modules at once
728    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
760/// Query versions of multiple Python modules in a single subprocess.
761///
762/// VAL-PLAT-015: Must use a single subprocess invocation.
763/// Returns `component_id=version` pairs for all requested components.
764pub 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
769/// Batch version query with provided interpreters (testable).
770pub 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    // Build the Python script for batch version query
787    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                        // Skip lines where id contains characters typical of
816                        // Python library warnings that leak to stdout instead
817                        // of stderr (e.g. "[2026-05-18 ...] [WARNING] ...").
818                        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    // Fallback: return "unknown" for all
832    component_ids
833        .iter()
834        .map(|id| VersionInfo {
835            component_id: (*id).to_string(),
836            version: "unknown".to_string(),
837        })
838        .collect()
839}
840
841/// Detect all installed components using the full detection pipeline.
842///
843/// Combines path-based, command-based, Python module (batch), and git-based
844/// detection into a single scan.
845pub fn detect_all_installed() -> Vec<String> {
846    let home = resolve_user_home();
847    detect_all_installed_with_home(&home)
848}
849
850/// Detect all installed components with explicit home directory (testable).
851pub fn detect_all_installed_with_home(home: &Path) -> Vec<String> {
852    let mut installed = Vec::new();
853
854    // Path-based: ROCm
855    if is_component_installed_with_home("rocm", home) {
856        installed.push("rocm".to_string());
857    }
858
859    // Command-based components
860    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    // Python modules: batch detection
869    let python_installed = detect_python_modules();
870    installed.extend(python_installed);
871
872    // Git-based
873    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    // Path-based: permanent-env
880    if is_component_installed_with_home("permanent-env", home) {
881        installed.push("permanent-env".to_string());
882    }
883
884    // migraphx: the python-module batch (`import migraphx`) is insufficient on
885    // Arch/CachyOS, where the `migraphx` pip wheel is unavailable — the C++
886    // driver (from the `migraphx` system package) is the install target. If the
887    // python check missed it, fall back to a functional driver so `rusty-stack
888    // update` reports it installed (and stops proposing a redundant install).
889    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// ===========================================================================
901// Tests
902// ===========================================================================
903
904#[cfg(test)]
905mod tests {
906    use super::*;
907
908    // -----------------------------------------------------------------------
909    // VAL-PLAT-009: Component registry contains all 21 known components
910    // -----------------------------------------------------------------------
911
912    #[test]
913    fn test_registry_has_exactly_21_components() {
914        let components = known_components();
915        // 22 known components: the 21 historical IDs plus `migraphx-python`
916        // (the standalone AMDMIGraphX Python bindings, mapped onto the migraphx
917        // python import for detection). Update together with the manifest and
918        // the expected-ids list when adding a component.
919        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")); // case-sensitive
1003    }
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    // -----------------------------------------------------------------------
1034    // VAL-PLAT-010: Display name mapping is bijective for all known components
1035    // -----------------------------------------------------------------------
1036
1037    #[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        // Verify round-trip: display_name(id) -> display_name -> id == original id
1072        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    // -----------------------------------------------------------------------
1102    // VAL-PLAT-011: Path-based detection (ROCm, rocm-smi, permanent-env)
1103    // -----------------------------------------------------------------------
1104
1105    #[test]
1106    fn test_rocm_detection_requires_both_version_file_and_rocminfo() {
1107        // On a real system with ROCm, this should return true
1108        // On a system without ROCm, this should return false
1109        // The test just verifies it doesn't panic
1110        let result = is_component_installed("rocm");
1111        // Just verify it runs without panic
1112        let _ = result;
1113    }
1114
1115    #[test]
1116    fn test_permanent_env_detection_checks_marker() {
1117        let dir = tempfile::tempdir().unwrap();
1118
1119        // Without marker string
1120        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        // With marker string
1127        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    // -----------------------------------------------------------------------
1148    // VAL-PLAT-012: Python module detection via importlib
1149    // -----------------------------------------------------------------------
1150
1151    #[test]
1152    fn test_python_module_detection_for_pytorch() {
1153        // On a real system with PyTorch installed, this should return true
1154        // On a system without PyTorch, it should return false
1155        // The test just verifies it doesn't panic
1156        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        // Verify the function runs and returns a Vec<String>
1168        // The "single subprocess" guarantee is structural (one Command invocation)
1169        let result = detect_python_modules();
1170        // All entries should be valid component IDs
1171        for id in &result {
1172            assert!(is_known_component(id), "Unknown component ID: {id}");
1173        }
1174    }
1175
1176    // -----------------------------------------------------------------------
1177    // VAL-PLAT-013: Git-based detection (ComfyUI, vllm-studio, textgen)
1178    // -----------------------------------------------------------------------
1179
1180    #[test]
1181    fn test_git_based_detection_comfyui_with_git_dir() {
1182        let dir = tempfile::tempdir().unwrap();
1183
1184        // Create ComfyUI/.git
1185        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        // ComfyUI dir exists but no .git
1194        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    // -----------------------------------------------------------------------
1215    // VAL-PLAT-014: Version querying returns correct format per component type
1216    // -----------------------------------------------------------------------
1217
1218    #[test]
1219    fn test_version_query_rocm_format() {
1220        let version = get_version("rocm");
1221        // Should be non-empty and not "unknown" on a system with ROCm
1222        assert!(!version.is_empty());
1223    }
1224
1225    #[test]
1226    fn test_version_query_permanent_env() {
1227        let dir = tempfile::tempdir().unwrap();
1228
1229        // Not installed
1230        let version = get_version_with_home("permanent-env", dir.path());
1231        assert_eq!(version, "not installed");
1232
1233        // Installed
1234        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    // -----------------------------------------------------------------------
1256    // VAL-PLAT-015: Batch Python version uses single subprocess
1257    // -----------------------------------------------------------------------
1258
1259    #[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        // Check that each result has the right component_id
1267        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        // nonexistent-mod should be "unknown" (not in mapping)
1272        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    // -----------------------------------------------------------------------
1290    // VAL-PLAT-016: Batch Python module detection uses single subprocess
1291    // -----------------------------------------------------------------------
1292
1293    #[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    // -----------------------------------------------------------------------
1316    // ComponentInfo serde roundtrip
1317    // -----------------------------------------------------------------------
1318
1319    #[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    // -----------------------------------------------------------------------
1354    // Installer script mappings
1355    // -----------------------------------------------------------------------
1356
1357    #[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        // rocm-smi, permanent-env, and native Rust components don't have installer scripts
1396        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    // -----------------------------------------------------------------------
1409    // Real system integration (manual verification helpers)
1410    // -----------------------------------------------------------------------
1411
1412    #[test]
1413    fn test_real_system_detect_all() {
1414        let installed = detect_all_installed();
1415        // On the real system, we expect at least ROCm to be installed
1416        // This test is informational — it just verifies the pipeline doesn't panic
1417        println!("Installed components: {:?}", installed);
1418        // Test passes if we get here without panicking
1419    }
1420
1421    #[test]
1422    fn test_real_system_version_queries() {
1423        // Query versions for all components — informational
1424        for c in known_components() {
1425            let version = get_version(&c.id);
1426            println!("{}: {}", display_name(&c.id), version);
1427        }
1428        // Test passes if we get here without panicking
1429    }
1430
1431    #[test]
1432    fn test_extract_semver_parses_migraphx_driver_output() {
1433        // The C++ driver prints its project version with a long date/git suffix:
1434        // `MIGraphX Version: 2.15.0.20250912-17-220-g07ef4ba4f9-dirty`. The version
1435        // fallback in get_version_with_home relies on extract_semver reducing this
1436        // to the leading X.Y.Z so the planner sees `2.15.0` (newer than the
1437        // manifest's 2.12.0) and does not propose a downgrade.
1438        assert_eq!(
1439            extract_semver("MIGraphX Version: 2.15.0.20250912-17-220-g07ef4ba4f9-dirty"),
1440            "2.15.0"
1441        );
1442        // Two-part versions (X.Y) are also accepted.
1443        assert_eq!(extract_semver("MIGraphX Version: 2.15"), "2.15");
1444    }
1445}