Skip to main content

rusty_stack/
installer.rs

1use crate::benchmark_logs::{
2    benchmark_log_directories, extract_benchmark_json_value, find_latest_log_in_dirs,
3};
4use crate::component_status::{
5    component_verification_commands, is_component_installed_by_id, modules_available,
6    python_interpreters, python_search_paths, verification_commands, VerificationCommand,
7};
8use crate::config::InstallerConfig;
9use crate::state::{Category, Component};
10use anyhow::{bail, Context, Result};
11use chrono::Local;
12use std::collections::HashMap;
13use std::io::{BufRead, BufReader, Read, Write};
14use std::path::{Path, PathBuf};
15use std::process::{Command, Stdio};
16use std::sync::{
17    atomic::{AtomicBool, Ordering},
18    mpsc::Receiver,
19    mpsc::Sender,
20    Arc, Mutex,
21};
22use std::time::Duration;
23use std::{fs, thread};
24
25#[derive(Debug, Clone)]
26pub enum InstallerEvent {
27    Log(String, bool),
28    Progress {
29        component_id: String,
30        progress: f32,
31        message: String,
32    },
33    ComponentStart {
34        component_id: String,
35        name: String,
36    },
37    ComponentComplete {
38        component_id: String,
39        success: bool,
40        message: String,
41    },
42    VerificationReport {
43        component_id: String,
44        lines: Vec<String>,
45    },
46    Finished {
47        success: bool,
48    },
49}
50
51pub fn run_installation(
52    components: Vec<Component>,
53    config: InstallerConfig,
54    sudo_password: Option<String>,
55    sender: Sender<InstallerEvent>,
56    input_rx: Receiver<String>,
57) {
58    run_installation_with_version(components, config, sudo_password, sender, input_rx, None);
59}
60
61/// Run installation with an optional planner-selected target version.
62///
63/// Normal installs keep the historical API and let each installer choose its
64/// bundled compatible default. Update applies can pass the exact manifest
65/// target through this path without changing component selection or package
66/// source logic.
67pub fn run_installation_with_version(
68    components: Vec<Component>,
69    config: InstallerConfig,
70    sudo_password: Option<String>,
71    sender: Sender<InstallerEvent>,
72    input_rx: Receiver<String>,
73    target_version: Option<String>,
74) {
75    let scripts_dir = &config.scripts_dir;
76    let batch_mode = config.batch_mode;
77    let install_method = config.install_method.clone();
78    let total = components.len() as f32;
79    let mut index = 0usize;
80    let mut python_candidates = python_interpreters();
81    let user_home = resolve_mlstack_user_home();
82    let input_rx = Arc::new(Mutex::new(input_rx));
83
84    // An explicit, concrete update target (from a confirmed update apply) is
85    // the consent to override sealed components: the plan showed the version
86    // jump and the user selected it. registry_gate reuses a sealed install
87    // unless force+unseal are set, which would silently skip the very
88    // component the update advertised — and the post-install version check
89    // would then fail and re-offer the same update on every scan.
90    // Scoped: the previous values are restored on exit (including early
91    // returns) so a later installation in the same process — e.g. the next
92    // component in a sequential apply, whose target may be opaque — cannot
93    // inherit this invocation's consent.
94    let _unseal_scope = if is_explicit_update_target(target_version.as_deref()) {
95        Some(ScopedEnvOverride::setting(&[
96            ("MLSTACK_FORCE_REINSTALL", "1"),
97            ("MLSTACK_UNSEAL_CORE", "1"),
98        ]))
99    } else {
100        None
101    };
102
103    match ensure_mlstack_env(&user_home, &install_method, false) {
104        Ok(EnvUpdate::Created) => {
105            let _ = sender.send(InstallerEvent::Log(
106                format!("Created .mlstack_env in {}", user_home),
107                false,
108            ));
109        }
110        Ok(EnvUpdate::Updated) => {
111            let _ = sender.send(InstallerEvent::Log(
112                format!("Updated .mlstack_env in {}", user_home),
113                false,
114            ));
115        }
116        Ok(EnvUpdate::Unchanged) => {}
117        Err(err) => {
118            let _ = sender.send(InstallerEvent::Log(
119                format!("Failed to ensure .mlstack_env: {}", err),
120                false,
121            ));
122        }
123    }
124
125    // Check if any selected component needs sudo
126    let any_component_needs_sudo = components.iter().any(|c| c.needs_sudo);
127
128    if needs_sudo() && any_component_needs_sudo {
129        if let Some(password) = sudo_password.clone() {
130            if let Err(err) = validate_sudo(password) {
131                let _ = sender.send(InstallerEvent::Log(
132                    format!("Sudo validation failed: {}", err),
133                    false,
134                ));
135                let _ = sender.send(InstallerEvent::Finished { success: false });
136                return;
137            }
138        } else if can_sudo_non_interactive() {
139            let _ = sender.send(InstallerEvent::Log(
140                "Using cached/passwordless sudo credentials".into(),
141                false,
142            ));
143        } else {
144            let _ = sender.send(InstallerEvent::Log(
145                "Sudo password missing; cannot continue".into(),
146                false,
147            ));
148            let _ = sender.send(InstallerEvent::Finished { success: false });
149            return;
150        }
151    }
152
153    // Tenet 1: anchor bare-`rusty` installs to the SINGLE managed global env
154    // (~/.mlstack/global) when the user did not request a named env and did not
155    // pin an explicit interpreter. Previously the bare TUI fell through to a
156    // *discovered* interpreter, so "global installs ALL install to a SINGLE
157    // default env" only held for `rusty install --global`. Best-effort: if venv
158    // creation fails (no bootstrap python), resolution falls back to discovery.
159    let no_named_env = std::env::var("MLSTACK_ENV_NAME")
160        .map(|v| v.trim().is_empty())
161        .unwrap_or(true);
162    let no_explicit_python = std::env::var("MLSTACK_PYTHON_BIN")
163        .map(|v| v.trim().is_empty())
164        .unwrap_or(true);
165    // A "global" install = no named env requested AND no interpreter pinned.
166    // Captured here (before MLSTACK_PYTHON_BIN is set below) so the post-install
167    // auto-source step can gate on it.
168    let is_global_install = no_named_env && no_explicit_python;
169    if is_global_install {
170        let bootstrap = resolve_python_bin();
171        if let Ok(global_py) = crate::platform::environment::ensure_global_venv(&bootstrap) {
172            std::env::set_var(
173                "MLSTACK_PYTHON_BIN",
174                global_py.to_string_lossy().to_string(),
175            );
176        } else if std::env::var("MLSTACK_PYTHON_BIN").is_err() {
177            // ensure failed and nothing pinned — surface it so the user knows
178            // the install is landing on a discovered interpreter, not global.
179            let _ = sender.send(InstallerEvent::Log(
180                "[native] could not create managed global venv (~/.mlstack/global); \
181                 falling back to a discovered interpreter"
182                    .into(),
183                false,
184            ));
185        }
186    }
187
188    let mut overall_success = true;
189    let env_exports = load_mlstack_env_exports(&user_home);
190    let persistent_python = env_exports
191        .get("MLSTACK_PYTHON_BIN")
192        .map(|v| v.trim().to_string())
193        .filter(|v| !v.is_empty());
194    // Tenet 1: MLSTACK_ENV_NAME (set by `rusty install --env <name>`) isolates
195    // ALL components into ~/.mlstack/envs/<name>/. Falls back to the persisted
196    // target, then discovery.
197    let python_bin = std::env::var("MLSTACK_ENV_NAME")
198        .ok()
199        .map(|n| n.trim().to_string())
200        .filter(|n| !n.is_empty())
201        .map(|n| {
202            crate::platform::environment::mlstack_env_dir(&n)
203                .join("bin")
204                .join("python")
205                .to_string_lossy()
206                .to_string()
207        })
208        .or(persistent_python)
209        .unwrap_or_else(resolve_python_bin);
210    python_candidates.retain(|candidate| candidate != &python_bin);
211    python_candidates.insert(0, python_bin.clone());
212    let _ = sender.send(InstallerEvent::Log(
213        format!("Selected python interpreter: {}", python_bin),
214        false,
215    ));
216
217    // VAL-INSTALL-049: Sort components in dependency order so dependencies
218    // are installed before dependents. Only sorts installer components;
219    // verification and performance components maintain their original order.
220    let components = sort_components_by_dependencies(components);
221
222    for component in components {
223        index += 1;
224        let _ = sender.send(InstallerEvent::ComponentStart {
225            component_id: component.id.clone(),
226            name: component.name.clone(),
227        });
228
229        if component.category == Category::Maintenance && component.id.starts_with("verify-") {
230            let outcome = run_verification(&component, &python_candidates, &sender, &user_home);
231            let _ = sender.send(InstallerEvent::VerificationReport {
232                component_id: component.id.clone(),
233                lines: outcome.report_lines.clone(),
234            });
235            if !outcome.success {
236                overall_success = false;
237            }
238            let _ = sender.send(InstallerEvent::ComponentComplete {
239                component_id: component.id.clone(),
240                success: outcome.success,
241                message: if outcome.success {
242                    format!("{} verification complete", component.name)
243                } else {
244                    format!("{} verification completed with issues", component.name)
245                },
246            });
247
248            let overall_progress = index as f32 / total;
249            let _ = sender.send(InstallerEvent::Progress {
250                component_id: "__overall__".into(),
251                progress: overall_progress,
252                message: format!(
253                    "Finished {} ({:.0}%)",
254                    component.name,
255                    overall_progress * 100.0
256                ),
257            });
258            continue;
259        }
260
261        // VAL-INSTALL-031: For native Rust installer components, dispatch
262        // to the native module instead of spawning a bash subprocess.
263        let is_native = crate::installers::components::is_native_component(&component.id);
264
265        let mut install_success = true;
266        let mut error_msg = String::new();
267
268        if is_native {
269            // VAL-INSTALL-032: Dispatch to correct Rust module per component ID.
270            // The native installer constructs commands and executes them directly,
271            // preserving sudo behavior, environment variable injection, and
272            // InstallerEvent progress reporting.
273            let ctx = NativeInstallerContext {
274                sudo_password: sudo_password.clone(),
275                batch_mode,
276                install_method: &install_method,
277                sender: &sender,
278                user_home: &user_home,
279                env_exports: &env_exports,
280                vllm_version_lag: config.vllm_version_lag,
281                vllm_version_min_age_days: config.vllm_version_min_age_days,
282                target_version: target_version.clone(),
283            };
284            // T2: sealed-core no-override gate + registry population.
285            match registry_gate(&component, &sender) {
286                Ok(false) => { /* sealed core already installed — reuse, skip reinstall */ }
287                Ok(true) => {
288                    if let Err(err) = run_native_installer(&component, &ctx) {
289                        install_success = false;
290                        let chain = err
291                            .chain()
292                            .map(|e| e.to_string())
293                            .collect::<Vec<_>>()
294                            .join(": ");
295                        error_msg = format_user_friendly_error(&component.id, &chain);
296                        // Log the full error chain for debugging
297                        let err_label = if component.category == Category::Performance {
298                            "benchmarks"
299                        } else {
300                            "installation"
301                        };
302                        let _ = sender.send(InstallerEvent::Log(
303                            format!("[ERROR] {} {} failed: {}", component.name, err_label, chain),
304                            false,
305                        ));
306                    }
307                }
308                Err(blocked) => {
309                    install_success = false;
310                    error_msg = format_user_friendly_error(&component.id, &blocked.to_string());
311                    let _ = sender.send(InstallerEvent::Log(
312                        format!("[ERROR] {} blocked: {}", component.name, blocked),
313                        false,
314                    ));
315                }
316            }
317        } else {
318            // Legacy path: spawn bash subprocess for non-ported components
319            // (verification, performance, or any future shell-based components)
320            let script_path = format!("{}/{}", scripts_dir, component.script);
321            if fs::metadata(&script_path).is_err() {
322                overall_success = false;
323                let _ = sender.send(InstallerEvent::ComponentComplete {
324                    component_id: component.id.clone(),
325                    success: false,
326                    message: format!("Script not found: {}", script_path),
327                });
328                continue;
329            }
330
331            if let Err(err) = run_script(
332                &component,
333                &script_path,
334                sudo_password.clone(),
335                batch_mode,
336                &install_method,
337                &sender,
338                Arc::clone(&input_rx),
339            ) {
340                install_success = false;
341                let chain = err
342                    .chain()
343                    .map(|e| e.to_string())
344                    .collect::<Vec<_>>()
345                    .join(": ");
346                error_msg = format_user_friendly_error(&component.id, &chain);
347                // Log the full error chain for debugging
348                let err_label = if component.category == Category::Performance {
349                    "benchmarks"
350                } else {
351                    "installation"
352                };
353                let _ = sender.send(InstallerEvent::Log(
354                    format!("[ERROR] {} {} failed: {}", component.name, err_label, chain),
355                    false,
356                ));
357            }
358        }
359
360        let verify_label = if component.category == Category::Performance {
361            "Validating"
362        } else {
363            "Verifying"
364        };
365        let _ = sender.send(InstallerEvent::Progress {
366            component_id: component.id.clone(),
367            progress: 0.8,
368            message: format!("{} {}", verify_label, component.name),
369        });
370
371        // Benchmarks are their own verification — run_native_benchmark already
372        // returned a definitive SUCCESS/FAILED. The file-based verification
373        // below checks for benchmark log files, which the native benchmark
374        // runners do not write, so it would spuriously fail a benchmark that
375        // succeeded (e.g. "no benchmark logs found"). Use the benchmark's own
376        // result as the verdict for Performance components.
377        let verification_outcome = if component.category == Category::Performance {
378            VerificationOutcome {
379                success: install_success,
380                report_lines: vec![format!(
381                    "Benchmark {}",
382                    if install_success {
383                        "completed successfully"
384                    } else {
385                        "reported failures"
386                    }
387                )],
388            }
389        } else if is_native_maintenance_action(&component) {
390            native_maintenance_action_outcome(&component, install_success)
391        } else {
392            run_component_verification(&component, &python_candidates, &sender, &user_home)
393        };
394        let _ = sender.send(InstallerEvent::VerificationReport {
395            component_id: component.id.clone(),
396            lines: verification_outcome.report_lines.clone(),
397        });
398
399        if !verification_outcome.success {
400            overall_success = false;
401        }
402
403        // Verification can only make the verdict STRICTER, never rescue a
404        // failed install. A failed install step means the operation did not
405        // complete as intended; verification passing on pre-existing artifacts
406        // (e.g. a stale `/opt/rocm` left by an incomplete purge) must NOT seal
407        // the component as installed — otherwise a broken install is recorded
408        // as good (Tenet 5: honest verification). The previous behavior sealed
409        // ROCm as installed after a fully-aborted install merely because
410        // rocminfo still ran against the surviving `/opt/rocm`.
411        let final_success = install_success && verification_outcome.success;
412        if !install_success && verification_outcome.success {
413            let _ = sender.send(InstallerEvent::Log(
414                format!(
415                    "[{}] install step failed — NOT marking installed despite passing \
416                     verification (verification likely ran against pre-existing/stale artifacts)",
417                    component.name
418                ),
419                true,
420            ));
421        }
422        if !final_success {
423            overall_success = false;
424        } else {
425            // Success is sealed only after both the install step and its final
426            // verification pass.
427            registry_record(&component);
428        }
429
430        // Debug logging for environment components
431        if component.id == "permanent-env"
432            || component.id == "enhanced-env"
433            || component.id == "basic-env"
434        {
435            let _ = sender.send(InstallerEvent::Log(
436                format!(
437                    "[DEBUG] Component {}: install_success={}, verification_success={}, final={}",
438                    component.id, install_success, verification_outcome.success, final_success
439                ),
440                false,
441            ));
442            if !install_success {
443                let _ = sender.send(InstallerEvent::Log(
444                    format!("[DEBUG] Install error: {}", error_msg),
445                    false,
446                ));
447            }
448        }
449
450        let completion_msg = if !install_success {
451            error_msg
452        } else if component.category == Category::Performance {
453            if verification_outcome.success {
454                format!("{} benchmarks completed", component.name)
455            } else {
456                format!("{} benchmarks completed with errors", component.name)
457            }
458        } else if verification_outcome.success {
459            format!("{} completed", component.name)
460        } else {
461            format!("{} completed with verification errors", component.name)
462        };
463
464        let _ = sender.send(InstallerEvent::ComponentComplete {
465            component_id: component.id.clone(),
466            success: final_success,
467            message: completion_msg,
468        });
469
470        let overall_progress = index as f32 / total;
471        let _ = sender.send(InstallerEvent::Progress {
472            component_id: "__overall__".into(),
473            progress: overall_progress,
474            message: format!(
475                "Finished {} ({:.0}%)",
476                component.name,
477                overall_progress * 100.0
478            ),
479        });
480    }
481
482    // Refresh ~/.mlstack_env from POST-INSTALL state. ensure_mlstack_env ran once
483    // at the start of the run, but components install the files it derives values
484    // from — e.g. /opt/rocm/.info/version is absent until the ROCm component lands,
485    // so a start-of-run capture would freeze ROCM_VERSION at the fallback (7.2.0).
486    // Re-running here re-derives ROCM_VERSION / GPU_ARCH / HSA_OVERRIDE /
487    // HIP_VISIBLE_DEVICES / MLSTACK_PYTHON_BIN from the now-installed reality.
488    // Best-effort: never fail the install over an env refresh.
489    match ensure_mlstack_env(&user_home, &install_method, false) {
490        Ok(EnvUpdate::Created) | Ok(EnvUpdate::Updated) => {
491            let _ = sender.send(InstallerEvent::Log(
492                "[post-install] Refreshed ~/.mlstack_env from installed state \
493                 (ROCM_VERSION / GPU_ARCH / etc.)"
494                    .into(),
495                false,
496            ));
497        }
498        Ok(EnvUpdate::Unchanged) => {}
499        Err(e) => {
500            let _ = sender.send(InstallerEvent::Log(
501                format!("[post-install] Warning: could not refresh ~/.mlstack_env: {e}"),
502                false,
503            ));
504        }
505    }
506
507    // Auto-source ~/.mlstack_env into bash/zsh (fish auto-loads the conf.d file
508    // written above) on shell launch — but ONLY for a Global install. Named /
509    // env-isolated installs must not mutate the global shell; their env is sourced
510    // manually. Idempotent + uninstall-safe (see offer_mlstack_env_source).
511    if is_global_install {
512        if let Err(e) = crate::platform::environment::offer_mlstack_env_source() {
513            let _ = sender.send(InstallerEvent::Log(
514                format!(
515                    "[post-install] Warning: could not auto-source ~/.mlstack_env into shell rc: {e}"
516                ),
517                false,
518            ));
519        }
520    }
521
522    if overall_success && config.star_repos {
523        let _ = star_repo(&config, &sender);
524    }
525
526    // Check for reboot-required marker (set by ROCm force-reinstall)
527    if std::path::Path::new("/tmp/mlstack-reboot-required").exists() {
528        let _ = sender.send(InstallerEvent::Log(
529            "⚠ REBOOT REQUIRED: ROCm packages were reinstalled. A reboot is needed for kernel module changes to take effect.".to_string(),
530            false,
531        ));
532        let _ = std::fs::remove_file("/tmp/mlstack-reboot-required");
533    }
534
535    // Fix pip cache directory ownership after all installations complete.
536    // Some installation steps use sudo, which can create pip cache files
537    // owned by root. This prevents the unprivileged user from writing to
538    // the cache, causing permission errors on subsequent pip install runs.
539    {
540        use crate::installers::common::fix_pip_cache_ownership;
541        let result = fix_pip_cache_ownership(&user_home);
542        match &result {
543            crate::installers::common::PipCacheFixResult::Fixed => {
544                let _ = sender.send(InstallerEvent::Log(
545                    "[post-install] Fixed pip cache directory ownership (~/.cache/pip)".into(),
546                    false,
547                ));
548            }
549            crate::installers::common::PipCacheFixResult::FixFailed(err) => {
550                let _ = sender.send(InstallerEvent::Log(
551                    format!(
552                        "[post-install] Warning: could not fix pip cache ownership: {}",
553                        err
554                    ),
555                    false,
556                ));
557            }
558            _ => {
559                // NoCacheDir or OwnershipCorrect — nothing to report
560            }
561        }
562    }
563
564    let _ = sender.send(InstallerEvent::Finished {
565        success: overall_success,
566    });
567}
568
569fn validate_sudo(password: String) -> Result<()> {
570    let mut child = Command::new("sudo")
571        .arg("-S")
572        .arg("-v")
573        .stdin(Stdio::piped())
574        .stdout(Stdio::null())
575        .stderr(Stdio::piped())
576        .spawn()
577        .context("Failed to spawn sudo")?;
578
579    let mut stdin = child.stdin.take().context("Failed to open stdin")?;
580    stdin.write_all(password.as_bytes())?;
581    stdin.write_all(b"\n")?;
582    stdin.flush()?;
583    drop(stdin);
584
585    let output = child.wait_with_output()?;
586    if output.status.success() {
587        return Ok(());
588    }
589
590    let stderr = String::from_utf8_lossy(&output.stderr);
591    let detail = stderr.lines().next().unwrap_or("sudo validation failed");
592    anyhow::bail!(detail.to_string())
593}
594
595fn can_sudo_non_interactive() -> bool {
596    Command::new("sudo")
597        .arg("-n")
598        .arg("true")
599        .stdin(Stdio::null())
600        .stdout(Stdio::null())
601        .stderr(Stdio::null())
602        .status()
603        .map(|s| s.success())
604        .unwrap_or(false)
605}
606
607#[derive(Debug, Clone, Copy, PartialEq, Eq)]
608enum EnvUpdate {
609    Created,
610    Updated,
611    Unchanged,
612}
613
614struct VerificationCheckResult {
615    status: VerificationResult,
616    output: Vec<String>,
617}
618
619#[derive(Debug, Clone)]
620struct VerificationOutcome {
621    success: bool,
622    report_lines: Vec<String>,
623}
624
625/// Create a symlink so libdrm can find amdgpu.ids at /opt/amdgpu/share/libdrm/.
626///
627/// ROCm packages (comgr, etc.) compile libdrm with a custom prefix of /opt/amdgpu,
628/// causing libdrm to look for amdgpu.ids at /opt/amdgpu/share/libdrm/amdgpu.ids first.
629/// On Arch and most distros the file lives at /usr/share/libdrm/amdgpu.ids.
630/// Without the symlink, every libdrm call prints:
631///   `/opt/amdgpu/share/libdrm/amdgpu.ids: No such file or directory`
632///
633/// This function tries direct filesystem creation first, then falls back to sudo.
634fn fix_libdrm_amdgpu_ids() {
635    let target = Path::new("/usr/share/libdrm/amdgpu.ids");
636    let link = Path::new("/opt/amdgpu/share/libdrm/amdgpu.ids");
637
638    if !target.exists() {
639        return;
640    }
641    if link.exists() {
642        return; // Already fixed
643    }
644
645    // Try direct creation (works if user has write access to /opt)
646    if let Some(parent) = link.parent() {
647        if fs::create_dir_all(parent).is_ok() {
648            #[cfg(unix)]
649            {
650                let relative = compute_relative_symlink(link, target);
651                if std::os::unix::fs::symlink(&relative, link).is_ok() {
652                    tracing::info!(
653                        "Created libdrm symlink: {} -> {}",
654                        link.display(),
655                        relative.display()
656                    );
657                    return;
658                }
659            }
660            #[cfg(not(unix))]
661            {
662                let _ = (link, target);
663            }
664        }
665    }
666
667    // Fallback: try with sudo. `-n` keeps it strictly non-interactive — fail fast
668    // (no tty password prompt) when there are no cached credentials, then fall
669    // through to the manual suggestion below. `.output()` already captures
670    // stderr so nothing leaks to the terminal; `-n` makes the best-effort intent
671    // explicit and guarantees the install never blocks on a password read.
672    let result = Command::new("sudo")
673        .args(["-n", "mkdir", "-p", "/opt/amdgpu/share/libdrm/"])
674        .stdin(std::process::Stdio::null())
675        .output();
676
677    if let Ok(out) = result {
678        if out.status.success() {
679            let result2 = Command::new("sudo")
680                .args([
681                    "-n",
682                    "ln",
683                    "-sf",
684                    "/usr/share/libdrm/amdgpu.ids",
685                    "/opt/amdgpu/share/libdrm/amdgpu.ids",
686                ])
687                .stdin(std::process::Stdio::null())
688                .output();
689            if let Ok(out2) = result2 {
690                if out2.status.success() {
691                    tracing::info!(
692                        "Created libdrm symlink via sudo: {} -> {}",
693                        link.display(),
694                        target.display()
695                    );
696                    return;
697                }
698            }
699        }
700    }
701
702    // Couldn't fix — log a suggestion
703    tracing::warn!(
704        "Cannot create /opt/amdgpu/share/libdrm/amdgpu.ids symlink (need sudo). \
705         Run manually: sudo mkdir -p /opt/amdgpu/share/libdrm && \
706         sudo ln -sf /usr/share/libdrm/amdgpu.ids /opt/amdgpu/share/libdrm/amdgpu.ids"
707    );
708}
709
710/// Compute a relative path from `link` to `target` for symlink creation.
711fn compute_relative_symlink(link: &Path, target: &Path) -> PathBuf {
712    // Go up from link's parent to /, then down to target
713    let mut ups = 0u32;
714    for _ in link.parent().unwrap_or(Path::new(".")).ancestors().skip(1) {
715        ups += 1;
716    }
717    let mut result = PathBuf::new();
718    for _ in 0..ups.saturating_sub(1) {
719        result.push("..");
720    }
721    result.push(target.strip_prefix("/").unwrap_or(target));
722    result
723}
724
725fn ensure_mlstack_env(user_home: &str, install_method: &str, force: bool) -> Result<EnvUpdate> {
726    let env_path = PathBuf::from(user_home).join(".mlstack_env");
727    let normalized_install_method = match install_method.trim().to_ascii_lowercase().as_str() {
728        "global" => "global",
729        "venv" => "venv",
730        "auto" => "auto",
731        _ => "auto",
732    };
733    let env_exports = load_mlstack_env_exports(user_home);
734    let persistent_python = env_exports
735        .get("MLSTACK_PYTHON_BIN")
736        .map(|v| v.trim().to_string())
737        .filter(|v| !v.is_empty());
738    // Tenet 1: MLSTACK_ENV_NAME (set by `rusty install --env <name>`) isolates
739    // ALL components into ~/.mlstack/envs/<name>/. Falls back to the persisted
740    // target, then global managed python, then discovery.
741    let python_bin = std::env::var("MLSTACK_ENV_NAME")
742        .ok()
743        .map(|n| n.trim().to_string())
744        .filter(|n| !n.is_empty())
745        .map(|n| {
746            crate::platform::environment::mlstack_env_dir(&n)
747                .join("bin")
748                .join("python")
749                .to_string_lossy()
750                .to_string()
751        })
752        .or(persistent_python)
753        .unwrap_or_else(|| {
754            // Prefer managed global python (~/.mlstack/global/bin/python)
755            let global_py = crate::platform::environment::mlstack_global_python();
756            if global_py.exists() {
757                global_py.to_string_lossy().to_string()
758            } else {
759                resolve_python_bin()
760            }
761        });
762
763    // FAIL-LOUD: require discrete AMD GPU via sysfs detector
764    let dgpus = crate::gpu::detect_discrete_amd_gpus();
765    if dgpus.is_empty() {
766        bail!("cannot detect AMD dGPU arch via sysfs — ensure amdgpu driver loaded");
767    }
768    let primary = &dgpus[0];
769    let gpu_arch = primary.gfx_arch.as_ref().ok_or_else(|| {
770        anyhow::anyhow!(
771            "could not resolve gfx arch for PCI device {} — set GPU_ARCH manually",
772            primary.pci_device_id
773        )
774    })?;
775    // ROCR_VISIBLE_DEVICES / HIP_VISIBLE_DEVICES filter by the *original* ROCr
776    // enumeration order (CPU + iGPU + dGPU agents as `rocminfo` prints them),
777    // NOT a re-numbered post-filter sequence. On an APU+dGPU host the iGPU is a
778    // low ROCr agent index, so emitting the compacted filtered-list indices
779    // (0,1,...) would point ROCr at the iGPU/CPU instead of the dGPU — defeating
780    // the iGPU-exclusion hardening. Use the real ROCm agent indices captured by
781    // the gpu detector (from rocminfo) when available; fall back to compacted
782    // indices only when rocminfo is absent (a rocminfo-less host has no separate
783    // CPU/iGPU agent, so there is no index gap to worry about — this matches
784    // bootstrap::env_setup::detect_discrete_gpus's lspci/render-node fallbacks,
785    // which also compact).
786    let discrete_indices = if dgpus.iter().all(|g| g.rocm_index.is_some()) {
787        dgpus
788            .iter()
789            .map(|g| g.rocm_index.expect("checked Some above").to_string())
790            .collect::<Vec<_>>()
791            .join(",")
792    } else {
793        dgpus
794            .iter()
795            .enumerate()
796            .map(|(i, _)| i.to_string())
797            .collect::<Vec<_>>()
798            .join(",")
799    };
800    // After filtering, the dGPU is always index 0 of the *visible* (re-numbered)
801    // set, regardless of its original ROCr index. PYTORCH_ROCM_DEVICE selects
802    // within the post-filter visible set, so it stays "0".
803    let primary_gpu = "0";
804    // Per-GPU identity emitted to the env so the benchmark renders a real GPU
805    // inventory from ONE source (~/.mlstack_env) — no ad-hoc detection elsewhere.
806    // VRAM from the sysfs detector (dgpus.vram_bytes); clean marketing names +
807    // compute units from rocminfo (crate::gpu's lspci names are ugly PCI strings).
808    let gpu_vram_gb_csv = dgpus
809        .iter()
810        .map(|g| match g.vram_bytes {
811            Some(b) => format!("{:.0}", b as f64 / (1024.0 * 1024.0 * 1024.0)),
812            None => "0".to_string(),
813        })
814        .collect::<Vec<_>>()
815        .join(",");
816    let (gpu_names_csv, gpu_cus_csv) = gpu_identity_from_rocminfo(&dgpus);
817    let gpu_pairs = [
818        ("MLSTACK_GPU_NAMES", gpu_names_csv.as_str()),
819        ("MLSTACK_GPU_VRAM_GB", gpu_vram_gb_csv.as_str()),
820        ("MLSTACK_GPU_CUS", gpu_cus_csv.as_str()),
821    ];
822
823    // Precise HSA override: use GpuArchInfo mapping for gfx1101→11.0.1 etc.
824    // TheRock runtimes (7.14+/10.x) fail HSA init when the override is set, so
825    // an empty value means "keep it unset" (it is removed from existing files).
826    let hsa_override =
827        if crate::bootstrap::env_setup::rocm_version_is_therock(&detect_rocm_version())
828            || std::env::var("ROCM_CHANNEL")
829                .ok()
830                .and_then(|c| crate::installers::components::rocm::RocmChannel::parse(&c))
831                .map(|c| c.is_therock() && rocm_sdk_venv_active())
832                .unwrap_or(false)
833        {
834            String::new()
835        } else {
836            crate::bootstrap::env_setup::GpuArchInfo::from_arch(gpu_arch).hsa_override_gfx_version
837        };
838
839    // `force` (force-reinstall / explicit env rewrite): skip the in-place merge
840    // and fall through to the regenerate path below, which truncates-then-writes
841    // the canonical env from current system state. This purges stale/drifted
842    // content while staying scoped to rusty's own env file (~/.mlstack_env) —
843    // no user files outside rusty's control are touched.
844    if env_path.exists() && !force {
845        let contents = fs::read_to_string(&env_path).context("Failed to read .mlstack_env")?;
846        let rocm_version = detect_rocm_version();
847        let rocm_home = "/opt/rocm";
848        let rocm_lib = "/opt/rocm/lib";
849        let defaults = [
850            ("ROCM_VERSION", rocm_version.as_str()),
851            ("ROCM_CHANNEL", "latest"),
852            ("GPU_ARCH", gpu_arch),
853            ("PYTORCH_ROCM_ARCH", gpu_arch),
854            ("GPU_ARCHS", gpu_arch),
855            ("ROCM_HOME", rocm_home),
856            ("ROCM_PATH", rocm_home),
857            ("HIP_PATH", rocm_home),
858            ("HSA_OVERRIDE_GFX_VERSION", hsa_override.as_str()),
859            ("HIP_VISIBLE_DEVICES", discrete_indices.as_str()),
860            ("CUDA_VISIBLE_DEVICES", discrete_indices.as_str()),
861            // ROCR_VISIBLE_DEVICES — the Linux ROCr (HSA) runtime filter, the
862            // lowest level. AMD's guidance: on Linux use ROCR_VISIBLE_DEVICES; it
863            // filters before HIP. CUDA becomes usable on AMD via HIP only when the
864            // device is visible at this layer, so emit all three (HIP/CUDA/ROCR).
865            ("ROCR_VISIBLE_DEVICES", discrete_indices.as_str()),
866            ("PYTORCH_ROCM_DEVICE", primary_gpu),
867            ("MLSTACK_PYTHON_BIN", python_bin.as_str()),
868            ("UV_PYTHON", python_bin.as_str()),
869            ("MLSTACK_INSTALL_METHOD", normalized_install_method),
870            ("INSTALL_METHOD", normalized_install_method),
871            ("PYTHONPATH", rocm_lib),
872            // PYTORCH_CUDA_ALLOC_CONF — PyTorch's REAL caching-allocator var (the
873            // ROCm backend reads it as-is via the HIP shim; there is no _HIP_ or
874            // bare-ALLOC variant that works). Append-on-missing only — NOT
875            // normalized — so a user's custom allocator config is preserved rather
876            // than clobbered with this default.
877            ("PYTORCH_CUDA_ALLOC_CONF", "max_split_size_mb:512"),
878            // PYTORCH_HIP_ALLOC_CONF — the ROCm/HIP-canonical allocator var
879            // (docs.pytorch.org/.../hip). Emitted alongside the CUDA name so the
880            // config is honored regardless of which a given PyTorch build reads.
881            ("PYTORCH_HIP_ALLOC_CONF", "max_split_size_mb:512"),
882            // libdrm amdgpu ASIC id table (ROCm's amdgpu.ids lookup) — constant path.
883            ("AMDGPU_ASIC_ID_TABLE_PATH", "/usr/share/libdrm/amdgpu.ids"),
884            ("AMDGPU_ASIC_ID_TABLE_PATHS", "/usr/share/libdrm"),
885        ];
886        let (sanitized, _changed) = sanitize_mlstack_env(
887            &contents,
888            &defaults,
889            discrete_indices.as_str(),
890            rocm_home,
891            python_bin.as_str(),
892            normalized_install_method,
893            hsa_override.as_str(),
894        );
895        // Per-GPU metadata (quoted — names contain spaces; the unquoted sanitize
896        // path truncates at the first space). Canonical GPU info source.
897        let updated = ensure_gpu_metadata_exports(&sanitized, &gpu_pairs);
898        if updated != contents {
899            fs::write(&env_path, &updated).context("Failed to update .mlstack_env")?;
900            return Ok(EnvUpdate::Updated);
901        }
902        return Ok(EnvUpdate::Unchanged);
903    }
904
905    if let Some(parent) = env_path.parent() {
906        fs::create_dir_all(parent).context("Failed to create home directory")?;
907    }
908
909    let rocm_version = detect_rocm_version();
910    let rocm_home = "/opt/rocm";
911
912    // Single canonical env generator (bash) — shared with the bootstrap path so
913    // there is ONE source of truth for the ROCm env (device filter, arch, paths,
914    // perf/triton/vLLM/MPI). Only rusty-stack install metadata is appended.
915    let mut content = crate::bootstrap::env_setup::generate_env_file_content(
916        &discrete_indices,
917        rocm_home,
918        &rocm_version,
919        "latest",
920        gpu_arch,
921        &hsa_override,
922        &python_bin,
923    );
924    content.push_str(&format!(
925        "\n# Rusty-Stack install metadata\nexport MLSTACK_INSTALL_METHOD={}\nexport INSTALL_METHOD={}\n",
926        normalized_install_method, normalized_install_method,
927    ));
928    // Per-GPU metadata (quoted — names contain spaces). Canonical GPU info source.
929    content = ensure_gpu_metadata_exports(&content, &gpu_pairs);
930
931    fs::write(&env_path, &content).context("Failed to create .mlstack_env")?;
932
933    // Fish mirror — auto-loaded from ~/.config/fish/conf.d/mlstack_env.fish so
934    // fish shells get the identical device-filtered ROCm env without sourcing.
935    let fish_dir = PathBuf::from(user_home).join(".config/fish/conf.d");
936    if fs::create_dir_all(&fish_dir).is_ok() {
937        let fish_content = crate::bootstrap::env_setup::generate_fish_env_file_content(
938            &discrete_indices,
939            rocm_home,
940            &rocm_version,
941            "latest",
942            gpu_arch,
943            &hsa_override,
944            &python_bin,
945        );
946        let fish_path = fish_dir.join("mlstack_env.fish");
947        fs::write(&fish_path, fish_content)
948            .context("Failed to write fish conf.d/mlstack_env.fish")?;
949    }
950
951    // Fix libdrm amdgpu.ids symlink for ROCm packages that look in /opt/amdgpu
952    // The amdgpu.ids file is typically at /usr/share/libdrm/amdgpu.ids but
953    // libdrm compiled with ROCm looks at /opt/amdgpu/share/libdrm/ first.
954    fix_libdrm_amdgpu_ids();
955
956    Ok(EnvUpdate::Created)
957}
958
959/// Clean GPU marketing names + compute-unit counts from rocminfo, for the
960/// discrete GPUs (Device Type: GPU, iGPUs filtered), ordered to match
961/// ROCR_VISIBLE_DEVICES. lspci descriptions (what crate::gpu's marketing_name
962/// holds) are ugly PCI strings; rocminfo's Marketing Name is the clean product
963/// name + Compute Unit is the CU count. Returns (names_csv, cus_csv). Falls
964/// back to dgpus' lspci name + 0 CUs if rocminfo is unavailable or mismatches.
965fn gpu_identity_from_rocminfo(dgpus: &[crate::gpu::AmdGpu]) -> (String, String) {
966    let count = dgpus.len();
967    let text =
968        match std::process::Command::new(crate::installers::common::utils::resolve_rocminfo_path())
969            .output()
970        {
971            Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).to_string(),
972            _ => return gpu_identity_fallback(dgpus),
973        };
974    let mut entries: Vec<(String, u32)> = Vec::new();
975    let mut name = String::new();
976    let mut cus: u32 = 0;
977    let mut is_gpu = false;
978    let mut started = false;
979    for line in text.lines() {
980        let t = line.trim();
981        if t.starts_with("Agent ") && t.contains(char::is_numeric) {
982            if started && is_gpu && !name.is_empty() && !crate::gpu::is_integrated_gpu_name(&name) {
983                entries.push((name.clone(), cus));
984            }
985            name.clear();
986            cus = 0;
987            is_gpu = false;
988            started = false;
989        } else if let Some(v) = t.strip_prefix("Marketing Name:") {
990            name = v.trim().to_string();
991            started = true;
992        } else if let Some(v) = t.strip_prefix("Device Type:") {
993            is_gpu = v.trim().contains("GPU");
994            started = true;
995        } else if let Some(v) = t.strip_prefix("Compute Unit:") {
996            cus = v.trim().parse().unwrap_or(0);
997            started = true;
998        }
999    }
1000    if started && is_gpu && !name.is_empty() && !crate::gpu::is_integrated_gpu_name(&name) {
1001        entries.push((name, cus));
1002    }
1003    entries.truncate(count);
1004    if entries.len() == count {
1005        let names = entries
1006            .iter()
1007            .map(|(n, _)| n.clone())
1008            .collect::<Vec<_>>()
1009            .join(",");
1010        let cus = entries
1011            .iter()
1012            .map(|(_, c)| c.to_string())
1013            .collect::<Vec<_>>()
1014            .join(",");
1015        (names, cus)
1016    } else {
1017        gpu_identity_fallback(dgpus)
1018    }
1019}
1020
1021/// Fallback: dgpus' lspci marketing names + 0 CUs (when rocminfo is unavailable
1022/// or its GPU-agent count doesn't match the sysfs detector's discrete count).
1023fn gpu_identity_fallback(dgpus: &[crate::gpu::AmdGpu]) -> (String, String) {
1024    let names = dgpus
1025        .iter()
1026        .map(|g| {
1027            g.marketing_name
1028                .clone()
1029                .unwrap_or_else(|| format!("AMD GPU {}", g.pci_device_id))
1030        })
1031        .collect::<Vec<_>>()
1032        .join(",");
1033    let cus = dgpus
1034        .iter()
1035        .map(|_| "0".to_string())
1036        .collect::<Vec<_>>()
1037        .join(",");
1038    (names, cus)
1039}
1040
1041/// Ensure `MLSTACK_GPU_NAMES` + `MLSTACK_GPU_VRAM_GB` are present as QUOTED
1042/// exports. GPU marketing names contain spaces ("AMD Radeon RX 7900 XTX"), and
1043/// the sanitize path writes `export KEY=VALUE` unquoted (truncating at the first
1044/// space), so these two are emitted/normalized here instead. Values are
1045/// comma-separated, ordered to match ROCR_VISIBLE_DEVICES. This is the canonical
1046/// GPU info (names + VRAM + arch via GPU_ARCH) that the benchmark + verification
1047/// read from the env rather than re-detecting.
1048fn ensure_gpu_metadata_exports(contents: &str, pairs: &[(&str, &str)]) -> String {
1049    let mut lines: Vec<String> = contents.lines().map(|l| l.to_string()).collect();
1050    for (key, value) in pairs {
1051        let quoted = format!("export {key}=\"{value}\"");
1052        let prefix = format!("export {key}=");
1053        match lines
1054            .iter()
1055            .position(|l| l.trim_start().starts_with(&prefix))
1056        {
1057            Some(idx) => lines[idx] = quoted,
1058            None => lines.push(quoted),
1059        }
1060    }
1061    let mut out = lines.join("\n");
1062    if !out.ends_with('\n') {
1063        out.push('\n');
1064    }
1065    out
1066}
1067
1068fn sanitize_mlstack_env(
1069    contents: &str,
1070    defaults: &[(&str, &str)],
1071    gpu_list: &str,
1072    rocm_home: &str,
1073    python_bin: &str,
1074    install_method: &str,
1075    hsa_override: &str,
1076) -> (String, bool) {
1077    let mut changed = false;
1078    let mut lines = Vec::new();
1079    let mut seen = std::collections::HashSet::new();
1080    // Defaults lookup for keys not covered by explicit params (ROCM_VERSION,
1081    // GPU_ARCH, PYTORCH_ROCM_ARCH, GPU_ARCHS, ROCM_HOME, PYTORCH_ROCM_DEVICE) so
1082    // they are normalized — not preserved verbatim — and the env cannot drift
1083    // stale (e.g. ROCM_VERSION=7.2.0 frozen in the file while 7.2.4 is installed).
1084    let default_for = |key: &str| -> &str {
1085        defaults
1086            .iter()
1087            .find(|(k, _)| *k == key)
1088            .map(|(_, v)| *v)
1089            .unwrap_or("")
1090    };
1091
1092    for line in contents.lines() {
1093        let trimmed = line.trim_start();
1094        if let Some(key) = extract_env_key(trimmed) {
1095            seen.insert(key.to_string());
1096        }
1097        // Stage 2: strip vars that don't belong on a ROCm stack — decided by
1098        // FUNCTION, not name. CUDA-named vars ROCm actually uses are KEPT
1099        // (CUDA_VISIBLE_DEVICES, PYTORCH_CUDA_ALLOC_CONF — PyTorch's real caching
1100        // allocator var, read as-is by the ROCm backend via the HIP shim). Strip:
1101        //   - TORCH_CUDA_ARCH_LIST: CUDA compute-capability BUILD targets — ROCm
1102        //     uses PYTORCH_ROCM_ARCH (gfx), so this is meaningless/harmful here.
1103        //   - PYTORCH_ALLOC_CONF: a non-standard no-op rename (PyTorch never
1104        //     dropped the _CUDA) — strip from legacy files so only the functional
1105        //     PYTORCH_CUDA_ALLOC_CONF remains.
1106        if hsa_override.is_empty() && trimmed.starts_with("export HSA_OVERRIDE_GFX_VERSION=") {
1107            changed = true;
1108            continue;
1109        }
1110        if trimmed.starts_with("export TORCH_CUDA_ARCH_LIST=")
1111            || trimmed.starts_with("export PYTORCH_ALLOC_CONF=")
1112        {
1113            changed = true;
1114            continue;
1115        }
1116        let (line, changed_hip) = normalize_env_value(line, "HIP_PATH", "/opt/rocm");
1117        let (line, changed_rocm) = normalize_env_value(&line, "ROCM_PATH", "/opt/rocm");
1118        let (line, changed_python) = normalize_env_value(&line, "MLSTACK_PYTHON_BIN", python_bin);
1119        let (line, changed_uv_python) = normalize_env_value(&line, "UV_PYTHON", python_bin);
1120        let (line, changed_method) =
1121            normalize_env_value(&line, "MLSTACK_INSTALL_METHOD", install_method);
1122        let (line, changed_method_alias) =
1123            normalize_env_value(&line, "INSTALL_METHOD", install_method);
1124        let (line, changed_hsa) = if hsa_override.is_empty() {
1125            (line, false)
1126        } else {
1127            normalize_env_value(&line, "HSA_OVERRIDE_GFX_VERSION", hsa_override)
1128        };
1129        let (line, changed_hip_vis) =
1130            normalize_visible_devices(&line, "HIP_VISIBLE_DEVICES", gpu_list);
1131        let (line, changed_cuda_vis) =
1132            normalize_visible_devices(&line, "CUDA_VISIBLE_DEVICES", gpu_list);
1133        let (line, changed_rocr_vis) =
1134            normalize_visible_devices(&line, "ROCR_VISIBLE_DEVICES", gpu_list);
1135        // Normalize the remaining defaults that were previously preserved verbatim
1136        // and could drift stale. ROCM_VERSION was the reported bug; the arch/home
1137        // cluster is the same class.
1138        let (line, changed_rocm_ver) =
1139            normalize_env_value(&line, "ROCM_VERSION", default_for("ROCM_VERSION"));
1140        let (line, changed_gpu_arch) =
1141            normalize_env_value(&line, "GPU_ARCH", default_for("GPU_ARCH"));
1142        let (line, changed_pytorch_arch) =
1143            normalize_env_value(&line, "PYTORCH_ROCM_ARCH", default_for("PYTORCH_ROCM_ARCH"));
1144        let (line, changed_gpu_archs) =
1145            normalize_env_value(&line, "GPU_ARCHS", default_for("GPU_ARCHS"));
1146        let (line, changed_rocm_home) =
1147            normalize_env_value(&line, "ROCM_HOME", default_for("ROCM_HOME"));
1148        let (line, changed_pytorch_dev) = normalize_env_value(
1149            &line,
1150            "PYTORCH_ROCM_DEVICE",
1151            default_for("PYTORCH_ROCM_DEVICE"),
1152        );
1153        let (line, changed_pythonpath) = if line.starts_with("export PYTHONPATH=") {
1154            let desired = format!("export PYTHONPATH={}/lib:$PYTHONPATH", rocm_home);
1155            if line.trim() != desired {
1156                (desired, true)
1157            } else {
1158                (line, false)
1159            }
1160        } else {
1161            (line, false)
1162        };
1163
1164        // Enforce safe PATH and LD_LIBRARY_PATH
1165        let (line, changed_path) = if line.starts_with("export PATH=") {
1166            let desired = format!("export PATH=\"/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games:{}/bin:{}/hip/bin:$PATH\"", rocm_home, rocm_home);
1167            if line.trim() != desired {
1168                (desired, true)
1169            } else {
1170                (line, false)
1171            }
1172        } else {
1173            (line, false)
1174        };
1175
1176        let (line, changed_ld) = if line.starts_with("export LD_LIBRARY_PATH=") {
1177            let desired = format!(
1178                "export LD_LIBRARY_PATH=\"$HOME/.mlstack/libmpi-compat:$HOME/.mlstack/libmpi-compat-user-$(id -u):{}/lib:{}/hip/lib:{}/opencl/lib:$LD_LIBRARY_PATH\"",
1179                rocm_home, rocm_home, rocm_home
1180            );
1181            if line.trim() != desired {
1182                (desired, true)
1183            } else {
1184                (line, false)
1185            }
1186        } else {
1187            (line, false)
1188        };
1189
1190        changed |= changed_hip
1191            || changed_rocm
1192            || changed_python
1193            || changed_uv_python
1194            || changed_method
1195            || changed_method_alias
1196            || changed_hsa
1197            || changed_hip_vis
1198            || changed_cuda_vis
1199            || changed_rocr_vis
1200            || changed_rocm_ver
1201            || changed_gpu_arch
1202            || changed_pytorch_arch
1203            || changed_gpu_archs
1204            || changed_rocm_home
1205            || changed_pytorch_dev
1206            || changed_pythonpath
1207            || changed_path
1208            || changed_ld;
1209        lines.push(line);
1210    }
1211
1212    for (key, value) in defaults {
1213        if !seen.contains(*key) {
1214            lines.push(format!("export {}={}", key, value));
1215            changed = true;
1216        }
1217    }
1218
1219    let mut output = lines.join("\n");
1220    output.push('\n');
1221    (output, changed)
1222}
1223
1224fn extract_env_key(line: &str) -> Option<&str> {
1225    // Use rfind to handle both direct exports and if-guarded exports
1226    let export_idx = line.rfind("export ")?;
1227    let rest = &line[export_idx + 7..]; // skip "export "
1228    rest.split('=').next().map(str::trim)
1229}
1230
1231fn normalize_env_value(line: &str, key: &str, desired: &str) -> (String, bool) {
1232    let marker = format!("export {}=", key);
1233    // Use rfind to handle both direct exports and if-guarded exports
1234    if let Some(export_idx) = line.rfind(&marker) {
1235        let after_marker = &line[export_idx + marker.len()..];
1236        let current = after_marker.trim().trim_matches('"');
1237        // Strip shell suffixes before comparing
1238        let current = strip_shell_suffixes_from_value(current);
1239        if current != desired {
1240            // Replace the entire line with a direct export (removing if-guard)
1241            return (format!("export {}={}", key, desired), true);
1242        }
1243    }
1244    (line.to_string(), false)
1245}
1246
1247fn normalize_visible_devices(line: &str, key: &str, desired: &str) -> (String, bool) {
1248    let desired = desired.trim();
1249    if desired.is_empty() {
1250        return (line.to_string(), false);
1251    }
1252
1253    let marker = format!("export {}=", key);
1254    // Use rfind to handle both direct exports and if-guarded exports
1255    if let Some(export_idx) = line.rfind(&marker) {
1256        let after_marker = &line[export_idx + marker.len()..];
1257        let current = after_marker.trim().trim_matches('"');
1258        // Strip shell suffixes before comparing
1259        let current = strip_shell_suffixes_from_value(current);
1260
1261        // Always update if current differs from desired
1262        // This handles:
1263        // 1. "0" -> "0,1" (adding more GPUs)
1264        // 2. "0,1,2" -> "0,1" (filtering out iGPUs)
1265        // 3. "0,1" -> "0,1" (no change)
1266        if current != desired {
1267            // Replace the entire line with a direct export (removing if-guard)
1268            return (format!("export {}={}", key, desired), true);
1269        }
1270    }
1271    (line.to_string(), false)
1272}
1273
1274/// Remove `HSA_OVERRIDE_GFX_VERSION` from a child process when the active ROCm
1275/// runtime is TheRock (7.14+/10.x): it breaks HSA init there. Also clears any
1276/// value inherited from the parent shell.
1277fn strip_hsa_override_for_therock(command: &mut Command, env_exports: &HashMap<String, String>) {
1278    let version = env_exports
1279        .get("ROCM_VERSION")
1280        .cloned()
1281        .unwrap_or_else(detect_rocm_version);
1282    if crate::bootstrap::env_setup::rocm_version_is_therock(&version)
1283        || crate::bootstrap::env_setup::rocm_version_is_therock(&detect_rocm_version())
1284    {
1285        command.env_remove("HSA_OVERRIDE_GFX_VERSION");
1286    }
1287}
1288
1289/// True when a user-space ROCm SDK (TheRock) venv is installed under
1290/// `~/.mlstack/rocm/`, i.e. the runtime in use is not the system /opt/rocm.
1291fn rocm_sdk_venv_python() -> Option<std::path::PathBuf> {
1292    let home = std::env::var("HOME").ok()?;
1293    let entries = match fs::read_dir(format!("{home}/.mlstack/rocm")) {
1294        Ok(d) => d,
1295        Err(_) => return None,
1296    };
1297    entries
1298        .flatten()
1299        .map(|e| e.path().join("bin/python"))
1300        .find(|p| p.exists())
1301}
1302
1303fn rocm_sdk_venv_active() -> bool {
1304    rocm_sdk_venv_python().is_some()
1305}
1306
1307fn detect_rocm_version() -> String {
1308    if let Ok(version) = fs::read_to_string("/opt/rocm/.info/version") {
1309        return version.trim().to_string();
1310    }
1311    // No system ROCm: fall back to the user-space TheRock SDK venv, if any.
1312    if let Some(py) = rocm_sdk_venv_python() {
1313        if let Ok(out) = Command::new(py)
1314            .args(["-c", "import importlib.metadata as m; print(m.version('rocm'))"])
1315            .output()
1316        {
1317            let v = String::from_utf8_lossy(&out.stdout).trim().to_string();
1318            if out.status.success() && !v.is_empty() {
1319                return v;
1320            }
1321        }
1322    }
1323    "7.2.0".to_string()
1324}
1325
1326fn run_verification(
1327    component: &Component,
1328    python_candidates: &[String],
1329    sender: &Sender<InstallerEvent>,
1330    user_home: &str,
1331) -> VerificationOutcome {
1332    let steps = verification_commands(&component.id, python_candidates);
1333    let mut results: HashMap<String, VerificationCheckResult> = HashMap::new();
1334    let mut executed = 0usize;
1335    let mut failed = 0usize;
1336
1337    for step in &steps {
1338        if let Some((benchmark_ok, benchmark_output)) = verify_benchmark_target(&step.target_id) {
1339            executed += 1;
1340            if benchmark_ok {
1341                results.insert(
1342                    step.target_id.clone(),
1343                    VerificationCheckResult {
1344                        status: VerificationResult::Verified,
1345                        output: benchmark_output,
1346                    },
1347                );
1348            } else {
1349                failed += 1;
1350                results.insert(
1351                    step.target_id.clone(),
1352                    VerificationCheckResult {
1353                        status: VerificationResult::Failed,
1354                        output: benchmark_output,
1355                    },
1356                );
1357                let _ = sender.send(InstallerEvent::Log(
1358                    format!("Verification failed for {}", step.label),
1359                    false,
1360                ));
1361            }
1362            continue;
1363        }
1364
1365        if !is_component_installed_by_id(&step.target_id, python_candidates) {
1366            results.insert(
1367                step.target_id.clone(),
1368                VerificationCheckResult {
1369                    status: VerificationResult::Missing,
1370                    output: vec!["Component not found in path or site-packages".to_string()],
1371                },
1372            );
1373            let _ = sender.send(InstallerEvent::Log(
1374                format!("Skipping {} (not installed)", step.label),
1375                false,
1376            ));
1377            continue;
1378        }
1379        if !modules_available(&step.modules, python_candidates) {
1380            results.insert(
1381                step.target_id.clone(),
1382                VerificationCheckResult {
1383                    status: VerificationResult::Missing,
1384                    output: vec![format!(
1385                        "Required Python modules missing: {}",
1386                        step.modules.join(", ")
1387                    )],
1388                },
1389            );
1390            let _ = sender.send(InstallerEvent::Log(
1391                format!("Skipping {} (module not available)", step.label),
1392                false,
1393            ));
1394            continue;
1395        }
1396        executed += 1;
1397        let _ = sender.send(InstallerEvent::Log(
1398            format!("Verifying {}...", step.label),
1399            false,
1400        ));
1401        match run_verification_command(step, sender) {
1402            Ok((success, output)) => {
1403                if success {
1404                    results.insert(
1405                        step.target_id.clone(),
1406                        VerificationCheckResult {
1407                            status: VerificationResult::Verified,
1408                            output,
1409                        },
1410                    );
1411                } else {
1412                    failed += 1;
1413                    results.insert(
1414                        step.target_id.clone(),
1415                        VerificationCheckResult {
1416                            status: VerificationResult::Failed,
1417                            output,
1418                        },
1419                    );
1420                    let _ = sender.send(InstallerEvent::Log(
1421                        format!("Verification failed for {}", step.label),
1422                        false,
1423                    ));
1424                }
1425            }
1426            Err(err) => {
1427                failed += 1;
1428                results.insert(
1429                    step.target_id.clone(),
1430                    VerificationCheckResult {
1431                        status: VerificationResult::Failed,
1432                        output: vec![err.to_string()],
1433                    },
1434                );
1435                let _ = sender.send(InstallerEvent::Log(
1436                    format!("Failed to execute verification for {}: {}", step.label, err),
1437                    false,
1438                ));
1439            }
1440        }
1441    }
1442
1443    let report_lines = build_verification_report(python_candidates, user_home, &results, &steps);
1444    let success = executed > 0 && failed == 0;
1445
1446    if executed == 0 {
1447        let _ = sender.send(InstallerEvent::Log(
1448            "No installed components found to verify".into(),
1449            false,
1450        ));
1451    }
1452
1453    if failed > 0 {
1454        let _ = sender.send(InstallerEvent::Log(
1455            format!("Verification completed with {} failed checks", failed),
1456            false,
1457        ));
1458    }
1459
1460    VerificationOutcome {
1461        success,
1462        report_lines,
1463    }
1464}
1465
1466fn build_verification_report(
1467    python_candidates: &[String],
1468    user_home: &str,
1469    results: &HashMap<String, VerificationCheckResult>,
1470    steps: &[VerificationCommand],
1471) -> Vec<String> {
1472    let mut lines = Vec::new();
1473    lines.push("Environment:".to_string());
1474    for (key, value) in collect_env_info(user_home) {
1475        lines.push(format!("  {}: {}", key, value));
1476    }
1477
1478    lines.push("".to_string());
1479    lines.push("Python Interpreters:".to_string());
1480    for py in python_candidates {
1481        lines.push(format!("  - {}", py));
1482    }
1483
1484    lines.push("".to_string());
1485    lines.push("Checks:".to_string());
1486    for step in steps {
1487        if let Some(res) = results.get(&step.target_id) {
1488            lines.push(format!("  - {}: {}", step.label, res.status.label()));
1489            for out_line in &res.output {
1490                lines.push(format!("    > {}", out_line));
1491            }
1492        } else {
1493            lines.push(format!("  - {}: Skipped", step.label));
1494        }
1495    }
1496
1497    lines
1498}
1499
1500fn run_component_verification(
1501    component: &Component,
1502    python_candidates: &[String],
1503    sender: &Sender<InstallerEvent>,
1504    user_home: &str,
1505) -> VerificationOutcome {
1506    let steps = component_verification_commands(&component.id, python_candidates);
1507    let mut results: HashMap<String, VerificationCheckResult> = HashMap::new();
1508    let mut executed = 0usize;
1509    let mut failed = 0usize;
1510
1511    for step in &steps {
1512        if let Some((benchmark_ok, benchmark_output)) = verify_benchmark_target(&step.target_id) {
1513            executed += 1;
1514            if benchmark_ok {
1515                results.insert(
1516                    step.target_id.clone(),
1517                    VerificationCheckResult {
1518                        status: VerificationResult::Verified,
1519                        output: benchmark_output,
1520                    },
1521                );
1522            } else {
1523                failed += 1;
1524                results.insert(
1525                    step.target_id.clone(),
1526                    VerificationCheckResult {
1527                        status: VerificationResult::Failed,
1528                        output: benchmark_output,
1529                    },
1530                );
1531            }
1532            continue;
1533        }
1534
1535        if !modules_available(&step.modules, python_candidates) {
1536            results.insert(
1537                step.target_id.clone(),
1538                VerificationCheckResult {
1539                    status: VerificationResult::Missing,
1540                    output: vec![format!(
1541                        "Required Python modules missing: {}",
1542                        step.modules.join(", ")
1543                    )],
1544                },
1545            );
1546            continue;
1547        }
1548        executed += 1;
1549        match run_verification_command(step, sender) {
1550            Ok((success, output)) => {
1551                if success {
1552                    results.insert(
1553                        step.target_id.clone(),
1554                        VerificationCheckResult {
1555                            status: VerificationResult::Verified,
1556                            output,
1557                        },
1558                    );
1559                } else {
1560                    failed += 1;
1561                    results.insert(
1562                        step.target_id.clone(),
1563                        VerificationCheckResult {
1564                            status: VerificationResult::Failed,
1565                            output,
1566                        },
1567                    );
1568                }
1569            }
1570            Err(err) => {
1571                failed += 1;
1572                results.insert(
1573                    step.target_id.clone(),
1574                    VerificationCheckResult {
1575                        status: VerificationResult::Failed,
1576                        output: vec![err.to_string()],
1577                    },
1578                );
1579            }
1580        }
1581    }
1582
1583    let report_lines = build_component_report(component, user_home, &results, &steps);
1584    let success = executed > 0 && failed == 0;
1585
1586    VerificationOutcome {
1587        success,
1588        report_lines,
1589    }
1590}
1591
1592fn is_native_maintenance_action(component: &Component) -> bool {
1593    component.category == Category::Maintenance && !component.id.starts_with("verify-")
1594}
1595
1596fn native_maintenance_action_outcome(
1597    component: &Component,
1598    install_success: bool,
1599) -> VerificationOutcome {
1600    VerificationOutcome {
1601        success: install_success,
1602        report_lines: vec![format!(
1603            "{} native action {}",
1604            component.name,
1605            if install_success {
1606                "reported success"
1607            } else {
1608                "reported failure"
1609            }
1610        )],
1611    }
1612}
1613
1614fn build_component_report(
1615    component: &Component,
1616    user_home: &str,
1617    results: &HashMap<String, VerificationCheckResult>,
1618    steps: &[VerificationCommand],
1619) -> Vec<String> {
1620    let mut lines = Vec::new();
1621    lines.push(format!("Component: {}", component.name));
1622
1623    for (key, value) in collect_env_info(user_home) {
1624        lines.push(format!("  {}: {}", key, value));
1625    }
1626
1627    // Add benchmark results for benchmark components
1628    if let Some(benchmark_lines) = extract_benchmark_results(&component.id) {
1629        lines.push("".to_string());
1630        lines.push("Benchmark Results:".to_string());
1631        lines.extend(benchmark_lines);
1632    }
1633
1634    lines.push("".to_string());
1635    lines.push("Verification Checks:".to_string());
1636
1637    for step in steps {
1638        if let Some(res) = results.get(&step.target_id) {
1639            lines.push(format!("  - {}: {}", step.label, res.status.label()));
1640            for out_line in &res.output {
1641                lines.push(format!("    > {}", out_line));
1642            }
1643        } else {
1644            lines.push(format!("  - {}: Skipped", step.label));
1645        }
1646    }
1647
1648    lines
1649}
1650
1651fn extract_benchmark_results(component_id: &str) -> Option<Vec<String>> {
1652    let log_dirs = benchmark_log_directories();
1653    if log_dirs.is_empty() {
1654        return None;
1655    }
1656
1657    // Map component IDs to log file patterns
1658    let pattern = match component_id {
1659        "mlperf-inference" => "all_benchmarks",
1660        "rocm-benchmarks" => "gpu-capability_benchmarks",
1661        "gpu-memory-bandwidth" => "memory-bandwidth_benchmarks",
1662        "rocm-smi-bench" => "gpu-capability_benchmarks",
1663        "pytorch-performance" => "pytorch_performance",
1664        "vllm-performance" => "vllm_benchmarks",
1665        "deepspeed-performance" => "deepspeed_benchmarks",
1666        "megatron-performance" => "megatron_benchmarks",
1667        "onnx-performance" => "onnx_benchmarks",
1668        "all-benchmarks" => "all_benchmarks",
1669        _ => return None,
1670    };
1671
1672    let log_file = find_latest_log_in_dirs(&log_dirs, pattern)?;
1673    let contents = std::fs::read_to_string(log_file).ok()?;
1674
1675    let Some(json) = extract_benchmark_json_value(&contents) else {
1676        return Some(vec!["  No metrics found in log".to_string()]);
1677    };
1678
1679    let mut result_lines = benchmark_result_lines_from_json(&json);
1680    if result_lines.is_empty() {
1681        result_lines.push("  No metrics found in log".to_string());
1682    }
1683
1684    Some(result_lines)
1685}
1686
1687fn benchmark_result_lines_from_json(json: &serde_json::Value) -> Vec<String> {
1688    let mut result_lines = Vec::new();
1689
1690    // Handle nested results object (for "all" benchmarks) or a single
1691    // benchmark's scalar metric object persisted under `results`.
1692    if let Some(results) = json.get("results").and_then(|r| r.as_object()) {
1693        let nested_suite = results.values().any(|value| {
1694            value.as_object().is_some_and(|obj| {
1695                obj.contains_key("success")
1696                    || obj.contains_key("metrics")
1697                    || obj.contains_key("execution_time_ms")
1698            })
1699        });
1700        if nested_suite {
1701            for (bench_name, bench_data) in results {
1702                if bench_data.is_object() {
1703                    let status = bench_data
1704                        .get("success")
1705                        .and_then(|v| v.as_bool())
1706                        .map(|ok| if ok { "SUCCESS" } else { "FAILED" })
1707                        .unwrap_or("UNKNOWN");
1708                    let time_ms = bench_data
1709                        .get("execution_time_ms")
1710                        .and_then(|v| v.as_u64())
1711                        .unwrap_or(0);
1712                    result_lines.push(format!(
1713                        "  === {} ({}, {} ms) ===",
1714                        bench_name.replace('_', " ").to_uppercase(),
1715                        status,
1716                        time_ms
1717                    ));
1718                    if let Some(metrics) = bench_data.get("metrics").and_then(|m| m.as_object()) {
1719                        append_benchmark_metric_lines(&mut result_lines, metrics, "    ");
1720                    }
1721                    append_benchmark_error_lines(&mut result_lines, bench_data, "    ");
1722                    result_lines.push(String::new());
1723                }
1724            }
1725        } else {
1726            append_benchmark_metric_lines(&mut result_lines, results, "  ");
1727            append_benchmark_error_lines(&mut result_lines, json, "  ");
1728        }
1729    } else if let Some(metrics) = json.get("metrics").and_then(|m| m.as_object()) {
1730        // Handle single benchmark results
1731        append_benchmark_metric_lines(&mut result_lines, metrics, "  ");
1732        append_benchmark_error_lines(&mut result_lines, json, "  ");
1733    }
1734
1735    result_lines
1736}
1737
1738fn append_benchmark_metric_lines(
1739    lines: &mut Vec<String>,
1740    metrics: &serde_json::Map<String, serde_json::Value>,
1741    indent: &str,
1742) {
1743    for (key, value) in metrics {
1744        let value_str = match value {
1745            serde_json::Value::Number(n) => n.to_string(),
1746            serde_json::Value::String(s) => s.clone(),
1747            serde_json::Value::Bool(b) => b.to_string(),
1748            serde_json::Value::Array(arr) => format!("{} values", arr.len()),
1749            _ => continue,
1750        };
1751        let formatted_key = key.replace('_', " ").to_uppercase();
1752        lines.push(format!("{}{}: {}", indent, formatted_key, value_str));
1753    }
1754}
1755
1756fn append_benchmark_error_lines(lines: &mut Vec<String>, value: &serde_json::Value, indent: &str) {
1757    if let Some(errors) = value.get("errors").and_then(|v| v.as_array()) {
1758        for err in errors.iter().filter_map(|v| v.as_str()).take(3) {
1759            lines.push(format!("{}ERROR: {}", indent, err));
1760        }
1761    }
1762}
1763
1764fn benchmark_pattern_for_target(target_id: &str) -> Option<&'static str> {
1765    match target_id {
1766        "mlperf-inference" => Some("all_benchmarks"),
1767        "rocm-benchmarks" => Some("gpu-capability_benchmarks"),
1768        "gpu-memory-bandwidth" => Some("memory-bandwidth_benchmarks"),
1769        "rocm-smi-bench" => Some("gpu-capability_benchmarks"),
1770        "pytorch-performance" => Some("pytorch_performance"),
1771        "vllm-performance" => Some("vllm_benchmarks"),
1772        "deepspeed-performance" => Some("deepspeed_benchmarks"),
1773        "megatron-performance" => Some("megatron_benchmarks"),
1774        "onnx-performance" => Some("onnx_benchmarks"),
1775        "all-benchmarks" => Some("all_benchmarks"),
1776        _ => None,
1777    }
1778}
1779
1780fn benchmark_success_and_errors(value: &serde_json::Value) -> (bool, Vec<String>) {
1781    if let Some(success) = value.get("success").and_then(|v| v.as_bool()) {
1782        let mut details = Vec::new();
1783        if !success {
1784            if let Some(reason) = value.get("reason").and_then(|v| v.as_str()) {
1785                let reason = reason.trim();
1786                if !reason.is_empty() {
1787                    details.push(reason.to_string());
1788                }
1789            }
1790            if let Some(errors) = value.get("errors").and_then(|v| v.as_array()) {
1791                for err in errors.iter().filter_map(|entry| entry.as_str()).take(4) {
1792                    let trimmed = err.trim();
1793                    if !trimmed.is_empty() {
1794                        details.push(trimmed.to_string());
1795                    }
1796                }
1797            }
1798            if details.is_empty() {
1799                details.push("Benchmark JSON reported success=false".to_string());
1800            }
1801        }
1802        return (success, details);
1803    }
1804
1805    if let Some(results) = value.get("results").and_then(|v| v.as_object()) {
1806        let mut failed = Vec::new();
1807        let mut seen_result = false;
1808        for (name, item) in results {
1809            if let Some(item_success) = item.get("success").and_then(|v| v.as_bool()) {
1810                seen_result = true;
1811                if !item_success {
1812                    let mut reason = item
1813                        .get("reason")
1814                        .and_then(|v| v.as_str())
1815                        .map(|s| s.trim().to_string())
1816                        .filter(|s| !s.is_empty())
1817                        .unwrap_or_else(|| format!("{name}: success=false"));
1818                    if reason == format!("{name}: success=false") {
1819                        if let Some(errors) = item.get("errors").and_then(|v| v.as_array()) {
1820                            if let Some(first) = errors.iter().filter_map(|v| v.as_str()).next() {
1821                                let first = first.trim();
1822                                if !first.is_empty() {
1823                                    reason = format!("{name}: {first}");
1824                                }
1825                            }
1826                        }
1827                    }
1828                    failed.push(reason);
1829                }
1830            }
1831        }
1832        if seen_result {
1833            return (failed.is_empty(), failed);
1834        }
1835    }
1836
1837    (
1838        false,
1839        vec!["Benchmark JSON does not contain a recognizable success field".to_string()],
1840    )
1841}
1842
1843fn verify_benchmark_target(target_id: &str) -> Option<(bool, Vec<String>)> {
1844    let pattern = benchmark_pattern_for_target(target_id)?;
1845    let log_dirs = benchmark_log_directories();
1846    if log_dirs.is_empty() {
1847        return Some((
1848            false,
1849            vec!["No benchmark log directories found".to_string()],
1850        ));
1851    }
1852
1853    let Some(log_file) = find_latest_log_in_dirs(&log_dirs, pattern) else {
1854        return Some((
1855            false,
1856            vec![format!("No benchmark logs found for pattern '{pattern}'")],
1857        ));
1858    };
1859
1860    let mut output = vec![format!("Latest benchmark log: {}", log_file.display())];
1861    let contents = match std::fs::read_to_string(&log_file) {
1862        Ok(contents) => contents,
1863        Err(err) => {
1864            output.push(format!("Could not read benchmark log: {err}"));
1865            return Some((false, output));
1866        }
1867    };
1868
1869    let Some(json) = extract_benchmark_json_value(&contents) else {
1870        output.push("Could not parse benchmark JSON payload from log".to_string());
1871        return Some((false, output));
1872    };
1873
1874    let (success, mut details) = benchmark_success_and_errors(&json);
1875    output.append(&mut details);
1876    Some((success, output))
1877}
1878
1879fn collect_env_info(user_home: &str) -> Vec<(String, String)> {
1880    let mut values = load_mlstack_env_exports(user_home);
1881
1882    let keys = [
1883        "ROCM_VERSION",
1884        "ROCM_CHANNEL",
1885        "GPU_ARCH",
1886        "GPU_ARCHS",
1887        "PYTORCH_ROCM_ARCH",
1888        "HIP_VISIBLE_DEVICES",
1889        "CUDA_VISIBLE_DEVICES",
1890        "ROCR_VISIBLE_DEVICES",
1891        "PYTORCH_ROCM_DEVICE",
1892        "HSA_OVERRIDE_GFX_VERSION",
1893        "ROCM_HOME",
1894        "ROCM_PATH",
1895        "HIP_PATH",
1896        "MLSTACK_PYTHON_BIN",
1897        "PYTHONPATH",
1898    ];
1899
1900    for key in keys {
1901        if !values.contains_key(key) {
1902            if let Ok(value) = std::env::var(key) {
1903                values.insert(key.to_string(), value);
1904            }
1905        }
1906    }
1907
1908    keys.iter()
1909        .map(|key| {
1910            let default_val = if *key == "ROCM_HOME" || *key == "ROCM_PATH" || *key == "HIP_PATH" {
1911                "/opt/rocm".to_string()
1912            } else {
1913                "unknown".to_string()
1914            };
1915            (
1916                key.to_string(),
1917                values.get(*key).cloned().unwrap_or(default_val),
1918            )
1919        })
1920        .collect()
1921}
1922
1923fn load_mlstack_env_exports(user_home: &str) -> HashMap<String, String> {
1924    let mut values: HashMap<String, String> = HashMap::new();
1925    for home in candidate_mlstack_homes(user_home) {
1926        let env_path = PathBuf::from(home).join(".mlstack_env");
1927        if let Ok(contents) = fs::read_to_string(&env_path) {
1928            for line in contents.lines() {
1929                if let Some((key, value)) = parse_env_export(line) {
1930                    values.entry(key).or_insert(value);
1931                }
1932            }
1933        }
1934    }
1935
1936    values
1937}
1938
1939fn parse_env_export(line: &str) -> Option<(String, String)> {
1940    let export_idx = line.rfind("export ")?;
1941    let rest = &line[export_idx + 7..];
1942    let mut parts = rest.splitn(2, '=');
1943    let key = parts.next()?.trim().to_string();
1944    let value = parts.next()?.trim().trim_matches('"').to_string();
1945    // Strip shell suffixes from if-guard lines: "; fi", "&& ..."
1946    let value = strip_shell_suffixes_from_value(&value);
1947    Some((key, value))
1948}
1949
1950/// Strip shell command suffixes from an env file value.
1951///
1952/// The `.mlstack_env` file uses if-guard patterns:
1953/// `if [ -z "${VAR:-}" ]; then export VAR=value; fi`
1954///
1955/// When parsing the `export VAR=value` part, the value may contain
1956/// the trailing `; fi`. This function strips it.
1957fn strip_shell_suffixes_from_value(value: &str) -> String {
1958    let mut v = value.to_string();
1959    // Strip trailing "; fi" (from if-guard lines)
1960    if let Some(idx) = v.find("; fi") {
1961        v.truncate(idx);
1962    }
1963    // Strip trailing "&& ..." (from chained commands)
1964    if let Some(idx) = v.find("&& ") {
1965        v.truncate(idx);
1966    }
1967    v.trim().to_string()
1968}
1969
1970fn detect_onnx_rocm_provider(python: &str) -> Option<String> {
1971    let output = Command::new(python)
1972        .arg("-c")
1973        .arg(
1974            "import onnxruntime, pathlib; base=pathlib.Path(onnxruntime.__file__).parent; libs=list(base.rglob('libonnxruntime_providers_rocm.so')); print(libs[0] if libs else '')",
1975        )
1976        .output()
1977        .ok()?;
1978    if !output.status.success() {
1979        return None;
1980    }
1981    let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
1982    if value.is_empty() {
1983        None
1984    } else {
1985        Some(value)
1986    }
1987}
1988
1989fn push_unique_env_path(paths: &mut Vec<String>, value: &str) {
1990    let candidate = value.trim();
1991    if candidate.is_empty() {
1992        return;
1993    }
1994    if paths.iter().any(|existing| existing == candidate) {
1995        return;
1996    }
1997    paths.push(candidate.to_string());
1998}
1999
2000fn user_home_from_passwd(user_name: &str) -> Option<String> {
2001    let target = user_name.trim();
2002    if target.is_empty() {
2003        return None;
2004    }
2005    let passwd = fs::read_to_string("/etc/passwd").ok()?;
2006    passwd.lines().find_map(|line| {
2007        if line.trim().is_empty() || line.starts_with('#') {
2008            return None;
2009        }
2010        let fields: Vec<&str> = line.split(':').collect();
2011        if fields.len() < 6 || fields[0] != target {
2012            return None;
2013        }
2014        let home = fields[5].trim();
2015        if home.is_empty() {
2016            None
2017        } else {
2018            Some(home.to_string())
2019        }
2020    })
2021}
2022
2023fn passwd_homes_with_mlstack() -> Vec<String> {
2024    let mut homes = Vec::new();
2025    let Ok(passwd) = fs::read_to_string("/etc/passwd") else {
2026        return homes;
2027    };
2028
2029    for line in passwd.lines() {
2030        if line.trim().is_empty() || line.starts_with('#') {
2031            continue;
2032        }
2033        let fields: Vec<&str> = line.split(':').collect();
2034        if fields.len() < 6 {
2035            continue;
2036        }
2037        let home = fields[5].trim();
2038        if home.is_empty() {
2039            continue;
2040        }
2041        if Path::new(home).join(".mlstack").is_dir()
2042            || Path::new(home).join(".mlstack_env").is_file()
2043        {
2044            push_unique_env_path(&mut homes, home);
2045        }
2046    }
2047
2048    homes
2049}
2050
2051/// Pin `name` to `==version` in pip args when an update plan supplies a
2052/// target version. Overrides any existing specifier on the package's
2053/// requirement token (`name==x`, `name>=x`, `name[extra]==x`) so the planned
2054/// pin wins, and drops `--upgrade`, since an exact pin already moves the
2055/// package (and must not upgrade sibling requirements such as einops).
2056fn pin_pip_requirement(args: Vec<String>, name: &str, version: Option<&str>) -> Vec<String> {
2057    let Some(v) = version.map(str::trim).filter(|v| !v.is_empty()) else {
2058        return args;
2059    };
2060    // Opaque targets ("latest", "installed") are not versions: pinning them
2061    // would produce `name==latest` and drop --upgrade, failing the apply.
2062    // Leave the installer's unversioned upgrade command untouched so pip
2063    // resolves a real release.
2064    if !is_explicit_update_target(Some(v)) {
2065        return args;
2066    }
2067    args.into_iter()
2068        .filter(|a| a != "--upgrade")
2069        .map(|a| {
2070            // Requirement token = name + optional extras/specifier; compare
2071            // on the bare package name only.
2072            let base = a
2073                .split(['=', '<', '>', '!', '~', '[', ' ', '\t'])
2074                .next()
2075                .unwrap_or(&a)
2076                .trim();
2077            if base == name {
2078                format!("{name}=={v}")
2079            } else {
2080                a
2081            }
2082        })
2083        .collect()
2084}
2085
2086pub(crate) fn candidate_mlstack_homes(preferred_home: &str) -> Vec<String> {
2087    let mut homes = Vec::new();
2088    push_unique_env_path(&mut homes, preferred_home);
2089
2090    if let Ok(value) = std::env::var("MLSTACK_USER_HOME") {
2091        push_unique_env_path(&mut homes, &value);
2092    }
2093    if let Ok(value) = std::env::var("HOME") {
2094        push_unique_env_path(&mut homes, &value);
2095    }
2096
2097    for key in ["SUDO_USER", "USER", "LOGNAME"] {
2098        if let Ok(user_name) = std::env::var(key) {
2099            if let Some(home) = user_home_from_passwd(&user_name) {
2100                push_unique_env_path(&mut homes, &home);
2101            }
2102        }
2103    }
2104
2105    for home in passwd_homes_with_mlstack() {
2106        push_unique_env_path(&mut homes, &home);
2107    }
2108
2109    homes
2110}
2111
2112fn resolve_mlstack_user_home() -> String {
2113    let fallback = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
2114    let candidates = candidate_mlstack_homes(&fallback);
2115
2116    for home in &candidates {
2117        if Path::new(home).join(".mlstack_env").is_file() {
2118            return home.clone();
2119        }
2120    }
2121    for home in &candidates {
2122        if Path::new(home).join(".mlstack").is_dir() {
2123            return home.clone();
2124        }
2125    }
2126
2127    candidates.into_iter().next().unwrap_or(fallback)
2128}
2129
2130fn mlstack_mpi_compat_dirs(user_home: &str) -> Vec<String> {
2131    let mut dirs = Vec::new();
2132    for home in candidate_mlstack_homes(user_home) {
2133        let mlstack_dir = PathBuf::from(home).join(".mlstack");
2134
2135        let primary = mlstack_dir.join("libmpi-compat");
2136        if primary.is_dir() {
2137            dirs.push(primary.to_string_lossy().to_string());
2138        }
2139
2140        if let Ok(entries) = fs::read_dir(&mlstack_dir) {
2141            for entry in entries.flatten() {
2142                let path = entry.path();
2143                if !path.is_dir() {
2144                    continue;
2145                }
2146                let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
2147                    continue;
2148                };
2149                if name.starts_with("libmpi-compat-user-") {
2150                    dirs.push(path.to_string_lossy().to_string());
2151                }
2152            }
2153        }
2154    }
2155
2156    dirs.sort();
2157    dirs.dedup();
2158    dirs
2159}
2160
2161fn build_verification_ld_library_path(user_home: &str) -> String {
2162    let mut paths: Vec<String> = Vec::new();
2163
2164    for compat_dir in mlstack_mpi_compat_dirs(user_home) {
2165        push_unique_env_path(&mut paths, &compat_dir);
2166    }
2167
2168    for rocm_path in ["/opt/rocm/lib", "/opt/rocm/hip/lib", "/opt/rocm/opencl/lib"] {
2169        if Path::new(rocm_path).exists() {
2170            push_unique_env_path(&mut paths, rocm_path);
2171        }
2172    }
2173
2174    if let Ok(existing) = std::env::var("LD_LIBRARY_PATH") {
2175        for part in existing.split(':') {
2176            push_unique_env_path(&mut paths, part);
2177        }
2178    }
2179
2180    paths.join(":")
2181}
2182
2183fn run_verification_command(
2184    step: &VerificationCommand,
2185    sender: &Sender<InstallerEvent>,
2186) -> Result<(bool, Vec<String>)> {
2187    let user_home = resolve_mlstack_user_home();
2188    let user_name = std::env::var("USER").unwrap_or_else(|_| "user".into());
2189    let env_exports = load_mlstack_env_exports(&user_home);
2190
2191    let mut command = Command::new(&step.program);
2192    command.args(&step.args);
2193
2194    let extra_paths = python_search_paths();
2195    let mut pythonpath = std::env::var("PYTHONPATH").unwrap_or_default();
2196    if !extra_paths.is_empty() {
2197        if !pythonpath.is_empty() {
2198            pythonpath.push(':');
2199        }
2200        pythonpath.push_str(&extra_paths.join(":"));
2201    }
2202
2203    // Clean PYTHONPATH of any /tmp paths to avoid importing from build dirs
2204    pythonpath = pythonpath
2205        .split(':')
2206        .filter(|part| !part.starts_with("/tmp/"))
2207        .collect::<Vec<_>>()
2208        .join(":");
2209
2210    if !pythonpath.is_empty() {
2211        command.env("PYTHONPATH", pythonpath);
2212    }
2213
2214    let verification_ld = build_verification_ld_library_path(&user_home);
2215    if !verification_ld.is_empty() {
2216        command.env("LD_LIBRARY_PATH", verification_ld);
2217    }
2218
2219    if step.target_id == "onnx" {
2220        if let Some(lib) = detect_onnx_rocm_provider(&step.program) {
2221            command.env("ORT_ROCM_EP_PROVIDER_PATH", lib);
2222        }
2223    }
2224
2225    let selected_python = if !step.modules.is_empty() {
2226        step.program.clone()
2227    } else {
2228        resolve_python_bin()
2229    };
2230    let gpu_arch = env_exports
2231        .get("GPU_ARCH")
2232        .map(|v| v.trim().to_string())
2233        .filter(|v| !v.is_empty())
2234        .unwrap_or_else(|| {
2235            detect_gpu_arch().unwrap_or_else(|| {
2236                eprintln!("Warning: no GPU arch detected, falling back to gfx1100");
2237                "gfx1100".to_string()
2238            })
2239        });
2240    let py_rocm_arch = env_exports
2241        .get("PYTORCH_ROCM_ARCH")
2242        .map(|v| v.trim().to_string())
2243        .filter(|v| !v.is_empty())
2244        .unwrap_or_else(|| gpu_arch.clone());
2245    let mut gpu_archs = env_exports
2246        .get("GPU_ARCHS")
2247        .map(|v| v.trim().to_string())
2248        .filter(|v| !v.is_empty())
2249        .unwrap_or_else(|| gpu_arch.clone());
2250    if step.target_id == "aiter" {
2251        gpu_archs = py_rocm_arch
2252            .split(';')
2253            .next()
2254            .map(|v| v.trim().to_string())
2255            .filter(|v| !v.is_empty())
2256            .unwrap_or_else(|| gpu_arch.clone());
2257    }
2258    let mut hip_visible = env_exports
2259        .get("HIP_VISIBLE_DEVICES")
2260        .map(|v| v.trim().to_string())
2261        .filter(|v| !v.is_empty());
2262    let mut cuda_visible = env_exports
2263        .get("CUDA_VISIBLE_DEVICES")
2264        .map(|v| v.trim().to_string())
2265        .filter(|v| !v.is_empty())
2266        .or_else(|| hip_visible.clone());
2267    let mut rocr_visible = env_exports
2268        .get("ROCR_VISIBLE_DEVICES")
2269        .map(|v| v.trim().to_string())
2270        .filter(|v| !v.is_empty())
2271        .or_else(|| hip_visible.clone());
2272    if step.target_id == "aiter" {
2273        let base_list = hip_visible
2274            .clone()
2275            .or_else(|| cuda_visible.clone())
2276            .unwrap_or_else(detect_gpu_list);
2277        let ordered = prioritize_gpu_list_for_arch(&base_list, &gpu_archs);
2278        hip_visible = Some(ordered.clone());
2279        cuda_visible = Some(ordered.clone());
2280        rocr_visible = Some(ordered);
2281    }
2282    let hsa_override = env_exports
2283        .get("HSA_OVERRIDE_GFX_VERSION")
2284        .map(|v| v.trim().to_string())
2285        .filter(|v| !v.is_empty());
2286
2287    command
2288        .env("HOME", &user_home)
2289        .env("USER", &user_name)
2290        .env("LOGNAME", &user_name)
2291        .env("MLSTACK_USER_HOME", &user_home)
2292        .env("MLSTACK_PYTHON_BIN", &selected_python)
2293        .env("UV_PYTHON", &selected_python)
2294        .env("PYTHONWARNINGS", "ignore")
2295        .env("PYTHONUNBUFFERED", "1")
2296        .env("ROCM_HOME", "/opt/rocm")
2297        .env("HIP_PATH", "/opt/rocm")
2298        .env("GPU_ARCH", &gpu_arch)
2299        .env("PYTORCH_ROCM_ARCH", &py_rocm_arch)
2300        .env("GPU_ARCHS", &gpu_archs)
2301        .stdout(Stdio::piped())
2302        .stderr(Stdio::piped());
2303
2304    if let Some(value) = hip_visible {
2305        command.env("HIP_VISIBLE_DEVICES", value);
2306    }
2307    if let Some(value) = cuda_visible {
2308        command.env("CUDA_VISIBLE_DEVICES", value);
2309    }
2310    if let Some(value) = rocr_visible {
2311        command.env("ROCR_VISIBLE_DEVICES", value);
2312    }
2313    if let Some(value) = hsa_override {
2314        command.env("HSA_OVERRIDE_GFX_VERSION", value);
2315    }
2316    strip_hsa_override_for_therock(&mut command, &env_exports);
2317    if step.target_id == "aiter" {
2318        let aiter_jit_dir = PathBuf::from(&user_home)
2319            .join(".mlstack")
2320            .join("aiter")
2321            .join("jit");
2322        let _ = fs::create_dir_all(&aiter_jit_dir);
2323        command.env("AITER_JIT_DIR", aiter_jit_dir);
2324    }
2325
2326    if step.target_id == "mpi4py" {
2327        // Verification imports should avoid GPU-aware OpenMPI accelerator paths that can segfault.
2328        command
2329            .env("OMPI_MCA_btl", "^smcuda")
2330            .env("OMPI_MCA_opal_cuda_support", "0")
2331            .env("OMPI_MCA_opal_accelerator_rocm_enable", "0");
2332    }
2333
2334    let output = command
2335        .output()
2336        .with_context(|| format!("Failed to run verification for {}", step.label))?;
2337
2338    let mut captured_output = Vec::new();
2339    const MAX_CAPTURED_OUTPUT_LINES: usize = 24;
2340
2341    let stdout = String::from_utf8_lossy(&output.stdout);
2342    for raw in stdout.replace('\r', "\n").lines() {
2343        let clean_line = sanitize_output_line(raw);
2344        if should_suppress_verification_line(&clean_line, &step.target_id) {
2345            continue;
2346        }
2347        if !clean_line.trim().is_empty() && captured_output.len() < MAX_CAPTURED_OUTPUT_LINES {
2348            let line = format!("{}: {}", step.label, clean_line);
2349            let _ = sender.send(InstallerEvent::Log(line.clone(), false));
2350            captured_output.push(clean_line);
2351        }
2352    }
2353
2354    let stderr = String::from_utf8_lossy(&output.stderr);
2355    for raw in stderr.replace('\r', "\n").lines() {
2356        let clean_line = sanitize_output_line(raw);
2357        if should_suppress_verification_line(&clean_line, &step.target_id) {
2358            continue;
2359        }
2360        if !clean_line.trim().is_empty() && captured_output.len() < MAX_CAPTURED_OUTPUT_LINES {
2361            let line = format!("{}: {}", step.label, clean_line);
2362            let _ = sender.send(InstallerEvent::Log(line.clone(), false));
2363            captured_output.push(clean_line);
2364        }
2365    }
2366
2367    if output.status.success() && captured_output.len() == MAX_CAPTURED_OUTPUT_LINES {
2368        captured_output.push("Output truncated".to_string());
2369    }
2370
2371    Ok((output.status.success(), captured_output))
2372}
2373
2374// ===========================================================================
2375// Dependency-aware component sorting (VAL-INSTALL-049)
2376// ===========================================================================
2377
2378/// Sort installer components in topological order so dependencies are
2379/// installed before dependents. Verification and performance components
2380/// maintain their original relative order at the end.
2381///
2382/// # Validation Assertions
2383///
2384/// - **VAL-INSTALL-048**: Dependency graph is acyclic
2385/// - **VAL-INSTALL-049**: installer.rs respects dependency ordering
2386fn sort_components_by_dependencies(components: Vec<Component>) -> Vec<Component> {
2387    use crate::installers::components::topological_sort;
2388
2389    // Separate installer components from verification/performance components
2390    let mut installer_comps: Vec<Component> = Vec::new();
2391    let mut other_comps: Vec<Component> = Vec::new();
2392
2393    for comp in components {
2394        if comp.category == Category::Maintenance || comp.category == Category::Performance {
2395            other_comps.push(comp);
2396        } else {
2397            installer_comps.push(comp);
2398        }
2399    }
2400
2401    // Get IDs for topological sort
2402    let ids: Vec<String> = installer_comps.iter().map(|c| c.id.clone()).collect();
2403
2404    match topological_sort(&ids) {
2405        Ok(sorted_ids) => {
2406            // Reorder installer_comps to match sorted IDs
2407            let mut reordered = Vec::with_capacity(installer_comps.len());
2408            for id in &sorted_ids {
2409                if let Some(pos) = installer_comps.iter().position(|c| c.id == *id) {
2410                    reordered.push(installer_comps.remove(pos));
2411                }
2412            }
2413            // Append any remaining (shouldn't happen if topological_sort is correct)
2414            reordered.extend(installer_comps);
2415
2416            // Append verification/performance components at the end
2417            reordered.extend(other_comps);
2418            reordered
2419        }
2420        Err(_) => {
2421            // If topological sort fails (cycle), keep original order
2422            // but still append other components at the end
2423            installer_comps.extend(other_comps);
2424            installer_comps
2425        }
2426    }
2427}
2428
2429// ===========================================================================
2430// Native Rust installer dispatch (VAL-INSTALL-031, VAL-INSTALL-032)
2431// ===========================================================================
2432
2433/// Context for native installer execution. Groups related parameters
2434/// to keep function signatures under clippy's 7-argument threshold.
2435struct NativeInstallerContext<'a> {
2436    sudo_password: Option<String>,
2437    batch_mode: bool,
2438    install_method: &'a str,
2439    sender: &'a Sender<InstallerEvent>,
2440    user_home: &'a str,
2441    env_exports: &'a HashMap<String, String>,
2442    /// vLLM source-build supply-chain gate (version lag + min age), from config.
2443    vllm_version_lag: u32,
2444    vllm_version_min_age_days: u32,
2445    /// Exact manifest target for update-driven installs, when provided.
2446    target_version: Option<String>,
2447}
2448
2449// ---------------------------------------------------------------------------
2450// Command execution primitives (VAL-INSTALL-031)
2451// ---------------------------------------------------------------------------
2452
2453/// A command that can be executed natively without bash.
2454///
2455/// This enum wraps the different command types produced by installer modules
2456/// (`ShellCommand`, `PipCommand`, `SystemCommand`, `PackageCommand`) into a
2457/// single type for uniform execution.
2458enum NativeCommand {
2459    /// A shell command (program + args + env vars + optional working dir).
2460    Shell {
2461        program: String,
2462        args: Vec<String>,
2463        env: Vec<(String, String)>,
2464        working_dir: Option<PathBuf>,
2465    },
2466    /// A pip/uv install command (program + args, no extra env).
2467    Pip { program: String, args: Vec<String> },
2468    /// A system package command (program + args, no extra env).
2469    System { program: String, args: Vec<String> },
2470    /// A package manager command (program + args, may already include sudo).
2471    Package { program: String, args: Vec<String> },
2472}
2473
2474impl NativeCommand {
2475    /// Create a NativeCommand from any type that exposes `program`, `args`, and `env` fields.
2476    /// This works with all component-specific ShellCommand types since they all share
2477    /// the same field layout: `program: String`, `args: Vec<String>`, `env: Vec<(String, String)>`.
2478    fn from_shell_cmd(program: &str, args: &[String], env: &[(String, String)]) -> Self {
2479        NativeCommand::Shell {
2480            program: program.to_string(),
2481            args: args.to_vec(),
2482            env: env.to_vec(),
2483            working_dir: None,
2484        }
2485    }
2486
2487    /// Create a NativeCommand from a ShellCommand that has a working_dir.
2488    fn from_shell_cmd_with_dir(
2489        program: &str,
2490        args: &[String],
2491        env: &[(String, String)],
2492        working_dir: Option<PathBuf>,
2493    ) -> Self {
2494        NativeCommand::Shell {
2495            program: program.to_string(),
2496            args: args.to_vec(),
2497            env: env.to_vec(),
2498            working_dir,
2499        }
2500    }
2501}
2502
2503/// Execute a native command, streaming output to the event sender.
2504///
2505/// This function:
2506/// - Converts the command to `std::process::Command`
2507/// - Wraps with `sudo -S -p ''` when `sudo_pw` is `Some` and the command
2508///   does not already include sudo (VAL-INSTALL-033)
2509/// - Injects environment variables from the command struct (VAL-INSTALL-034)
2510/// - Spawns the process and streams stdout/stderr line-by-line as
2511///   `InstallerEvent::Log` (VAL-INSTALL-035)
2512/// - Checks exit status and returns `Err` on non-zero (VAL-INSTALL-036)
2513///
2514/// # Validation Assertions
2515/// Check if command output indicates a package is already up to date.
2516///
2517/// yay/pacman output patterns that should be treated as success even with
2518/// non-zero exit codes:
2519/// - `warning: <pkg> is up to date -- skipping`
2520/// - `there is nothing to do`
2521/// - `already installed`
2522fn is_up_to_date_output(output: &str) -> bool {
2523    let lower = output.to_lowercase();
2524    lower.contains("up to date -- skipping")
2525        || lower.contains("up to date, skipping")
2526        || lower.contains("up to date -- reinstalling")
2527        || lower.contains("there is nothing to do")
2528        || lower.contains("already installed")
2529        || (lower.contains("warning:") && lower.contains("up to date"))
2530}
2531
2532/// Prepend `/opt/rocm/bin` and `/opt/rocm/hip/bin` to `inherited` (de-duplicated).
2533///
2534/// `rocm-sdk-core` (pip) installs `rocminfo`/`hipcc`/`hipconfig`/`rocm-smi`
2535/// shims into the venv `bin/`; these are frequently non-functional and, because
2536/// the venv `bin/` precedes `/opt/rocm/bin` on PATH, they shadow the working
2537/// `/opt/rocm/bin` binaries. Tools that resolve these via PATH — e.g. aiter's
2538/// `chip_info._detect_native` (rocminfo) during JIT — then fail. This restores
2539/// correct resolution.
2540///
2541/// Returns the inherited `PATH` unchanged when `/opt/rocm/bin` is absent (no
2542/// system ROCm install to prefer).
2543fn safe_rocm_path(inherited: &str) -> String {
2544    const ROCM_BINS: &[&str] = &["/opt/rocm/bin", "/opt/rocm/hip/bin"];
2545    if !Path::new("/opt/rocm/bin").exists() {
2546        return inherited.to_string();
2547    }
2548    let mut parts: Vec<&str> = ROCM_BINS.to_vec();
2549    for entry in inherited.split(':') {
2550        if !entry.is_empty() && !ROCM_BINS.contains(&entry) {
2551            parts.push(entry);
2552        }
2553    }
2554    parts.join(":")
2555}
2556
2557///
2558/// - **VAL-INSTALL-031**: No `Command::new("bash")` for native components
2559/// - **VAL-INSTALL-033**: sudo behavior preserved
2560/// - **VAL-INSTALL-034**: Environment variable injection preserved
2561/// - **VAL-INSTALL-035**: InstallerEvent progress reporting preserved
2562fn execute_native_command(
2563    cmd: &NativeCommand,
2564    sudo_pw: Option<&str>,
2565    sender: &Sender<InstallerEvent>,
2566    component_name: &str,
2567) -> Result<()> {
2568    let (program, args, envs, working_dir) = match cmd {
2569        NativeCommand::Shell {
2570            program,
2571            args,
2572            env,
2573            working_dir,
2574        } => (
2575            program.clone(),
2576            args.clone(),
2577            env.clone(),
2578            working_dir.clone(),
2579        ),
2580        NativeCommand::Pip { program, args } => (program.clone(), args.clone(), vec![], None),
2581        NativeCommand::System { program, args } => (program.clone(), args.clone(), vec![], None),
2582        NativeCommand::Package { program, args } => (program.clone(), args.clone(), vec![], None),
2583    };
2584
2585    // ── No-CUDA chokepoint (hard-prime tenet) ──────────────────────────
2586    // Reject ANY explicit nvidia/cuda runtime package or CUDA wheel URL from
2587    // being pip-installed by ANY component, regardless of command type (covers
2588    // NativeCommand::Pip AND NativeCommand::Shell `python3 -m pip install …`).
2589    // Permits torch/triton (core, ROCm-managed, from the ROCm index) but
2590    // rejects nvidia-*/cuda*/cudnn/cublas/.../cupy-cuda* and +cuXXX wheels.
2591    // Per-component `--no-deps` + requirements filtering handle the transitive
2592    // case; this is the universal, unbypassable explicit-package guard.
2593    let invokes_pip = program == "uv"
2594        || program.ends_with("pip")
2595        || program.ends_with("pip3")
2596        || args.iter().any(|a| a == "pip");
2597    let is_install = args.iter().any(|a| a == "install");
2598    if invokes_pip && is_install {
2599        let mut skip_next = false;
2600        for a in &args {
2601            if skip_next {
2602                // The argument following -r/--requirement/-c/--constraint is a
2603                // requirements/constraint FILENAME, not a package — never block
2604                // it (a file named `cuda.txt` is not a CUDA package).
2605                skip_next = false;
2606                continue;
2607            }
2608            if a == "-r" || a == "--requirement" || a == "-c" || a == "--constraint" {
2609                skip_next = true;
2610                continue;
2611            }
2612            if a.starts_with('-') || a == "install" || a == "." || a == "-e" || a.is_empty() {
2613                continue;
2614            }
2615            if crate::installers::common::nvidia_blocklist::is_nvidia_cuda_runtime_package(a) {
2616                let _ = sender.send(InstallerEvent::Log(
2617                    format!("[native] BLOCKED nvidia/cuda package in {component_name}: {a}"),
2618                    true,
2619                ));
2620                anyhow::bail!(
2621                    "NVIDIA/CUDA contamination blocked: component '{component_name}' attempted \
2622                     to pip install '{a}' — violates the No-CUDA hard-prime tenet"
2623                );
2624            }
2625            if a.starts_with("http")
2626                && crate::installers::common::nvidia_blocklist::is_cuda_wheel_url(a)
2627            {
2628                let _ = sender.send(InstallerEvent::Log(
2629                    format!("[native] BLOCKED cuda wheel URL in {component_name}: {a}"),
2630                    true,
2631                ));
2632                anyhow::bail!("NVIDIA/CUDA wheel URL blocked in {component_name}: {a}");
2633            }
2634        }
2635    }
2636
2637    // Build the std::process::Command, wrapping with sudo if needed.
2638    // Package commands may already include "sudo" as the program.
2639    let already_sudo = program == "sudo";
2640    let mut command = if let Some(_pw) = sudo_pw {
2641        if already_sudo {
2642            // Command already has sudo — just inject password via stdin
2643            let mut c = Command::new(&program);
2644            c.args(&args);
2645            c
2646        } else {
2647            let mut c = Command::new("sudo");
2648            c.arg("-S").arg("-p").arg("").arg("--").arg(&program);
2649            c.args(&args);
2650            // Log that we're using sudo
2651            let _ = sender.send(InstallerEvent::Log(
2652                format!("[native] Running with sudo: {} {}", program, args.join(" ")),
2653                false,
2654            ));
2655            c
2656        }
2657    } else {
2658        let mut c = Command::new(&program);
2659        c.args(&args);
2660        c
2661    };
2662
2663    // Inject environment variables (VAL-INSTALL-034)
2664    for (key, value) in &envs {
2665        command.env(key, value);
2666    }
2667
2668    // Guarantee the real ROCm CLI tools precede any pip-installed shims on PATH.
2669    // `rocm-sdk-core` (pip) drops shims for rocminfo/hipcc/hipconfig/rocm-smi
2670    // into ~/.mlstack/global/bin; on this box its rocminfo shim exits 8
2671    // (HSA_STATUS_ERROR_OUT_OF_RESOURCES) while /opt/rocm/bin/rocminfo works.
2672    // Because ~/.mlstack/global/bin precedes /opt/rocm/bin on the inherited
2673    // PATH, tools that resolve rocminfo via PATH — notably aiter's
2674    // chip_info._detect_native at JIT time — hit the broken shim and crash.
2675    // Prepend /opt/rocm/bin(+hip/bin) once here so every native command is safe.
2676    command.env(
2677        "PATH",
2678        safe_rocm_path(&std::env::var("PATH").unwrap_or_default()),
2679    );
2680
2681    // Set working directory if specified (e.g., for pip install -e .)
2682    if let Some(ref dir) = working_dir {
2683        command.current_dir(dir);
2684    }
2685
2686    // Set up stdio for streaming
2687    command
2688        .stdin(Stdio::piped())
2689        .stdout(Stdio::piped())
2690        .stderr(Stdio::piped());
2691
2692    // Log the command being executed
2693    let cmd_str = if args.is_empty() {
2694        program.clone()
2695    } else {
2696        format!("{} {}", program, args.join(" "))
2697    };
2698    let _ = sender.send(InstallerEvent::Log(
2699        format!("[native] $ {}", cmd_str),
2700        false,
2701    ));
2702
2703    let mut child = command.spawn().with_context(|| {
2704        let mut hint = String::new();
2705        // Provide actionable hints for common spawn failures
2706        if cmd_str.starts_with("sudo") {
2707            hint = " — ensure sudo is installed and you have the correct permissions".to_string();
2708        } else if cmd_str.starts_with("pip")
2709            || cmd_str.starts_with("uv")
2710            || cmd_str.starts_with("python")
2711        {
2712            hint = " — ensure Python/pip is installed and accessible".to_string();
2713        } else if cmd_str.starts_with("git") {
2714            hint = " — ensure git is installed".to_string();
2715        } else if cmd_str.starts_with("cmake") || cmd_str.starts_with("make") {
2716            hint = " — ensure build tools (cmake, make, gcc) are installed".to_string();
2717        } else if cmd_str.starts_with("rocminfo") || cmd_str.starts_with("rocm-smi") {
2718            hint = " — ensure ROCm is installed and /opt/rocm/bin is in PATH".to_string();
2719        }
2720        format!(
2721            "Failed to spawn command '{}' for {}.{}",
2722            cmd_str, component_name, hint
2723        )
2724    })?;
2725
2726    // Write sudo password to stdin if needed, then close stdin
2727    // Always close stdin to signal to the child that no more input is coming
2728    if let Some(pw) = sudo_pw {
2729        if let Some(mut stdin) = child.stdin.take() {
2730            let _ = stdin.write_all(pw.as_bytes());
2731            let _ = stdin.write_all(b"\n");
2732            let _ = stdin.flush();
2733            drop(stdin);
2734        }
2735    } else {
2736        // Close stdin even when no sudo password to prevent child from
2737        // blocking on stdin read
2738        drop(child.stdin.take());
2739    }
2740
2741    // Stream stdout and stderr in separate threads (VAL-INSTALL-035)
2742    // Also capture combined output for up-to-date pattern detection.
2743    let sender_stdout = sender.clone();
2744    let stdout_stream = match child.stdout.take() {
2745        Some(s) => s,
2746        None => {
2747            let _ = sender.send(InstallerEvent::Log(
2748                format!(
2749                    "[ERROR] {} — failed to capture stdout from spawned process",
2750                    component_name
2751                ),
2752                false,
2753            ));
2754            bail!(
2755                "{} — failed to capture stdout from spawned process. \
2756                 This usually indicates a system resource issue.",
2757                component_name
2758            );
2759        }
2760    };
2761    let stdout_captured: Arc<Mutex<String>> = Arc::new(Mutex::new(String::new()));
2762    let stdout_captured_clone = stdout_captured.clone();
2763    let stdout_handle = thread::spawn(move || {
2764        let reader = BufReader::new(stdout_stream);
2765        for line in reader.lines() {
2766            match line {
2767                Ok(l) => {
2768                    let clean = strip_ansi_codes(&l);
2769                    if !clean.trim().is_empty() {
2770                        let _ = sender_stdout.send(InstallerEvent::Log(clean.clone(), false));
2771                        if let Ok(mut buf) = stdout_captured_clone.lock() {
2772                            buf.push_str(&clean);
2773                            buf.push('\n');
2774                        }
2775                    }
2776                }
2777                Err(_) => break,
2778            }
2779        }
2780    });
2781
2782    let sender_stderr = sender.clone();
2783    let stderr_stream = match child.stderr.take() {
2784        Some(s) => s,
2785        None => {
2786            let _ = sender.send(InstallerEvent::Log(
2787                format!(
2788                    "[ERROR] {} — failed to capture stderr from spawned process",
2789                    component_name
2790                ),
2791                false,
2792            ));
2793            bail!(
2794                "{} — failed to capture stderr from spawned process. \
2795                 This usually indicates a system resource issue.",
2796                component_name
2797            );
2798        }
2799    };
2800    let stderr_captured: Arc<Mutex<String>> = Arc::new(Mutex::new(String::new()));
2801    let stderr_captured_clone = stderr_captured.clone();
2802    let stderr_handle = thread::spawn(move || {
2803        let reader = BufReader::new(stderr_stream);
2804        for line in reader.lines() {
2805            match line {
2806                Ok(l) => {
2807                    let clean = strip_ansi_codes(&l);
2808                    if !clean.trim().is_empty() {
2809                        let _ = sender_stderr.send(InstallerEvent::Log(clean.clone(), false));
2810                        if let Ok(mut buf) = stderr_captured_clone.lock() {
2811                            buf.push_str(&clean);
2812                            buf.push('\n');
2813                        }
2814                    }
2815                }
2816                Err(_) => break,
2817            }
2818        }
2819    });
2820
2821    // Wait for completion
2822    let status = child.wait().with_context(|| {
2823        format!(
2824            "{} — command process wait failed. \
2825             The process may have been killed or the system is under heavy load.",
2826            component_name
2827        )
2828    })?;
2829    let _ = stdout_handle.join();
2830    let _ = stderr_handle.join();
2831
2832    if !status.success() {
2833        // Check if the output indicates the package is already up to date.
2834        // yay/pacman may return non-zero exit codes for warnings like
2835        // "warning: rocminfo is up to date -- skipping" which should be
2836        // treated as success, not failure.
2837        let combined_output = {
2838            let stdout_buf = stdout_captured.lock().unwrap_or_else(|e| e.into_inner());
2839            let stderr_buf = stderr_captured.lock().unwrap_or_else(|e| e.into_inner());
2840            format!("{}{}", *stdout_buf, *stderr_buf)
2841        };
2842
2843        if is_up_to_date_output(&combined_output) {
2844            let _ = sender.send(InstallerEvent::Log(
2845                format!(
2846                    "[native] {} — package already up to date, treating as success",
2847                    component_name
2848                ),
2849                false,
2850            ));
2851            return Ok(());
2852        }
2853
2854        let code = status.code().unwrap_or(-1);
2855        let error_detail = match code {
2856            1 => "General error — check the command output above for details".to_string(),
2857            2 => "Misuse of shell builtins — check command arguments".to_string(),
2858            126 => "Command not executable — check file permissions".to_string(),
2859            127 => "Command not found — ensure the required tool is installed".to_string(),
2860            130 => "Process terminated by Ctrl+C (SIGINT)".to_string(),
2861            137 => "Process killed (SIGKILL) — possibly out of memory".to_string(),
2862            139 => "Segmentation fault — this is a bug in the invoked program".to_string(),
2863            n if n > 128 => format!(
2864                "Process terminated by signal {} — check system logs",
2865                n - 128
2866            ),
2867            _ => format!(
2868                "Command exited with code {} — check the output above for details",
2869                code
2870            ),
2871        };
2872        let _ = sender.send(InstallerEvent::Log(
2873            format!(
2874                "[ERROR] {} failed: {} (exit code {} at {})",
2875                component_name,
2876                error_detail,
2877                code,
2878                Local::now().format("%H:%M:%S")
2879            ),
2880            false,
2881        ));
2882        bail!(
2883            "{} failed: {} (exit code {})",
2884            component_name,
2885            error_detail,
2886            code
2887        );
2888    }
2889
2890    Ok(())
2891}
2892
2893// ---------------------------------------------------------------------------
2894// Helper: build base env vars shared by all native installers
2895// ---------------------------------------------------------------------------
2896
2897/// Build the common environment variables that all native installers receive.
2898///
2899/// These mirror the environment injected by `run_script()` to ensure
2900/// behavioral parity (VAL-INSTALL-034).
2901fn build_common_env(ctx: &NativeInstallerContext) -> Vec<(String, String)> {
2902    let mut env = Vec::new();
2903
2904    // Core environment from .mlstack_env
2905    env.push(("HOME".into(), ctx.user_home.to_string()));
2906    if let Ok(user) = std::env::var("USER") {
2907        env.push(("USER".into(), user.clone()));
2908        env.push(("LOGNAME".into(), user));
2909    }
2910    env.push(("MLSTACK_USER_HOME".into(), ctx.user_home.to_string()));
2911    env.push((
2912        "MLSTACK_BATCH_MODE".into(),
2913        if ctx.batch_mode { "1" } else { "0" }.into(),
2914    ));
2915    env.push((
2916        "MLSTACK_INSTALL_METHOD".into(),
2917        ctx.install_method.to_string(),
2918    ));
2919    env.push(("INSTALL_METHOD".into(), ctx.install_method.to_string()));
2920    env.push(("RUSTY_STACK".into(), "true".into()));
2921
2922    // ROCm / GPU environment from .mlstack_env exports
2923    for key in &[
2924        "ROCM_VERSION",
2925        "ROCM_CHANNEL",
2926        "GPU_ARCH",
2927        "GPU_ARCHS",
2928        "PYTORCH_ROCM_ARCH",
2929        "HIP_VISIBLE_DEVICES",
2930        "CUDA_VISIBLE_DEVICES",
2931        "ROCR_VISIBLE_DEVICES",
2932        "PYTORCH_ROCM_DEVICE",
2933        "HSA_OVERRIDE_GFX_VERSION",
2934        "MLSTACK_PYTHON_BIN",
2935        "UV_PYTHON",
2936    ] {
2937        if let Some(value) = ctx.env_exports.get(*key) {
2938            let trimmed = value.trim();
2939            if !trimmed.is_empty() {
2940                env.push((key.to_string(), trimmed.to_string()));
2941            }
2942        }
2943    }
2944
2945    // ROCm paths (always set)
2946    env.push(("ROCM_HOME".into(), "/opt/rocm".into()));
2947    env.push(("ROCM_PATH".into(), "/opt/rocm".into()));
2948    env.push(("HIP_PATH".into(), "/opt/rocm".into()));
2949    env.push(("HIP_ROOT_DIR".into(), "/opt/rocm".into()));
2950    env.push(("ROCM_ROOT".into(), "/opt/rocm".into()));
2951    env.push(("HIPCC_BIN_DIR".into(), "/opt/rocm/bin".into()));
2952
2953    // Python/pip settings
2954    env.push(("PIP_BREAK_SYSTEM_PACKAGES".into(), "1".into()));
2955    env.push(("PIP_ROOT_USER_ACTION".into(), "ignore".into()));
2956    env.push(("UV_PIP_BREAK_SYSTEM_PACKAGES".into(), "1".into()));
2957    env.push(("UV_SYSTEM_PYTHON".into(), "1".into()));
2958    env.push(("PYTHONUNBUFFERED".into(), "1".into()));
2959
2960    env
2961}
2962
2963// ---------------------------------------------------------------------------
2964// Git clone idempotency helper (fix-git-clone-idempotency)
2965// ---------------------------------------------------------------------------
2966
2967/// Check if a directory exists and contains a `.git` subdirectory,
2968/// indicating it is an existing git repository.
2969///
2970/// Uses `symlink_metadata` instead of `is_dir()` to handle root-owned
2971/// directories that may not be readable by the current user under sudo.
2972fn is_existing_git_repo(target_dir: &str) -> bool {
2973    let path = Path::new(target_dir);
2974    // Use symlink_metadata which doesn't follow symlinks and works
2975    // even when the directory owner differs from the current user
2976    match std::fs::symlink_metadata(target_dir) {
2977        Ok(meta) if meta.is_dir() => {
2978            // Also use symlink_metadata for .git check to handle permission edge cases
2979            path.join(".git")
2980                .symlink_metadata()
2981                .map(|m| m.is_dir() || m.is_file())
2982                .unwrap_or(false)
2983        }
2984        _ => false,
2985    }
2986}
2987
2988/// Clone a git repository, or pull updates if the directory already exists.
2989///
2990/// This function makes git clone idempotent:
2991/// - If `target_dir` does **not** exist (or has no `.git` subdirectory),
2992///   it runs `git clone <extra_args> <repo_url> <target_dir>`.
2993/// - If `target_dir` exists and contains a `.git` subdirectory, it runs
2994///   `git -C <target_dir> pull --ff-only` to update. If that fails, it
2995///   falls back to `git -C <target_dir> fetch --all` followed by
2996///   `git -C <target_dir> reset --hard origin/HEAD`.
2997///
2998/// All commands are executed via `execute_native_command()`.
2999/// Fix ownership of a directory tree that may have been created by root via sudo.
3000///
3001/// When `git_clone_or_pull` runs git operations with sudo, the resulting files
3002/// are owned by root. This prevents subsequent user-space builds from writing
3003/// to the directory. This function chowns the tree back to the real user.
3004fn fix_directory_ownership(dir: &str, sudo_pw: Option<&str>, sender: &Sender<InstallerEvent>) {
3005    // Check if the directory is actually owned by root
3006    let stat_output = std::process::Command::new("stat")
3007        .args(["-c", "%U", dir])
3008        .output();
3009
3010    let needs_fix = match stat_output {
3011        Ok(out) if out.status.success() => {
3012            let owner = String::from_utf8_lossy(&out.stdout).trim().to_string();
3013            owner == "root"
3014        }
3015        _ => true, // Can't determine — try anyway
3016    };
3017
3018    if !needs_fix {
3019        return;
3020    }
3021
3022    let real_user = std::env::var("SUDO_USER")
3023        .or_else(|_| std::env::var("USER"))
3024        .unwrap_or_else(|_| "scooter".to_string());
3025
3026    let _ = sender.send(InstallerEvent::Log(
3027        format!("[native] Fixing ownership of {} -> {}", dir, real_user),
3028        false,
3029    ));
3030
3031    if sudo_pw.is_some() {
3032        let chown_cmd = NativeCommand::from_shell_cmd(
3033            "chown",
3034            &[
3035                "-R".to_string(),
3036                format!("{}:{}", real_user, real_user),
3037                dir.to_string(),
3038            ],
3039            &[],
3040        );
3041        let _ = execute_native_command(&chown_cmd, sudo_pw, sender, "ownership-fix");
3042    }
3043    // If no sudo_pw available, the directory should already be user-owned
3044}
3045
3046fn git_clone_or_pull(
3047    repo_url: &str,
3048    target_dir: &str,
3049    extra_clone_args: &[&str],
3050    sudo_pw: Option<&str>,
3051    sender: &Sender<InstallerEvent>,
3052    component_name: &str,
3053) -> Result<()> {
3054    if is_existing_git_repo(target_dir) {
3055        let _ = sender.send(InstallerEvent::Log(
3056            format!(
3057                "[native] {} — directory '{}' already exists, pulling updates instead of cloning",
3058                component_name, target_dir
3059            ),
3060            false,
3061        ));
3062
3063        // Try fast-forward pull first
3064        let pull_cmd = NativeCommand::from_shell_cmd(
3065            "git",
3066            &[
3067                "-C".to_string(),
3068                target_dir.to_string(),
3069                "pull".to_string(),
3070                "--ff-only".to_string(),
3071            ],
3072            &[],
3073        );
3074        let pull_result = execute_native_command(&pull_cmd, sudo_pw, sender, component_name);
3075
3076        if pull_result.is_ok() {
3077            return Ok(());
3078        }
3079
3080        // Pull failed — log warning and try fetch + reset
3081        let _ = sender.send(InstallerEvent::Log(
3082            format!(
3083                "[native] {} — git pull --ff-only failed, trying fetch + reset --hard origin/HEAD",
3084                component_name
3085            ),
3086            false,
3087        ));
3088
3089        let fetch_cmd = NativeCommand::from_shell_cmd(
3090            "git",
3091            &[
3092                "-C".to_string(),
3093                target_dir.to_string(),
3094                "fetch".to_string(),
3095                "--all".to_string(),
3096            ],
3097            &[],
3098        );
3099        let fetch_ok = execute_native_command(&fetch_cmd, sudo_pw, sender, component_name).is_ok();
3100
3101        let reset_ok = if fetch_ok {
3102            let reset_cmd = NativeCommand::from_shell_cmd(
3103                "git",
3104                &[
3105                    "-C".to_string(),
3106                    target_dir.to_string(),
3107                    "reset".to_string(),
3108                    "--hard".to_string(),
3109                    "origin/HEAD".to_string(),
3110                ],
3111                &[],
3112            );
3113            execute_native_command(&reset_cmd, sudo_pw, sender, component_name).is_ok()
3114        } else {
3115            false
3116        };
3117
3118        if fetch_ok && reset_ok {
3119            return Ok(());
3120        }
3121
3122        // .git exists but pull + fetch + reset all failed → the repo is corrupt
3123        // or unusable (e.g. a purged/empty .git left by a prior run). Remove it
3124        // and re-clone fresh: the recursive call sees no .git → clone branch.
3125        let _ = sender.send(InstallerEvent::Log(
3126            format!(
3127                "[native] {} — existing .git is corrupt/unusable (pull+fetch+reset failed); removing and re-cloning",
3128                component_name
3129            ),
3130            false,
3131        ));
3132        if std::fs::remove_dir_all(target_dir).is_err() {
3133            let rm_cmd = NativeCommand::from_shell_cmd(
3134                "rm",
3135                &["-rf".to_string(), target_dir.to_string()],
3136                &[],
3137            );
3138            let _ = execute_native_command(&rm_cmd, sudo_pw, sender, component_name);
3139        }
3140        // Guard against infinite recursion: if the corrupt repo could not be
3141        // removed (e.g. root-owned .git left by a prior sudo run while this call
3142        // has no sudo_pw), is_existing_git_repo stays true and the recursive
3143        // call would re-enter the pull/fetch/reset branch and recurse forever —
3144        // hanging the installer thread and risking stack overflow. Bail loudly
3145        // instead so the operator sees a clear, actionable failure.
3146        if Path::new(target_dir).join(".git").exists() {
3147            bail!(
3148                "{} — could not remove corrupt repo at '{}' (.git still present); \
3149                 aborting re-clone to avoid infinite recursion",
3150                component_name,
3151                target_dir
3152            );
3153        }
3154        git_clone_or_pull(
3155            repo_url,
3156            target_dir,
3157            extra_clone_args,
3158            sudo_pw,
3159            sender,
3160            component_name,
3161        )
3162    } else {
3163        // Directory may exist but not be a git repo, or may not exist at all.
3164        // If a non-empty directory exists without .git, git clone will fail with
3165        // "fatal: destination path already exists". Handle this by removing
3166        // the incomplete directory before cloning.
3167        let target_path = Path::new(target_dir);
3168        if target_path.is_dir() && !target_path.join(".git").exists() {
3169            let _ = sender.send(InstallerEvent::Log(
3170                format!(
3171                    "[native] {} — directory '{}' exists but is not a git repo; removing incomplete clone",
3172                    component_name, target_dir
3173                ),
3174                false,
3175            ));
3176            // Try plain remove_dir_all first (works if user owns all files)
3177            if std::fs::remove_dir_all(target_dir).is_err() {
3178                // Fallback: sudo rm -rf for root-owned files
3179                let rm_cmd = NativeCommand::from_shell_cmd(
3180                    "rm",
3181                    &["-rf".to_string(), target_dir.to_string()],
3182                    &[],
3183                );
3184                let rm_result = execute_native_command(&rm_cmd, sudo_pw, sender, component_name);
3185                if let Err(e) = rm_result {
3186                    let _ = sender.send(InstallerEvent::Log(
3187                        format!(
3188                            "[native] [WARN] {} — could not remove stale dir: {}. Clone may fail.",
3189                            component_name, e
3190                        ),
3191                        false,
3192                    ));
3193                }
3194            }
3195        }
3196
3197        let _ = sender.send(InstallerEvent::Log(
3198            format!(
3199                "[native] {} — cloning '{}' into '{}'",
3200                component_name, repo_url, target_dir
3201            ),
3202            false,
3203        ));
3204
3205        let mut args: Vec<String> = vec!["clone".to_string()];
3206        for extra in extra_clone_args {
3207            args.push(extra.to_string());
3208        }
3209        args.push(repo_url.to_string());
3210        args.push(target_dir.to_string());
3211
3212        let clone_cmd = NativeCommand::from_shell_cmd("git", &args, &[]);
3213        execute_native_command(&clone_cmd, sudo_pw, sender, component_name)
3214    }
3215}
3216
3217// ---------------------------------------------------------------------------
3218// Per-component dispatch (VAL-INSTALL-032)
3219// ---------------------------------------------------------------------------
3220
3221/// Execute a native Rust installer for a ported component.
3222///
3223/// This function dispatches to the appropriate Rust installer module based
3224/// on the component ID. It preserves:
3225/// - **Sudo behavior** (VAL-INSTALL-033): Components with `needs_sudo:true`
3226///   still elevate privileges via `sudo -S`.
3227/// - **Environment variable injection** (VAL-INSTALL-034): Same env vars
3228///   as the bash path (ROCM_VERSION, GPU_ARCH, etc.).
3229/// - **InstallerEvent progress reporting** (VAL-INSTALL-035): Same event
3230///   types emitted (Log, Progress, ComponentStart, ComponentComplete).
3231/// - **Error message format** (VAL-INSTALL-036): Error messages match
3232///   original scripts.
3233///
3234/// # Validation Assertions
3235///
3236/// - **VAL-INSTALL-031**: No bash subprocess for ported components
3237///
3238/// Filter a pip requirements file to exclude CUDA/nvidia packages and wheels.
3239///
3240/// Delegates to the single canonical blocklist
3241/// [`crate::installers::common::nvidia_blocklist::filter_requirements_file`]
3242/// (Stage 2: one unified blocklist replaces the three former divergent copies
3243/// in `installer.rs`, `megatron.rs`, and `textgen.rs`). Excludes `nvidia-*`,
3244/// `cuda*`, `cudnn`/`cublas`/`cufft`/`curand`/`cusolver`/`cusparse`/`nccl`/
3245/// `nvtx`/`nvjitlink`/`tensorrt`, the torch family (ROCm-managed), and any
3246/// URL wheel carrying a CUDA marker (`+cu1`/`cu124`/`cu128`/…).
3247fn filter_cuda_requirements(req_path: &str) -> std::io::Result<String> {
3248    crate::installers::common::nvidia_blocklist::filter_requirements_file(req_path)
3249}
3250
3251/// Check whether force-reinstall mode is active.
3252///
3253/// Reads `MLSTACK_FORCE_REINSTALL` first, falls back to `FORCE`.
3254/// Recognises common truthy values: 1, true, yes, on (case-insensitive).
3255fn is_force_reinstall() -> bool {
3256    std::env::var("MLSTACK_FORCE_REINSTALL")
3257        .or_else(|_| std::env::var("FORCE"))
3258        .map(|v| {
3259            matches!(
3260                v.trim().to_ascii_lowercase().as_str(),
3261                "1" | "true" | "yes" | "on"
3262            )
3263        })
3264        .unwrap_or(false)
3265}
3266
3267/// Does the importable `triton` already satisfy the requested `target` version?
3268///
3269/// The triton dispatch arm skips the heavy from-source build when triton is
3270/// already importable (PyTorch bundles `triton-rocm`). But an explicit
3271/// `rusty-stack update triton` with a concrete target (e.g. `3.7.0`) must not
3272/// be short-circuited by a bare import success if the installed triton is
3273/// OLDER than the target — otherwise the post-install honesty guard marks the
3274/// update failed because the version didn't advance.
3275///
3276/// Returns `true` (safe to skip) when:
3277///   - `target` is `None`/empty/`latest` (no concrete version to satisfy), OR
3278///   - force-reinstall is requested (the caller decides; this returns false so
3279///     the build path runs), OR
3280///   - the installed triton version is current-or-newer than `target`.
3281fn triton_installed_satisfies_target(python_bin: &str, target: Option<&str>) -> bool {
3282    let target = match target {
3283        Some(t) => t.trim(),
3284        None => return true, // no concrete target → bare import success is enough
3285    };
3286    // Opaque targets ("latest", "installed") → no concrete version to satisfy.
3287    if target.is_empty() || target == "latest" || target == "installed" {
3288        return true;
3289    }
3290    // Force-reinstall overrides the skip in the caller; signal "do not skip".
3291    if is_force_reinstall() {
3292        return false;
3293    }
3294    // Query the installed triton version and compare against the target.
3295    let installed = std::process::Command::new(python_bin)
3296        .arg("-c")
3297        .arg(
3298            "try:\n    import triton, importlib.metadata as im\n    \
3299             print(getattr(triton, '__version__', None) or im.version('triton') \
3300             or im.version('triton-rocm') or im.version('pytorch-triton'))\n\
3301             except Exception:\n    pass",
3302        )
3303        .output()
3304        .ok()
3305        .and_then(|o| {
3306            if o.status.success() {
3307                String::from_utf8(o.stdout).ok()
3308            } else {
3309                None
3310            }
3311        })
3312        .map(|s| s.trim().to_string())
3313        .filter(|s| !s.is_empty());
3314    match installed {
3315        Some(ver) => version_satisfies(&ver, target),
3316        None => false, // can't determine → don't risk skipping a real update
3317    }
3318}
3319
3320/// Is `installed` current-or-newer than `target` (loose semver-ish compare)?
3321///
3322/// Truncates at the first non-numeric segment (PEP 440 dev/local builds like
3323/// `3.7.0.dev40+g6c48c5fa0` still compare as `3.7.0`) and compares
3324/// major.minor.patch numerically. Returns `true` when installed >= target.
3325fn version_satisfies(installed: &str, target: &str) -> bool {
3326    fn parts(v: &str) -> Vec<u64> {
3327        v.split('-')
3328            .next()
3329            .unwrap_or("")
3330            .split('.')
3331            .filter_map(|s| s.parse::<u64>().ok())
3332            .collect()
3333    }
3334    let a = parts(installed.trim());
3335    let b = parts(target.trim());
3336    if a.is_empty() || b.is_empty() {
3337        // Can't parse → be conservative, treat as NOT satisfying (don't skip).
3338        return false;
3339    }
3340    for (ai, bi) in a.iter().zip(b.iter()) {
3341        match ai.cmp(bi) {
3342            std::cmp::Ordering::Greater => return true,
3343            std::cmp::Ordering::Less => return false,
3344            std::cmp::Ordering::Equal => continue,
3345        }
3346    }
3347    // All compared equal so far. installed >= target if installed has at least
3348    // as many components (e.g. 3.7.0 >= 3.7), else treat equal-length as equal.
3349    a.len() >= b.len()
3350}
3351
3352/// Build a pip uninstall command that works with both `uv` and `pip3`.
3353///
3354/// `uv` requires `uv pip uninstall` (not `uv uninstall` which doesn't exist).
3355/// Note: `uv pip uninstall` does NOT accept `-y` — it's non-interactive by default.
3356fn build_pip_uninstall(packages: &[&str]) -> NativeCommand {
3357    let use_uv = crate::installers::common::utils::command_exists("uv");
3358    if use_uv {
3359        let mut args = vec!["pip".to_string(), "uninstall".to_string()];
3360        for pkg in packages {
3361            args.push(pkg.to_string());
3362        }
3363        NativeCommand::Pip {
3364            program: "uv".to_string(),
3365            args,
3366        }
3367    } else {
3368        let mut args = vec!["uninstall".to_string(), "-y".to_string()];
3369        for pkg in packages {
3370            args.push(pkg.to_string());
3371        }
3372        NativeCommand::Pip {
3373            program: "pip3".to_string(),
3374            args,
3375        }
3376    }
3377}
3378
3379/// Thoroughly purge a Python package and all its related packages.
3380///
3381/// This is the nuclear option for force-reinstall: removes the main package,
3382/// its known aliases, related dist-info directories, and common build artifacts.
3383/// Each uninstall is non-fatal (logged but doesn't fail the install).
3384fn purge_component_packages(
3385    packages: &[&str],
3386    sender: &Sender<InstallerEvent>,
3387    component_name: &str,
3388) {
3389    let _ = sender.send(InstallerEvent::Log(
3390        format!(
3391            "[native] {} — purging packages: {}",
3392            component_name,
3393            packages.join(", ")
3394        ),
3395        false,
3396    ));
3397
3398    // Run the uninstall command (non-fatal)
3399    let uninstall_cmd = build_pip_uninstall(packages);
3400    let _ = execute_native_command(&uninstall_cmd, None, sender, component_name);
3401}
3402
3403/// Purge PyTorch and all related packages.
3404fn purge_pytorch(sender: &Sender<InstallerEvent>, component_name: &str) {
3405    purge_component_packages(
3406        &[
3407            "torch",
3408            "torchvision",
3409            "torchaudio",
3410            "pytorch-triton-rocm",
3411            "pytorch-triton",
3412        ],
3413        sender,
3414        component_name,
3415    );
3416}
3417
3418/// Purge Triton and related packages.
3419fn purge_triton(sender: &Sender<InstallerEvent>, component_name: &str) {
3420    purge_component_packages(
3421        &["triton", "triton-rocm", "pytorch-triton-rocm"],
3422        sender,
3423        component_name,
3424    );
3425}
3426
3427/// Purge AITER and related packages.
3428fn purge_aiter(sender: &Sender<InstallerEvent>, component_name: &str) {
3429    purge_component_packages(&["aiter", "amd-aiter"], sender, component_name);
3430}
3431
3432/// Purge Flash Attention and related packages.
3433fn purge_flash_attn(sender: &Sender<InstallerEvent>, component_name: &str) {
3434    purge_component_packages(
3435        &["flash-attn", "flash_attn", "flash_attention_amd"],
3436        sender,
3437        component_name,
3438    );
3439}
3440
3441/// Purge vLLM and related packages.
3442fn purge_vllm(sender: &Sender<InstallerEvent>, component_name: &str) {
3443    purge_component_packages(&["vllm"], sender, component_name);
3444}
3445
3446/// ROCm channel for this installation run, in update parity with the planner.
3447///
3448/// The planner (update scan) resolves the ROCm target with
3449/// `RocmChannel::configured()` — `ROCM_CHANNEL` env, else `.mlstack_env`, else
3450/// Latest, with an invalid nonblank value authoritative for Latest. A concrete
3451/// planned target maps back to exactly that channel so the applied install
3452/// matches what the user confirmed, even if `ROCM_CHANNEL` changes between
3453/// scan and apply. Without a concrete target the same shared resolver runs
3454/// directly. (The previous env/env_exports pre-layer could select the env
3455/// file's channel while the planner resolved Latest, diverging the targets.)
3456fn update_rocm_channel(target: Option<&str>) -> crate::installers::components::rocm::RocmChannel {
3457    use crate::installers::components::rocm::RocmChannel;
3458    if is_explicit_update_target(target) {
3459        if let Some(channel) = target.and_then(RocmChannel::for_version_to_channel) {
3460            return channel;
3461        }
3462        // Unrecognized version string: fall through to the shared resolver
3463        // rather than guessing a channel the planner did not offer.
3464    }
3465    RocmChannel::configured()
3466}
3467
3468/// Sets process env overrides and restores the previous values on drop.
3469///
3470/// Scopes installation consent (force-reinstall / unseal-core) to a single
3471/// `run_installation*` invocation: a sequential apply loop in one process
3472/// must not let component A's consent leak into component B, whose target may
3473/// deliberately be opaque (no override). Drop restores even on early returns,
3474/// in reverse construction order.
3475struct ScopedEnvOverride {
3476    previous: Vec<(String, Option<String>)>,
3477}
3478
3479impl ScopedEnvOverride {
3480    fn setting(entries: &[(&str, &str)]) -> Self {
3481        let previous = entries
3482            .iter()
3483            .map(|(key, value)| {
3484                let old = std::env::var(key).ok();
3485                std::env::set_var(key, value);
3486                ((*key).to_string(), old)
3487            })
3488            .collect();
3489        Self { previous }
3490    }
3491}
3492
3493impl Drop for ScopedEnvOverride {
3494    fn drop(&mut self) {
3495        for (key, old) in &self.previous {
3496            match old {
3497                Some(value) => std::env::set_var(key, value),
3498                None => std::env::remove_var(key),
3499            }
3500        }
3501    }
3502}
3503
3504/// Is this an explicit, concrete update target from a confirmed apply?
3505///
3506/// `Some("7.14.1")` → yes. `None` (plain install) or an opaque placeholder
3507/// (`latest`, `installed`, `""`) → no: those carry no user-confirmed version
3508/// jump, so sealed-core reuse must stay in effect.
3509fn is_explicit_update_target(target: Option<&str>) -> bool {
3510    match target {
3511        Some(t) => {
3512            let t = t.trim();
3513            !t.is_empty() && !t.eq_ignore_ascii_case("latest") && !t.eq_ignore_ascii_case("installed")
3514        }
3515        None => false,
3516    }
3517}
3518
3519/// Has the user explicitly requested to unseal core components (force-reinstall
3520/// past the sealed-core no-override gate)? Env: `MLSTACK_UNSEAL_CORE=1`.
3521fn unseal_core_requested() -> bool {
3522    std::env::var("MLSTACK_UNSEAL_CORE")
3523        .map(|v| {
3524            matches!(
3525                v.trim().to_ascii_lowercase().as_str(),
3526                "1" | "true" | "yes" | "on"
3527            )
3528        })
3529        .unwrap_or(false)
3530}
3531
3532/// Sealed-core no-override gate (Tenet 2). Called before each component install.
3533///
3534/// Returns:
3535/// - `Ok(true)`  — proceed with the install.
3536/// - `Ok(false)` — the component is a sealed core that is already installed;
3537///   REUSE it (skip reinstall) instead of overriding.
3538/// - `Err`       — blocked: a force-reinstall targeted a sealed core without
3539///   `MLSTACK_UNSEAL_CORE=1` (refuse to clobber an installed core component).
3540fn registry_gate(component: &Component, sender: &Sender<InstallerEvent>) -> Result<bool> {
3541    let registry = crate::core::registry::InstalledComponentRegistry::load();
3542    if !registry.is_sealed(&component.id) {
3543        return Ok(true);
3544    }
3545    if is_force_reinstall() && !unseal_core_requested() {
3546        // A sealed core must never be silently overridden — but a stray
3547        // MLSTACK_FORCE_REINSTALL / FORCE env var (note: `FORCE` is a very
3548        // generic name) must NOT hard-fail the whole update either. Reuse the
3549        // installed core and tell the user how to actually override it. The
3550        // previous `bail!` here cascaded into "blocked by dependency" failures
3551        // and got wrapped in a misleading pip/network error.
3552        let _ = sender.send(InstallerEvent::Log(
3553            format!(
3554                "[native] {} is a sealed CORE component — force ignored, reusing the installed copy. \
3555                 Set MLSTACK_UNSEAL_CORE=1 to intentionally override it.",
3556                component.name
3557            ),
3558            false,
3559        ));
3560        return Ok(false);
3561    }
3562    if !is_force_reinstall() {
3563        let _ = sender.send(InstallerEvent::Log(
3564            format!(
3565                "[native] {} already installed (sealed core) — reusing, NOT reinstalling",
3566                component.name
3567            ),
3568            false,
3569        ));
3570        return Ok(false);
3571    }
3572    Ok(true)
3573}
3574
3575/// Record a successful install in the registry (Tenet 2). Seals core AND
3576/// install-once components (rocm/pytorch/triton/aiter/flash-attn/…) so later
3577/// installs reuse them and never override. Populates source/location/pip-package
3578/// metadata so the registry is the real single-source-of-truth (consulted by
3579/// dep-reuse and uninstall), not a hollow id+seal stub.
3580fn registry_record(component: &Component) {
3581    use crate::core::registry::{InstalledComponent, InstalledComponentRegistry};
3582    let mut registry = InstalledComponentRegistry::load();
3583    let record = InstalledComponent {
3584        id: component.id.clone(),
3585        version: component_version(&component.id),
3586        source_index: component_source_index(&component.id),
3587        location: install_target_location(),
3588        sealed: crate::core::registry::should_seal_component(&component.id),
3589        installed_at: Some(chrono::Utc::now().to_rfc3339()),
3590        pip_packages: component_pip_packages(&component.id),
3591    };
3592    registry.mark_installed(record);
3593    if let Err(e) = registry.save() {
3594        // Tenet 2: a failed save means the component is NOT recorded, so a
3595        // later install could override a sealed core. Surface it loudly rather
3596        // than silently dropping the result.
3597        eprintln!("[WARN] Failed to save installed component registry: {e}");
3598    }
3599}
3600
3601/// The pip package names a component owns (for registry dep-tracking +
3602/// uninstall enumeration). Empty for system/repo components (rocm, comfyui,
3603/// textgen) which don't install a single owned pip package.
3604fn component_pip_packages(id: &str) -> Vec<String> {
3605    match id {
3606        "pytorch" => vec!["torch", "torchvision", "torchaudio", "pytorch-triton-rocm"],
3607        "triton" => vec!["triton", "pytorch-triton-rocm"],
3608        "vllm" | "vllm-multi" => vec!["vllm"],
3609        "deepspeed" => vec!["deepspeed", "einops"],
3610        "megatron" => vec!["megatron-core"],
3611        "bitsandbytes" => vec!["bitsandbytes"],
3612        "flash-attn" | "flash-attn-triton" | "flash-attn-ck" | "flash_attention" => {
3613            vec!["flash-attn", "flash_attn"]
3614        }
3615        "aiter" => vec!["aiter"],
3616        "onnx" | "onnxruntime" => vec!["onnxruntime", "onnxruntime-rocm"],
3617        "migraphx" => vec!["migraphx"],
3618        "mpi4py" => vec!["mpi4py"],
3619        "wandb" => vec!["wandb"],
3620        "fastvideo" => vec!["fastvideo"],
3621        _ => Vec::new(),
3622    }
3623    .into_iter()
3624    .map(String::from)
3625    .collect()
3626}
3627
3628/// Where a component was installed — the named env, the managed global env, or
3629/// the pinned interpreter. Recorded so the registry is the single source of
3630/// truth for "where the single source lives" (Tenet 2).
3631fn install_target_location() -> Option<String> {
3632    if let Ok(name) = std::env::var("MLSTACK_ENV_NAME") {
3633        let name = name.trim();
3634        if !name.is_empty() {
3635            return Some(
3636                crate::platform::environment::mlstack_env_dir(name)
3637                    .to_string_lossy()
3638                    .into_owned(),
3639            );
3640        }
3641    }
3642    let global = crate::platform::environment::mlstack_global_dir();
3643    if global.exists() {
3644        return Some(global.to_string_lossy().into_owned());
3645    }
3646    std::env::var("MLSTACK_PYTHON_BIN")
3647        .ok()
3648        .map(|s| s.trim().to_string())
3649        .filter(|s| !s.is_empty())
3650}
3651
3652/// The wheel index / source a component was installed from, where statically
3653/// known. PyTorch comes from PyTorch's own ROCm wheel index
3654/// (download.pytorch.org/whl/rocmX.Y); triton is the `triton-rocm` wheel bundled
3655/// as a PyTorch dependency; ROCm lives at /opt/rocm. `None` for components with
3656/// no fixed index.
3657fn component_source_index(id: &str) -> Option<String> {
3658    match id {
3659        "pytorch" => Some("https://download.pytorch.org/whl/rocm".to_string()),
3660        "triton" => Some("triton-rocm (bundled with PyTorch)".to_string()),
3661        "rocm" => Some("/opt/rocm".to_string()),
3662        _ => None,
3663    }
3664}
3665
3666/// Best-effort installed version, where cheaply detectable. ROCm version from
3667/// `/opt/rocm/.info/version`; `None` otherwise (version is not load-bearing for
3668/// the seal/no-override gate — pip_packages/location/source_index are).
3669fn component_version(id: &str) -> Option<String> {
3670    match id {
3671        "rocm" => {
3672            let v = detect_rocm_version();
3673            if v.is_empty() {
3674                None
3675            } else {
3676                Some(v)
3677            }
3678        }
3679        _ => None,
3680    }
3681}
3682
3683/// Ensure the invoking user is a member of the given system groups, adding them
3684/// via `sudo usermod -aG` when missing.
3685///
3686/// Restores a step the original `install_rocm.sh` had (`sudo usermod -aG
3687/// render,video "$target_user"`) that the Rust port dropped: without `render`
3688/// membership a user cannot open `/dev/kfd` / render nodes on distros that mode
3689/// them `0660` (the documented ROCm requirement; works by accident on distros
3690/// that mode them `0666`).
3691///
3692/// System-aware + best-effort:
3693/// - Resolves the *real* target user via `SUDO_USER` (then `USER`), so an elevated
3694///   `rusty-stack` adds the invoking user, not root.
3695/// - Reads the user's *configured* groups (`id -nG <user>` → `/etc/group`), not the
3696///   running process's supplemental groups, so it's correct even under sudo.
3697/// - Silently skips any group the distro doesn't define (`getent group`).
3698/// - Never fails the install: every decision is logged via `sender`.
3699fn ensure_user_in_groups(
3700    groups: &[&str],
3701    sudo_pw: Option<&str>,
3702    sender: &Sender<InstallerEvent>,
3703    component_name: &str,
3704) {
3705    use std::process::Command;
3706
3707    // Prefer SUDO_USER: if rusty-stack is elevated, USER==root but we want the
3708    // invoking user added to the groups.
3709    let user = std::env::var("SUDO_USER")
3710        .ok()
3711        .map(|u| u.trim().to_string())
3712        .filter(|u| !u.is_empty())
3713        .or_else(|| {
3714            std::env::var("USER")
3715                .ok()
3716                .map(|u| u.trim().to_string())
3717                .filter(|u| !u.is_empty())
3718        });
3719    let user = match user {
3720        Some(u) => u,
3721        None => {
3722            let _ = sender.send(InstallerEvent::Log(
3723                format!(
3724                    "[native] {component_name} — could not determine target user; skipping \
3725                     group check. Add yourself manually: sudo usermod -aG {} $USER",
3726                    groups.join(",")
3727                ),
3728                false,
3729            ));
3730            return;
3731        }
3732    };
3733
3734    // The user's configured groups (resolved from /etc/group via `id -nG`), NOT
3735    // the running process's supplemental groups — correct even under sudo.
3736    let current_groups: Vec<String> = Command::new("id")
3737        .args(["-nG", &user])
3738        .output()
3739        .ok()
3740        .filter(|o| o.status.success())
3741        .map(|o| {
3742            String::from_utf8_lossy(&o.stdout)
3743                .split_whitespace()
3744                .map(|s| s.to_string())
3745                .collect()
3746        })
3747        .unwrap_or_default();
3748
3749    // Keep only groups that EXIST on this system AND that the user lacks.
3750    let mut to_add: Vec<&str> = Vec::new();
3751    for g in groups {
3752        let exists = Command::new("getent")
3753            .args(["group", g])
3754            .output()
3755            .map(|o| o.status.success())
3756            .unwrap_or(false);
3757        if !exists {
3758            let _ = sender.send(InstallerEvent::Log(
3759                format!(
3760                    "[native] {component_name} — group '{g}' not present on this system; skipping"
3761                ),
3762                false,
3763            ));
3764            continue;
3765        }
3766        if !current_groups.iter().any(|c| c == g) {
3767            to_add.push(*g);
3768        }
3769    }
3770
3771    if to_add.is_empty() {
3772        let _ = sender.send(InstallerEvent::Log(
3773            format!(
3774                "[native] {component_name} — user '{user}' already in all required group(s); nothing to add"
3775            ),
3776            false,
3777        ));
3778        return;
3779    }
3780
3781    let csv = to_add.join(",");
3782
3783    // Add via `sudo usermod -aG`. Needs root → set up askpass so it's non-interactive.
3784    let askpass = sudo_pw.filter(|p| !p.is_empty()).and_then(|p| {
3785        match crate::installers::common::askpass::Askpass::new(p) {
3786            Ok(g) => Some(g),
3787            Err(e) => {
3788                let _ = sender.send(InstallerEvent::Log(
3789                    format!(
3790                        "[native] {component_name} — askpass setup failed ({e}); cannot add \
3791                         groups non-interactively"
3792                    ),
3793                    true,
3794                ));
3795                None
3796            }
3797        }
3798    });
3799
3800    let added = if let Some(ap) = askpass {
3801        // `sudo -A usermod -aG …` — pass sudo_pw = None so execute_native_command
3802        // does not double-wrap in `sudo -S`; SUDO_ASKPASS supplies the password.
3803        let args = vec![
3804            "-A".to_string(),
3805            "usermod".to_string(),
3806            "-aG".to_string(),
3807            csv.clone(),
3808            user.clone(),
3809        ];
3810        let envs = vec![("SUDO_ASKPASS".to_string(), ap.path().to_string())];
3811        let cmd = NativeCommand::from_shell_cmd("sudo", &args, &envs);
3812        execute_native_command(&cmd, None, sender, component_name).is_ok()
3813    } else {
3814        let _ = sender.send(InstallerEvent::Log(
3815            format!(
3816                "[native] {component_name} — no sudo password available; please run: \
3817                 sudo usermod -aG {csv} {user}"
3818            ),
3819            true,
3820        ));
3821        return;
3822    };
3823
3824    if added {
3825        let _ = sender.send(InstallerEvent::Log(
3826            format!(
3827                "[native] {component_name} — added user '{user}' to group(s): {csv}. \
3828                 A re-login (or reboot) is required for the new groups to take effect."
3829            ),
3830            false,
3831        ));
3832    }
3833}
3834
3835/// Probe and repair RCCL without installing external dependencies.
3836fn run_rccl_remediation(ctx: &NativeInstallerContext, component_name: &str) -> Result<()> {
3837    let sender = ctx.sender;
3838    let rccl_remediation = crate::installers::components::rccl::RcclInstaller::new(
3839        ctx.user_home,
3840        resolve_python_bin(),
3841        ctx.env_exports
3842            .get("ROCM_PATH")
3843            .cloned()
3844            .unwrap_or_else(|| "/opt/rocm".to_string()),
3845        ctx.env_exports
3846            .get("ROCM_VERSION")
3847            .cloned()
3848            .unwrap_or_else(|| "7.2.0".to_string()),
3849        ctx.env_exports
3850            .get("ROCM_CHANNEL")
3851            .cloned()
3852            .unwrap_or_else(|| "latest".to_string()),
3853        ctx.env_exports
3854            .get("ROCR_VISIBLE_DEVICES")
3855            .cloned()
3856            .or_else(|| ctx.env_exports.get("HIP_VISIBLE_DEVICES").cloned()),
3857    );
3858
3859    let _ = sender.send(InstallerEvent::Log(
3860        "[native] Probing RCCL multi-GPU collectives...".into(),
3861        false,
3862    ));
3863    if let Some(build) = rccl_remediation.prepare_remediation()? {
3864        let _ = sender.send(InstallerEvent::Log(
3865            "[native] Known RCCL collective failure detected; building sealed overlay...".into(),
3866            false,
3867        ));
3868        execute_native_command(
3869            &NativeCommand::from_shell_cmd(&build.program, &build.args, &build.env),
3870            None,
3871            sender,
3872            component_name,
3873        )?;
3874        rccl_remediation.activate_and_verify()?;
3875        let _ = sender.send(InstallerEvent::Log(
3876            "[native] RCCL sealed overlay activated and validated on both GPUs".into(),
3877            false,
3878        ));
3879    } else {
3880        let _ = sender.send(InstallerEvent::Log(
3881            "[native] RCCL probe healthy or existing sealed overlay revalidated; no repair needed"
3882                .into(),
3883            false,
3884        ));
3885    }
3886    Ok(())
3887}
3888
3889/// Execute the native Rust installer dispatch for a ported component.
3890///
3891/// - **VAL-INSTALL-032**: installer.rs dispatches to correct Rust module per ID
3892/// - **VAL-INSTALL-033**: sudo behavior preserved for needs_sudo:true components
3893/// - **VAL-INSTALL-034**: Environment variable injection preserved
3894/// - **VAL-INSTALL-035**: InstallerEvent progress reporting preserved
3895/// - **VAL-INSTALL-036**: Error message format matches original scripts
3896fn run_native_installer(component: &Component, ctx: &NativeInstallerContext) -> Result<()> {
3897    let sender = ctx.sender;
3898    let _ = sender.send(InstallerEvent::Log(
3899        format!("[native] Installing {} via Rust module...", component.name),
3900        false,
3901    ));
3902
3903    let _ = sender.send(InstallerEvent::Progress {
3904        component_id: component.id.clone(),
3905        progress: 0.1,
3906        message: format!("Starting {} installation", component.name),
3907    });
3908
3909    // VAL-INSTALL-033: Preserve sudo behavior for components that need it
3910    let component_needs_sudo = component.needs_sudo && needs_sudo();
3911    let cached_sudo =
3912        component_needs_sudo && ctx.sudo_password.is_none() && can_sudo_non_interactive();
3913    let sudo_pw: Option<&str> = if component_needs_sudo {
3914        ctx.sudo_password
3915            .as_deref()
3916            .or(if cached_sudo { Some("") } else { None })
3917    } else {
3918        None
3919    };
3920
3921    // VAL-INSTALL-036: Error message format matches original scripts
3922    if component_needs_sudo && sudo_pw.is_none() {
3923        bail!("{} requires sudo but no password provided", component.name);
3924    }
3925
3926    // Build common env vars for all commands (VAL-INSTALL-034)
3927    let common_env = build_common_env(ctx);
3928
3929    // VAL-INSTALL-032: Per-component match dispatch covering all 24 components.
3930    // Each arm instantiates the correct installer, calls build_*_command()
3931    // methods in the correct sequence, and executes via execute_native_command().
3932    match component.id.as_str() {
3933        // ── permanent-env ──────────────────────────────────────────────
3934        "permanent-env" => {
3935            use crate::installers::components::permanent_env::PermanentEnvInstaller;
3936            // REGENERATE ~/.mlstack_env from current system state. This is the
3937            // component's namesake job: running it — even after initial creation,
3938            // even with force-reinstall — must rewrite the env file so values like
3939            // ROCM_VERSION track what is actually installed. (run_installation also
3940            // refreshes at start + post-loop, but the component owns this explicitly
3941            // so the option does what its name says.) Best-effort: log on error,
3942            // never fail the component over an env refresh.
3943            // Force-reinstall (or any explicit permanent-env run) regenerates
3944            // ~/.mlstack_env from current state instead of merging — see the
3945            // `force` doc on ensure_mlstack_env.
3946            match ensure_mlstack_env(ctx.user_home, ctx.install_method, is_force_reinstall()) {
3947                Ok(EnvUpdate::Created) | Ok(EnvUpdate::Updated) => {
3948                    let _ = sender.send(InstallerEvent::Log(
3949                        "[native] Permanent ROCm Env — regenerated ~/.mlstack_env from current state".into(),
3950                        false,
3951                    ));
3952                }
3953                Ok(EnvUpdate::Unchanged) => {
3954                    let _ = sender.send(InstallerEvent::Log(
3955                        "[native] Permanent ROCm Env — ~/.mlstack_env already up to date".into(),
3956                        false,
3957                    ));
3958                }
3959                Err(e) => {
3960                    let _ = sender.send(InstallerEvent::Log(
3961                        format!(
3962                            "[native] Permanent ROCm Env — could not regenerate ~/.mlstack_env: {e}"
3963                        ),
3964                        true,
3965                    ));
3966                }
3967            }
3968            // Build triton directories, write activate snippets, offer global prepend
3969            crate::platform::environment::write_activate_snippets()?;
3970            crate::platform::environment::offer_global_prepend(true)?;
3971            let inst = PermanentEnvInstaller::default();
3972            let cmd = inst.build_mkdir_triton_dirs_command();
3973            execute_native_command(
3974                &NativeCommand::from_shell_cmd(&cmd.program, &cmd.args, &cmd.env),
3975                sudo_pw,
3976                sender,
3977                &component.name,
3978            )?;
3979        }
3980
3981        // ── rocm ──────────────────────────────────────────────────────
3982        "rocm" => {
3983            use crate::installers::components::rocm::{RocmConfig, RocmInstaller};
3984            let rocm_force = is_force_reinstall();
3985            use crate::installers::components::rocm::RocmChannel;
3986            let rocm_channel = update_rocm_channel(ctx.target_version.as_deref());
3987            let inst = RocmInstaller::new(RocmConfig {
3988                force_reinstall: rocm_force,
3989                channel: rocm_channel,
3990                gfx_arch: detect_gpu_arch().unwrap_or_else(|| "gfx1100".to_string()),
3991                legacy_fallback: std::env::var("ROCM_LEGACY_VERSION")
3992                    .map(|v| v.trim() == "7.2.3")
3993                    .unwrap_or(false),
3994                ..RocmConfig::default()
3995            });
3996            let distro = crate::installers::common::DistroFacade::detect();
3997            let is_arch = distro.is_arch_family();
3998            if rocm_channel.is_therock() && (is_arch || rocm_channel == RocmChannel::Preview) {
3999                // TheRock (7.14+/10.x) has no pacman packages, and 10.x has no
4000                // apt/rpm repo: install the ROCm SDK into a user-space venv.
4001                let home = std::env::var("HOME").unwrap_or_default();
4002                let venv = format!("{home}/.mlstack/rocm/{}", inst.effective_version());
4003                let index = inst
4004                    .pip_index_url()
4005                    .ok_or_else(|| anyhow::anyhow!("TheRock channel has no pip index"))?
4006                    .to_string();
4007                if !std::process::Command::new("uv")
4008                    .arg("--version")
4009                    .output()
4010                    .map(|o| o.status.success())
4011                    .unwrap_or(false)
4012                {
4013                    bail!(
4014                        "`uv` is required to install ROCm {} into a user-space environment; install it from https://docs.astral.sh/uv/ and retry",
4015                        inst.effective_version()
4016                    );
4017                }
4018                let venv_python =
4019                    std::env::var("ROCM_VENV_PYTHON").unwrap_or_else(|_| "3.12".to_string());
4020                let req = inst.pip_requirement();
4021                let steps: Vec<(&str, Vec<String>)> = vec![
4022                    (
4023                        "uv",
4024                        vec![
4025                            "venv".into(),
4026                            "--python".into(),
4027                            venv_python.clone(),
4028                            venv.clone(),
4029                        ],
4030                    ),
4031                    (
4032                        "uv",
4033                        vec![
4034                            "pip".into(),
4035                            "install".into(),
4036                            "--python".into(),
4037                            format!("{venv}/bin/python"),
4038                            "--index-url".into(),
4039                            index,
4040                            "--extra-index-url".into(),
4041                            "https://pypi.org/simple".into(),
4042                            "--index-strategy".into(),
4043                            "unsafe-best-match".into(),
4044                            req,
4045                        ],
4046                    ),
4047                ];
4048                for (prog, args) in steps {
4049                    execute_native_command(
4050                        &NativeCommand::Pip {
4051                            program: prog.to_string(),
4052                            args,
4053                        },
4054                        None,
4055                        sender,
4056                        &component.name,
4057                    )?;
4058                }
4059                let _ = sender.send(InstallerEvent::Log(
4060                    format!(
4061                        "[native] ROCm {} installed to {venv}",
4062                        inst.effective_version()
4063                    ),
4064                    false,
4065                ));
4066            } else if is_arch {
4067                // Arch/pacman path. The AUR helper (`yay`) MUST run as the
4068                // user — makepkg refuses root, and `yay` warns "Avoid running
4069                // yay as root/sudo". This branch therefore does NOT reuse the
4070                // sudo-wrapped package loop below; it runs a single user-space
4071                // `yay` and feeds its internal `sudo pacman` via SUDO_ASKPASS.
4072                let all_pkgs = inst.pacman_rocm_packages();
4073                let force_reinstall = is_force_reinstall();
4074                let pkgs_to_install = if force_reinstall {
4075                    all_pkgs.clone()
4076                } else {
4077                    let need_install = RocmInstaller::filter_already_installed_pacman(&all_pkgs);
4078                    if need_install.is_empty() {
4079                        let _ = sender.send(InstallerEvent::Log(
4080                            format!(
4081                                "[native] {} — all ROCm packages already installed, skipping",
4082                                component.name
4083                            ),
4084                            false,
4085                        ));
4086                        return Ok(());
4087                    }
4088                    need_install
4089                };
4090
4091                let _ = sender.send(InstallerEvent::Log(
4092                    format!(
4093                        "[native] {} — installing {} of {} ROCm packages via yay \
4094                         (user-space, non-interactive): {}",
4095                        component.name,
4096                        pkgs_to_install.len(),
4097                        all_pkgs.len(),
4098                        pkgs_to_install.join(", ")
4099                    ),
4100                    false,
4101                ));
4102
4103                // Build a fully non-interactive `yay` command. `--noconfirm`
4104                // skips pacman + yay menus. `--sudoflags=-A` combined with
4105                // SUDO_ASKPASS hands the password to yay's internal sudo with
4106                // no TTY. Force-reinstall omits `--needed` so yay reinstalls in
4107                // place — the previous explicit `pacman -Rns` pre-removal was
4108                // removed: it aborted on dependency conflicts
4109                // (hip-runtime-amd/hipblaslt/migraphx/miopen-hip depend on the
4110                // ROCm libs) and was redundant, since `yay -S` without
4111                // `--needed` already reinstalls.
4112                let mut args = vec!["-S".to_string()];
4113                if !force_reinstall {
4114                    args.push("--needed".to_string());
4115                }
4116                args.push("--noconfirm".to_string());
4117
4118                // Askpass guard — keeps the password file alive for the op.
4119                let askpass = sudo_pw.filter(|p| !p.is_empty()).and_then(|p| {
4120                    match crate::installers::common::askpass::Askpass::new(p) {
4121                        Ok(g) => Some(g),
4122                        Err(e) => {
4123                            let _ = sender.send(InstallerEvent::Log(
4124                                format!(
4125                                    "[native] {} — askpass setup failed ({e}); yay's \
4126                                     privileged steps may not authenticate",
4127                                    component.name
4128                                ),
4129                                true,
4130                            ));
4131                            None
4132                        }
4133                    }
4134                });
4135                let mut envs: Vec<(String, String)> = Vec::new();
4136                if let Some(ref ap) = askpass {
4137                    envs.push(("SUDO_ASKPASS".to_string(), ap.path().to_string()));
4138                    args.push("--sudoflags=-A".to_string());
4139                } else {
4140                    let _ = sender.send(InstallerEvent::Log(
4141                        format!(
4142                            "[native] {} — no sudo password supplied; yay will only \
4143                             authenticate if sudo is cached / passwordless",
4144                            component.name
4145                        ),
4146                        false,
4147                    ));
4148                }
4149                args.extend(pkgs_to_install);
4150
4151                let yay_cmd = NativeCommand::Shell {
4152                    program: "yay".to_string(),
4153                    args,
4154                    env: envs,
4155                    working_dir: None,
4156                };
4157                // Pass sudo_pw = None: `execute_native_command` would otherwise
4158                // wrap the program in `sudo -S` (running yay as root → refused).
4159                execute_native_command(&yay_cmd, None, sender, &component.name)?;
4160            } else {
4161                let therock = rocm_channel.is_therock();
4162                let commands = if distro.uses_apt() {
4163                    if therock {
4164                        inst.therock_apt_commands(&distro)
4165                            .map_err(|e| anyhow::anyhow!(e))?
4166                    } else {
4167                        inst.apt_install_commands()
4168                    }
4169                } else if distro.uses_dnf() || distro.uses_yum() {
4170                    if therock {
4171                        inst.therock_rpm_commands(&distro)
4172                            .map_err(|e| anyhow::anyhow!(e))?
4173                    } else {
4174                        let rhel_ver = distro.version().split('.').next().unwrap_or("9");
4175                        inst.dnf_install_commands(rhel_ver)
4176                    }
4177                } else if distro.uses_zypper() {
4178                    if therock {
4179                        inst.therock_rpm_commands(&distro)
4180                            .map_err(|e| anyhow::anyhow!(e))?
4181                    } else {
4182                        inst.zypper_install_commands()
4183                    }
4184                } else {
4185                    bail!(
4186                        "Unsupported distribution for ROCm installation: {} ({})",
4187                        distro.name(),
4188                        distro.id()
4189                    );
4190                };
4191                for pkg_cmd in &commands {
4192                    let native_cmd = NativeCommand::Package {
4193                        program: pkg_cmd.program.clone(),
4194                        args: pkg_cmd.args.clone(),
4195                    };
4196                    // Package commands may already include "sudo" as the program;
4197                    // don't double-wrap with sudo.
4198                    execute_native_command(&native_cmd, sudo_pw, sender, &component.name)?;
4199                }
4200            }
4201
4202            // After successful ROCm install, create reboot marker when force-reinstalling
4203            if is_force_reinstall() {
4204                let marker_path = std::path::Path::new("/tmp/mlstack-reboot-required");
4205                let _ = std::fs::write(
4206                    marker_path,
4207                    "ROCm packages were reinstalled. Reboot required for kernel module changes.\n",
4208                );
4209                let _ = sender.send(InstallerEvent::Log(
4210                    "[native] ROCm — reboot recommended for kernel module changes".to_string(),
4211                    false,
4212                ));
4213            }
4214
4215            // Ensure the user can actually access GPU compute devices: add to
4216            // render/video if missing. The original install_rocm.sh did
4217            // `sudo usermod -aG render,video "$target_user"`; the Rust port had
4218            // dropped it. Idempotent + system-aware (skips absent groups).
4219            ensure_user_in_groups(&["render", "video"], sudo_pw, sender, &component.name);
4220        }
4221
4222        // ── pytorch ───────────────────────────────────────────────────
4223        "pytorch" => {
4224            use crate::installers::components::pytorch::{PyTorchConfig, PyTorchInstaller};
4225            let force = is_force_reinstall();
4226            let python_bin = resolve_python_bin();
4227            let config = PyTorchConfig {
4228                python_bin: python_bin.clone(),
4229                force_reinstall: force,
4230                ..PyTorchConfig::default()
4231            };
4232            let inst = PyTorchInstaller::new(config);
4233            let use_uv = crate::installers::common::utils::command_exists("uv");
4234            let rocm_mm = ctx
4235                .env_exports
4236                .get("ROCM_VERSION")
4237                .map(|v| {
4238                    let parts: Vec<&str> = v.split('.').collect();
4239                    if parts.len() >= 2 {
4240                        format!("{}.{}", parts[0], parts[1])
4241                    } else {
4242                        v.clone()
4243                    }
4244                })
4245                .unwrap_or_else(|| "7.2".to_string());
4246
4247            // Force reinstall: thoroughly purge previous PyTorch (non-fatal)
4248            if force {
4249                purge_pytorch(sender, &component.name);
4250            }
4251
4252            // Step 1: Install PyTorch FIRST, from the ROCm wheel index. This MUST
4253            // precede the common-deps step: `torchsde` depends on `torch>=1.6.0`,
4254            // and if torch isn't already installed, pip resolves that dep from the
4255            // DEFAULT PyPI index — which is the CUDA build, dragging in the entire
4256            // nvidia-*-cu13 / cuda-toolkit stack. Installing the ROCm torch first
4257            // satisfies torchsde's dep so the deps step never pulls CUDA torch.
4258            // (The original install_pytorch_rocm.sh installed torchsde/sentencepiece
4259            // AFTER PyTorch — the port had inverted the order.)
4260            let install_cmd =
4261                inst.build_install_command(&rocm_mm, use_uv, ctx.target_version.as_deref());
4262            execute_native_command(
4263                &NativeCommand::Pip {
4264                    program: install_cmd.program.clone(),
4265                    args: install_cmd.args.clone(),
4266                },
4267                None,
4268                sender,
4269                &component.name,
4270            )?;
4271
4272            // Step 2: Install common deps (torchsde, sentencepiece) from PyPI. With
4273            // the ROCm torch already installed, torchsde's torch dep is satisfied —
4274            // pip (default --upgrade-strategy=only-if-needed) won't replace it with
4275            // the CUDA build.
4276            let deps_cmd = inst.build_common_deps_command(use_uv);
4277            let _ = common_env.clone(); // Available for future env injection
4278            execute_native_command(
4279                &NativeCommand::Pip {
4280                    program: deps_cmd.program.clone(),
4281                    args: deps_cmd.args.clone(),
4282                },
4283                None, // pip commands don't need sudo
4284                sender,
4285                &component.name,
4286            )?;
4287
4288            // Step 3: Rusty centrally owns FastVideo's build/import prerequisites.
4289            // Install exact pins without dependency resolution, then verify them
4290            // read-only. Component installers may consume these packages but may
4291            // never mutate or resolve the managed environment themselves.
4292            let managed_python = resolve_python_bin();
4293            let managed_install =
4294                crate::installers::common::managed_python_install_command(managed_python.clone());
4295            execute_native_command(
4296                &NativeCommand::Pip {
4297                    program: managed_install.program,
4298                    args: managed_install.args,
4299                },
4300                None,
4301                sender,
4302                &component.name,
4303            )?;
4304
4305            let managed_verify =
4306                crate::installers::common::managed_python_verify_command(managed_python);
4307            execute_native_command(
4308                &NativeCommand::from_shell_cmd(&managed_verify.program, &managed_verify.args, &[]),
4309                None,
4310                sender,
4311                &component.name,
4312            )?;
4313        }
4314
4315        // ── triton ────────────────────────────────────────────────────
4316        "triton" => {
4317            use crate::installers::components::triton::{TritonConfig, TritonInstaller};
4318
4319            // Skip the from-source build if `triton` is already importable AND
4320            // the installed version satisfies the requested target.
4321            // PyTorch's ROCm wheels bundle `triton-rocm` (the prebuilt ROCm
4322            // triton), so once PyTorch is installed `import triton` already works.
4323            // The ROCm/triton from-source build is heavy (pulls the NVIDIA
4324            // toolchain, builds LLVM) and brittle (permission hazard on
4325            // ~/.triton/llvm when prior steps ran under sudo), so it is both
4326            // redundant and risky when triton-rocm already provides it.
4327            //
4328            // BUT: an explicit `rusty-stack update triton` with a pending version
4329            // (ctx.target_version = e.g. "3.7.0") must NOT be short-circuited by a
4330            // bare `import triton` success — the bundled triton may be older than
4331            // the target, and skipping would leave the post-install honesty guard
4332            // (apply.rs) to mark the update failed because the version didn't
4333            // advance. So: skip only when (a) there is no concrete target, OR
4334            // (b) the installed triton version already satisfies the target.
4335            let python_bin = resolve_python_bin();
4336            let triton_present = std::process::Command::new(&python_bin)
4337                .arg("-c")
4338                .arg("import triton")
4339                .status()
4340                .map(|s| s.success())
4341                .unwrap_or(false);
4342            let triton_satisfies_target = triton_present
4343                && triton_installed_satisfies_target(&python_bin, ctx.target_version.as_deref());
4344            if triton_satisfies_target {
4345                let _ = sender.send(InstallerEvent::Log(
4346                    "[native] Triton — already importable (triton-rocm bundled with PyTorch) \
4347                     and satisfies the requested target; skipping the redundant from-source \
4348                     ROCm/triton build"
4349                        .into(),
4350                    false,
4351                ));
4352                return Ok(());
4353            }
4354
4355            let triton_force = is_force_reinstall();
4356            let inst = TritonInstaller::new(TritonConfig {
4357                python_bin: resolve_python_bin(),
4358                ..Default::default()
4359            });
4360            let target_dir = format!("{}/.mlstack/triton", ctx.user_home);
4361
4362            // Force reinstall: thoroughly purge previous Triton (non-fatal)
4363            if triton_force {
4364                purge_triton(sender, &component.name);
4365            }
4366
4367            // Step 1: git clone (idempotent — pulls if already exists)
4368            // NOTE: git operations may use sudo for the clone/pull, but we fix
4369            // ownership afterward so the build (which runs as the user) can succeed.
4370            git_clone_or_pull(
4371                inst.git_clone_url(),
4372                &target_dir,
4373                &[],
4374                sudo_pw,
4375                sender,
4376                &component.name,
4377            )?;
4378
4379            // Fix ownership: if the triton dir was created by sudo, the user can't build in it.
4380            // Chown recursively to the real user.
4381            fix_directory_ownership(&target_dir, sudo_pw, sender);
4382
4383            // Add safe.directory exception for dubious ownership (git runs under different uid)
4384            let safe_dir_cmd = NativeCommand::from_shell_cmd(
4385                "git",
4386                &[
4387                    "config".to_string(),
4388                    "--global".to_string(),
4389                    "--add".to_string(),
4390                    "safe.directory".to_string(),
4391                    target_dir.clone(),
4392                ],
4393                &[],
4394            );
4395            let _ = execute_native_command(&safe_dir_cmd, None, sender, &component.name);
4396
4397            // Step 2: git fetch the target branch
4398            let branch = inst.select_branch().to_string();
4399            let fetch_branch_cmd = NativeCommand::from_shell_cmd(
4400                "git",
4401                &[
4402                    "-C".to_string(),
4403                    target_dir.clone(),
4404                    "fetch".to_string(),
4405                    "origin".to_string(),
4406                    branch.clone(),
4407                ],
4408                &[],
4409            );
4410            let _ = execute_native_command(&fetch_branch_cmd, None, sender, &component.name);
4411
4412            // Step 3: git checkout
4413            let checkout_cmd = inst.build_git_checkout_command();
4414            let mut checkout_args = vec!["-C".to_string(), target_dir.clone()];
4415            checkout_args.extend(checkout_cmd.args.iter().cloned());
4416            let checkout_result = execute_native_command(
4417                &NativeCommand::from_shell_cmd(
4418                    &checkout_cmd.program,
4419                    &checkout_args,
4420                    &checkout_cmd.env,
4421                ),
4422                None,
4423                sender,
4424                &component.name,
4425            );
4426            if let Err(err) = checkout_result {
4427                let _ = sender.send(InstallerEvent::Log(
4428                    format!(
4429                        "[native] [WARN] {} git checkout failed (non-fatal): {}",
4430                        component.name, err
4431                    ),
4432                    false,
4433                ));
4434            }
4435
4436            // Step 4: prerequisites — run WITHOUT sudo so pip targets the user's Python
4437            let cmd = inst.build_prerequisites_command();
4438            execute_native_command(
4439                &NativeCommand::from_shell_cmd(&cmd.program, &cmd.args, &cmd.env),
4440                None,
4441                sender,
4442                &component.name,
4443            )?;
4444
4445            // Step 5: pip install — run WITHOUT sudo so it installs into user's Python
4446            let cmd = inst.build_pip_install_command(&target_dir);
4447            execute_native_command(
4448                &NativeCommand::from_shell_cmd_with_dir(
4449                    &cmd.program,
4450                    &cmd.args,
4451                    &cmd.env,
4452                    cmd.working_dir.clone(),
4453                ),
4454                None,
4455                sender,
4456                &component.name,
4457            )?;
4458        }
4459
4460        // ── mpi4py ────────────────────────────────────────────────────
4461        "mpi4py" => {
4462            use crate::installers::components::mpi4py::{Mpi4PyConfig, Mpi4PyInstaller};
4463            let force = is_force_reinstall();
4464            let inst = Mpi4PyInstaller::new(Mpi4PyConfig {
4465                python_bin: resolve_python_bin(),
4466                force_reinstall: force,
4467                ..Mpi4PyConfig::default()
4468            });
4469            let distro = crate::installers::common::DistroFacade::detect();
4470
4471            // Force reinstall: purge previous mpi4py (non-fatal)
4472            if force {
4473                purge_component_packages(&["mpi4py"], sender, &component.name);
4474            }
4475
4476            // Step 1: Install system MPI
4477            let cmd = inst.build_system_mpi_install_command(&distro);
4478            execute_native_command(
4479                &NativeCommand::from_shell_cmd(&cmd.program, &cmd.args, &cmd.env),
4480                sudo_pw,
4481                sender,
4482                &component.name,
4483            )?;
4484
4485            // Step 2: pip install mpi4py
4486            let mpi_path = std::path::PathBuf::from("/usr");
4487            let cmd = inst.build_pip_install_command(&mpi_path);
4488            execute_native_command(
4489                &NativeCommand::from_shell_cmd(&cmd.program, &cmd.args, &cmd.env),
4490                None,
4491                sender,
4492                &component.name,
4493            )?;
4494        }
4495
4496        // ── deepspeed ─────────────────────────────────────────────────
4497        "deepspeed" => {
4498            use crate::installers::components::deepspeed::{DeepSpeedConfig, DeepSpeedInstaller};
4499            let force = is_force_reinstall();
4500            let config = DeepSpeedConfig {
4501                python_bin: resolve_python_bin(),
4502                force_reinstall: force,
4503                ..DeepSpeedConfig::default()
4504            };
4505            let inst = DeepSpeedInstaller::new(config);
4506
4507            // Force reinstall: purge previous deepspeed (non-fatal)
4508            if force {
4509                purge_component_packages(&["deepspeed"], sender, &component.name);
4510            }
4511
4512            // Step 1: Install dependencies
4513            let deps_cmd = inst.build_deps_install_command();
4514            execute_native_command(
4515                &NativeCommand::Pip {
4516                    program: deps_cmd.program.clone(),
4517                    args: deps_cmd.args.clone(),
4518                },
4519                None,
4520                sender,
4521                &component.name,
4522            )?;
4523
4524            // Step 2: Install DeepSpeed
4525            let install_cmd = inst.build_install_command();
4526            let install_args = pin_pip_requirement(
4527                install_cmd.args.clone(),
4528                "deepspeed",
4529                ctx.target_version.as_deref(),
4530            );
4531            execute_native_command(
4532                &NativeCommand::Pip {
4533                    program: install_cmd.program.clone(),
4534                    args: install_args,
4535                },
4536                None,
4537                sender,
4538                &component.name,
4539            )?;
4540        }
4541
4542        // ── ml-stack-core ─────────────────────────────────────────────
4543        "ml-stack-core" => {
4544            use crate::installers::components::ml_stack::{MlStackConfig, MlStackInstaller};
4545            let inst = MlStackInstaller::new(MlStackConfig {
4546                python_bin: resolve_python_bin(),
4547                ..Default::default()
4548            });
4549            let distro = crate::installers::common::DistroFacade::detect();
4550
4551            // Step 1: Install system dependencies
4552            let migraphx_cmd = inst.build_migraphx_install_command(&distro);
4553            execute_native_command(
4554                &NativeCommand::System {
4555                    program: migraphx_cmd.program.clone(),
4556                    args: migraphx_cmd.args.clone(),
4557                },
4558                sudo_pw,
4559                sender,
4560                &component.name,
4561            )?;
4562
4563            let rccl_cmd = inst.build_rccl_install_command(&distro);
4564            execute_native_command(
4565                &NativeCommand::System {
4566                    program: rccl_cmd.program.clone(),
4567                    args: rccl_cmd.args.clone(),
4568                },
4569                sudo_pw,
4570                sender,
4571                &component.name,
4572            )?;
4573
4574            // The distro package stays authoritative. Only the exact ROCm#6074
4575            // two-GPU failure triggers a sealed, stack-owned RCCL overlay.
4576            run_rccl_remediation(ctx, &component.name)?;
4577
4578            let mpi_cmd = inst.build_mpi_install_command(&distro);
4579            execute_native_command(
4580                &NativeCommand::System {
4581                    program: mpi_cmd.program.clone(),
4582                    args: mpi_cmd.args.clone(),
4583                },
4584                sudo_pw,
4585                sender,
4586                &component.name,
4587            )?;
4588
4589            // Step 2: Clone Megatron into a fixed dir, then `pip install -e .` FROM that
4590            // dir. Previously the clone had no destination (landed in the inherited CWD)
4591            // and the pip step had no working_dir, so `pip install -e .` would target
4592            // whatever dir rusty-stack was launched from — not Megatron-LM.
4593            let megatron_dir = format!("{}/.mlstack/src/Megatron-LM", ctx.user_home);
4594            let megatron_clone = inst.build_megatron_clone_command(&megatron_dir);
4595            execute_native_command(
4596                &NativeCommand::System {
4597                    program: megatron_clone.program.clone(),
4598                    args: megatron_clone.args.clone(),
4599                },
4600                sudo_pw,
4601                sender,
4602                &component.name,
4603            )?;
4604
4605            let megatron_install = inst.build_megatron_install_command();
4606            execute_native_command(
4607                &NativeCommand::from_shell_cmd_with_dir(
4608                    &megatron_install.program,
4609                    &megatron_install.args,
4610                    &[],
4611                    Some(PathBuf::from(&megatron_dir)),
4612                ),
4613                None,
4614                sender,
4615                &component.name,
4616            )?;
4617        }
4618
4619        // ── flash-attn-triton / flash-attn-ck ─────────────────────────
4620        // Both backends build from the SAME official ROCm/flash-attention repo.
4621        // CK is the repo's default backend; Triton is enabled via
4622        // FLASH_ATTENTION_TRITON_AMD_ENABLE. They install the identical
4623        // `flash_attn` package → mutually exclusive; only the last-installed is
4624        // active (tracked by ~/.mlstack/flash-attention/.backend).
4625        //   - triton: full forward + backward (recommended for RDNA3)
4626        //   - ck:     forward-only on RDNA3 (backward unimplemented —
4627        //             ROCm/composable_kernel#1434)
4628        "flash-attn" | "flash-attn-triton" | "flash-attn-ck" => {
4629            let force = is_force_reinstall();
4630            // The generic "flash-attn" id (e.g. from migration/update) defaults to
4631            // the recommended Triton backend.
4632            let is_triton = matches!(component.id.as_str(), "flash-attn" | "flash-attn-triton");
4633            let backend_name = if is_triton { "triton" } else { "ck" };
4634
4635            // Force reinstall: thoroughly purge previous Flash Attention (non-fatal)
4636            if force {
4637                purge_flash_attn(sender, &component.name);
4638            }
4639
4640            let target_dir = format!("{}/.mlstack/flash-attention", ctx.user_home);
4641
4642            git_clone_or_pull(
4643                "https://github.com/ROCm/flash-attention.git",
4644                &target_dir,
4645                &[],
4646                sudo_pw,
4647                sender,
4648                &component.name,
4649            )?;
4650
4651            // Fix ownership if cloned with sudo
4652            fix_directory_ownership(&target_dir, sudo_pw, sender);
4653
4654            // Add safe.directory for git operations
4655            let safe_dir_cmd = NativeCommand::from_shell_cmd(
4656                "git",
4657                &[
4658                    "config".to_string(),
4659                    "--global".to_string(),
4660                    "--add".to_string(),
4661                    "safe.directory".to_string(),
4662                    target_dir.clone(),
4663                ],
4664                &[],
4665            );
4666            let _ = execute_native_command(&safe_dir_cmd, None, sender, &component.name);
4667
4668            // Resolve GPU arch and HSA version from env
4669            let gpu_arch = ctx
4670                .env_exports
4671                .get("GPU_ARCH")
4672                .cloned()
4673                .unwrap_or_else(|| "gfx1100".to_string());
4674            let hsa_version = ctx
4675                .env_exports
4676                .get("HSA_OVERRIDE_GFX_VERSION")
4677                .cloned()
4678                .unwrap_or_else(|| "11.0.0".to_string());
4679
4680            // Flash Attention's real build/runtime deps — einops, ninja, packaging,
4681            // psutil — are NOT guaranteed by the dep graph (get_dependencies =
4682            // [pytorch, rocm]; no deepspeed), so install them explicitly first.
4683            // None are CUDA runtime packages, and the universal pip chokepoint still
4684            // blocks nvidia-*/cuda*. torch itself comes from the pytorch dependency.
4685            let fa_deps_args = vec![
4686                "-m".to_string(),
4687                "pip".to_string(),
4688                "install".to_string(),
4689                "--break-system-packages".to_string(),
4690                "einops".to_string(),
4691                "ninja".to_string(),
4692                "packaging".to_string(),
4693                "psutil".to_string(),
4694            ];
4695            execute_native_command(
4696                &NativeCommand::Shell {
4697                    program: resolve_python_bin(),
4698                    args: fa_deps_args,
4699                    env: vec![],
4700                    working_dir: None,
4701                },
4702                None, // pip runs as user, not sudo
4703                sender,
4704                &component.name,
4705            )?;
4706
4707            // --no-deps on the FA package itself: with einops/ninja/packaging/psutil
4708            // now installed explicitly + torch from pytorch, --no-deps scopes this to
4709            // flash_attn and can NEVER pull a CUDA torch to satisfy a torch requirement.
4710            // -v surfaces ninja/compiler output (e.g. "[N/M] Compiling …") so the
4711            // build log is detailed AND parse_progress can drive the bar with real
4712            // per-TU progress instead of pip's opaque "still running…" heartbeat.
4713            let mut pip_args = vec![
4714                "-m".to_string(),
4715                "pip".to_string(),
4716                "install".to_string(),
4717                "-v".to_string(),
4718                "--no-build-isolation".to_string(),
4719                "--no-deps".to_string(),
4720                "--break-system-packages".to_string(),
4721                ".".to_string(),
4722            ];
4723            if force {
4724                pip_args.push("--force-reinstall".to_string());
4725            }
4726
4727            let mut pip_env = vec![
4728                ("GPU_ARCH".to_string(), gpu_arch.clone()),
4729                ("PYTORCH_ROCM_ARCH".to_string(), gpu_arch),
4730                ("ROCM_PATH".to_string(), "/opt/rocm".to_string()),
4731                ("HSA_OVERRIDE_GFX_VERSION".to_string(), hsa_version),
4732            ];
4733            if is_triton {
4734                // Triton backend opt-in (full forward + backward on RDNA3/4).
4735                pip_env.push((
4736                    "FLASH_ATTENTION_TRITON_AMD_ENABLE".to_string(),
4737                    "TRUE".to_string(),
4738                ));
4739            } else {
4740                // CK backend (repo default — Triton flag omitted). The
4741                // composable-kernel compile is RAM-heavy; cap parallel jobs so
4742                // builds don't OOM on boxes with <96 GB RAM.
4743                let jobs = std::thread::available_parallelism()
4744                    .map(|n| n.get())
4745                    .unwrap_or(4)
4746                    .min(8);
4747                pip_env.push(("MAX_JOBS".to_string(), jobs.to_string()));
4748            }
4749
4750            execute_native_command(
4751                &NativeCommand::Shell {
4752                    // Managed interpreter (~/.mlstack/global) — same value the
4753                    // config was built with. NEVER bare system python3.
4754                    program: resolve_python_bin(),
4755                    args: pip_args,
4756                    env: pip_env,
4757                    working_dir: Some(PathBuf::from(&target_dir)),
4758                },
4759                None, // pip must run as user, not sudo
4760                sender,
4761                &component.name,
4762            )?;
4763
4764            // Record the active backend so detection is HONEST: "installed" requires
4765            // `flash_attn` importable AND a known-backend marker. Only the
4766            // last-installed backend reports installed (mutual exclusivity). The
4767            // marker is part of the detection contract, so a write failure MUST fail
4768            // the install — otherwise the package is installed but untrackable.
4769            let marker = PathBuf::from(&target_dir).join(".backend");
4770            std::fs::write(&marker, backend_name).map_err(|err| {
4771                anyhow::anyhow!(
4772                    "{}: failed to write Flash Attention backend marker {}: {} \
4773                     — package installed but untrackable; failing install",
4774                    component.name,
4775                    marker.display(),
4776                    err
4777                )
4778            })?;
4779        }
4780
4781        // ── repair-stack ──────────────────────────────────────────────
4782        // VAL-INSTALL-051: Repair dispatches to native Rust installer
4783        // modules for each step, not bash scripts.
4784        "repair-stack" => {
4785            use crate::installers::components::repair::RepairInstaller;
4786            let repair_ids = RepairInstaller::native_component_ids();
4787
4788            let _ = sender.send(InstallerEvent::Log(
4789                "[native] Starting ML Stack repair (8 steps via native installers)...".to_string(),
4790                false,
4791            ));
4792
4793            for step_id in &repair_ids {
4794                // Build a synthetic Component for the sub-installer
4795                // and dispatch through run_native_installer recursively.
4796                let sub_component = Component {
4797                    id: (*step_id).to_string(),
4798                    name: (*step_id).to_string(),
4799                    description: String::new(),
4800                    script: String::new(),
4801                    category: Category::Core,
4802                    required: true,
4803                    selected: true,
4804                    installed: false,
4805                    progress: 0.0,
4806                    estimate: String::new(),
4807                    needs_sudo: component.needs_sudo,
4808                    experimental: false,
4809                    note: None,
4810                };
4811
4812                let _ = sender.send(InstallerEvent::Progress {
4813                    component_id: component.id.clone(),
4814                    progress: 0.5,
4815                    message: format!("Repairing {}...", step_id),
4816                });
4817
4818                if let Err(err) = run_native_installer(&sub_component, ctx) {
4819                    // Log but continue — repair tries all steps
4820                    let _ = sender.send(InstallerEvent::Log(
4821                        format!("[native] Repair step '{}' failed: {}", step_id, err),
4822                        false,
4823                    ));
4824                }
4825            }
4826        }
4827
4828        "rccl-repair" => {
4829            run_rccl_remediation(ctx, &component.name)?;
4830        }
4831
4832        // ── megatron ──────────────────────────────────────────────────
4833        "megatron" => {
4834            use crate::installers::components::megatron::{MegatronConfig, MegatronInstaller};
4835            let force = is_force_reinstall();
4836            let inst = MegatronInstaller::new(MegatronConfig {
4837                python_bin: resolve_python_bin(),
4838                force_reinstall: force,
4839                ..MegatronConfig::default()
4840            });
4841
4842            // Force reinstall: purge previous megatron-core (non-fatal)
4843            if force {
4844                purge_component_packages(&["megatron-core", "megatron"], sender, &component.name);
4845            }
4846
4847            // Step 1: Install dependencies
4848            for dep_pkg in &["ninja", "packaging", "wheel"] {
4849                let cmd = inst.build_dep_install_command(dep_pkg);
4850                execute_native_command(
4851                    &NativeCommand::from_shell_cmd(&cmd.program, &cmd.args, &cmd.env),
4852                    None,
4853                    sender,
4854                    &component.name,
4855                )?;
4856            }
4857
4858            // Step 2: git clone (idempotent — pulls if already exists)
4859            let clone_cmd = inst.build_git_clone_command();
4860            let clone_target_str = clone_cmd.args.last().cloned().unwrap_or_default();
4861            git_clone_or_pull(
4862                "https://github.com/NVIDIA/Megatron-LM.git",
4863                &clone_target_str,
4864                &[],
4865                sudo_pw,
4866                sender,
4867                &component.name,
4868            )?;
4869
4870            // Step 2b: Fix ownership — git_clone_or_pull runs with sudo, leaving
4871            // the tree root-owned. pip install -e . runs as the user and must
4872            // create megatron_core.egg-info in the clone dir; without chown it
4873            // fails "could not create 'megatron_core.egg-info': Permission denied".
4874            // (Mirrors the aiter installer's post-clone chown.)
4875            let run_user = std::env::var("SUDO_USER")
4876                .or_else(|_| std::env::var("USER"))
4877                .unwrap_or_default();
4878            if !run_user.is_empty() {
4879                let chown_cmd = NativeCommand::from_shell_cmd(
4880                    "chown",
4881                    &[
4882                        "-R".to_string(),
4883                        format!("{run_user}:{run_user}"),
4884                        clone_target_str.clone(),
4885                    ],
4886                    &[],
4887                );
4888                let _ = execute_native_command(&chown_cmd, sudo_pw, sender, &component.name);
4889            }
4890
4891            // Step 3: Remove stale egg-info directories that may be root-owned
4892            // from a prior sudo install. Without this, `pip install -e .` fails with
4893            // "Cannot update time stamp of directory 'megatron_core.egg-info'".
4894            let clone_target_path = PathBuf::from(&clone_target_str);
4895            if let Ok(entries) = std::fs::read_dir(&clone_target_path) {
4896                for entry in entries.flatten() {
4897                    let name = entry.file_name();
4898                    let name_str = name.to_string_lossy();
4899                    if name_str.ends_with(".egg-info") {
4900                        let egg_path = entry.path();
4901                        // Try plain remove first
4902                        if std::fs::remove_dir_all(&egg_path).is_err() {
4903                            // Root-owned: try sudo rm -rf
4904                            let rm_cmd = NativeCommand::from_shell_cmd(
4905                                "rm",
4906                                &["-rf".to_string(), egg_path.to_string_lossy().to_string()],
4907                                &[],
4908                            );
4909                            let _ =
4910                                execute_native_command(&rm_cmd, sudo_pw, sender, &component.name);
4911                        }
4912                    }
4913                }
4914            }
4915
4916            // Step 4: pip install (uses working_dir to run in the cloned Megatron-LM dir)
4917            let cmd = inst.build_pip_install_command();
4918            execute_native_command(
4919                &NativeCommand::from_shell_cmd_with_dir(
4920                    &cmd.program,
4921                    &cmd.args,
4922                    &cmd.env,
4923                    cmd.working_dir.clone(),
4924                ),
4925                None,
4926                sender,
4927                &component.name,
4928            )?;
4929
4930            // Step 5: Install filtered Megatron requirements without CUDA/NVIDIA deps.
4931            let requirements_candidates = [
4932                clone_target_path
4933                    .join("requirements")
4934                    .join("requirements.txt"),
4935                clone_target_path.join("requirements.txt"),
4936                clone_target_path
4937                    .join("requirements")
4938                    .join("requirements_amd.txt"),
4939            ];
4940            for req_path in requirements_candidates.iter().filter(|p| p.exists()) {
4941                let filtered = match filter_cuda_requirements(&req_path.to_string_lossy()) {
4942                    Ok(f) => f,
4943                    Err(e) => {
4944                        let _ = sender.send(InstallerEvent::Log(
4945                            format!(
4946                                "[native] WARN: could not read Megatron requirements {}: {e} — skipping requirements install",
4947                                req_path.display()
4948                            ),
4949                            true,
4950                        ));
4951                        anyhow::bail!(
4952                            "failed to read Megatron requirements {}: {e}",
4953                            req_path.display()
4954                        );
4955                    }
4956                };
4957                if filtered.is_empty() {
4958                    continue;
4959                }
4960                let tmp_dir = std::env::temp_dir().join("rusty-stack-megatron");
4961                let _ = std::fs::create_dir_all(&tmp_dir);
4962                let filtered_path = tmp_dir.join("requirements_filtered.txt");
4963                std::fs::write(&filtered_path, &filtered).with_context(|| {
4964                    format!(
4965                        "Failed to write filtered Megatron requirements to {:?}",
4966                        filtered_path
4967                    )
4968                })?;
4969                let filtered_path_str = filtered_path.to_string_lossy().to_string();
4970                let filtered_cmd = inst.build_requirements_install_command(&filtered_path_str);
4971                execute_native_command(
4972                    &NativeCommand::from_shell_cmd(
4973                        &filtered_cmd.program,
4974                        &filtered_cmd.args,
4975                        &filtered_cmd.env,
4976                    ),
4977                    None,
4978                    sender,
4979                    &component.name,
4980                )?;
4981                let _ = std::fs::remove_file(&filtered_path);
4982                break;
4983            }
4984        }
4985
4986        // ── vllm ──────────────────────────────────────────────────────
4987        "vllm" => {
4988            use crate::installers::components::vllm_multi::{VllmConfig, VllmInstaller};
4989            let vllm_force = is_force_reinstall();
4990            let inst = VllmInstaller::new(VllmConfig {
4991                python_bin: resolve_python_bin(),
4992                ..Default::default()
4993            });
4994
4995            // Force reinstall: thoroughly purge previous vLLM (non-fatal)
4996            if vllm_force {
4997                purge_vllm(sender, &component.name);
4998            }
4999
5000            // Step 0: build tools (cmake/ninja/wheel/setuptools/patchelf) for the
5001            // source compile of vLLM's C/HIP extensions.
5002            let cmd = inst.build_tools_install_command();
5003            execute_native_command(
5004                &NativeCommand::from_shell_cmd(&cmd.program, &cmd.args, &cmd.env),
5005                None,
5006                sender,
5007                &component.name,
5008            )?;
5009
5010            // Resolve the vLLM version per the user's supply-chain gate (default:
5011            // skip the newest release = lag 1). Queries PyPI for releases + dates,
5012            // picks the newest satisfying the version-lag + age policy. Falls back
5013            // to a pinned constant if PyPI is unreachable.
5014            // An update apply installs exactly the planned (reviewed) version;
5015            // opaque targets (`latest`, `installed`) are not versions — the
5016            // policy resolver picks a real release instead.
5017            let vllm_version = match ctx.target_version.as_deref() {
5018                Some(v) if is_explicit_update_target(Some(v)) => v.trim().to_string(),
5019                _ => inst.resolve_version_with_policy(
5020                    ctx.vllm_version_lag,
5021                    ctx.vllm_version_min_age_days,
5022                ),
5023            };
5024            let _ = sender.send(InstallerEvent::Log(
5025                format!(
5026                    "[native] vLLM: selected {vllm_version} (gate: lag={}, min_age_days={}) — \
5027                     building from source (ROCm, env torch)",
5028                    ctx.vllm_version_lag, ctx.vllm_version_min_age_days
5029                ),
5030                false,
5031            ));
5032            // Build vLLM FROM SOURCE against the env's torch (--no-deps so pip
5033            // never swaps torch; --no-build-isolation compiles against the
5034            // installed torch). Pinned + VLLM_VERSION_OVERRIDE so metadata matches
5035            // the sdist filename (no +rocmNNN cascade). Heavy compile.
5036            let cmd = inst.build_source_install_command(&vllm_version);
5037            execute_native_command(
5038                &NativeCommand::from_shell_cmd(&cmd.program, &cmd.args, &cmd.env),
5039                None,
5040                sender,
5041                &component.name,
5042            )?;
5043
5044            // Step 2: versioned runtime deps (mistral-common, openai-harmony,
5045            // triton-kernels, outlines-core, xgrammar, llguidance) — none pin
5046            // torch, so the env torch stays intact.
5047            let cmd = inst.build_versioned_deps_command();
5048            execute_native_command(
5049                &NativeCommand::from_shell_cmd(&cmd.program, &cmd.args, &cmd.env),
5050                None,
5051                sender,
5052                &component.name,
5053            )?;
5054
5055            // Step 3: core runtime deps (accelerate, fastapi, transformers, …).
5056            let cmd = inst.build_deps_install_command();
5057            execute_native_command(
5058                &NativeCommand::from_shell_cmd(&cmd.program, &cmd.args, &cmd.env),
5059                None,
5060                sender,
5061                &component.name,
5062            )?;
5063
5064            // Step 4: fail loud if the explicit --no-deps closure is incomplete.
5065            // This is read-only: it prevents a false "vLLM completed" without
5066            // letting pip mutate torch/ROCm/NVIDIA/CUDA dependencies.
5067            let cmd = inst.build_integrity_check_command();
5068            execute_native_command(
5069                &NativeCommand::from_shell_cmd(&cmd.program, &cmd.args, &cmd.env),
5070                None,
5071                sender,
5072                &component.name,
5073            )?;
5074        }
5075
5076        // ── aiter ─────────────────────────────────────────────────────
5077        "aiter" => {
5078            use crate::installers::components::aiter::{AiterConfig, AiterInstaller};
5079            // Source the build/HSA arch from the detector (env file) — NEVER default to
5080            // gfx1100, which would mis-compile the wheel on RDNA2/RDNA4 hardware.
5081            let detected_gpu_arch = ctx
5082                .env_exports
5083                .get("GPU_ARCH")
5084                .map(|v| v.trim().to_string())
5085                .filter(|v| !v.is_empty())
5086                .unwrap_or_else(|| detect_gpu_arch().unwrap_or_else(|| "gfx1100".to_string()));
5087            let inst = AiterInstaller::new(AiterConfig {
5088                python_bin: resolve_python_bin(),
5089                gpu_arch: detected_gpu_arch,
5090                ..Default::default()
5091            });
5092            let target_dir = format!("{}/.mlstack/aiter", ctx.user_home);
5093
5094            // Force reinstall: thoroughly purge previous AITER (non-fatal)
5095            if is_force_reinstall() {
5096                purge_aiter(sender, &component.name);
5097            }
5098
5099            // Step 1: git clone (idempotent — pulls if already exists)
5100            git_clone_or_pull(
5101                inst.git_clone_url(),
5102                &target_dir,
5103                &["--recursive"],
5104                sudo_pw,
5105                sender,
5106                &component.name,
5107            )?;
5108
5109            // Step 1b: Fix ownership of cloned directory.
5110            // git_clone_or_pull may run with sudo, leaving files root-owned.
5111            // pip install runs as the user and needs write access for metadata.
5112            let run_user = std::env::var("SUDO_USER")
5113                .or_else(|_| std::env::var("USER"))
5114                .unwrap_or_default();
5115            if !run_user.is_empty() {
5116                let chown_cmd = NativeCommand::from_shell_cmd(
5117                    "chown",
5118                    &[
5119                        "-R".to_string(),
5120                        format!("{run_user}:{run_user}"),
5121                        target_dir.clone(),
5122                    ],
5123                    &[],
5124                );
5125                let _ = execute_native_command(&chown_cmd, sudo_pw, sender, &component.name);
5126            }
5127
5128            // Step 2: Install dependencies
5129            let cmd = inst.build_deps_install_command();
5130            execute_native_command(
5131                &NativeCommand::from_shell_cmd(&cmd.program, &cmd.args, &cmd.env),
5132                sudo_pw,
5133                sender,
5134                &component.name,
5135            )?;
5136
5137            // Step 3: pip install
5138            let cmd = inst.build_pip_install_command(&target_dir);
5139            execute_native_command(
5140                &NativeCommand::from_shell_cmd_with_dir(
5141                    &cmd.program,
5142                    &cmd.args,
5143                    &cmd.env,
5144                    cmd.working_dir.clone(),
5145                ),
5146                None,
5147                sender,
5148                &component.name,
5149            )?;
5150
5151            // Step 4: Fix ownership of aiter directories unconditionally.
5152            // AITER's JIT compiler writes to ~/.aiter/jit which fails if root-owned
5153            // from a prior sudo install or if pip's subprocess created it as root.
5154            let run_user = std::env::var("SUDO_USER")
5155                .or_else(|_| std::env::var("USER"))
5156                .unwrap_or_default();
5157            if !run_user.is_empty() {
5158                let user_home = &ctx.user_home;
5159                for aiter_dir in [
5160                    format!("{user_home}/.aiter"),
5161                    format!("{user_home}/.mlstack/aiter"),
5162                ] {
5163                    let dir_path = PathBuf::from(&aiter_dir);
5164                    if dir_path.exists() {
5165                        // Check if we can't write to it — then fix permissions
5166                        let needs_fix = std::fs::read_dir(&dir_path)
5167                            .map(|mut entries| entries.next().is_some())
5168                            .unwrap_or(false)
5169                            && std::fs::write(dir_path.join(".permission_check"), b"").is_err();
5170                        if needs_fix {
5171                            let chown_cmd = NativeCommand::from_shell_cmd(
5172                                "chown",
5173                                &[
5174                                    "-R".to_string(),
5175                                    format!("{run_user}:{run_user}"),
5176                                    aiter_dir.clone(),
5177                                ],
5178                                &[],
5179                            );
5180                            let _ = execute_native_command(
5181                                &chown_cmd,
5182                                sudo_pw,
5183                                sender,
5184                                &component.name,
5185                            );
5186                        } else {
5187                            // Clean up the check file
5188                            let _ = std::fs::remove_file(dir_path.join(".permission_check"));
5189                        }
5190                    }
5191                }
5192            }
5193        }
5194        "vllm-studio" => {
5195            use crate::installers::components::vllm_studio::{
5196                VllmStudioConfig, VllmStudioInstaller,
5197            };
5198            let inst = VllmStudioInstaller::new(VllmStudioConfig {
5199                python_bin: resolve_python_bin(),
5200                ..Default::default()
5201            });
5202
5203            // Step 1: git clone (idempotent — pulls if already exists)
5204            let clone_cmd = inst.build_git_clone_command();
5205            let target_dir = clone_cmd.args.last().cloned().unwrap_or_default();
5206            git_clone_or_pull(
5207                inst.repo_url(),
5208                &target_dir,
5209                &[],
5210                None,
5211                sender,
5212                &component.name,
5213            )?;
5214
5215            // Step 2: Install packages
5216            let pkg_mgr = if crate::installers::common::utils::command_exists("npm") {
5217                "npm"
5218            } else {
5219                "yarn"
5220            };
5221            let cmd = inst.build_pkg_install_command(pkg_mgr);
5222            execute_native_command(
5223                &NativeCommand::from_shell_cmd(&cmd.program, &cmd.args, &cmd.env),
5224                None,
5225                sender,
5226                &component.name,
5227            )?;
5228
5229            // Step 3: Build frontend (non-fatal — build script may not exist yet)
5230            // Run from the frontend/ subdirectory where package.json with "build" script lives.
5231            let frontend_dir = PathBuf::from(&target_dir).join("frontend");
5232            let cmd = inst.build_frontend_build_command(pkg_mgr);
5233            let build_result = execute_native_command(
5234                &NativeCommand::from_shell_cmd_with_dir(
5235                    &cmd.program,
5236                    &cmd.args,
5237                    &cmd.env,
5238                    Some(frontend_dir),
5239                ),
5240                None,
5241                sender,
5242                &component.name,
5243            );
5244            if let Err(err) = build_result {
5245                let _ = sender.send(InstallerEvent::Log(
5246                    format!(
5247                        "[native] [WARN] {} frontend build failed (non-fatal): {}",
5248                        component.name, err
5249                    ),
5250                    false,
5251                ));
5252            }
5253        }
5254
5255        // ── comfyui ───────────────────────────────────────────────────
5256        "comfyui" => {
5257            use crate::installers::components::comfyui::{ComfyuiConfig, ComfyuiInstaller};
5258            let inst = ComfyuiInstaller::new(ComfyuiConfig {
5259                python_bin: resolve_python_bin(),
5260                ..Default::default()
5261            });
5262
5263            // Step 1: Check PyTorch
5264            let cmd = inst.build_pytorch_check_command();
5265            execute_native_command(
5266                &NativeCommand::from_shell_cmd(&cmd.program, &cmd.args, &cmd.env),
5267                None,
5268                sender,
5269                &component.name,
5270            )?;
5271
5272            // Step 2: git clone (idempotent — pulls if already exists)
5273            let clone_cmd = inst.build_git_clone_command();
5274            let target_dir = clone_cmd.args.last().cloned().unwrap_or_default();
5275            git_clone_or_pull(
5276                inst.repo_url(),
5277                &target_dir,
5278                &[],
5279                None,
5280                sender,
5281                &component.name,
5282            )?;
5283
5284            // Step 3: pip install — filter requirements to exclude CUDA deps.
5285            // (Stage 2 tenet: no nvidia deps EVER. ComfyUI previously installed
5286            // requirements.txt verbatim, which could pull xformers/flash-attn
5287            // CUDA wheels. The ROCm torch is already present, so torch lines are
5288            // stripped and reused, not reinstalled from a CUDA index.)
5289            let req_path = format!("{}/requirements.txt", target_dir);
5290            if std::path::Path::new(&req_path).exists() {
5291                let filtered = match filter_cuda_requirements(&req_path) {
5292                    Ok(f) => f,
5293                    Err(e) => {
5294                        let _ = sender.send(InstallerEvent::Log(
5295                            format!(
5296                                "[native] WARN: could not read ComfyUI requirements {req_path}: {e} — skipping requirements install"
5297                            ),
5298                            true,
5299                        ));
5300                        anyhow::bail!("failed to read ComfyUI requirements {req_path}: {e}");
5301                    }
5302                };
5303                if !filtered.is_empty() {
5304                    let tmp_dir = std::env::temp_dir().join("rusty-stack-comfyui");
5305                    let _ = std::fs::create_dir_all(&tmp_dir);
5306                    let filtered_path = tmp_dir.join("requirements_filtered.txt");
5307                    std::fs::write(&filtered_path, &filtered).with_context(|| {
5308                        format!(
5309                            "Failed to write filtered requirements to {:?}",
5310                            filtered_path
5311                        )
5312                    })?;
5313                    let filtered_path_str = filtered_path.to_string_lossy().to_string();
5314                    let _ = sender.send(InstallerEvent::Log(
5315                        format!(
5316                            "[native] Installing filtered requirements (CUDA deps excluded) from {}",
5317                            req_path
5318                        ),
5319                        false,
5320                    ));
5321                    let cmd = inst.build_pip_install_command(&filtered_path_str);
5322                    execute_native_command(
5323                        &NativeCommand::from_shell_cmd(&cmd.program, &cmd.args, &cmd.env),
5324                        None,
5325                        sender,
5326                        &component.name,
5327                    )?;
5328                    let _ = std::fs::remove_file(&filtered_path);
5329                } else {
5330                    let _ = sender.send(InstallerEvent::Log(
5331                        "[native] [WARN] All ComfyUI requirements were filtered out, nothing to install".to_string(),
5332                        false,
5333                    ));
5334                }
5335            } else {
5336                let _ = sender.send(InstallerEvent::Log(
5337                    "[native] [WARN] ComfyUI requirements.txt not found, skipping dependency installation".to_string(),
5338                    false,
5339                ));
5340            }
5341        }
5342
5343        // ── textgen ───────────────────────────────────────────────────
5344        "textgen" => {
5345            use crate::installers::components::textgen::{TextgenConfig, TextgenInstaller};
5346            // Route through the canonical managed interpreter (~/.mlstack/global),
5347            // NOT bare system python3 — every installer must respect the created env.
5348            let textgen_python = resolve_python_bin();
5349            // textgen: always use python -m pip --break-system-packages.
5350            // uv pip install --system fails on uv-managed Pythons ("externally managed");
5351            // in the managed venv `python -m pip` works and --break-system-packages is a
5352            // harmless no-op.
5353            let inst = TextgenInstaller::new(TextgenConfig {
5354                python_bin: textgen_python.clone(),
5355                use_uv: false,
5356                break_system_packages: true,
5357                ..Default::default()
5358            });
5359
5360            // Step 1: git clone (idempotent — pulls if already exists)
5361            let clone_cmd = inst.build_git_clone_command();
5362            let target_dir = clone_cmd.args.last().cloned().unwrap_or_default();
5363            git_clone_or_pull(
5364                inst.repo_url(),
5365                &target_dir,
5366                &[],
5367                None,
5368                sender,
5369                &component.name,
5370            )?;
5371
5372            // Step 2: Install AMD requirements FIRST.
5373            // AMD requirements contain ROCm-specific wheels (e.g. llama-cpp+rocm7.2)
5374            // that must be installed before the filtered generic requirements.
5375            // This ensures ROCm wheels take precedence over any CUDA fallbacks.
5376            let amd_req_full = format!("{}/requirements/full/requirements_amd.txt", target_dir);
5377            let amd_req_subdir = format!("{}/requirements/requirements_amd.txt", target_dir);
5378            let amd_req_path = if std::path::Path::new(&amd_req_full).exists() {
5379                amd_req_full
5380            } else if std::path::Path::new(&amd_req_subdir).exists() {
5381                amd_req_subdir
5382            } else {
5383                String::new()
5384            };
5385            if !amd_req_path.is_empty() {
5386                let _ = sender.send(InstallerEvent::Log(
5387                    "[native] Installing AMD-specific requirements first (ROCm wheels)".to_string(),
5388                    false,
5389                ));
5390                let cmd = inst.build_amd_requirements_command(&amd_req_path);
5391                execute_native_command(
5392                    &NativeCommand::from_shell_cmd(&cmd.program, &cmd.args, &cmd.env),
5393                    None,
5394                    sender,
5395                    &component.name,
5396                )?;
5397            }
5398
5399            // Step 3: Install filtered main requirements.
5400            // The original script filters with grep -v to exclude CUDA/nvidia packages:
5401            //   grep -v -E '^(nvidia-|cuda|tensorrt|triton([=<>!\[]|$)|xformers|
5402            //                flash-attn|torch([=<>! ]|$)|torchvision|torchaudio)'
5403            // Additionally, we exclude any .whl URLs containing CUDA markers (+cu\d)
5404            // and any ik_llama lines (CUDA-only builds).
5405            let req_path_root = format!("{}/requirements.txt", target_dir);
5406            let req_path_full = format!("{}/requirements/full/requirements.txt", target_dir);
5407            let req_path_subdir = format!("{}/requirements/requirements.txt", target_dir);
5408            let raw_req_path = if std::path::Path::new(&req_path_full).exists() {
5409                req_path_full
5410            } else if std::path::Path::new(&req_path_root).exists() {
5411                req_path_root
5412            } else if std::path::Path::new(&req_path_subdir).exists() {
5413                req_path_subdir
5414            } else {
5415                String::new()
5416            };
5417
5418            if !raw_req_path.is_empty() {
5419                // Read and filter the requirements file to exclude CUDA deps
5420                let filtered = match filter_cuda_requirements(&raw_req_path) {
5421                    Ok(f) => f,
5422                    Err(e) => {
5423                        let _ = sender.send(InstallerEvent::Log(
5424                            format!(
5425                                "[native] WARN: could not read textgen requirements {raw_req_path}: {e} — skipping requirements install"
5426                            ),
5427                            true,
5428                        ));
5429                        anyhow::bail!("failed to read textgen requirements {raw_req_path}: {e}");
5430                    }
5431                };
5432                if !filtered.is_empty() {
5433                    // Write filtered requirements to a temp file
5434                    let tmp_dir = std::env::temp_dir().join("rusty-stack-textgen");
5435                    let _ = std::fs::create_dir_all(&tmp_dir);
5436                    let filtered_path = tmp_dir.join("requirements_filtered.txt");
5437                    std::fs::write(&filtered_path, &filtered).with_context(|| {
5438                        format!(
5439                            "Failed to write filtered requirements to {:?}",
5440                            filtered_path
5441                        )
5442                    })?;
5443                    let filtered_path_str = filtered_path.to_string_lossy().to_string();
5444                    let _ = sender.send(InstallerEvent::Log(
5445                        format!(
5446                            "[native] Installing filtered requirements (CUDA deps excluded) from {}",
5447                            raw_req_path
5448                        ),
5449                        false,
5450                    ));
5451                    let cmd = inst.build_pip_install_command(&filtered_path_str);
5452                    execute_native_command(
5453                        &NativeCommand::from_shell_cmd(&cmd.program, &cmd.args, &cmd.env),
5454                        None,
5455                        sender,
5456                        &component.name,
5457                    )?;
5458                    let _ = std::fs::remove_file(&filtered_path);
5459                } else {
5460                    let _ = sender.send(InstallerEvent::Log(
5461                        "[native] [WARN] All requirements were filtered out, nothing to install"
5462                            .to_string(),
5463                        false,
5464                    ));
5465                }
5466            } else {
5467                let _ = sender.send(InstallerEvent::Log(
5468                    "[native] [WARN] requirements.txt not found, skipping dependency installation"
5469                        .to_string(),
5470                    false,
5471                ));
5472            }
5473        }
5474
5475        // ── onnx ──────────────────────────────────────────────────────
5476        "onnx" => {
5477            use crate::installers::components::onnxruntime::{
5478                install_method_from_env, prebuilt_version_from_env, OnnxInstallMethod,
5479                OnnxRuntimeConfig, OnnxRuntimeInstaller,
5480            };
5481
5482            let detected_rocm_version = detect_rocm_version();
5483            let detected_gpu_arch = detect_gpu_arch().unwrap_or_else(|| {
5484                eprintln!("Warning: no GPU arch detected for ONNX, falling back to gfx1100");
5485                "gfx1100".to_string()
5486            });
5487
5488            // Install method + version are user-selectable (env vars today; the
5489            // TUI options screen will set these). Defaults: PyPI prebuilt
5490            // `onnxruntime-migraphx` at PREBUILT_MIGRAPHX_VERSION (1.27.1 — kept
5491            // in lock-step with the manifest `onnx` target + DEFAULT_ONNXRUNTIME_VERSION
5492            // so the post-install honesty guard verifies the version the default
5493            // wheel path actually installs).
5494            let install_method = install_method_from_env();
5495            let prebuilt_version = prebuilt_version_from_env();
5496
5497            // Build config with detected hardware info
5498            let config = OnnxRuntimeConfig {
5499                rocm_version: Some(detected_rocm_version.clone()),
5500                rocm_release: Some({
5501                    // Derive release from version: keep full semver when available.
5502                    // If only major.minor is detected, default ROCm 7.2 to current 7.2.4.
5503                    let parts: Vec<&str> = detected_rocm_version.split('.').collect();
5504                    if parts.len() >= 3 {
5505                        detected_rocm_version.clone()
5506                    } else if parts.len() >= 2 && parts[0] == "7" && parts[1] == "2" {
5507                        "7.2.4".to_string()
5508                    } else if parts.len() >= 2 {
5509                        format!("{}.{}.3", parts[0], parts[1])
5510                    } else {
5511                        "7.2.4".to_string()
5512                    }
5513                }),
5514                gpu_arch: Some(detected_gpu_arch),
5515                python_bin: resolve_python_bin(),
5516                install_method,
5517                runtime_version: ctx.target_version.clone(),
5518                prebuilt_version,
5519                ..Default::default()
5520            };
5521            let inst = OnnxRuntimeInstaller::new(config);
5522
5523            // Step 1: Uninstall ALL existing onnxruntime variants
5524            let cmd = inst.build_uninstall_command();
5525            let _ = execute_native_command(
5526                &NativeCommand::from_shell_cmd_with_dir(
5527                    &cmd.program,
5528                    &cmd.args,
5529                    &cmd.env,
5530                    cmd.working_dir.clone(),
5531                ),
5532                None,
5533                sender,
5534                &component.name,
5535            );
5536
5537            // Step 2: install per the selected method (MLSTACK_ONNX_INSTALL_METHOD).
5538            match install_method {
5539                OnnxInstallMethod::MigraphxWheel => {
5540                    // Default: pinned onnxruntime-migraphx from PyPI. The AMD
5541                    // manylinux repo lags (1.23.2 at ROCm 7.2.4) and 404s on
5542                    // newer versions. --no-deps honors the No-CUDA hard-prime.
5543                    let _ = sender.send(InstallerEvent::Log(
5544                        format!(
5545                            "[native] {} — installing onnxruntime-migraphx=={} from PyPI (ROCm {})",
5546                            component.name,
5547                            inst.prebuilt_version(),
5548                            detected_rocm_version
5549                        ),
5550                        false,
5551                    ));
5552                    let cmd = inst.build_migraphx_install_command();
5553                    execute_native_command(
5554                        &NativeCommand::from_shell_cmd_with_dir(
5555                            &cmd.program,
5556                            &cmd.args,
5557                            &cmd.env,
5558                            cmd.working_dir.clone(),
5559                        ),
5560                        None,
5561                        sender,
5562                        &component.name,
5563                    )?;
5564                }
5565                OnnxInstallMethod::PrebuiltWheel => {
5566                    // Legacy: prebuilt onnxruntime-rocm from PyPI. May be
5567                    // ABI-incompatible with the installed ROCm.
5568                    let _ = sender.send(InstallerEvent::Log(
5569                        format!(
5570                            "[native] {} — installing prebuilt onnxruntime-rocm from PyPI \
5571                             (legacy; may be ABI-incompatible with ROCm {})",
5572                            component.name, detected_rocm_version
5573                        ),
5574                        false,
5575                    ));
5576                    let cmd = inst.build_prebuilt_install_command();
5577                    execute_native_command(
5578                        &NativeCommand::from_shell_cmd_with_dir(
5579                            &cmd.program,
5580                            &cmd.args,
5581                            &cmd.env,
5582                            cmd.working_dir.clone(),
5583                        ),
5584                        None,
5585                        sender,
5586                        &component.name,
5587                    )?;
5588                }
5589                OnnxInstallMethod::SourceBuild => {
5590                    // Build from source against /opt/rocm, targeting runtime_version.
5591                    let _ = sender.send(InstallerEvent::Log(
5592                        format!(
5593                            "[native] {} — building from source (target {}, ROCm {}). Heavy build.",
5594                            component.name,
5595                            inst.runtime_version(),
5596                            detected_rocm_version
5597                        ),
5598                        false,
5599                    ));
5600                    let rocm_env = crate::installers::common::RocmEnv::from_known(
5601                        Some(std::path::PathBuf::from("/opt/rocm")),
5602                        detected_rocm_version.clone(),
5603                    );
5604                    for cmd in [
5605                        inst.build_git_clone_command(),
5606                        inst.build_git_checkout_command(),
5607                        inst.build_git_submodule_command(),
5608                        inst.build_build_command(&rocm_env),
5609                        inst.build_wheel_install_command(),
5610                    ] {
5611                        execute_native_command(
5612                            &NativeCommand::from_shell_cmd_with_dir(
5613                                &cmd.program,
5614                                &cmd.args,
5615                                &cmd.env,
5616                                cmd.working_dir.clone(),
5617                            ),
5618                            None,
5619                            sender,
5620                            &component.name,
5621                        )?;
5622                    }
5623                }
5624            }
5625
5626            // Step 3: Validate AMD execution provider availability before accepting install
5627            let cmd = inst.build_provider_validation_command();
5628            execute_native_command(
5629                &NativeCommand::from_shell_cmd_with_dir(
5630                    &cmd.program,
5631                    &cmd.args,
5632                    &cmd.env,
5633                    cmd.working_dir.clone(),
5634                ),
5635                None,
5636                sender,
5637                &component.name,
5638            )?;
5639
5640            // Step 4: Run model optimizer on known .onnx model paths
5641            let model_dirs = [
5642                dirs::home_dir().map(|h| h.join(".mlstack/models")),
5643                dirs::home_dir().map(|h| h.join(".local/share/models")),
5644            ];
5645            for model_dir in model_dirs.iter().flatten() {
5646                if model_dir.exists() {
5647                    if let Ok(entries) = std::fs::read_dir(model_dir) {
5648                        for entry in entries.flatten() {
5649                            let path = entry.path();
5650                            if path.extension().map(|e| e == "onnx").unwrap_or(false) {
5651                                let model_path = path.to_string_lossy().to_string();
5652                                // Skip already-optimized models
5653                                if model_path.ends_with(".onnx.optimized") {
5654                                    continue;
5655                                }
5656                                let _ = sender.send(InstallerEvent::Log(
5657                                    format!(
5658                                        "[native] {} — optimizing model: {}",
5659                                        component.name, model_path
5660                                    ),
5661                                    false,
5662                                ));
5663                                let cmd = inst.build_model_optimizer_command(&model_path);
5664                                let _ = execute_native_command(
5665                                    &NativeCommand::from_shell_cmd_with_dir(
5666                                        &cmd.program,
5667                                        &cmd.args,
5668                                        &cmd.env,
5669                                        cmd.working_dir.clone(),
5670                                    ),
5671                                    None,
5672                                    sender,
5673                                    &component.name,
5674                                );
5675                            }
5676                        }
5677                    }
5678                }
5679            }
5680
5681            persist_onnx_install_status(
5682                inst.prebuilt_version(),
5683                &["MIGraphXExecutionProvider", "CPUExecutionProvider"],
5684            );
5685
5686            let _ = sender.send(InstallerEvent::Log(
5687                format!("[native] {} — ONNX Runtime (MIGraphX) installed successfully. \
5688                    Source build available via OnnxInstallMethod::SourceBuild for ROCMExecutionProvider.",
5689                    component.name),
5690                false,
5691            ));
5692        }
5693
5694        // ── bitsandbytes ──────────────────────────────────────────────
5695        "bitsandbytes" => {
5696            use crate::installers::components::bitsandbytes_multi::{
5697                BitsAndBytesConfig, BitsAndBytesInstaller,
5698            };
5699            let inst = BitsAndBytesInstaller::new(BitsAndBytesConfig {
5700                python_bin: resolve_python_bin(),
5701                ..Default::default()
5702            });
5703            let target_dir = format!("{}/.mlstack/bitsandbytes", ctx.user_home);
5704
5705            // Force reinstall: purge previous bitsandbytes (non-fatal)
5706            if is_force_reinstall() {
5707                purge_component_packages(&["bitsandbytes"], sender, &component.name);
5708            }
5709
5710            // Step 1: git clone (idempotent — pulls if already exists)
5711            git_clone_or_pull(
5712                "https://github.com/ROCm/bitsandbytes.git",
5713                &target_dir,
5714                &["--recursive"],
5715                sudo_pw,
5716                sender,
5717                &component.name,
5718            )?;
5719
5720            // Step 2: Install from PyPI
5721            let cmd = inst.build_pypi_install_command();
5722            let cmd_args = pin_pip_requirement(
5723                cmd.args.clone(),
5724                "bitsandbytes",
5725                ctx.target_version.as_deref(),
5726            );
5727            execute_native_command(
5728                &NativeCommand::from_shell_cmd(&cmd.program, &cmd_args, &cmd.env),
5729                None,
5730                sender,
5731                &component.name,
5732            )?;
5733        }
5734
5735        // ── rocm-smi ──────────────────────────────────────────────────
5736        "rocm-smi" => {
5737            use crate::installers::components::rocm_smi::RocmSmiInstaller;
5738            let inst = RocmSmiInstaller::new();
5739            let distro = crate::installers::common::DistroFacade::detect();
5740            let target_dir = format!("{}/.mlstack/rocm_smi", ctx.user_home);
5741
5742            // Step 1: git clone (idempotent — pulls if already exists)
5743            git_clone_or_pull(
5744                inst.git_clone_url(),
5745                &target_dir,
5746                &[],
5747                sudo_pw,
5748                sender,
5749                &component.name,
5750            )?;
5751
5752            // Step 2: package install (skip on Arch if rocm-smi already on PATH)
5753            if !(distro.family() == crate::platform::detection::DistroFamily::Arch
5754                && crate::installers::common::utils::command_exists("rocm-smi"))
5755            {
5756                let cmd = inst.build_package_install_command(&distro);
5757                execute_native_command(
5758                    &NativeCommand::from_shell_cmd(&cmd.program, &cmd.args, &cmd.env),
5759                    sudo_pw,
5760                    sender,
5761                    &component.name,
5762                )?;
5763            } else {
5764                let _ = sender.send(InstallerEvent::Log(
5765                    format!(
5766                        "[native] {} — rocm-smi already on PATH, skipping package install",
5767                        component.name
5768                    ),
5769                    false,
5770                ));
5771            }
5772        }
5773
5774        // ── migraphx ──────────────────────────────────────────────────
5775        "migraphx" => {
5776            use crate::installers::components::migraphx_multi::{
5777                MigraphxConfig, MigraphxInstaller,
5778            };
5779            let inst = MigraphxInstaller::new(MigraphxConfig {
5780                python_bin: resolve_python_bin(),
5781                ..Default::default()
5782            });
5783            let distro = crate::installers::common::DistroFacade::detect();
5784            let packages = inst.required_packages(&distro);
5785
5786            // Step 1: system package install
5787            let cmd = inst.build_package_install_command(&distro, &packages);
5788            execute_native_command(
5789                &NativeCommand::from_shell_cmd(&cmd.program, &cmd.args, &cmd.env),
5790                sudo_pw,
5791                sender,
5792                &component.name,
5793            )?;
5794
5795            // Step 2: pip install (skip on Arch — no pip wheel available)
5796            if inst.should_skip_pip_install(&distro) {
5797                if let Some(msg) = inst.build_limitation_message(&distro) {
5798                    let _ = sender.send(InstallerEvent::Log(
5799                        format!("[native] [INFO] {}", msg),
5800                        false,
5801                    ));
5802                }
5803            } else {
5804                let cmd = inst.build_pip_install_command();
5805                execute_native_command(
5806                    &NativeCommand::from_shell_cmd(&cmd.program, &cmd.args, &cmd.env),
5807                    None,
5808                    sender,
5809                    &component.name,
5810                )?;
5811            }
5812
5813            // Step 3: functional check — migraphx must actually LOAD. On Arch a
5814            // partial upgrade can leave migraphx installed but unrunnable (e.g.
5815            // libprotobuf.so.35.1.0 / libabsl SONAME desync). Surface the missing
5816            // libs + the fix instead of a vague "verification errors".
5817            match crate::installers::components::migraphx_multi::migraphx_driver_status() {
5818                Ok(()) => {}
5819                Err(missing) => {
5820                    let detail = if missing.is_empty() {
5821                        "migraphx-driver would not run".to_string()
5822                    } else {
5823                        format!("missing shared libraries: {}", missing.join(", "))
5824                    };
5825                    let _ = sender.send(InstallerEvent::Log(
5826                        format!(
5827                            "[native] [WARN] {} installed but NOT functional — {detail}. \
5828                             This is usually a library version desync from a partial upgrade. \
5829                             Run `sudo pacman -Syu` (Arch/CachyOS) or the equivalent system \
5830                             update to sync dependencies, then re-verify.",
5831                            component.name
5832                        ),
5833                        true,
5834                    ));
5835                }
5836            }
5837        }
5838
5839        // ── pytorch-profiler ──────────────────────────────────────────
5840        "pytorch-profiler" => {
5841            use crate::installers::components::pytorch_profiler::{
5842                PytorchProfilerConfig, PytorchProfilerInstaller,
5843            };
5844            let inst = PytorchProfilerInstaller::new(PytorchProfilerConfig {
5845                python_bin: resolve_python_bin(),
5846                ..Default::default()
5847            });
5848
5849            // Step 1: pip install
5850            let cmd = inst.build_install_command();
5851            execute_native_command(
5852                &NativeCommand::from_shell_cmd(&cmd.program, &cmd.args, &cmd.env),
5853                None,
5854                sender,
5855                &component.name,
5856            )?;
5857        }
5858
5859        // ── wandb ─────────────────────────────────────────────────────
5860        "wandb" => {
5861            use crate::installers::components::wandb::{WandbConfig, WandbInstaller};
5862            let inst = WandbInstaller::new(WandbConfig {
5863                python_bin: resolve_python_bin(),
5864                ..Default::default()
5865            });
5866
5867            // Step 1: pip install
5868            let cmd = inst.build_install_command();
5869            let cmd_args = pin_pip_requirement(
5870                cmd.args.clone(),
5871                "wandb",
5872                ctx.target_version.as_deref(),
5873            );
5874            execute_native_command(
5875                &NativeCommand::from_shell_cmd(&cmd.program, &cmd_args, &cmd.env),
5876                None,
5877                sender,
5878                &component.name,
5879            )?;
5880        }
5881
5882        // ── amdgpu-drivers ────────────────────────────────────────────
5883        "amdgpu-drivers" => {
5884            use crate::installers::components::amdgpu_drivers::{AmdgpuConfig, AmdgpuInstaller};
5885            let inst = AmdgpuInstaller::new(AmdgpuConfig::default());
5886            let distro = crate::installers::common::DistroFacade::detect();
5887            let packages = inst.required_packages(&distro);
5888
5889            // Step 1: package install
5890            let cmd = inst.build_package_install_command(&distro, &packages);
5891            execute_native_command(
5892                &NativeCommand::from_shell_cmd(&cmd.program, &cmd.args, &cmd.env),
5893                sudo_pw,
5894                sender,
5895                &component.name,
5896            )?;
5897
5898            // Step 2: env vars (HSA_OVERRIDE_GFX_VERSION / PYTORCH_ROCM_ARCH) are sourced
5899            // from the persistent env file (~/.mlstack_env via ensure_mlstack_env) — they
5900            // belong to ROCm RUNTIME, not driver install. The previous per-step `export X=Y`
5901            // was doubly broken: a bare `export` run as a subprocess is a no-op (it cannot
5902            // mutate the parent shell), and HSA_OVERRIDE was hardcoded to 11.0.0.
5903
5904            // Step 3: verify
5905            let cmd = inst.build_verify_command();
5906            execute_native_command(
5907                &NativeCommand::from_shell_cmd(&cmd.program, &cmd.args, &cmd.env),
5908                None,
5909                sender,
5910                &component.name,
5911            )?;
5912
5913            // Driver access requires render/video group membership on distros that
5914            // mode /dev/kfd + render nodes 0660. Idempotent + system-aware.
5915            ensure_user_in_groups(&["render", "video"], sudo_pw, sender, &component.name);
5916        }
5917
5918        // ── migraphx-python ───────────────────────────────────────────
5919        "migraphx-python" => {
5920            use crate::installers::components::migraphx_python::{
5921                build_python_source_requested, source_branch, MigraphxPythonConfig,
5922                MigraphxPythonInstaller,
5923            };
5924            let inst = MigraphxPythonInstaller::new(MigraphxPythonConfig {
5925                python_bin: resolve_python_bin(),
5926                ..Default::default()
5927            });
5928
5929            // Check distro availability — skip on Arch (no pip wheel)
5930            let distro = crate::installers::common::DistroFacade::detect();
5931            if !inst.is_available_on_distro(&distro) {
5932                if build_python_source_requested() {
5933                    // Opt-in source build (Arch): clone AMDMIGraphX + build the
5934                    // standalone python bindings against /opt/rocm. Heavy build.
5935                    let _ = sender.send(InstallerEvent::Log(
5936                        "[native] MLSTACK_MIGRAPHX_BUILD_PYTHON=1 — building Python \
5937                         bindings from source (AMDMIGraphX). Heavy build (~20-40 min)."
5938                            .into(),
5939                        false,
5940                    ));
5941                    let workdir = std::env::temp_dir()
5942                        .join("rusty-migraphx-python")
5943                        .to_string_lossy()
5944                        .to_string();
5945                    let _ = std::fs::remove_dir_all(&workdir);
5946                    let gpu_arch =
5947                        std::env::var("GPU_ARCH").unwrap_or_else(|_| "gfx1100".to_string());
5948                    let cmds = inst.build_source_commands(
5949                        &workdir,
5950                        "/opt/rocm",
5951                        &gpu_arch,
5952                        &source_branch(),
5953                    );
5954                    for cmd in cmds {
5955                        execute_native_command(
5956                            &NativeCommand::from_shell_cmd(&cmd.program, &cmd.args, &cmd.env),
5957                            None,
5958                            sender,
5959                            &component.name,
5960                        )?;
5961                    }
5962                } else if let Some(msg) = inst.build_unavailable_message(&distro) {
5963                    let _ = sender.send(InstallerEvent::Log(
5964                        format!("[native] [SKIP] {}", msg),
5965                        false,
5966                    ));
5967                }
5968            } else {
5969                // Step 1: pip install
5970                let cmd = inst.build_install_command();
5971                execute_native_command(
5972                    &NativeCommand::from_shell_cmd(&cmd.program, &cmd.args, &cmd.env),
5973                    None,
5974                    sender,
5975                    &component.name,
5976                )?;
5977            }
5978        }
5979
5980        // ── enhanced-env ──────────────────────────────────────────────
5981        "enhanced-env" => {
5982            // Enhanced env setup is handled by ensure_mlstack_env() in
5983            // run_installation() above. This is a no-op placeholder for
5984            // the dispatch table to ensure complete coverage.
5985            let _ = sender.send(InstallerEvent::Log(
5986                "[native] Enhanced environment setup handled during pre-install phase".into(),
5987                false,
5988            ));
5989        }
5990
5991        // ── Benchmark components ──────────────────────────────────────
5992        "mlperf-inference"
5993        | "rocm-benchmarks"
5994        | "gpu-memory-bandwidth"
5995        | "rocm-smi-bench"
5996        | "pytorch-performance"
5997        | "vllm-performance"
5998        | "deepspeed-performance"
5999        | "megatron-performance"
6000        | "onnx-performance"
6001        | "rusty-llama-performance"
6002        | "flash-attention-ck-performance"
6003        | "all-benchmarks" => {
6004            run_native_benchmark(&component.id, sender)?;
6005        }
6006
6007        // ── FastVideo ──────────────────────────────────────────────────
6008        "fastvideo" => {
6009            use crate::installers::components::fastvideo::{
6010                detect_discrete_gpu_archs_from_sysfs, fastvideo_build_warning, post_checkout_plan,
6011                resolve_gpu_archs, resolve_install_user, select_visible_gpu_archs, FastVideoConfig,
6012                FastVideoInstallStep, FastVideoInstaller,
6013            };
6014            let detected_archs =
6015                detect_discrete_gpu_archs_from_sysfs(PathBuf::from("/sys/class/drm").as_path());
6016            let selected_archs = select_visible_gpu_archs(
6017                &detected_archs,
6018                ctx.env_exports
6019                    .get("ROCR_VISIBLE_DEVICES")
6020                    .map(String::as_str),
6021                ctx.env_exports
6022                    .get("HIP_VISIBLE_DEVICES")
6023                    .map(String::as_str),
6024                ctx.env_exports
6025                    .get("CUDA_VISIBLE_DEVICES")
6026                    .map(String::as_str),
6027            );
6028            let gpu_archs = resolve_gpu_archs(
6029                &selected_archs,
6030                ctx.env_exports.get("GPU_ARCHS").map(String::as_str),
6031                ctx.env_exports.get("GPU_ARCH").map(String::as_str),
6032                None,
6033            );
6034            let python_bin = ctx
6035                .env_exports
6036                .get("MLSTACK_PYTHON_BIN")
6037                .map(|v| v.trim().to_string())
6038                .filter(|v| !v.is_empty())
6039                .unwrap_or_else(resolve_python_bin);
6040            let inst = FastVideoInstaller::new(FastVideoConfig {
6041                gpu_archs: gpu_archs.clone(),
6042                python_bin: python_bin.clone(),
6043            });
6044            let build_dir = PathBuf::from("/tmp/FastVideo_ROCm_build");
6045
6046            // Always start from a fresh directory. Reusing a failed build could
6047            // execute stale sitecustomize/build files or contaminate the wheel.
6048            let initial_cleanup = inst.cleanup();
6049            execute_native_command(
6050                &NativeCommand::from_shell_cmd(
6051                    &initial_cleanup.program,
6052                    &initial_cleanup.args,
6053                    &initial_cleanup.env,
6054                ),
6055                sudo_pw,
6056                sender,
6057                &component.name,
6058            )?;
6059
6060            // mkdir -p build dir
6061            let mkdir_cmd = inst.mkdir_build_dir();
6062            execute_native_command(
6063                &NativeCommand::from_shell_cmd(&mkdir_cmd.program, &mkdir_cmd.args, &mkdir_cmd.env),
6064                sudo_pw,
6065                sender,
6066                &component.name,
6067            )?;
6068
6069            // Clone only into the freshly emptied directory.
6070            let clone_cmd = inst.git_clone();
6071            execute_native_command(
6072                &NativeCommand::from_shell_cmd_with_dir(
6073                    &clone_cmd.program,
6074                    &clone_cmd.args,
6075                    &clone_cmd.env,
6076                    clone_cmd.working_dir,
6077                ),
6078                sudo_pw,
6079                sender,
6080                &component.name,
6081            )?;
6082
6083            // git checkout branch
6084            let checkout_cmd = inst.git_checkout();
6085            execute_native_command(
6086                &NativeCommand::from_shell_cmd_with_dir(
6087                    &checkout_cmd.program,
6088                    &checkout_cmd.args,
6089                    &checkout_cmd.env,
6090                    Some(build_dir.clone()),
6091                ),
6092                sudo_pw,
6093                sender,
6094                &component.name,
6095            )?;
6096
6097            let sudo_user = std::env::var("SUDO_USER").ok();
6098            let user = std::env::var("USER").ok();
6099            let run_user = resolve_install_user(sudo_user.as_deref(), user.as_deref())
6100                .context("FastVideo install user unavailable from SUDO_USER or USER")?;
6101
6102            for step in post_checkout_plan() {
6103                let step_sudo = if step.requires_sudo() { sudo_pw } else { None };
6104                if step == FastVideoInstallStep::KernelInstall {
6105                    let snapshot = inst.distribution_snapshot(
6106                        "before",
6107                        crate::installers::common::nvidia_blocklist::NVIDIA_RUNTIME_PREFIXES,
6108                    );
6109                    execute_native_command(
6110                        &NativeCommand::from_shell_cmd(
6111                            &snapshot.program,
6112                            &snapshot.args,
6113                            &snapshot.env,
6114                        ),
6115                        None,
6116                        sender,
6117                        &component.name,
6118                    )?;
6119                }
6120
6121                let command = match step {
6122                    FastVideoInstallStep::Chown => {
6123                        let cmd = inst.chown_build_dir(&run_user);
6124                        NativeCommand::from_shell_cmd(&cmd.program, &cmd.args, &cmd.env)
6125                    }
6126                    FastVideoInstallStep::ManagedDependenciesPreflight => {
6127                        let cmd = inst.managed_dependencies_preflight();
6128                        NativeCommand::from_shell_cmd(&cmd.program, &cmd.args, &cmd.env)
6129                    }
6130                    FastVideoInstallStep::FlashAttentionPreflight => {
6131                        let cmd = inst.flash_attention_preflight();
6132                        NativeCommand::from_shell_cmd(&cmd.program, &cmd.args, &cmd.env)
6133                    }
6134                    FastVideoInstallStep::SourcePolicyPreflight => {
6135                        let cmd = inst.source_policy_preflight();
6136                        NativeCommand::from_shell_cmd_with_dir(
6137                            &cmd.program,
6138                            &cmd.args,
6139                            &cmd.env,
6140                            cmd.working_dir,
6141                        )
6142                    }
6143                    FastVideoInstallStep::KernelInstall => {
6144                        let _ = sender.send(InstallerEvent::Log(
6145                            fastvideo_build_warning(&gpu_archs),
6146                            false,
6147                        ));
6148                        let cmd = inst.pip_install_kernel();
6149                        NativeCommand::from_shell_cmd_with_dir(
6150                            &cmd.program,
6151                            &cmd.args,
6152                            &cmd.env,
6153                            cmd.working_dir,
6154                        )
6155                    }
6156                    FastVideoInstallStep::PackageInstall => {
6157                        let cmd = inst.pip_install_package();
6158                        NativeCommand::from_shell_cmd_with_dir(
6159                            &cmd.program,
6160                            &cmd.args,
6161                            &cmd.env,
6162                            cmd.working_dir,
6163                        )
6164                    }
6165                };
6166                execute_native_command(&command, step_sudo, sender, &component.name)?;
6167            }
6168
6169            let snapshot = inst.distribution_snapshot(
6170                "after",
6171                crate::installers::common::nvidia_blocklist::NVIDIA_RUNTIME_PREFIXES,
6172            );
6173            execute_native_command(
6174                &NativeCommand::from_shell_cmd(&snapshot.program, &snapshot.args, &snapshot.env),
6175                None,
6176                sender,
6177                &component.name,
6178            )?;
6179
6180            // cleanup
6181            let cleanup_cmd = inst.cleanup();
6182            execute_native_command(
6183                &NativeCommand::from_shell_cmd(
6184                    &cleanup_cmd.program,
6185                    &cleanup_cmd.args,
6186                    &cleanup_cmd.env,
6187                ),
6188                sudo_pw,
6189                sender,
6190                &component.name,
6191            )?;
6192        }
6193        // ── llama-cpp ──────────────────────────────────────────────────
6194        "llama-cpp" => {
6195            use crate::installers::components::llama_cpp::{LlamaCppConfig, LlamaCppInstaller};
6196            let gpu_arch = ctx
6197                .env_exports
6198                .get("GPU_ARCH")
6199                .map(|v| v.trim().to_string())
6200                .filter(|v| !v.is_empty())
6201                .unwrap_or_else(|| {
6202                    detect_gpu_arch().unwrap_or_else(|| {
6203                        eprintln!("Warning: no GPU arch detected, falling back to gfx1100");
6204                        "gfx1100".to_string()
6205                    })
6206                });
6207            let channel = ctx
6208                .env_exports
6209                .get("ROCM_CHANNEL")
6210                .map(|v| v.trim().to_string())
6211                .filter(|v| !v.is_empty())
6212                .unwrap_or_else(|| "latest".to_string());
6213            let inst = LlamaCppInstaller::new(LlamaCppConfig {
6214                gpu_arch: gpu_arch.clone(),
6215                channel: channel.clone(),
6216                ..Default::default()
6217            });
6218            let home = ctx.user_home;
6219            let manifest = inst.check_latest_release();
6220            let strategy = inst.resolve_install_strategy(home, manifest.as_ref());
6221            match &strategy {
6222                crate::installers::components::llama_cpp::InstallStrategy::Prebuilt(plan) => {
6223                    let _ = sender.send(InstallerEvent::Log(
6224                        format!(
6225                            "llama-cpp install strategy selected: prebuilt ({})",
6226                            plan.arch
6227                        ),
6228                        false,
6229                    ));
6230                    if let Err(err) = inst.download_prebuilt_binary(plan) {
6231                        let _ = sender.send(InstallerEvent::Log(
6232                            format!(
6233                                "llama-cpp prebuilt download failed; falling back to source: {}",
6234                                err
6235                            ),
6236                            false,
6237                        ));
6238                        inst.run_source_install(home).map_err(|source_err| {
6239                            anyhow::anyhow!(
6240                                "llama-cpp prebuilt install failed and source fallback failed: {}",
6241                                source_err
6242                            )
6243                        })?;
6244                    }
6245                }
6246                crate::installers::components::llama_cpp::InstallStrategy::Source(plan) => {
6247                    let _ = sender.send(InstallerEvent::Log(
6248                        format!(
6249                            "llama-cpp install strategy selected: source ({})",
6250                            plan.reason
6251                        ),
6252                        false,
6253                    ));
6254                    inst.run_source_install(home).map_err(|err| {
6255                        anyhow::anyhow!("llama-cpp source install failed: {}", err)
6256                    })?;
6257                }
6258            }
6259
6260            // ── Post-install verification (VAL-CROSS-008/009/010/011) ──
6261            // Binaries resolve from the managed install dir inside verify; the
6262            // clone dir is only a best-effort search root for a verification model.
6263            let clone_dir = inst.build_dir_path().to_string();
6264            let verification =
6265                crate::installers::components::llama_cpp::verify_installed_binary(home, &clone_dir);
6266            let _ = sender.send(InstallerEvent::Log(
6267                format!(
6268                    "llama-cpp post-install verification: {}",
6269                    verification.summary
6270                ),
6271                false,
6272            ));
6273            if verification.stronger_check_passed {
6274                let _ = sender.send(InstallerEvent::Log(
6275                    format!(
6276                        "llama-cpp llama-bench: prefill={:.0} t/s, decode={:.0} t/s",
6277                        verification.bench_prefill_tps.unwrap_or(0.0),
6278                        verification.bench_decode_tps.unwrap_or(0.0),
6279                    ),
6280                    false,
6281                ));
6282            }
6283            if verification.rdna3_is_valid.unwrap_or(false) {
6284                let _ = sender.send(InstallerEvent::Log(
6285                    format!(
6286                        "llama-cpp RDNA3 proof: device=RDNA3, WMMA={:.0} t/s",
6287                        verification.rdna3_wmma_tps.unwrap_or(0.0),
6288                    ),
6289                    false,
6290                ));
6291            }
6292        }
6293
6294        // ── Unknown component ─────────────────────────────────────────
6295        _ => {
6296            bail!(
6297                "No native installer dispatch for component '{}'. \
6298                 This should not happen — is_native_component() should return false for this ID.",
6299                component.id
6300            );
6301        }
6302    }
6303
6304    let _ = sender.send(InstallerEvent::Progress {
6305        component_id: component.id.clone(),
6306        progress: 0.9,
6307        message: format!("{} installation complete", component.name),
6308    });
6309
6310    Ok(())
6311}
6312
6313fn run_script(
6314    component: &Component,
6315    script_path: &str,
6316    sudo_password: Option<String>,
6317    batch_mode: bool,
6318    install_method: &str,
6319    sender: &Sender<InstallerEvent>,
6320    input_rx: Arc<Mutex<Receiver<String>>>,
6321) -> Result<()> {
6322    let user_home = resolve_mlstack_user_home();
6323    let user_name = std::env::var("USER").unwrap_or_else(|_| "user".into());
6324    let preserve_env = "HOME,USER,LOGNAME,PATH,MLSTACK_USER_HOME,MLSTACK_SKIP_TORCH_INSTALL,MLSTACK_PYTHON_BIN,MLSTACK_ENV_NAME,MLSTACK_INSTALL_METHOD,INSTALL_METHOD,PIP_BREAK_SYSTEM_PACKAGES,PIP_ROOT_USER_ACTION,UV_PIP_BREAK_SYSTEM_PACKAGES,UV_SYSTEM_PYTHON,PYTHONPATH,LD_LIBRARY_PATH,ROCM_HOME,ROCM_PATH,ROCM_VERSION,ROCM_CHANNEL,GPU_ARCH,GPU_ARCHS,PYTORCH_ROCM_ARCH,HSA_OVERRIDE_GFX_VERSION,HIP_VISIBLE_DEVICES,CUDA_VISIBLE_DEVICES,ROCR_VISIBLE_DEVICES,AITER_JIT_DIR,HIP_PATH,HIP_ROOT_DIR,ROCM_ROOT,HIPCC_BIN_DIR,VLLM_TARGET_DEVICE,VLLM_USE_ROCM,USE_ROCM,VLLM_VERSION,UV_PYTHON,DS_ACCELERATOR,FORCE,PYTORCH_REINSTALL,MLSTACK_FORCE_REINSTALL,MLSTACK_SUDO_PASSWORD,MLSTACK_TARGET_UID,MLSTACK_TARGET_GID";
6325
6326    let venv_bin = PathBuf::from(&user_home).join("rocm_venv").join("bin");
6327    let local_bin = PathBuf::from(&user_home).join(".local").join("bin");
6328    let mut path = std::env::var("PATH").unwrap_or_default();
6329
6330    // Add .local/bin if missing
6331    if local_bin.exists() {
6332        let local_str = local_bin.to_string_lossy();
6333        if !path.split(':').any(|entry| entry == local_str) {
6334            if path.is_empty() {
6335                path = local_str.to_string();
6336            } else {
6337                path = format!("{}:{}", path, local_str);
6338            }
6339        }
6340    }
6341
6342    if venv_bin.exists() {
6343        let venv_str = venv_bin.to_string_lossy();
6344        if !path.split(':').any(|entry| entry == venv_str) {
6345            if path.is_empty() {
6346                path = venv_str.to_string();
6347            } else {
6348                path = format!("{}:{}", venv_str, path);
6349            }
6350        }
6351    }
6352
6353    let env_exports = load_mlstack_env_exports(&user_home);
6354    let persistent_python = env_exports
6355        .get("MLSTACK_PYTHON_BIN")
6356        .map(|v| v.trim().to_string())
6357        .filter(|v| !v.is_empty());
6358    // Tenet 1: MLSTACK_ENV_NAME (set by `rusty install --env <name>`) isolates
6359    // ALL components into ~/.mlstack/envs/<name>/. Falls back to the persisted
6360    // target, then discovery.
6361    let python_bin = std::env::var("MLSTACK_ENV_NAME")
6362        .ok()
6363        .map(|n| n.trim().to_string())
6364        .filter(|n| !n.is_empty())
6365        .map(|n| {
6366            crate::platform::environment::mlstack_env_dir(&n)
6367                .join("bin")
6368                .join("python")
6369                .to_string_lossy()
6370                .to_string()
6371        })
6372        .or(persistent_python)
6373        .unwrap_or_else(resolve_python_bin);
6374    let force_reinstall = is_force_reinstall();
6375    let force_env_value = if force_reinstall { "true" } else { "false" };
6376    let sudo_password_env = sudo_password.clone();
6377    let user_home_meta = fs::metadata(&user_home).ok();
6378    #[cfg(unix)]
6379    let (mlstack_target_uid, mlstack_target_gid) = {
6380        use std::os::unix::fs::MetadataExt;
6381        (
6382            user_home_meta.as_ref().map(|meta| meta.uid().to_string()),
6383            user_home_meta.as_ref().map(|meta| meta.gid().to_string()),
6384        )
6385    };
6386    #[cfg(not(unix))]
6387    let (mlstack_target_uid, mlstack_target_gid): (Option<String>, Option<String>) = (None, None);
6388
6389    // Check if this specific component needs sudo
6390    let component_needs_sudo = component.needs_sudo && needs_sudo();
6391
6392    let mut command = if component_needs_sudo {
6393        let mut cmd = Command::new("sudo");
6394        cmd.arg("-S")
6395            .arg("-p")
6396            .arg("")
6397            .arg(format!("--preserve-env={}", preserve_env))
6398            .arg("bash")
6399            .arg(script_path);
6400        cmd
6401    } else {
6402        let mut cmd = Command::new("bash");
6403        cmd.arg(script_path);
6404        cmd
6405    };
6406
6407    let mut pythonpath = std::env::var("PYTHONPATH").unwrap_or_default();
6408    let rocm_lib = "/opt/rocm/lib";
6409    if !pythonpath.split(':').any(|entry| entry == rocm_lib) {
6410        if pythonpath.is_empty() {
6411            pythonpath = rocm_lib.to_string();
6412        } else {
6413            pythonpath = format!("{}:{}", rocm_lib, pythonpath);
6414        }
6415    }
6416
6417    // Clean PYTHONPATH of any /tmp paths to avoid importing from build dirs
6418    pythonpath = pythonpath
6419        .split(':')
6420        .filter(|part| !part.starts_with("/tmp/"))
6421        .collect::<Vec<_>>()
6422        .join(":");
6423
6424    let rocm_version = env_exports
6425        .get("ROCM_VERSION")
6426        .map(|v| v.trim().to_string())
6427        .filter(|v| !v.is_empty())
6428        .unwrap_or_else(detect_rocm_version);
6429    let gpu_arch = env_exports
6430        .get("GPU_ARCH")
6431        .map(|v| v.trim().to_string())
6432        .filter(|v| !v.is_empty())
6433        .or_else(|| {
6434            env_exports
6435                .get("PYTORCH_ROCM_ARCH")
6436                .map(|v| v.split(';').next().unwrap_or("").trim().to_string())
6437                .filter(|v| !v.is_empty())
6438        })
6439        .unwrap_or_else(|| {
6440            detect_gpu_arch().unwrap_or_else(|| {
6441                eprintln!("Warning: no GPU arch detected, falling back to gfx1100");
6442                "gfx1100".to_string()
6443            })
6444        });
6445    let py_rocm_arch = env_exports
6446        .get("PYTORCH_ROCM_ARCH")
6447        .map(|v| v.trim().to_string())
6448        .filter(|v| !v.is_empty())
6449        .unwrap_or_else(|| gpu_arch.clone());
6450    let mut gpu_archs = env_exports
6451        .get("GPU_ARCHS")
6452        .map(|v| v.trim().to_string())
6453        .filter(|v| !v.is_empty())
6454        .unwrap_or_else(|| gpu_arch.clone());
6455    let mut gpu_list = env_exports
6456        .get("HIP_VISIBLE_DEVICES")
6457        .map(|v| v.trim().to_string())
6458        .filter(|v| !v.is_empty())
6459        .or_else(|| {
6460            env_exports
6461                .get("CUDA_VISIBLE_DEVICES")
6462                .map(|v| v.trim().to_string())
6463                .filter(|v| !v.is_empty())
6464        })
6465        .unwrap_or_else(detect_gpu_list);
6466    let hsa_override = env_exports
6467        .get("HSA_OVERRIDE_GFX_VERSION")
6468        .map(|v| v.trim().to_string())
6469        .filter(|v| !v.is_empty());
6470    let aiter_jit_dir = PathBuf::from(&user_home)
6471        .join(".mlstack")
6472        .join("aiter")
6473        .join("jit");
6474
6475    if component.id == "aiter" {
6476        gpu_archs = py_rocm_arch
6477            .split(';')
6478            .next()
6479            .map(|v| v.trim().to_string())
6480            .filter(|v| !v.is_empty())
6481            .unwrap_or_else(|| gpu_arch.clone());
6482        gpu_list = prioritize_gpu_list_for_arch(&gpu_list, &gpu_archs);
6483        let _ = fs::create_dir_all(&aiter_jit_dir);
6484    }
6485
6486    command
6487        .stdin(Stdio::piped())
6488        .stdout(Stdio::piped())
6489        .stderr(Stdio::piped())
6490        .env("HOME", &user_home)
6491        .env("USER", &user_name)
6492        .env("LOGNAME", &user_name)
6493        .env("PATH", &path)
6494        .env("PYTHONPATH", &pythonpath)
6495        .env("MLSTACK_USER_HOME", &user_home)
6496        .env("MLSTACK_PYTHON_BIN", &python_bin)
6497        .env("UV_PYTHON", &python_bin)
6498        .env("ROCM_HOME", "/opt/rocm")
6499        .env("ROCM_PATH", "/opt/rocm")
6500        .env("ROCM_VERSION", &rocm_version)
6501        .env("ROCM_CHANNEL", "latest")
6502        .env("GPU_ARCH", &gpu_arch)
6503        .env("GPU_ARCHS", &gpu_archs)
6504        .env("PYTORCH_ROCM_ARCH", &py_rocm_arch)
6505        .env("HIP_PATH", "/opt/rocm")
6506        .env("HIP_ROOT_DIR", "/opt/rocm")
6507        .env("ROCM_ROOT", "/opt/rocm")
6508        .env("HIPCC_BIN_DIR", "/opt/rocm/bin")
6509        .env("HIP_VISIBLE_DEVICES", &gpu_list)
6510        .env("CUDA_VISIBLE_DEVICES", &gpu_list)
6511        .env("ROCR_VISIBLE_DEVICES", &gpu_list)
6512        .env("MLSTACK_BATCH_MODE", if batch_mode { "1" } else { "0" })
6513        .env("MLSTACK_INSTALL_METHOD", install_method)
6514        .env("INSTALL_METHOD", install_method)
6515        .env("PIP_BREAK_SYSTEM_PACKAGES", "1")
6516        .env("PIP_ROOT_USER_ACTION", "ignore")
6517        .env("UV_PIP_BREAK_SYSTEM_PACKAGES", "1")
6518        .env("UV_SYSTEM_PYTHON", "1")
6519        .env("FORCE", force_env_value)
6520        .env("PYTORCH_REINSTALL", force_env_value)
6521        .env("MLSTACK_FORCE_REINSTALL", force_env_value)
6522        .env(
6523            "MLSTACK_SKIP_TORCH_INSTALL",
6524            if component.id == "pytorch" { "0" } else { "1" },
6525        )
6526        .env("RUSTY_STACK", "true");
6527
6528    if let Some(uid) = mlstack_target_uid.as_deref() {
6529        command.env("MLSTACK_TARGET_UID", uid);
6530    }
6531    if let Some(gid) = mlstack_target_gid.as_deref() {
6532        command.env("MLSTACK_TARGET_GID", gid);
6533    }
6534
6535    if let Some(password) = sudo_password_env {
6536        command.env("MLSTACK_SUDO_PASSWORD", password);
6537    }
6538
6539    if force_reinstall {
6540        if let Ok(script_contents) = fs::read_to_string(script_path) {
6541            let supports_force_flag = script_contents.contains("--force)")
6542                || script_contents.contains("--force ")
6543                || script_contents.contains("\"--force\"")
6544                || script_contents.contains("'--force'");
6545            if supports_force_flag {
6546                command.arg("--force");
6547            }
6548        }
6549    }
6550
6551    if let Some(value) = hsa_override {
6552        command.env("HSA_OVERRIDE_GFX_VERSION", value);
6553    }
6554    strip_hsa_override_for_therock(&mut command, &env_exports);
6555    if component.id == "aiter" {
6556        command.env("AITER_JIT_DIR", aiter_jit_dir);
6557    }
6558
6559    let mut child = command.spawn().with_context(|| {
6560        format!(
6561            "Failed to spawn installer script '{}' for {}. \
6562                 Ensure bash is installed and the script file is accessible.",
6563            script_path, component.name
6564        )
6565    })?;
6566
6567    let mut child_stdin = child.stdin.take().with_context(|| {
6568        format!(
6569            "Failed to open stdin for script '{}' — \
6570             this usually indicates a system resource issue.",
6571            script_path
6572        )
6573    })?;
6574    if let Some(password) = sudo_password {
6575        let _ = child_stdin.write_all(password.as_bytes());
6576        let _ = child_stdin.write_all(b"\n");
6577        let _ = child_stdin.flush();
6578    }
6579
6580    // Send default input if any
6581    if let Some(default_input) = default_component_input(&component.id) {
6582        let _ = child_stdin.write_all(default_input.as_bytes());
6583        let _ = child_stdin.flush();
6584    }
6585
6586    let stop_input = Arc::new(AtomicBool::new(false));
6587
6588    // Handle input forwarding
6589    let stop_input_clone = Arc::clone(&stop_input);
6590    let input_thread = thread::spawn(move || {
6591        while !stop_input_clone.load(Ordering::Relaxed) {
6592            let rx = match input_rx.lock() {
6593                Ok(guard) => guard,
6594                Err(_) => break, // Mutex poisoned, stop trying
6595            };
6596            match rx.recv_timeout(Duration::from_millis(100)) {
6597                Ok(input) => {
6598                    let _ = child_stdin.write_all(input.as_bytes());
6599                    let _ = child_stdin.flush();
6600                }
6601                Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
6602                    continue;
6603                }
6604                Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
6605            }
6606        }
6607    });
6608
6609    let sender_stdout = sender.clone();
6610    let component_id = component.id.clone();
6611    let component_name = component_name(component);
6612    let stdout_stream = match child.stdout.take() {
6613        Some(s) => s,
6614        None => {
6615            stop_input.store(true, Ordering::Relaxed);
6616            let _ = input_thread.join();
6617            let _ = sender.send(InstallerEvent::Log(
6618                format!(
6619                    "[ERROR] {} — failed to capture stdout from script process",
6620                    component.name
6621                ),
6622                false,
6623            ));
6624            bail!(
6625                "{} — failed to capture stdout from script process. \
6626                 This usually indicates a system resource issue.",
6627                component.name
6628            );
6629        }
6630    };
6631    let stdout_handle = thread::spawn(move || {
6632        let mut reader = BufReader::new(stdout_stream);
6633        let mut buffer = Vec::new();
6634        let mut chunk = [0u8; 1024];
6635
6636        while let Ok(n) = reader.read(&mut chunk) {
6637            if n == 0 {
6638                break;
6639            }
6640            for &b in chunk.iter().take(n) {
6641                if b == b'\n' || b == b'\r' {
6642                    let is_transient = b == b'\r';
6643                    if !buffer.is_empty() {
6644                        let line = String::from_utf8_lossy(&buffer).to_string();
6645                        let clean_line = strip_ansi_codes(&line);
6646                        if !clean_line.trim().is_empty() {
6647                            let _ = sender_stdout
6648                                .send(InstallerEvent::Log(clean_line.clone(), is_transient));
6649                            if let Some(progress) = parse_progress(&clean_line) {
6650                                let _ = sender_stdout.send(InstallerEvent::Progress {
6651                                    component_id: component_id.clone(),
6652                                    progress,
6653                                    message: format!(
6654                                        "{}: {}%",
6655                                        component_name,
6656                                        (progress * 100.0) as i32
6657                                    ),
6658                                });
6659                            }
6660                        }
6661                        buffer.clear();
6662                    }
6663                } else {
6664                    buffer.push(b);
6665                    if buffer.len() > 8192 {
6666                        buffer.clear();
6667                    }
6668                }
6669            }
6670        }
6671    });
6672
6673    let sender_stderr = sender.clone();
6674    let stderr_stream = match child.stderr.take() {
6675        Some(s) => s,
6676        None => {
6677            stop_input.store(true, Ordering::Relaxed);
6678            let _ = input_thread.join();
6679            let _ = stdout_handle.join();
6680            let _ = sender.send(InstallerEvent::Log(
6681                format!(
6682                    "[ERROR] {} — failed to capture stderr from script process",
6683                    component.name
6684                ),
6685                false,
6686            ));
6687            bail!(
6688                "{} — failed to capture stderr from script process. \
6689                 This usually indicates a system resource issue.",
6690                component.name
6691            );
6692        }
6693    };
6694    let stderr_handle = thread::spawn(move || {
6695        let mut reader = BufReader::new(stderr_stream);
6696        let mut buffer = Vec::new();
6697        let mut chunk = [0u8; 1024];
6698
6699        while let Ok(n) = reader.read(&mut chunk) {
6700            if n == 0 {
6701                break;
6702            }
6703            for &b in chunk.iter().take(n) {
6704                if b == b'\n' || b == b'\r' {
6705                    let is_transient = b == b'\r';
6706                    if !buffer.is_empty() {
6707                        let line = String::from_utf8_lossy(&buffer).to_string();
6708                        let clean_line = strip_ansi_codes(&line);
6709                        if !clean_line.trim().is_empty() {
6710                            let _ =
6711                                sender_stderr.send(InstallerEvent::Log(clean_line, is_transient));
6712                        }
6713                        buffer.clear();
6714                    }
6715                } else {
6716                    buffer.push(b);
6717                    if buffer.len() > 8192 {
6718                        buffer.clear();
6719                    }
6720                }
6721            }
6722        }
6723    });
6724
6725    let status = child.wait().with_context(|| {
6726        format!(
6727            "{} — script process wait failed. \
6728             The process may have been killed or the system is under heavy load.",
6729            component.name
6730        )
6731    })?;
6732    stop_input.store(true, Ordering::Relaxed);
6733    let _ = input_thread.join();
6734    let _ = stdout_handle.join();
6735    let _ = stderr_handle.join();
6736
6737    if !status.success() {
6738        let code = status.code().unwrap_or(-1);
6739        let error_detail = match code {
6740            1 => "General error — check the script output above for details".to_string(),
6741            2 => "Misuse of shell builtins — check script arguments".to_string(),
6742            126 => "Script not executable — check file permissions (chmod +x)".to_string(),
6743            127 => "Command not found in script — ensure required tools are installed".to_string(),
6744            130 => "Script terminated by Ctrl+C (SIGINT)".to_string(),
6745            137 => "Script killed (SIGKILL) — possibly out of memory".to_string(),
6746            139 => "Segmentation fault in script subprocess".to_string(),
6747            n if n > 128 => format!(
6748                "Script process terminated by signal {} — check system logs",
6749                n - 128
6750            ),
6751            _ => format!(
6752                "Script exited with code {} — check the output above for details",
6753                code
6754            ),
6755        };
6756        let _ = sender.send(InstallerEvent::Log(
6757            format!(
6758                "[ERROR] {} failed: {} (exit code {} at {})",
6759                component.name,
6760                error_detail,
6761                code,
6762                Local::now().format("%H:%M:%S")
6763            ),
6764            false,
6765        ));
6766        bail!(
6767            "{} failed: {} (exit code {})",
6768            component.name,
6769            error_detail,
6770            code
6771        );
6772    }
6773
6774    Ok(())
6775}
6776
6777fn star_repo(config: &InstallerConfig, sender: &Sender<InstallerEvent>) -> Result<()> {
6778    if !config.star_repos {
6779        return Ok(());
6780    }
6781
6782    let repo_url = "https://github.com/scooter-lacroix/Stan-s-ML-Stack";
6783
6784    // Check if gh cli is available and authenticated
6785    let gh_check = Command::new("gh")
6786        .arg("auth")
6787        .arg("status")
6788        .stdout(Stdio::null())
6789        .stderr(Stdio::null())
6790        .status();
6791
6792    if let Ok(status) = gh_check {
6793        if status.success() {
6794            let _ = sender.send(InstallerEvent::Log(
6795                "Starring ML Stack repository on GitHub...".into(),
6796                false,
6797            ));
6798            let star_status = Command::new("gh")
6799                .arg("repo")
6800                .arg("star")
6801                .arg(repo_url)
6802                .stdout(Stdio::null())
6803                .stderr(Stdio::null())
6804                .status();
6805
6806            if let Ok(s) = star_status {
6807                if s.success() {
6808                    let _ = sender.send(InstallerEvent::Log(
6809                        "Thank you for starring the repo! ✦".into(),
6810                        false,
6811                    ));
6812                }
6813            }
6814        }
6815    }
6816
6817    Ok(())
6818}
6819
6820fn component_name(comp: &Component) -> String {
6821    comp.name.clone()
6822}
6823
6824/// Format a user-friendly error message with actionable guidance based on
6825/// the component ID and the raw error chain.
6826///
6827/// This function translates cryptic error messages into helpful guidance
6828/// similar to what the original shell scripts provided with `set -e` and
6829/// explicit error handling.
6830///
6831/// Order of checks: component-specific → error-type-specific → generic fallback
6832fn format_user_friendly_error(component_id: &str, raw_error: &str) -> String {
6833    let error_lower = raw_error.to_lowercase();
6834
6835    // ── Component-specific guidance (highest priority) ──────────────
6836
6837    match component_id {
6838        "permanent-env" => {
6839            if error_lower.contains("rocminfo") {
6840                return format!(
6841                    "{}: Failed to detect GPU — rocminfo command failed. \
6842                     Ensure ROCm is properly installed and /opt/rocm/bin is in your PATH. \
6843                     Original error: {}",
6844                    component_id, raw_error
6845                );
6846            }
6847            return format!(
6848                "{}: Environment setup failed. \
6849                 Ensure ROCm is installed and GPU is accessible. \
6850                 Original error: {}",
6851                component_id, raw_error
6852            );
6853        }
6854        "rocm" if error_lower.contains("no such file") || error_lower.contains("not found") => {
6855            return format!(
6856                "{}: ROCm installation failed — required packages or repositories not found. \
6857                 Ensure your system's package manager is configured correctly and ROCm repositories are added. \
6858                 Original error: {}",
6859                component_id, raw_error
6860            );
6861        }
6862        "pytorch" => {
6863            if error_lower.contains("no such file") || error_lower.contains("not found") {
6864                return format!(
6865                    "{}: PyTorch installation failed — required packages not found. \
6866                     Ensure ROCm is installed first and Python 3.10+ is available. \
6867                     Original error: {}",
6868                    component_id, raw_error
6869                );
6870            }
6871            return format!(
6872                "{}: PyTorch installation failed. \
6873                 Ensure ROCm is installed first, Python 3.10-3.13 is available, \
6874                 and pip/uv can reach the PyTorch package index. \
6875                 Original error: {}",
6876                component_id, raw_error
6877            );
6878        }
6879        "triton" | "flash-attn" | "flash-attn-triton" | "flash-attn-ck" | "vllm" | "aiter" => {
6880            return format!(
6881                "{}: Installation failed. \
6882                 Ensure PyTorch and ROCm are properly installed first \
6883                 (these are required dependencies). \
6884                 Original error: {}",
6885                component_id, raw_error
6886            );
6887        }
6888        "megatron" => {
6889            return format!(
6890                "{}: Megatron-LM installation failed. \
6891                 Ensure PyTorch and MPI4Py are installed first (required dependencies). \
6892                 Original error: {}",
6893                component_id, raw_error
6894            );
6895        }
6896        "deepspeed" => {
6897            return format!(
6898                "{}: DeepSpeed installation failed. \
6899                 Ensure PyTorch is installed first (required dependency). \
6900                 Original error: {}",
6901                component_id, raw_error
6902            );
6903        }
6904        _ => {}
6905    }
6906
6907    // ── Error-type-specific guidance ────────────────────────────────
6908
6909    if error_lower.contains("permission denied") || error_lower.contains("sudo") {
6910        return format!(
6911            "{}: Permission denied. Try running with elevated privileges or check file/directory permissions. \
6912             Original error: {}",
6913            component_id, raw_error
6914        );
6915    }
6916
6917    if error_lower.contains("network")
6918        || error_lower.contains("connection")
6919        || error_lower.contains("timeout")
6920        || error_lower.contains("resolve")
6921    {
6922        return format!(
6923            "{}: Network error — failed to download packages or clone repositories. \
6924             Check your internet connection and try again. \
6925             Original error: {}",
6926            component_id, raw_error
6927        );
6928    }
6929
6930    if error_lower.contains("out of memory")
6931        || error_lower.contains("cannot allocate")
6932        || error_lower.contains("killed")
6933    {
6934        return format!(
6935            "{}: Out of memory — the build process was killed due to insufficient memory. \
6936             Try closing other applications or increasing swap space. \
6937             Original error: {}",
6938            component_id, raw_error
6939        );
6940    }
6941
6942    if error_lower.contains("exit code") {
6943        // Already has exit code context from our execute_native_command improvements
6944        return raw_error.to_string();
6945    }
6946
6947    if error_lower.contains("no such file") || error_lower.contains("not found") {
6948        return format!(
6949            "{}: Required file or command not found. \
6950             Ensure all dependencies are installed before this component. \
6951             Original error: {}",
6952            component_id, raw_error
6953        );
6954    }
6955
6956    // ── Generic fallback ────────────────────────────────────────────
6957
6958    format!(
6959        "{}: Installation failed. Check the log output above for details. \
6960         Original error: {}",
6961        component_id, raw_error
6962    )
6963}
6964
6965/// Detect GPU architecture via sysfs (NO rocminfo).
6966///
6967/// Returns `None` if no discrete AMD GPU is detected or gfx arch cannot be resolved.
6968/// Callers MUST handle the Option — NEVER silently default to gfx000.
6969pub(crate) fn detect_gpu_arch() -> Option<String> {
6970    crate::gpu::detect_discrete_amd_gpus()
6971        .first()
6972        .and_then(|g| g.gfx_arch.clone())
6973}
6974
6975// NOTE (v0.3.0): iGPU classification is unified in `crate::gpu`. The former
6976// local `IGPU_PATTERNS` + `is_igpu_name` lived here and diverged from the
6977// copies in `bootstrap/env_setup`, `hardware`, and `platform/linux`. All call
6978// sites now route through `crate::gpu::is_integrated_gpu_name` /
6979// `crate::gpu::is_integrated_by_pci_id` / `crate::gpu::device_is_integrated`.
6980
6981/// Detects the list of discrete AMD GPUs, filtering out integrated GPUs.
6982/// Returns a comma-separated string of GPU indices (e.g., "0,1" for first and second GPUs).
6983///
6984/// This is the SINGLE entry point every consumer should use for the
6985/// `HIP_VISIBLE_DEVICES` / `CUDA_VISIBLE_DEVICES` device mask (Stage 1).
6986/// Detects the list of discrete AMD GPUs, filtering out integrated GPUs.
6987/// Returns a comma-separated string of GPU indices (e.g., "0,1" for first and second GPUs).
6988///
6989/// This is the SINGLE entry point every consumer should use for the
6990/// `HIP_VISIBLE_DEVICES` / `CUDA_VISIBLE_DEVICES` device mask (Stage 1).
6991///
6992/// Returns `"0"` only as the legacy default when NO discrete GPU is detected —
6993/// preserving backward-compatible visibility for env-var defaulting. Consumers
6994/// that must distinguish "no dGPU" from "device 0" should call
6995/// [`detect_discrete_gpus`] instead, which returns an empty `Vec` when none is
6996/// found.
6997pub(crate) fn detect_gpu_list() -> String {
6998    let gpus = detect_discrete_gpus();
6999    if gpus.is_empty() {
7000        "0".to_string()
7001    } else {
7002        gpus.join(",")
7003    }
7004}
7005
7006/// Detect the discrete AMD GPU indices, **without** the legacy `"0"` fallback.
7007///
7008/// Returns an empty `Vec` when no discrete GPU is confirmed by any detector
7009/// (rocminfo → rocm-smi → lspci → sysfs). This is the canonical "how many real
7010/// dGPUs are present" answer — use it for `gpu_count` and for emitting device
7011/// masks only when a dGPU is actually proven (never leak a default `"0"` onto an
7012/// iGPU-only host). The resolution order mirrors [`detect_gpu_list`].
7013pub(crate) fn detect_discrete_gpus() -> Vec<String> {
7014    crate::gpu::detect_discrete_amd_gpus()
7015        .iter()
7016        .enumerate()
7017        .map(|(i, _)| i.to_string())
7018        .collect()
7019}
7020
7021/// Builds a map of ROCm GPU ID -> gfx architecture (e.g., "1" -> "gfx1100").
7022fn detect_gpu_arch_by_id() -> HashMap<String, String> {
7023    let mut mapping: HashMap<String, String> = HashMap::new();
7024
7025    let rocminfo_path = crate::installers::common::utils::resolve_rocminfo_path();
7026    let Ok(output) = Command::new(&rocminfo_path).output() else {
7027        return mapping;
7028    };
7029    let stdout = String::from_utf8_lossy(&output.stdout);
7030    let mut current_gpu_id: Option<String> = None;
7031
7032    for line in stdout.lines() {
7033        let line = line.trim();
7034        if line.contains("GPU ID:") {
7035            if let Some(id_part) = line.split(':').nth(1) {
7036                let id: String = id_part.chars().filter(|c| c.is_ascii_digit()).collect();
7037                if !id.is_empty() {
7038                    current_gpu_id = Some(id);
7039                }
7040            }
7041            continue;
7042        }
7043
7044        if !line.contains("Name:") || !line.contains("gfx") {
7045            continue;
7046        }
7047
7048        if let Some(id) = current_gpu_id.clone() {
7049            for token in line.split_whitespace() {
7050                let cleaned = token.trim_matches(|c: char| !c.is_alphanumeric());
7051                if cleaned.starts_with("gfx") {
7052                    mapping.entry(id).or_insert_with(|| cleaned.to_string());
7053                    break;
7054                }
7055            }
7056        }
7057    }
7058
7059    mapping
7060}
7061
7062/// Reorders a comma-separated GPU list so the first GPU matches the requested arch when possible.
7063/// It preserves all original indices and only changes ordering.
7064fn prioritize_gpu_list_for_arch(gpu_list: &str, target_arch: &str) -> String {
7065    let target_arch = target_arch.trim();
7066    if target_arch.is_empty() {
7067        return gpu_list.to_string();
7068    }
7069
7070    let arch_by_id = detect_gpu_arch_by_id();
7071    if arch_by_id.is_empty() {
7072        return gpu_list.to_string();
7073    }
7074
7075    let mut ids: Vec<String> = gpu_list
7076        .split(',')
7077        .map(|v| v.trim().to_string())
7078        .filter(|v| !v.is_empty())
7079        .collect();
7080    if ids.len() < 2 {
7081        return gpu_list.to_string();
7082    }
7083
7084    let preferred_pos = ids.iter().position(|id| {
7085        arch_by_id
7086            .get(id)
7087            .map(|v| v == target_arch)
7088            .unwrap_or(false)
7089    });
7090    if let Some(pos) = preferred_pos {
7091        if pos > 0 {
7092            let preferred = ids.remove(pos);
7093            ids.insert(0, preferred);
7094            return ids.join(",");
7095        }
7096    }
7097
7098    gpu_list.to_string()
7099}
7100
7101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7102enum VerificationResult {
7103    Verified,
7104    Failed,
7105    Missing,
7106}
7107
7108impl VerificationResult {
7109    fn label(&self) -> &'static str {
7110        match self {
7111            VerificationResult::Verified => "Verified",
7112            VerificationResult::Failed => "Failed",
7113            VerificationResult::Missing => "Missing",
7114        }
7115    }
7116}
7117
7118fn resolve_python_bin() -> String {
7119    if let Ok(value) = std::env::var("MLSTACK_PYTHON_BIN") {
7120        let value = value.trim();
7121        if !value.is_empty() {
7122            return value.to_string();
7123        }
7124    }
7125
7126    if let Ok(value) = std::env::var("UV_PYTHON") {
7127        let value = value.trim();
7128        if !value.is_empty() {
7129            return value.to_string();
7130        }
7131    }
7132
7133    // Tenet 1: prefer the managed global env (~/.mlstack/global) — the SINGLE
7134    // default env — over discovered interpreters, so the bare `rusty` TUI
7135    // anchors to it once it has been created (by run_installation / `rusty
7136    // install`). Explicit MLSTACK_PYTHON_BIN/UV_PYTHON above still win.
7137    let global_py = crate::platform::environment::mlstack_global_python();
7138    if global_py.exists() {
7139        return global_py.to_string_lossy().to_string();
7140    }
7141
7142    let home = std::env::var("HOME").unwrap_or_default();
7143
7144    // uv-managed Pythons — PREFERRED over system Pythons for ML workloads.
7145    // uv installs to ~/.local/share/uv/python/cpython-3.12.*/bin/python3
7146    // and symlinks to ~/.local/bin/python3
7147    // Priority order: 3.12 > 3.13 > 3.11 > 3.10 > everything else (3.14+ is fallback)
7148    let uv_python_dir = PathBuf::from(&home).join(".local/share/uv/python");
7149    if let Ok(entries) = std::fs::read_dir(&uv_python_dir) {
7150        let mut uv_pythons: Vec<PathBuf> = entries
7151            .filter_map(|e| e.ok())
7152            .filter_map(|e| {
7153                let bin = e.path().join("bin/python3");
7154                if bin.exists() {
7155                    Some(bin)
7156                } else {
7157                    None
7158                }
7159            })
7160            .collect();
7161        // Sort: 3.12 first, then 3.13, then 3.11, then 3.10, then others
7162        uv_pythons.sort_by(|a, b| {
7163            let va = python_version_tuple(a);
7164            let vb = python_version_tuple(b);
7165            python_ml_priority(&va).cmp(&python_ml_priority(&vb))
7166        });
7167        // First try: find one with ROCm torch
7168        for python in &uv_pythons {
7169            if python_has_rocm_torch(&python.to_string_lossy()) {
7170                return python.to_string_lossy().to_string();
7171            }
7172        }
7173        // Fallback: use first uv python (any will do)
7174        if let Some(first) = uv_pythons.first() {
7175            return first.to_string_lossy().to_string();
7176        }
7177    }
7178
7179    // ~/.local/bin/python3 — the uv symlink
7180    let local_bin_python = format!("{home}/.local/bin/python3");
7181    if Path::new(&local_bin_python).exists() {
7182        if python_has_rocm_torch(&local_bin_python) {
7183            return local_bin_python;
7184        }
7185        return local_bin_python;
7186    }
7187
7188    // Legacy candidates (component venvs, system pythons)
7189    let candidates = [
7190        Path::new(&home)
7191            .join("rocm_venv")
7192            .join("bin")
7193            .join("python"),
7194        PathBuf::from("/usr/local/bin/python3"),
7195        PathBuf::from("/usr/bin/python3"),
7196        PathBuf::from("python3"),
7197        PathBuf::from("python"),
7198    ];
7199
7200    for candidate in &candidates {
7201        let candidate_str = candidate.to_string_lossy();
7202        if candidate_str.is_empty() {
7203            continue;
7204        }
7205        if candidate.is_absolute() && !candidate.exists() {
7206            continue;
7207        }
7208        if python_has_rocm_torch(&candidate_str) {
7209            return candidate_str.to_string();
7210        }
7211    }
7212
7213    for candidate in &candidates {
7214        let candidate_str = candidate.to_string_lossy();
7215        if candidate.is_absolute() && !candidate.exists() {
7216            continue;
7217        }
7218        return candidate_str.to_string();
7219    }
7220
7221    "python3".to_string()
7222}
7223
7224/// Extract (major, minor) from a python binary path.
7225fn python_version_tuple(path: &Path) -> (u32, u32) {
7226    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
7227    // Walk up to parent dir name like "cpython-3.12.12-linux-x86_64-gnu"
7228    let parent = path
7229        .parent()
7230        .and_then(|p| p.parent())
7231        .and_then(|p| p.file_name())
7232        .and_then(|n| n.to_str())
7233        .unwrap_or("");
7234    // Try parent dir first (more reliable for uv installs)
7235    if let Some(v) = parse_cpython_version(parent) {
7236        return v;
7237    }
7238    // Fallback to binary name
7239    if let Some(rest) = name.strip_prefix("python") {
7240        let parts: Vec<&str> = rest.split('.').collect();
7241        if parts.len() >= 2 {
7242            if let (Ok(major), Ok(minor)) = (parts[0].parse::<u32>(), parts[1].parse::<u32>()) {
7243                return (major, minor);
7244            }
7245        }
7246    }
7247    (0, 0)
7248}
7249
7250/// Parse version from uv cpython dir name like "cpython-3.12.12-linux-x86_64-gnu".
7251fn parse_cpython_version(dir_name: &str) -> Option<(u32, u32)> {
7252    let rest = dir_name.strip_prefix("cpython-")?;
7253    let parts: Vec<&str> = rest.split('.').collect();
7254    if parts.len() >= 2 {
7255        let major = parts[0].parse::<u32>().ok()?;
7256        let minor = parts[1].parse::<u32>().ok()?;
7257        return Some((major, minor));
7258    }
7259    None
7260}
7261
7262/// Priority ordering for ML workloads: 3.12 > 3.13 > 3.11 > 3.10 > everything else.
7263fn python_ml_priority(version: &(u32, u32)) -> (i32, u32) {
7264    match version {
7265        (3, 12) => (0, version.1), // highest — ML stable target
7266        (3, 13) => (1, version.1),
7267        (3, 11) => (2, version.1),
7268        (3, 10) => (3, version.1),
7269        _ => (4, version.1), // 3.14+ is lowest priority
7270    }
7271}
7272
7273fn python_has_rocm_torch(python: &str) -> bool {
7274    Command::new(python)
7275        .arg("-c")
7276        .arg(
7277            "import torch, sys; ok=hasattr(torch,'__version__') and hasattr(torch,'version') and getattr(torch.version,'hip', None); sys.exit(0 if ok else 1)",
7278        )
7279        .stdout(Stdio::null())
7280        .stderr(Stdio::null())
7281        .status()
7282        .map(|status| status.success())
7283        .unwrap_or(false)
7284}
7285
7286fn default_component_input(component_id: &str) -> Option<&'static str> {
7287    match component_id {
7288        "ml-stack-core" => Some("9\n0\n"),
7289        _ => None,
7290    }
7291}
7292
7293fn parse_progress(line: &str) -> Option<f32> {
7294    let lower = line.to_lowercase();
7295    if let Some(percent_idx) = lower.find('%') {
7296        let prefix = &lower[..percent_idx];
7297        let digits: String = prefix
7298            .chars()
7299            .rev()
7300            .take_while(|c| c.is_ascii_digit())
7301            .collect();
7302        if !digits.is_empty() {
7303            let value: String = digits.chars().rev().collect();
7304            if let Ok(percent) = value.parse::<f32>() {
7305                return Some((percent / 100.0).min(1.0));
7306            }
7307        }
7308    }
7309    if lower.contains("progress") && lower.contains("100") {
7310        return Some(1.0);
7311    }
7312    // ninja/make build progress: "[N/M] action" -> N/M. Surfaced to the bar so
7313    // long native compiles (e.g. Flash Attention CK's ~2800 translation units)
7314    // show real per-TU progress instead of a frozen wheel-build spinner.
7315    if let Some(bracket) = line.find('[') {
7316        if let Some(close) = line[bracket..].find(']') {
7317            let inside = &line[bracket + 1..bracket + close];
7318            if let Some(slash) = inside.find('/') {
7319                let (n_str, m_str) = inside.split_at(slash);
7320                if let (Ok(n), Ok(m)) = (
7321                    n_str.trim().parse::<f32>(),
7322                    m_str[1..].trim().parse::<f32>(),
7323                ) {
7324                    if m > 0.0 && n <= m {
7325                        return Some((n / m).min(1.0));
7326                    }
7327                }
7328            }
7329        }
7330    }
7331    None
7332}
7333
7334fn needs_sudo() -> bool {
7335    #[cfg(unix)]
7336    {
7337        unsafe { libc::geteuid() != 0 }
7338    }
7339    #[cfg(not(unix))]
7340    {
7341        false
7342    }
7343}
7344
7345fn strip_ansi_codes(s: &str) -> String {
7346    let mut clean = String::with_capacity(s.len());
7347    let mut in_escape = false;
7348    for c in s.chars() {
7349        if c == '\x1b' {
7350            in_escape = true;
7351        } else if in_escape {
7352            if ('@'..='~').contains(&c) || c == 'm' {
7353                in_escape = false;
7354            }
7355        } else {
7356            clean.push(c);
7357        }
7358    }
7359    clean
7360}
7361
7362fn sanitize_output_line(line: &str) -> String {
7363    let stripped = strip_ansi_codes(line);
7364    let mut clean = String::with_capacity(stripped.len());
7365    for c in stripped.chars() {
7366        if c == '\t' {
7367            clean.push(' ');
7368        } else if !c.is_control() {
7369            clean.push(c);
7370        }
7371    }
7372    clean
7373}
7374
7375fn should_suppress_verification_line(line: &str, target_id: &str) -> bool {
7376    let trimmed = line.trim();
7377    if trimmed.is_empty() {
7378        return true;
7379    }
7380
7381    let lower = trimmed.to_lowercase();
7382    if is_exception_or_traceback_line(&lower) {
7383        return false;
7384    }
7385
7386    if lower.contains("amdgpu.ids")
7387        || lower.contains("tool-lib")
7388        || lower.contains("cannot load tool")
7389        || lower.contains("libdrm/amdgpu.ids: no such file or directory")
7390    {
7391        return true;
7392    }
7393
7394    if (target_id == "rocm" || target_id == "rocm-smi")
7395        && (lower.starts_with("======")
7396            || lower.starts_with("====")
7397            || lower.starts_with("---")
7398            || lower.contains("the info is not accurate")
7399            || lower.contains("================================")
7400            || lower.starts_with('{')
7401            || lower.starts_with('[')
7402            || lower.starts_with(']')
7403            || lower.starts_with('}')
7404            || lower.starts_with("\"card")
7405            || lower.starts_with("device ")
7406            || lower.starts_with("*****"))
7407    {
7408        return true;
7409    }
7410
7411    false
7412}
7413
7414fn is_exception_or_traceback_line(lowercase_line: &str) -> bool {
7415    lowercase_line.contains("traceback")
7416        || lowercase_line.contains("exception")
7417        || lowercase_line.contains("segmentation fault")
7418        || lowercase_line.contains("fatal:")
7419        || lowercase_line.contains("error:")
7420        || lowercase_line.starts_with("error ")
7421}
7422
7423/// Run a benchmark component natively via the `benchmark_runners` module.
7424///
7425/// Maps component IDs to benchmark names and dispatches through the
7426/// native Rust benchmark runner instead of spawning bash scripts.
7427fn run_native_benchmark(component_id: &str, sender: &Sender<InstallerEvent>) -> Result<()> {
7428    use crate::benchmark_runners;
7429
7430    let bench_name = match component_id {
7431        "mlperf-inference" => "all",
7432        "rocm-benchmarks" => "gpu-capability",
7433        "gpu-memory-bandwidth" => "memory-bandwidth",
7434        "rocm-smi-bench" => "gpu-capability",
7435        "pytorch-performance" => "pytorch",
7436        "vllm-performance" => "vllm",
7437        "deepspeed-performance" => "deepspeed",
7438        "megatron-performance" => "megatron",
7439        "onnx-performance" => "onnx",
7440        "rusty-llama-performance" => "llama-cpp",
7441        "flash-attention-ck-performance" => "flash-attention-ck",
7442        "all-benchmarks" => "all",
7443        _ => bail!("Unknown benchmark component: {}", component_id),
7444    };
7445
7446    let _ = sender.send(InstallerEvent::Log(
7447        format!("[native] Running {} benchmark...", component_id),
7448        false,
7449    ));
7450
7451    let _ = sender.send(InstallerEvent::Progress {
7452        component_id: component_id.to_string(),
7453        progress: 0.3,
7454        message: format!("Running {} benchmark", component_id),
7455    });
7456
7457    match benchmark_runners::run_benchmark(bench_name) {
7458        Ok(output) => {
7459            let _ = sender.send(InstallerEvent::Log(
7460                format!(
7461                    "[native] {} benchmark completed: {} ({} ms)",
7462                    component_id,
7463                    if output.success { "SUCCESS" } else { "FAILED" },
7464                    output.execution_time_ms
7465                ),
7466                false,
7467            ));
7468            // Persist the result as JSON to the benchmark log dir so the
7469            // Benchmarks page + HTML export pick it up (otherwise the summary
7470            // shows "No Data available"). Reshapes metrics into the top-level
7471            // "results" object that parse_benchmark_json expects.
7472            persist_benchmark_result(bench_name, &output);
7473            if !output.errors.is_empty() {
7474                for err in &output.errors {
7475                    let _ = sender.send(InstallerEvent::Log(
7476                        format!("[native] {} error: {}", component_id, err),
7477                        false,
7478                    ));
7479                }
7480            }
7481            if !output.success {
7482                let error_details = output.errors.join("; ");
7483                bail!(
7484                    "{} benchmark failed: {}",
7485                    component_id,
7486                    if error_details.is_empty() {
7487                        "unknown error".to_string()
7488                    } else {
7489                        error_details
7490                    }
7491                );
7492            }
7493            Ok(())
7494        }
7495        Err(err) => {
7496            let _ = sender.send(InstallerEvent::Log(
7497                format!("[native] {} benchmark error: {}", component_id, err),
7498                false,
7499            ));
7500            bail!("{} benchmark error: {}", component_id, err)
7501        }
7502    }
7503}
7504
7505/// Persist a benchmark result as JSON to the benchmark log dir so the Benchmarks
7506/// page (`load_benchmark_results`) and HTML export pick it up.
7507///
7508/// `convert_result` nests the metrics under `results.metrics`, but
7509/// `parse_benchmark_json` looks for the `prefill_*_tps_gpu*` / `decode_*_tps_gpu*`
7510/// keys at the `results` level (it does not recurse into `metrics`), so the
7511/// payload is reshaped to `{"results": <metrics>, ...}`.
7512///
7513/// Tries each scanned log dir in turn and writes to the first that succeeds —
7514/// `~/.rusty-stack/logs` can be root-owned (from a prior sudo run), so we fall
7515/// back through `$TMPDIR/rusty-stack/logs` (also scanned) rather than silently
7516/// dropping the result.
7517fn persist_onnx_install_status(version: &str, providers: &[&str]) {
7518    let provider = providers
7519        .iter()
7520        .find(|p| matches!(**p, "MIGraphXExecutionProvider" | "ROCMExecutionProvider"))
7521        .copied()
7522        .unwrap_or("none");
7523    persist_benchmark_result(
7524        "onnx",
7525        &crate::benchmark_runners::BenchmarkOutput {
7526            name: "onnx".to_string(),
7527            success: true,
7528            execution_time_ms: 0,
7529            results: serde_json::json!({
7530                "ort_version": version,
7531                "provider": provider,
7532                "providers_available": providers,
7533                "provider_priority": ["MIGraphXExecutionProvider", "ROCMExecutionProvider"],
7534                "install_status": "provider_ready",
7535                "benchmark_status": "not_run",
7536                "graph_opt_level": "ORT_ENABLE_ALL",
7537                "session_create_ms": 0.0,
7538                "inference_latency_p50_ms": 0.0,
7539                "inference_latency_p95_ms": 0.0,
7540                "inference_latency_p99_ms": 0.0,
7541                "throughput_inf_per_sec": 0.0,
7542                "peak_rss_mb": 0.0,
7543                "quantized_supported": false,
7544                "inference_samples": []
7545            }),
7546            errors: Vec::new(),
7547        },
7548    );
7549}
7550
7551fn persist_benchmark_result(bench_name: &str, output: &crate::benchmark_runners::BenchmarkOutput) {
7552    let metrics = output
7553        .results
7554        .get("metrics")
7555        .cloned()
7556        .unwrap_or_else(|| output.results.clone());
7557    let payload = serde_json::json!({
7558        "name": output.name.clone(),
7559        "success": output.success,
7560        "execution_time_ms": output.execution_time_ms as u64,
7561        "results": metrics,
7562        "errors": serde_json::to_value(&output.errors).unwrap_or_default(),
7563    });
7564    let body = match serde_json::to_string_pretty(&payload) {
7565        Ok(s) => s,
7566        Err(_) => return,
7567    };
7568    let ts = std::time::SystemTime::now()
7569        .duration_since(std::time::UNIX_EPOCH)
7570        .map(|d| d.as_secs())
7571        .unwrap_or(0);
7572    let fname = format!("{bench_name}_benchmarks_{ts}.json");
7573
7574    let mut candidates: Vec<std::path::PathBuf> =
7575        crate::benchmark_logs::benchmark_log_directories();
7576    if let Some(home) = std::env::var("HOME").ok().filter(|s| !s.is_empty()) {
7577        candidates.push(
7578            std::path::Path::new(&home)
7579                .join(".rusty-stack")
7580                .join("logs"),
7581        );
7582    }
7583    let tmp = std::env::var("TMPDIR").unwrap_or_else(|_| "/tmp".to_string());
7584    candidates.push(std::path::Path::new(&tmp).join("rusty-stack").join("logs"));
7585
7586    for dir in candidates {
7587        let _ = std::fs::create_dir_all(&dir);
7588        if std::fs::write(dir.join(&fname), &body).is_ok() {
7589            return;
7590        }
7591    }
7592}
7593
7594#[cfg(test)]
7595mod tests {
7596    #[test]
7597    fn pin_pip_requirement_pins_target_and_drops_upgrade() {
7598        let a: Vec<String> = ["-m", "pip", "install", "--upgrade", "deepspeed", "einops"]
7599            .iter().map(|s| s.to_string()).collect();
7600        let out = super::pin_pip_requirement(a.clone(), "deepspeed", Some(" 0.19.7 "));
7601        assert_eq!(out, ["-m", "pip", "install", "deepspeed==0.19.7", "einops"]);
7602        assert_eq!(super::pin_pip_requirement(a.clone(), "deepspeed", None), a);
7603        assert_eq!(super::pin_pip_requirement(a.clone(), "deepspeed", Some("")), a);
7604    }
7605
7606    #[test]
7607    fn pin_pip_requirement_overrides_existing_specifier() {
7608        // A planned pin replaces any specifier already on the requirement
7609        // token instead of coexisting with it (e.g. `name>=old name==new`).
7610        let a: Vec<String> = ["-m", "pip", "install", "deepspeed>=0.18", "einops"]
7611            .iter().map(|s| s.to_string()).collect();
7612        let out = super::pin_pip_requirement(a, "deepspeed", Some("0.19.7"));
7613        assert_eq!(out, ["-m", "pip", "install", "deepspeed==0.19.7", "einops"]);
7614        // Extras and sibling packages sharing the prefix are left alone.
7615        let b: Vec<String> = ["-m", "pip", "install", "deepspeed[extra]==0.18", "deepspeed-tools"]
7616            .iter().map(|s| s.to_string()).collect();
7617        let out = super::pin_pip_requirement(b, "deepspeed", Some("0.19.7"));
7618        assert_eq!(out, ["-m", "pip", "install", "deepspeed==0.19.7", "deepspeed-tools"]);
7619        // Opaque targets are not versions: the unversioned upgrade command
7620        // stays intact instead of becoming an invalid `name==latest` pin.
7621        let c: Vec<String> = ["-m", "pip", "install", "--upgrade", "deepspeed"]
7622            .iter().map(|s| s.to_string()).collect();
7623        assert_eq!(super::pin_pip_requirement(c.clone(), "deepspeed", Some("latest")), c);
7624        assert_eq!(super::pin_pip_requirement(c.clone(), "deepspeed", Some("installed")), c);
7625        assert_eq!(super::pin_pip_requirement(c.clone(), "deepspeed", Some(" Latest ")), c);
7626    }
7627
7628    #[test]
7629    fn explicit_update_target_predicate_and_gate_stay_paired() {
7630        let _guard = crate::test_support::lock_env();
7631        std::env::remove_var("MLSTACK_UNSEAL_CORE");
7632        // Only a concrete, confirmed version target counts as explicit.
7633        assert!(!super::is_explicit_update_target(None));
7634        assert!(!super::is_explicit_update_target(Some("")));
7635        assert!(!super::is_explicit_update_target(Some("   ")));
7636        assert!(!super::is_explicit_update_target(Some("latest")));
7637        assert!(!super::is_explicit_update_target(Some("Installed")));
7638        assert!(super::is_explicit_update_target(Some("7.14.1")));
7639        assert!(super::is_explicit_update_target(Some(" 0.19.7 ")));
7640        // With no explicit target (and no user-set unseal), the sealed-core
7641        // gate stays shut — plain installs must keep the reuse semantics.
7642        assert!(!super::unseal_core_requested());
7643    }
7644
7645    #[test]
7646    fn update_rocm_channel_maps_planned_target_and_matches_planner_precedence() {
7647        use crate::installers::components::rocm::RocmChannel;
7648        let _guard = crate::test_support::lock_env();
7649        // Concrete planned target → its own channel, regardless of env vars.
7650        std::env::set_var("ROCM_CHANNEL", "bogus");
7651        assert_eq!(super::update_rocm_channel(Some("7.14.1")), RocmChannel::Latest);
7652        assert_eq!(super::update_rocm_channel(Some("7.2.4")), RocmChannel::Legacy);
7653        // Without a concrete target the planner's shared resolver applies —
7654        // including the invalid-value-is-authoritative precedence (Latest,
7655        // never the env file's channel).
7656        assert_eq!(super::update_rocm_channel(None), RocmChannel::Latest);
7657        assert_eq!(super::update_rocm_channel(Some("latest")), RocmChannel::Latest);
7658        std::env::remove_var("ROCM_CHANNEL");
7659    }
7660
7661    #[test]
7662    fn scoped_env_override_restores_previous_values_on_drop() {
7663        let _guard = crate::test_support::lock_env();
7664        std::env::set_var("MLSTACK_FORCE_REINSTALL", "user-set");
7665        std::env::remove_var("MLSTACK_UNSEAL_CORE");
7666        {
7667            let _scope = super::ScopedEnvOverride::setting(&[
7668                ("MLSTACK_FORCE_REINSTALL", "1"),
7669                ("MLSTACK_UNSEAL_CORE", "1"),
7670            ]);
7671            assert_eq!(std::env::var("MLSTACK_FORCE_REINSTALL").as_deref(), Ok("1"));
7672            assert_eq!(std::env::var("MLSTACK_UNSEAL_CORE").as_deref(), Ok("1"));
7673        }
7674        // Drop restored the user-set value and removed the previously-absent
7675        // var — a sequential apply cannot inherit this invocation's consent.
7676        assert_eq!(
7677            std::env::var("MLSTACK_FORCE_REINSTALL").as_deref(),
7678            Ok("user-set")
7679        );
7680        assert!(std::env::var("MLSTACK_UNSEAL_CORE").is_err());
7681        std::env::remove_var("MLSTACK_FORCE_REINSTALL");
7682    }
7683
7684    use super::*;
7685    use std::sync::Mutex;
7686
7687    static ENV_LOCK: Mutex<()> = Mutex::new(());
7688
7689    // ── safe_rocm_path: real ROCm CLI tools must win over pip shims ────
7690    // Regression: rocm-sdk-core's rocminfo shim in ~/.mlstack/global/bin exits
7691    // 8 (HSA out of resources) but precedes /opt/rocm/bin on PATH, breaking
7692    // aiter's chip_info. safe_rocm_path must put /opt/rocm/bin first.
7693
7694    #[test]
7695    fn test_safe_rocm_path_prepends_rocm_bin() {
7696        // safe_rocm_path returns the inherited PATH unchanged when
7697        // /opt/rocm/bin is absent, so these prepend/dedup assertions only hold
7698        // on a host with ROCm installed. Guard like the pass-through test does
7699        // to avoid panicking on standard CI runners.
7700        if !Path::new("/opt/rocm/bin").exists() {
7701            eprintln!("skip: /opt/rocm/bin absent (CI host) — prepend behavior is pass-through");
7702            return;
7703        }
7704        let p = safe_rocm_path("/home/scooter/.mlstack/global/bin:/usr/bin:/bin");
7705        let rocm = p.find("/opt/rocm/bin").unwrap();
7706        let shim = p.find("/home/scooter/.mlstack/global/bin").unwrap();
7707        assert!(
7708            rocm < shim,
7709            "/opt/rocm/bin must precede the venv bin: got {p}"
7710        );
7711        // hip/bin too, and all original entries preserved.
7712        assert!(p.contains("/opt/rocm/hip/bin"));
7713        assert!(p.contains("/usr/bin") && p.contains("/bin"));
7714    }
7715
7716    #[test]
7717    fn test_safe_rocm_path_dedupes_rocm_entries() {
7718        // Dedup of literal /opt/rocm/bin substrings holds regardless of whether
7719        // ROCm is installed (the input carries the literals), but guard anyway
7720        // for consistency with the other two safe_rocm_path tests.
7721        if !Path::new("/opt/rocm/bin").exists() {
7722            eprintln!("skip: /opt/rocm/bin absent (CI host)");
7723            return;
7724        }
7725        let p = safe_rocm_path("/opt/rocm/bin:/usr/bin:/opt/rocm/hip/bin");
7726        assert_eq!(
7727            p.matches("/opt/rocm/bin").count(),
7728            1,
7729            "rocm/bin must appear exactly once: got {p}"
7730        );
7731        assert_eq!(p.matches("/opt/rocm/hip/bin").count(), 1);
7732    }
7733
7734    #[test]
7735    fn test_safe_rocm_path_passes_through_when_rocm_absent() {
7736        // When /opt/rocm/bin doesn't exist the function must not prepend a
7737        // non-existent dir (it would shadow nothing and mislead). It returns
7738        // the inherited PATH unchanged. We can't remove /opt/rocm in a test,
7739        // so assert the pure property: if rocm/bin IS present it's first,
7740        // otherwise identity. Guarded by existence on the host.
7741        let inherited = "/usr/local/bin:/usr/bin";
7742        if Path::new("/opt/rocm/bin").exists() {
7743            assert!(safe_rocm_path(inherited).starts_with("/opt/rocm/bin"));
7744        } else {
7745            assert_eq!(safe_rocm_path(inherited), inherited);
7746        }
7747    }
7748
7749    // ── iGPU filtering: parse_rocminfo_for_discrete_gpus (tenet 3) ─────
7750    // Target topology: 2 dGPU (gfx1100 RX 7900 XTX, gfx1101 RX 7800 XT) +
7751    // 1 iGPU (gfx1036 Raphael). The iGPU must NEVER appear; dGPUs never missed.
7752
7753    // -------------------------------------------------------------------
7754    // Error handling tests (fix-installation-silent-failures)
7755    // -------------------------------------------------------------------
7756
7757    #[test]
7758    fn test_format_user_friendly_error_permission_denied() {
7759        let msg = format_user_friendly_error("rocm", "Permission denied: /opt/rocm");
7760        assert!(
7761            msg.contains("Permission denied"),
7762            "Should mention permission issue"
7763        );
7764        assert!(
7765            msg.contains("elevated privileges"),
7766            "Should suggest running with sudo"
7767        );
7768    }
7769
7770    #[test]
7771    fn test_native_maintenance_actions_use_native_verdict() {
7772        let repair = Component {
7773            id: "rccl-repair".into(),
7774            name: "Repair RCCL Multi-GPU".into(),
7775            description: String::new(),
7776            script: String::new(),
7777            category: Category::Maintenance,
7778            required: false,
7779            selected: true,
7780            installed: false,
7781            progress: 0.0,
7782            estimate: String::new(),
7783            needs_sudo: false,
7784            experimental: false,
7785            note: None,
7786        };
7787        let verify = Component {
7788            id: "verify-basic".into(),
7789            name: "Verify Installation".into(),
7790            description: String::new(),
7791            script: String::new(),
7792            category: Category::Maintenance,
7793            required: false,
7794            selected: true,
7795            installed: false,
7796            progress: 0.0,
7797            estimate: String::new(),
7798            needs_sudo: false,
7799            experimental: false,
7800            note: None,
7801        };
7802        let normal = Component {
7803            id: "pytorch".into(),
7804            name: "PyTorch with ROCm".into(),
7805            description: String::new(),
7806            script: String::new(),
7807            category: Category::Foundation,
7808            required: true,
7809            selected: true,
7810            installed: false,
7811            progress: 0.0,
7812            estimate: String::new(),
7813            needs_sudo: false,
7814            experimental: false,
7815            note: None,
7816        };
7817
7818        assert!(is_native_maintenance_action(&repair));
7819        assert!(!is_native_maintenance_action(&verify));
7820        assert!(!is_native_maintenance_action(&normal));
7821
7822        let success = native_maintenance_action_outcome(&repair, true);
7823        assert!(success.success);
7824        assert!(success.report_lines[0].contains("reported success"));
7825
7826        let failure = native_maintenance_action_outcome(&repair, false);
7827        assert!(!failure.success);
7828        assert!(failure.report_lines[0].contains("reported failure"));
7829    }
7830
7831    #[test]
7832    fn test_benchmark_patterns_match_native_output_filenames() {
7833        assert_eq!(
7834            benchmark_pattern_for_target("rocm-benchmarks"),
7835            Some("gpu-capability_benchmarks")
7836        );
7837        assert_eq!(
7838            benchmark_pattern_for_target("gpu-memory-bandwidth"),
7839            Some("memory-bandwidth_benchmarks")
7840        );
7841        assert_eq!(
7842            benchmark_pattern_for_target("all-benchmarks"),
7843            Some("all_benchmarks")
7844        );
7845        assert_eq!(
7846            benchmark_pattern_for_target("onnx-performance"),
7847            Some("onnx_benchmarks")
7848        );
7849    }
7850
7851    #[test]
7852    fn test_single_benchmark_results_render_metrics_and_errors() {
7853        let lines = benchmark_result_lines_from_json(&serde_json::json!({
7854            "name": "vllm",
7855            "success": false,
7856            "execution_time_ms": 248842,
7857            "results": {
7858                "mode": "degraded",
7859                "reason": "model timed out",
7860                "throughput_tokens_per_sec": 0.0,
7861                "visible_devices": "0,1"
7862            },
7863            "errors": ["model timed out after 240 seconds"]
7864        }));
7865        let joined = lines.join("\n");
7866
7867        assert!(joined.contains("MODE: degraded"));
7868        assert!(joined.contains("REASON: model timed out"));
7869        assert!(joined.contains("THROUGHPUT TOKENS PER SEC: 0"));
7870        assert!(joined.contains("ERROR: model timed out after 240 seconds"));
7871    }
7872
7873    #[test]
7874    fn test_persist_onnx_install_status_writes_clean_status_artifact() {
7875        let _global_env = crate::test_support::lock_env();
7876        let _guard = ENV_LOCK.lock().unwrap();
7877        let old_home = std::env::var("HOME").ok();
7878        let old_tmpdir = std::env::var("TMPDIR").ok();
7879        let old_log_dir = std::env::var("MLSTACK_LOG_DIR").ok();
7880        let root = std::env::temp_dir().join(format!(
7881            "rusty-stack-persist-onnx-status-{}",
7882            std::process::id()
7883        ));
7884        let _ = std::fs::remove_dir_all(&root);
7885        let logs = root.join("logs");
7886        let tmp = root.join("tmp");
7887        std::fs::create_dir_all(&logs).unwrap();
7888        std::fs::create_dir_all(&tmp).unwrap();
7889        std::env::set_var("HOME", &root);
7890        std::env::set_var("TMPDIR", &tmp);
7891        std::env::set_var("MLSTACK_LOG_DIR", &logs);
7892
7893        persist_onnx_install_status(
7894            "1.27.1",
7895            &["MIGraphXExecutionProvider", "CPUExecutionProvider"],
7896        );
7897
7898        let written = std::fs::read_dir(&logs)
7899            .unwrap()
7900            .find_map(|entry| {
7901                let path = entry.ok()?.path();
7902                (path
7903                    .file_name()?
7904                    .to_string_lossy()
7905                    .starts_with("onnx_benchmarks_"))
7906                .then_some(path)
7907            })
7908            .expect("onnx status log should be written");
7909        let json: serde_json::Value =
7910            serde_json::from_str(&std::fs::read_to_string(written).unwrap()).unwrap();
7911
7912        if let Some(home) = old_home {
7913            std::env::set_var("HOME", home);
7914        } else {
7915            std::env::remove_var("HOME");
7916        }
7917        if let Some(tmpdir) = old_tmpdir {
7918            std::env::set_var("TMPDIR", tmpdir);
7919        } else {
7920            std::env::remove_var("TMPDIR");
7921        }
7922        if let Some(log_dir) = old_log_dir {
7923            std::env::set_var("MLSTACK_LOG_DIR", log_dir);
7924        } else {
7925            std::env::remove_var("MLSTACK_LOG_DIR");
7926        }
7927        let _ = std::fs::remove_dir_all(&root);
7928
7929        assert_eq!(json["success"], true);
7930        assert_eq!(json["errors"].as_array().unwrap().len(), 0);
7931        assert_eq!(json["results"]["provider"], "MIGraphXExecutionProvider");
7932        assert_eq!(json["results"]["benchmark_status"], "not_run");
7933    }
7934
7935    #[test]
7936    fn test_persist_full_suite_keeps_nested_results() {
7937        let _global_env = crate::test_support::lock_env();
7938        let _guard = ENV_LOCK.lock().unwrap();
7939        let old_home = std::env::var("HOME").ok();
7940        let old_tmpdir = std::env::var("TMPDIR").ok();
7941        let old_log_dir = std::env::var("MLSTACK_LOG_DIR").ok();
7942        let root = std::env::temp_dir().join(format!(
7943            "rusty-stack-persist-full-suite-{}",
7944            std::process::id()
7945        ));
7946        let _ = std::fs::remove_dir_all(&root);
7947        let logs = root.join("logs");
7948        let tmp = root.join("tmp");
7949        std::fs::create_dir_all(&logs).unwrap();
7950        std::fs::create_dir_all(&tmp).unwrap();
7951        std::env::set_var("HOME", &root);
7952        std::env::set_var("TMPDIR", &tmp);
7953        std::env::set_var("MLSTACK_LOG_DIR", &logs);
7954
7955        let output = crate::benchmark_runners::BenchmarkOutput {
7956            name: "all".into(),
7957            success: true,
7958            execution_time_ms: 1,
7959            results: serde_json::json!({
7960                "memory_bandwidth": {
7961                    "success": true,
7962                    "execution_time_ms": 1,
7963                    "metrics": {"hbm_peak_gb_s": 123.0}
7964                }
7965            }),
7966            errors: Vec::new(),
7967        };
7968        persist_benchmark_result("all", &output);
7969
7970        let written = std::fs::read_dir(&logs)
7971            .unwrap()
7972            .find_map(|entry| {
7973                let path = entry.ok()?.path();
7974                (path
7975                    .file_name()?
7976                    .to_string_lossy()
7977                    .starts_with("all_benchmarks_"))
7978                .then_some(path)
7979            })
7980            .expect("all benchmark log should be written");
7981        let json: serde_json::Value =
7982            serde_json::from_str(&std::fs::read_to_string(written).unwrap()).unwrap();
7983
7984        if let Some(home) = old_home {
7985            std::env::set_var("HOME", home);
7986        } else {
7987            std::env::remove_var("HOME");
7988        }
7989        if let Some(tmpdir) = old_tmpdir {
7990            std::env::set_var("TMPDIR", tmpdir);
7991        } else {
7992            std::env::remove_var("TMPDIR");
7993        }
7994        if let Some(log_dir) = old_log_dir {
7995            std::env::set_var("MLSTACK_LOG_DIR", log_dir);
7996        } else {
7997            std::env::remove_var("MLSTACK_LOG_DIR");
7998        }
7999        let _ = std::fs::remove_dir_all(&root);
8000
8001        assert_eq!(
8002            json["results"]["memory_bandwidth"]["metrics"]["hbm_peak_gb_s"],
8003            123.0
8004        );
8005    }
8006
8007    #[test]
8008    fn test_format_user_friendly_error_not_found() {
8009        let msg = format_user_friendly_error("pytorch", "No such file or directory: python3");
8010        assert!(msg.contains("not found"), "Should mention file not found");
8011        assert!(
8012            msg.contains("ROCm") || msg.contains("Python"),
8013            "Should mention ROCm or Python dependency, got: {}",
8014            msg
8015        );
8016    }
8017
8018    #[test]
8019    fn test_format_user_friendly_error_network() {
8020        // Use a generic component to test network error detection
8021        let msg = format_user_friendly_error(
8022            "onnxruntime",
8023            "network error: Could not resolve host: github.com",
8024        );
8025        assert!(
8026            msg.contains("Network error"),
8027            "Should mention network issue, got: {}",
8028            msg
8029        );
8030        assert!(
8031            msg.contains("internet connection"),
8032            "Should suggest checking internet, got: {}",
8033            msg
8034        );
8035    }
8036
8037    #[test]
8038    fn test_format_user_friendly_error_out_of_memory() {
8039        // Use a generic component to test OOM error detection
8040        let msg = format_user_friendly_error("onnxruntime", "out of memory during build");
8041        assert!(
8042            msg.contains("Out of memory"),
8043            "Should mention memory issue, got: {}",
8044            msg
8045        );
8046        assert!(
8047            msg.contains("swap space"),
8048            "Should suggest increasing swap, got: {}",
8049            msg
8050        );
8051    }
8052
8053    #[test]
8054    fn test_format_user_friendly_error_permanent_env_rocminfo() {
8055        let msg = format_user_friendly_error("permanent-env", "rocminfo command not found");
8056        assert!(
8057            msg.contains("rocminfo"),
8058            "Should mention rocminfo specifically"
8059        );
8060        assert!(
8061            msg.contains("ROCm is properly installed"),
8062            "Should suggest ROCm installation"
8063        );
8064    }
8065
8066    #[test]
8067    fn test_format_user_friendly_error_pytorch_specific() {
8068        // Use a generic error (not "not found") to hit the general pytorch handler
8069        let msg = format_user_friendly_error("pytorch", "pip install failed: build error");
8070        assert!(msg.contains("PyTorch"), "Should mention PyTorch by name");
8071        assert!(
8072            msg.contains("ROCm is installed first"),
8073            "Should mention ROCm dependency"
8074        );
8075        assert!(
8076            msg.contains("Python 3.10-3.13"),
8077            "Should mention Python version, got: {}",
8078            msg
8079        );
8080    }
8081
8082    #[test]
8083    fn test_format_user_friendly_error_triton_dependency() {
8084        let msg = format_user_friendly_error("triton", "git clone failed");
8085        assert!(
8086            msg.contains("PyTorch and ROCm"),
8087            "Should mention required dependencies"
8088        );
8089    }
8090
8091    #[test]
8092    fn test_format_user_friendly_error_megatron_dependency() {
8093        let msg = format_user_friendly_error("megatron", "pip install failed");
8094        assert!(
8095            msg.contains("PyTorch and MPI4Py"),
8096            "Should mention Megatron's specific dependencies"
8097        );
8098    }
8099
8100    #[test]
8101    fn test_format_user_friendly_error_deepspeed_dependency() {
8102        let msg = format_user_friendly_error("deepspeed", "build failed");
8103        assert!(
8104            msg.contains("PyTorch"),
8105            "Should mention DeepSpeed's dependency on PyTorch"
8106        );
8107    }
8108
8109    #[test]
8110    fn test_format_user_friendly_error_exit_code_passthrough() {
8111        let msg = format_user_friendly_error("rocm", "ROCm failed: General error (exit code 1)");
8112        // Exit code errors should be passed through as-is
8113        assert!(
8114            msg.contains("exit code 1"),
8115            "Should preserve exit code context"
8116        );
8117    }
8118
8119    #[test]
8120    fn test_format_user_friendly_error_unknown_component() {
8121        let msg = format_user_friendly_error("some-unknown-component", "something went wrong");
8122        assert!(
8123            msg.contains("Installation failed"),
8124            "Should have generic failure message"
8125        );
8126        assert!(
8127            msg.contains("something went wrong"),
8128            "Should include original error"
8129        );
8130    }
8131
8132    #[test]
8133    fn test_format_user_friendly_error_rocm_not_found() {
8134        let msg = format_user_friendly_error("rocm", "package not found: amdgpu-dkms");
8135        assert!(msg.contains("ROCm"), "Should mention ROCm specifically");
8136        assert!(msg.contains("repositories"), "Should mention repositories");
8137    }
8138
8139    #[test]
8140    fn test_execute_native_command_nonexistent_program() {
8141        // Verify that executing a non-existent command returns a proper error
8142        // (not a panic) and includes context about the failure
8143        let (tx, rx) = std::sync::mpsc::channel();
8144        let cmd = NativeCommand::Shell {
8145            program: "/nonexistent/program/that/does/not/exist".to_string(),
8146            args: vec![],
8147            env: vec![],
8148            working_dir: None,
8149        };
8150        let result = execute_native_command(&cmd, None, &tx, "test-component");
8151        assert!(result.is_err(), "Should fail for non-existent program");
8152        let err_msg = result.unwrap_err().to_string();
8153        assert!(!err_msg.is_empty(), "Error message should not be empty");
8154
8155        // Verify events were sent (no silent failure)
8156        drop(tx);
8157        let events: Vec<_> = rx.try_iter().collect();
8158        assert!(
8159            !events.is_empty(),
8160            "Should have sent at least one event before failing"
8161        );
8162    }
8163
8164    #[test]
8165    fn test_execute_native_command_captures_exit_code() {
8166        // Verify that a failing command sends error events with context
8167        let (tx, rx) = std::sync::mpsc::channel();
8168        let cmd = NativeCommand::Shell {
8169            program: "bash".to_string(),
8170            args: vec!["-c".to_string(), "echo test_output; exit 42".to_string()],
8171            env: vec![],
8172            working_dir: None,
8173        };
8174        let result = execute_native_command(&cmd, None, &tx, "test-component");
8175        assert!(
8176            result.is_err(),
8177            "Should fail for non-zero exit code, got: {:?}",
8178            result
8179        );
8180
8181        let err_msg = result.unwrap_err().to_string();
8182        assert!(
8183            err_msg.contains("42"),
8184            "Error should include exit code 42, got: {}",
8185            err_msg
8186        );
8187
8188        // Collect events to verify output was captured
8189        drop(tx);
8190        let events: Vec<_> = rx.try_iter().collect();
8191        let log_messages: Vec<&str> = events
8192            .iter()
8193            .filter_map(|e| match e {
8194                InstallerEvent::Log(msg, _) => Some(msg.as_str()),
8195                _ => None,
8196            })
8197            .collect();
8198        assert!(
8199            log_messages.iter().any(|m| m.contains("test_output")),
8200            "Should have captured stdout output before failure"
8201        );
8202        assert!(
8203            log_messages
8204                .iter()
8205                .any(|m| m.contains("[ERROR]") || m.contains("exit code")),
8206            "Should have logged error with exit code"
8207        );
8208    }
8209
8210    #[test]
8211    fn test_execute_native_command_success_sends_events() {
8212        // Verify that a successful command sends log events
8213        let (tx, rx) = std::sync::mpsc::channel();
8214        let cmd = NativeCommand::Shell {
8215            program: "bash".to_string(),
8216            args: vec![
8217                "-c".to_string(),
8218                "echo 'hello world'; echo 'second line'".to_string(),
8219            ],
8220            env: vec![],
8221            working_dir: None,
8222        };
8223        let result = execute_native_command(&cmd, None, &tx, "test-component");
8224        assert!(result.is_ok(), "Should succeed for zero exit code");
8225
8226        drop(tx);
8227        let events: Vec<_> = rx.try_iter().collect();
8228        let log_messages: Vec<&str> = events
8229            .iter()
8230            .filter_map(|e| match e {
8231                InstallerEvent::Log(msg, _) => Some(msg.as_str()),
8232                _ => None,
8233            })
8234            .collect();
8235        assert!(
8236            log_messages.iter().any(|m| m.contains("hello world")),
8237            "Should have captured first line of output"
8238        );
8239        assert!(
8240            log_messages.iter().any(|m| m.contains("second line")),
8241            "Should have captured second line of output"
8242        );
8243    }
8244
8245    // -----------------------------------------------------------------------
8246    // fix-textgen-git-clone-args: sudo wrapping only when sudo_pw is Some
8247    // -----------------------------------------------------------------------
8248
8249    #[test]
8250    fn test_no_sudo_wrap_when_password_is_none() {
8251        // Verify that when sudo_pw is None, the command is NOT wrapped with sudo.
8252        // This is the core fix for the textgen git clone failure where git received
8253        // empty arguments due to the sudo wrapper inserting "--" separator.
8254        let (tx, rx) = std::sync::mpsc::channel();
8255        let cmd = NativeCommand::Shell {
8256            program: "echo".to_string(),
8257            args: vec!["hello".to_string()],
8258            env: vec![],
8259            working_dir: None,
8260        };
8261        let result = execute_native_command(&cmd, None, &tx, "test-component");
8262        assert!(result.is_ok(), "Should succeed without sudo: {:?}", result);
8263
8264        drop(tx);
8265        let events: Vec<_> = rx.try_iter().collect();
8266        let log_messages: Vec<String> = events
8267            .iter()
8268            .filter_map(|e| match e {
8269                InstallerEvent::Log(msg, _) => Some(msg.clone()),
8270                _ => None,
8271            })
8272            .collect();
8273
8274        // The logged command should NOT contain "sudo" since we passed None
8275        let cmd_log = log_messages.iter().find(|m| m.contains("[native] $"));
8276        assert!(cmd_log.is_some(), "Should have logged the command");
8277        let cmd_log = cmd_log.unwrap();
8278        assert!(
8279            !cmd_log.contains("sudo"),
8280            "Command should NOT be wrapped with sudo when sudo_pw is None, got: {}",
8281            cmd_log
8282        );
8283        assert!(
8284            cmd_log.contains("echo hello"),
8285            "Command should be 'echo hello', got: {}",
8286            cmd_log
8287        );
8288    }
8289
8290    #[test]
8291    fn test_sudo_wrap_when_password_is_some() {
8292        // Verify that when sudo_pw is Some, the command IS wrapped with sudo.
8293        // We use a password value but run a command that doesn't actually need sudo
8294        // (echo) to avoid actual privilege escalation in tests.
8295        let (tx, rx) = std::sync::mpsc::channel();
8296        let cmd = NativeCommand::Shell {
8297            program: "echo".to_string(),
8298            args: vec!["hello".to_string()],
8299            env: vec![],
8300            working_dir: None,
8301        };
8302        // Pass a dummy password — echo doesn't need sudo but we test the wrapping
8303        let _result = execute_native_command(&cmd, Some("dummy_pw"), &tx, "test-component");
8304        // This may fail since sudo -S with a dummy password won't work, but we
8305        // just want to verify the command was constructed with sudo
8306        drop(tx);
8307        let events: Vec<_> = rx.try_iter().collect();
8308        let log_messages: Vec<String> = events
8309            .iter()
8310            .filter_map(|e| match e {
8311                InstallerEvent::Log(msg, _) => Some(msg.clone()),
8312                _ => None,
8313            })
8314            .collect();
8315
8316        // The logged command SHOULD contain "sudo" since we passed Some
8317        let has_sudo_log = log_messages.iter().any(|m| m.contains("Running with sudo"));
8318        assert!(
8319            has_sudo_log,
8320            "Should have logged 'Running with sudo' when sudo_pw is Some"
8321        );
8322    }
8323
8324    #[test]
8325    fn test_git_clone_without_sudo_works() {
8326        // Simulate the exact textgen git clone scenario:
8327        // git clone <URL> <dir> with sudo_pw = None
8328        // This should execute "git --version" successfully (we use --version
8329        // instead of clone to avoid actually cloning)
8330        let (tx, rx) = std::sync::mpsc::channel();
8331        let cmd = NativeCommand::Shell {
8332            program: "git".to_string(),
8333            args: vec!["--version".to_string()],
8334            env: vec![],
8335            working_dir: None,
8336        };
8337        let result = execute_native_command(&cmd, None, &tx, "textgen");
8338        assert!(
8339            result.is_ok(),
8340            "git --version should succeed without sudo wrapping: {:?}",
8341            result
8342        );
8343
8344        drop(tx);
8345        let events: Vec<_> = rx.try_iter().collect();
8346        let log_messages: Vec<String> = events
8347            .iter()
8348            .filter_map(|e| match e {
8349                InstallerEvent::Log(msg, _) => Some(msg.clone()),
8350                _ => None,
8351            })
8352            .collect();
8353
8354        // Verify git version output was captured
8355        assert!(
8356            log_messages.iter().any(|m| m.contains("git version")),
8357            "Should have captured 'git version' output, got: {:?}",
8358            log_messages
8359        );
8360
8361        // Verify no sudo wrapping in the command log
8362        let cmd_log = log_messages.iter().find(|m| m.contains("[native] $"));
8363        assert!(cmd_log.is_some(), "Should have logged the command");
8364        assert!(
8365            !cmd_log.unwrap().contains("sudo"),
8366            "git command should NOT be wrapped with sudo"
8367        );
8368    }
8369
8370    #[test]
8371    fn test_textgen_git_clone_args_not_empty() {
8372        // Verify that the textgen git clone command has proper args
8373        // (not empty strings that would cause git to print usage)
8374        use crate::installers::components::textgen::{TextgenConfig, TextgenInstaller};
8375        let inst = TextgenInstaller::new(TextgenConfig {
8376            install_dir: "/tmp/test-textgen".to_string(),
8377            python_bin: "python3".to_string(),
8378            use_uv: false,
8379            break_system_packages: true,
8380            dry_run: false,
8381        });
8382        let cmd = inst.build_git_clone_command();
8383
8384        // Verify no empty arguments
8385        for (i, arg) in cmd.args.iter().enumerate() {
8386            assert!(
8387                !arg.is_empty(),
8388                "Argument {} should not be empty in git clone command",
8389                i
8390            );
8391        }
8392
8393        // Verify the command structure
8394        assert_eq!(cmd.program, "git");
8395        assert_eq!(
8396            cmd.args.len(),
8397            3,
8398            "git clone should have 3 args: clone, URL, dir"
8399        );
8400        assert_eq!(cmd.args[0], "clone");
8401        assert_eq!(
8402            cmd.args[1],
8403            "https://github.com/oobabooga/text-generation-webui.git"
8404        );
8405        assert_eq!(cmd.args[2], "/tmp/test-textgen");
8406
8407        // Verify no "--" separator in args (which would be from sudo wrapping)
8408        assert!(
8409            !cmd.args.contains(&"--".to_string()),
8410            "git clone args should not contain '--' separator (from sudo wrapping)"
8411        );
8412    }
8413
8414    // -------------------------------------------------------------------
8415    // fix-pytorch-strict-validation: verification result tests
8416    // -------------------------------------------------------------------
8417
8418    #[test]
8419    fn test_verification_result_labels() {
8420        assert_eq!(VerificationResult::Verified.label(), "Verified");
8421        assert_eq!(VerificationResult::Failed.label(), "Failed");
8422        assert_eq!(VerificationResult::Missing.label(), "Missing");
8423    }
8424}