Skip to main content

rusty_stack/installers/components/
rocm.rs

1//! ROCm installer — ports `scripts/install_rocm.sh`.
2//!
3//! Constructs correct apt/dnf/pacman commands per distro family, with
4//! channel selection (Legacy/Stable/Latest) producing correct version
5//! pins and repository URLs.
6//!
7//! # Validation Assertions
8//!
9//! - **VAL-INSTALL-001**: ROCm installer correct package commands per distro
10//! - **VAL-INSTALL-002**: ROCm channel selection correct version pins and repo URLs
11
12use crate::installers::common::DistroFacade;
13use std::fmt;
14
15// ===========================================================================
16// Types
17// ===========================================================================
18
19/// Classic (amdgpu-install) ROCm fallback for the Legacy channel.
20pub const LEGACY_FALLBACK_VERSION: &str = "7.2.3";
21/// TheRock multi-arch apt/rpm repo root (ROCm 7.14+).
22pub const THEROCK_REPO_ROOT: &str = "https://repo.amd.com/rocm/packages-multi-arch";
23/// TheRock multi-arch apt GPG key.
24pub const THEROCK_GPG_URL: &str = "https://repo.amd.com/rocm/packages-multi-arch/gpg/rocm.gpg";
25/// Fingerprint of AMD's multi-arch repo signing key (verified after download).
26pub const THEROCK_GPG_FINGERPRINT: &str = "D0F004A0025A1145C7807FCD0701EAC4D5E02107";
27/// Default runfile build id in `rocm-installer-<ver>-<build>.run`.
28pub const RUNFILE_BUILD_DEFAULT: &str = "7";
29/// TheRock multi-arch pip index (7.14.x).
30pub const THEROCK_PIP_INDEX: &str = "https://repo.amd.com/rocm/whl-multi-arch/";
31/// ROCm 10.x preview pip index.
32pub const PREVIEW_PIP_INDEX: &str = "https://stable.repo.amd.com/rocm/whl-next/";
33/// TheRock tarball root.
34pub const THEROCK_TARBALL_ROOT: &str = "https://repo.amd.com/rocm/tarball-multi-arch";
35/// Runfile installer root.
36pub const RUNFILE_ROOT: &str = "https://repo.radeon.com/installer/rocm-runfile-installer";
37
38/// ROCm release channel.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
40pub enum RocmChannel {
41    /// ROCm 7.2.4 (7.2.3 fallback) — classic amdgpu-install line (Legacy).
42    Legacy,
43    /// ROCm 7.14.0 — TheRock (Stable).
44    Stable,
45    /// ROCm 7.14.1 — TheRock (Latest, default).
46    Latest,
47    /// ROCm 10.1.0 — TheRock Core SDK preview (pip/tarball only).
48    Preview,
49}
50
51impl fmt::Display for RocmChannel {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        match self {
54            RocmChannel::Legacy => write!(f, "legacy"),
55            RocmChannel::Stable => write!(f, "stable"),
56            RocmChannel::Latest => write!(f, "latest"),
57            RocmChannel::Preview => write!(f, "preview"),
58        }
59    }
60}
61
62impl RocmChannel {
63    /// Parse from a choice number (1-4).
64    pub fn from_choice(choice: u8) -> Option<Self> {
65        match choice {
66            1 => Some(RocmChannel::Legacy),
67            2 => Some(RocmChannel::Stable),
68            3 => Some(RocmChannel::Latest),
69            4 => Some(RocmChannel::Preview),
70            _ => None,
71        }
72    }
73
74    /// Parse a channel name (`ROCM_CHANNEL`); unknown values yield `None`.
75    pub fn parse(name: &str) -> Option<Self> {
76        match name.trim().to_ascii_lowercase().as_str() {
77            "legacy" => Some(RocmChannel::Legacy),
78            "stable" => Some(RocmChannel::Stable),
79            "latest" => Some(RocmChannel::Latest),
80            "preview" => Some(RocmChannel::Preview),
81            _ => None,
82        }
83    }
84
85    /// Channel the installer targets: `ROCM_CHANNEL` from the environment,
86    /// else from `~/.mlstack_env`, else [`RocmChannel::Latest`]. Shared by the
87    /// installer and `rusty-stack update` so both resolve the same target.
88    pub fn configured() -> Self {
89        // An invalid assignment at a higher-precedence source is authoritative:
90        // it stops the search and yields the default, never a stale lower one.
91        if let Ok(v) = std::env::var("ROCM_CHANNEL") {
92            if !v.trim().is_empty() {
93                return Self::parse(&v).unwrap_or(RocmChannel::Latest);
94            }
95        }
96        for home in Self::env_file_homes() {
97            if let Ok(body) = std::fs::read_to_string(std::path::Path::new(&home).join(".mlstack_env")) {
98                if let Some(found) = Self::channel_assignment(&body) {
99                    return found.unwrap_or(RocmChannel::Latest);
100                }
101            }
102        }
103        RocmChannel::Latest
104    }
105
106    /// Home directories to search for `.mlstack_env`, in the installer's
107    /// precedence: `MLSTACK_USER_HOME`, `HOME`, then the invoking user's
108    /// passwd home (`SUDO_USER`, `USER`, `LOGNAME`). Empty values are skipped.
109    fn env_file_homes() -> Vec<String> {
110        // Same resolver the installer uses, so planning and apply agree.
111        // Mirror `resolve_mlstack_user_home`: HOME is the preferred candidate.
112        let preferred = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
113        crate::installer::candidate_mlstack_homes(&preferred)
114    }
115
116    /// Last `ROCM_CHANNEL` assignment in an env file body. Understands plain
117    /// `export ROCM_CHANNEL=x`, the guarded form written by the env generator
118    /// (`if [ -z "${ROCM_CHANNEL:-}" ]; then export ROCM_CHANNEL=x; fi`), and
119    /// fish `set -gx ROCM_CHANNEL x`.
120    pub fn channel_from_env_body(body: &str) -> Option<Self> {
121        Self::channel_assignment(body).flatten()
122    }
123
124    /// `None` = no assignment; `Some(None)` = last assignment has an invalid value.
125    fn channel_assignment(body: &str) -> Option<Option<Self>> {
126        // The last recognized assignment is authoritative even when its value
127        // is invalid (caller then applies the default), so wrap in Some().
128        body.lines().rev().find_map(|l| {
129            let l = l.trim();
130            if l.starts_with('#') {
131                return None;
132            }
133            // Only genuine assignment statements: `export ROCM_CHANNEL=x`,
134            // `ROCM_CHANNEL=x` (spaces around `=` tolerated), the guarded
135            // `... then export ROCM_CHANNEL=x`, and fish
136            // `set -gx ROCM_CHANNEL x`. Other lines (echo, etc.) are ignored.
137            let stmt = l.rsplit(|c| c == ';').find(|p| p.contains("ROCM_CHANNEL")).unwrap_or(l);
138            let stmt = stmt.trim();
139            let stmt = ["then ", "or ", "and "]
140                .iter()
141                .find_map(|p| stmt.strip_prefix(p))
142                .unwrap_or(stmt)
143                .trim();
144            let raw = if let Some(r) = stmt
145                .strip_prefix("export ROCM_CHANNEL=")
146                .or_else(|| stmt.strip_prefix("ROCM_CHANNEL="))
147            {
148                r
149            } else if let Some(r) = stmt
150                // Spaced form: `export ROCM_CHANNEL = stable` or
151                // `ROCM_CHANNEL = "latest"` (must still be an assignment).
152                .strip_prefix("export ROCM_CHANNEL")
153                .or_else(|| stmt.strip_prefix("ROCM_CHANNEL"))
154                .map(str::trim_start)
155                .and_then(|r| r.strip_prefix('='))
156                .map(str::trim_start)
157            {
158                r
159            } else if let Some(r) = stmt
160                .strip_prefix("set -gx ROCM_CHANNEL ")
161                .or_else(|| stmt.strip_prefix("set -x ROCM_CHANNEL "))
162                .or_else(|| stmt.strip_prefix("set -Ux ROCM_CHANNEL "))
163            {
164                r
165            } else {
166                return None;
167            };
168            let v = raw
169                .trim_start()
170                .split(|c: char| c.is_whitespace() || c == ';')
171                .next()
172                .unwrap_or("")
173                .trim_matches(|c| c == '"' || c == '\'');
174            Some(Self::parse(v))
175        })
176    }
177
178    /// Channel for a detected ROCm version string.
179    pub fn for_version(version: &str) -> Self {
180        let mut it = version.split('.');
181        let major: u32 = it.next().and_then(|v| v.parse().ok()).unwrap_or(0);
182        let minor: u32 = it.next().and_then(|v| v.parse().ok()).unwrap_or(0);
183        let patch: u32 = it.next().and_then(|v| v.parse().ok()).unwrap_or(0);
184        match (major, minor) {
185            (10.., _) => RocmChannel::Preview,
186            (7, 14..) if patch >= 1 || minor > 14 => RocmChannel::Latest,
187            (7, 14..) => RocmChannel::Stable,
188            _ => RocmChannel::Legacy,
189        }
190    }
191
192    /// Inverse of [`Self::version`] for update planning: the channel whose
193    /// pinned version equals `version` (e.g. `"7.14.1"` → Latest, `"7.2.4"` →
194    /// Legacy). `None` when no channel pins that exact version.
195    pub fn for_version_to_channel(version: &str) -> Option<Self> {
196        let v = version.trim();
197        [
198            RocmChannel::Legacy,
199            RocmChannel::Stable,
200            RocmChannel::Latest,
201            RocmChannel::Preview,
202        ]
203        .into_iter()
204        .find(|c| c.version() == v)
205    }
206
207    /// True for TheRock-based releases (7.14+), which use the multi-arch repos.
208    pub fn is_therock(&self) -> bool {
209        !matches!(self, RocmChannel::Legacy)
210    }
211
212    /// Get the ROCm version string for this channel.
213    pub fn version(&self) -> &'static str {
214        match self {
215            RocmChannel::Legacy => "7.2.4",
216            RocmChannel::Stable => "7.14.0",
217            RocmChannel::Latest => "7.14.1",
218            RocmChannel::Preview => "10.1.0",
219        }
220    }
221
222    /// Get the ROCm directory path component for this channel.
223    pub fn dir_path(&self) -> &'static str {
224        self.version()
225    }
226
227    /// Get the classic package version string used in amdgpu-install names.
228    /// Only meaningful for the Legacy channel; TheRock uses `therock_pkg_revision`.
229    pub fn pkg_version(&self) -> &'static str {
230        match self {
231            RocmChannel::Legacy => "7.2.4.70204-1",
232            RocmChannel::Stable => "7.14.0",
233            RocmChannel::Latest => "7.14.1",
234            RocmChannel::Preview => "10.1.0",
235        }
236    }
237
238    /// Get the major.minor version (e.g., "7.14") for repo URLs and package names.
239    pub fn major_minor(&self) -> &'static str {
240        match self {
241            RocmChannel::Legacy => "7.2",
242            RocmChannel::Stable | RocmChannel::Latest => "7.14",
243            RocmChannel::Preview => "10.1",
244        }
245    }
246
247    /// Get the version for this channel, with optional manifest override.
248    ///
249    /// If a manifest is loaded, uses the manifest version for the "rocm" component.
250    /// Falls back to the hardcoded version if no manifest or manifest doesn't contain rocm.
251    pub fn version_from_manifest(manifest: &crate::core::manifest::Manifest) -> String {
252        manifest
253            .components
254            .iter()
255            .find(|c| c.id == "rocm")
256            .map(|c| c.version.clone())
257            .unwrap_or_else(|| RocmChannel::Latest.version().to_string())
258    }
259
260    /// Get the package version for this channel, with optional manifest override.
261    ///
262    /// Classic (<7.14) releases map `7.2.4` -> `7.2.4.70204-1`; TheRock releases
263    /// use the bare version.
264    pub fn pkg_version_from_manifest(manifest: &crate::core::manifest::Manifest) -> String {
265        let version = Self::version_from_manifest(manifest);
266        if version == "6.4.3" {
267            return "6.4.60403-1".to_string();
268        }
269        let parts: Vec<&str> = version.split('.').collect();
270        if parts.len() >= 3 {
271            let major: u64 = parts[0].parse().unwrap_or(7);
272            let minor: u64 = parts[1].parse().unwrap_or(2);
273            let patch: u64 = parts[2].parse().unwrap_or(0);
274            // Same classification as `for_version`, so 8.x/9.x stay classic.
275            if RocmChannel::for_version(&version).is_therock() {
276                return version;
277            }
278            let build = format!("{major}{minor:02}{patch:02}");
279            format!("{}.{build}-1", version)
280        } else {
281            RocmChannel::Latest.pkg_version().to_string()
282        }
283    }
284}
285
286/// Installation type selection.
287#[derive(Debug, Clone, Copy, PartialEq, Eq)]
288pub enum RocmInstallType {
289    /// ROCm runtime + basic tools.
290    Standard,
291    /// ROCm runtime only.
292    Minimal,
293    /// ROCm + development tools + libraries.
294    Full,
295    /// Select specific components.
296    Custom,
297}
298
299impl fmt::Display for RocmInstallType {
300    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
301        match self {
302            RocmInstallType::Standard => write!(f, "standard"),
303            RocmInstallType::Minimal => write!(f, "minimal"),
304            RocmInstallType::Full => write!(f, "full"),
305            RocmInstallType::Custom => write!(f, "custom"),
306        }
307    }
308}
309
310/// Configuration for the ROCm installer.
311#[derive(Debug, Clone)]
312pub struct RocmConfig {
313    /// Selected ROCm channel.
314    pub channel: RocmChannel,
315    /// Installation type.
316    pub install_type: RocmInstallType,
317    /// Whether to force reinstall.
318    pub force_reinstall: bool,
319    /// Whether to run in dry-run mode.
320    pub dry_run: bool,
321    /// Target GPU architecture (e.g. `gfx1100`) for TheRock per-arch packages.
322    pub gfx_arch: String,
323    /// Use the 7.2.3 classic build instead of 7.2.4 on the Legacy channel.
324    pub legacy_fallback: bool,
325}
326
327impl Default for RocmConfig {
328    fn default() -> Self {
329        Self {
330            channel: RocmChannel::Latest,
331            install_type: RocmInstallType::Standard,
332            force_reinstall: false,
333            dry_run: false,
334            gfx_arch: "gfx1100".to_string(),
335            legacy_fallback: false,
336        }
337    }
338}
339
340/// Constructed command for a package manager operation.
341#[derive(Debug, Clone, PartialEq, Eq)]
342pub struct PackageCommand {
343    /// The program to run (e.g., "sudo", "apt-get").
344    pub program: String,
345    /// Arguments to pass.
346    pub args: Vec<String>,
347}
348
349impl PackageCommand {
350    /// Format as a shell command string.
351    pub fn to_command_string(&self) -> String {
352        if self.args.is_empty() {
353            self.program.clone()
354        } else {
355            format!("{} {}", self.program, self.args.join(" "))
356        }
357    }
358}
359
360/// Constructed repository configuration for ROCm.
361#[derive(Debug, Clone)]
362pub struct RepoConfig {
363    /// Repository type (e.g., "ubuntu", "rhel", "sle").
364    pub repo_type: String,
365    /// Repository URL.
366    pub url: String,
367    /// GPG key URL.
368    pub gpg_key_url: String,
369    /// Target file path for the repo config.
370    pub file_path: String,
371}
372
373/// The ROCm installer.
374pub struct RocmInstaller {
375    config: RocmConfig,
376}
377
378impl RocmInstaller {
379    /// Create a new ROCm installer with the given config.
380    pub fn new(config: RocmConfig) -> Self {
381        Self { config }
382    }
383
384    /// Create with default config (Latest channel, Standard install).
385    pub fn with_defaults() -> Self {
386        Self::new(RocmConfig::default())
387    }
388
389    /// Get the ROCm channel.
390    pub fn channel(&self) -> RocmChannel {
391        self.config.channel
392    }
393
394    /// Effective ROCm version, honouring the Legacy 7.2.3 fallback.
395    pub fn effective_version(&self) -> &'static str {
396        if self.config.channel == RocmChannel::Legacy && self.config.legacy_fallback {
397            LEGACY_FALLBACK_VERSION
398        } else {
399            self.config.channel.version()
400        }
401    }
402
403    fn classic_release(&self) -> &'static str {
404        self.effective_version()
405    }
406
407    fn classic_pkg_version(&self) -> &'static str {
408        if self.config.channel == RocmChannel::Legacy && self.config.legacy_fallback {
409            "7.2.3.70203-1"
410        } else {
411            self.config.channel.pkg_version()
412        }
413    }
414
415    // -----------------------------------------------------------------------
416    // TheRock (7.14+) install methods
417    // -----------------------------------------------------------------------
418
419    /// Repo directory name for a distro on the TheRock multi-arch repo
420    /// (e.g. `ubuntu2404`, `debian12`, `rhel9`, `sles16`). `None` if unsupported.
421    pub fn therock_distro_dir(distro: &DistroFacade) -> Option<String> {
422        let v = distro.version();
423        let major = v.split('.').next().unwrap_or("");
424        if distro.uses_apt() {
425            if distro.id() == "debian" {
426                return match major {
427                    "12" | "13" => Some(format!("debian{major}")),
428                    _ => None,
429                };
430            }
431            let compact = v.replace('.', "");
432            match compact.as_str() {
433                "2204" | "2404" | "2604" => Some(format!("ubuntu{compact}")),
434                _ => match distro.codename() {
435                    "jammy" => Some("ubuntu2204".into()),
436                    "noble" => Some("ubuntu2404".into()),
437                    _ => None,
438                },
439            }
440        } else if distro.uses_dnf() || distro.uses_yum() {
441            match major {
442                "8" | "9" | "10" => Some(format!("rhel{major}")),
443                _ => None,
444            }
445        } else if distro.uses_zypper() {
446            match major {
447                "15" => Some("sles15".into()),
448                "16" => Some("sles16".into()),
449                _ => None,
450            }
451        } else {
452            None
453        }
454    }
455
456    /// Per-GPU metapackage name, e.g. `amdrocm7.14-gfx1100`.
457    pub fn therock_package(&self) -> String {
458        format!(
459            "amdrocm{}-{}",
460            self.config.channel.major_minor(),
461            self.config.gfx_arch
462        )
463    }
464
465    /// apt `Version` of the metapackage: `7.14.1` -> `7.14.1-*`.
466    ///
467    /// The glob is intentional: it pins the ROCm release but tracks the
468    /// repo's latest packaging revision (`-0`, `-1`, ...) of that release.
469    pub fn therock_apt_version_glob(&self) -> String {
470        format!("{}-*", self.config.channel.version())
471    }
472
473    /// Commands that configure the TheRock apt repo (key, source) and install.
474    pub fn therock_apt_commands(
475        &self,
476        distro: &DistroFacade,
477    ) -> Result<Vec<PackageCommand>, String> {
478        let dir = Self::therock_distro_dir(distro).ok_or_else(|| {
479            format!(
480                "ROCm {} has no apt repo for {} {} (supported: Ubuntu 22.04/24.04/26.04, Debian 12/13)",
481                self.config.channel.version(),
482                distro.id(),
483                distro.version()
484            )
485        })?;
486        let sh = |script: String| PackageCommand {
487            program: "sudo".to_string(),
488            args: vec!["sh".to_string(), "-c".to_string(), script],
489        };
490        // The key is fetched over TLS, then its fingerprint is checked against
491        // the pinned AMD ROCm signing key before it is trusted by apt.
492        let key_script = format!(
493            "set -e; mkdir -p /etc/apt/keyrings; \
494             wget -qO /tmp/rocm-multi-arch.gpg {THEROCK_GPG_URL}; \
495             gpg --show-keys --with-colons /tmp/rocm-multi-arch.gpg | grep -q '^fpr:::::::::{THEROCK_GPG_FINGERPRINT}:' \
496             || {{ echo 'ROCm repo key fingerprint mismatch' >&2; rm -f /tmp/rocm-multi-arch.gpg; exit 1; }}; \
497             mv /tmp/rocm-multi-arch.gpg /etc/apt/keyrings/rocm-multi-arch.gpg"
498        );
499        Ok(vec![
500            sh(key_script),
501            sh(format!(
502                "echo 'deb [arch=amd64 signed-by=/etc/apt/keyrings/rocm-multi-arch.gpg] {THEROCK_REPO_ROOT}/{dir} stable main' > /etc/apt/sources.list.d/rocm-multi-arch.list"
503            )),
504            PackageCommand {
505                program: "sudo".to_string(),
506                args: vec!["apt-get".to_string(), "update".to_string()],
507            },
508            PackageCommand {
509                program: "sudo".to_string(),
510                args: vec![
511                    "apt-get".to_string(),
512                    "install".to_string(),
513                    "-y".to_string(),
514                    format!("{}={}", self.therock_package(), self.therock_apt_version_glob()),
515                ],
516            },
517        ])
518    }
519
520    /// Commands that configure the TheRock rpm repo and install (dnf / zypper).
521    pub fn therock_rpm_commands(
522        &self,
523        distro: &DistroFacade,
524    ) -> Result<Vec<PackageCommand>, String> {
525        let dir = Self::therock_distro_dir(distro).ok_or_else(|| {
526            format!(
527                "ROCm {} has no rpm repo for {} {} (supported: RHEL 8/9/10, SLES 15/16)",
528                self.config.channel.version(),
529                distro.id(),
530                distro.version()
531            )
532        })?;
533        let url = format!("{THEROCK_REPO_ROOT}/{dir}/x86_64");
534        let pkg = format!(
535            "{}-{}",
536            self.therock_package(),
537            self.config.channel.version()
538        );
539        let sudo = |args: Vec<&str>| PackageCommand {
540            program: "sudo".to_string(),
541            args: args.into_iter().map(String::from).collect(),
542        };
543        let import_key = PackageCommand {
544            program: "sh".to_string(),
545            args: vec![
546                "-c".to_string(),
547                format!(
548                    "set -e; wget -qO /tmp/rocm-multi-arch.gpg {THEROCK_GPG_URL}; \
549                     gpg --show-keys --with-colons /tmp/rocm-multi-arch.gpg | grep -q '^fpr:::::::::{THEROCK_GPG_FINGERPRINT}:' \
550                     || {{ echo 'ROCm GPG key fingerprint mismatch' >&2; exit 1; }}; \
551                     sudo rpm --import /tmp/rocm-multi-arch.gpg"
552                ),
553            ],
554        };
555        Ok(if distro.uses_zypper() {
556            vec![
557                import_key,
558                sudo(vec![
559                    "zypper",
560                    "--non-interactive",
561                    "addrepo",
562                    "--refresh",
563                    &url,
564                    "rocm-multi-arch",
565                ]),
566                sudo(vec![
567                    "zypper",
568                    "--non-interactive",
569                    "refresh",
570                ]),
571                sudo(vec!["zypper", "--non-interactive", "install", &pkg]),
572            ]
573        } else {
574            vec![
575                import_key,
576                sudo(vec!["dnf", "config-manager", "--add-repo", &url]),
577                sudo(vec!["dnf", "install", "-y", &pkg]),
578            ]
579        })
580    }
581
582    /// pip requirement for the ROCm SDK on this channel
583    /// (`rocm[libraries,device-all]==7.14.1`).
584    pub fn pip_requirement(&self) -> String {
585        format!(
586            "rocm[libraries,device-all]=={}",
587            self.config.channel.version()
588        )
589    }
590
591    /// pip index URL for this channel (`None` for classic Legacy).
592    pub fn pip_index_url(&self) -> Option<&'static str> {
593        match self.config.channel {
594            RocmChannel::Legacy => None,
595            RocmChannel::Stable | RocmChannel::Latest => Some(THEROCK_PIP_INDEX),
596            RocmChannel::Preview => Some(PREVIEW_PIP_INDEX),
597        }
598    }
599
600    /// TheRock GPU family for tarball names (`gfx1100` -> `gfx110X-all`).
601    pub fn therock_family(gfx: &str) -> Option<String> {
602        let g = gfx.trim().to_ascii_lowercase();
603        let fam = match g.as_str() {
604            s if s.starts_with("gfx101") => "gfx101X-dgpu",
605            s if s.starts_with("gfx103") => "gfx103X-all",
606            s if s.starts_with("gfx110") => "gfx110X-all",
607            s if s.starts_with("gfx115") => "gfx115X-all",
608            s if s.starts_with("gfx120") => "gfx120X-all",
609            "gfx908" => "gfx908",
610            "gfx90a" => "gfx90a",
611            "gfx942" | "gfx941" | "gfx940" => "gfx94X-dcgpu",
612            "gfx950" => "gfx950-dcgpu",
613            _ => return None,
614        };
615        Some(fam.to_string())
616    }
617
618    /// TheRock tarball URL for the configured GPU and channel.
619    pub fn tarball_url(&self) -> Result<String, String> {
620        let family = Self::therock_family(&self.config.gfx_arch).ok_or_else(|| {
621            format!(
622                "no TheRock tarball family known for GPU architecture '{}'",
623                self.config.gfx_arch
624            )
625        })?;
626        Ok(format!(
627            "{THEROCK_TARBALL_ROOT}/therock-dist-linux-{family}-{}.tar.gz",
628            self.config.channel.version()
629        ))
630    }
631
632    /// Runfile installer URL (`rocm-installer-7.14.0-7.run`).
633    ///
634    /// The trailing number is the runfile build id; it defaults to
635    /// [`RUNFILE_BUILD_DEFAULT`] and can be overridden with `ROCM_RUNFILE_BUILD`.
636    pub fn runfile_url(&self) -> String {
637        let build = std::env::var("ROCM_RUNFILE_BUILD")
638            .ok()
639            .map(|b| b.trim().to_string())
640            .filter(|b| !b.is_empty() && b.chars().all(|c| c.is_ascii_digit()))
641            .unwrap_or_else(|| RUNFILE_BUILD_DEFAULT.to_string());
642        let v = self.config.channel.version();
643        let mm = self.config.channel.major_minor();
644        format!("{RUNFILE_ROOT}/rocm-rel-{mm}/rocm-installer-{v}-{build}.run")
645    }
646
647    // -----------------------------------------------------------------------
648    // Repository URL construction (VAL-INSTALL-002)
649    // -----------------------------------------------------------------------
650
651    /// Get the amdgpu/ROCm repo base URL for the given distro and channel.
652    ///
653    /// For apt-based distros, the URL is:
654    /// `https://repo.radeon.com/amdgpu/{major_minor}/ubuntu/{codename}`
655    ///
656    /// For RHEL-based distros:
657    /// `https://repo.radeon.com/amdgpu/{major_minor}/rhel/{rhel_version}`
658    ///
659    /// For SUSE:
660    /// `https://repo.radeon.com/amdgpu/{major_minor}/sle/{sles_version}`
661    pub fn repo_base_url(&self, distro: &DistroFacade) -> String {
662        let major_minor = self.config.channel.major_minor();
663        if distro.uses_apt() {
664            let codename = if distro.codename().is_empty() {
665                "noble"
666            } else {
667                distro.codename()
668            };
669            format!("https://repo.radeon.com/amdgpu/{major_minor}/ubuntu/{codename}")
670        } else if distro.uses_dnf() || distro.uses_yum() {
671            let rhel_version = Self::rhel_version(distro);
672            format!("https://repo.radeon.com/amdgpu/{major_minor}/rhel/{rhel_version}")
673        } else if distro.uses_zypper() {
674            format!("https://repo.radeon.com/amdgpu/{major_minor}/sle/15.7")
675        } else {
676            // Fallback for Arch and others
677            format!("https://repo.radeon.com/amdgpu/{major_minor}/ubuntu/noble")
678        }
679    }
680
681    /// Get the ROCm core repo URL for apt-based distros.
682    pub fn rocm_core_repo_url(&self, _distro: &DistroFacade) -> String {
683        let major_minor = self.config.channel.major_minor();
684        format!("https://repo.radeon.com/rocm/apt/{major_minor}")
685    }
686
687    /// Get the GPG key URL.
688    pub fn gpg_key_url(&self) -> &'static str {
689        "https://repo.radeon.com/rocm/rocm.gpg.key"
690    }
691
692    /// Derive the RHEL major version from distro info.
693    fn rhel_version(distro: &DistroFacade) -> &'static str {
694        let v = distro.version();
695        if v.starts_with("10") {
696            "10"
697        } else if v.starts_with("9") {
698            "9"
699        } else if v.starts_with("8") {
700            "8"
701        } else {
702            "9"
703        }
704    }
705
706    // -----------------------------------------------------------------------
707    // Package command construction (VAL-INSTALL-001)
708    // -----------------------------------------------------------------------
709
710    /// Construct the amdgpu-install package download URL for apt-based distros.
711    pub fn amdgpu_install_deb_url(&self) -> String {
712        let release_dir = self.classic_release();
713        let pkg_ver = self.classic_pkg_version();
714        format!(
715            "https://repo.radeon.com/amdgpu-install/{release_dir}/ubuntu/noble/amdgpu-install_{pkg_ver}_all.deb"
716        )
717    }
718
719    /// Construct the amdgpu-install RPM download URL.
720    pub fn amdgpu_install_rpm_url(&self, rhel_version: &str) -> String {
721        let release_dir = self.classic_release();
722        let pkg_ver = self.classic_pkg_version();
723        format!(
724            "https://repo.radeon.com/amdgpu-install/{release_dir}/rhel/{rhel_version}/amdgpu-install-{pkg_ver}.el{rhel_version}.noarch.rpm"
725        )
726    }
727
728    /// Construct the amdgpu-install SLES RPM download URL.
729    pub fn amdgpu_install_sles_rpm_url(&self) -> String {
730        let release_dir = self.classic_release();
731        let pkg_ver = self.classic_pkg_version();
732        format!(
733            "https://repo.radeon.com/amdgpu-install/{release_dir}/sle/15.7/amdgpu-install-{pkg_ver}.noarch.rpm"
734        )
735    }
736
737    /// Construct the apt-get install command for amdgpu-install.
738    pub fn apt_install_commands(&self) -> Vec<PackageCommand> {
739        debug_assert!(
740            !self.config.channel.is_therock(),
741            "TheRock channels use therock_*_commands"
742        );
743        let deb_url = self.amdgpu_install_deb_url();
744        vec![
745            // Download the .deb
746            PackageCommand {
747                program: "wget".to_string(),
748                args: vec!["-q".to_string(), deb_url.clone()],
749            },
750            // Install it
751            PackageCommand {
752                program: "sudo".to_string(),
753                args: vec![
754                    "dpkg".to_string(),
755                    "-i".to_string(),
756                    format!("amdgpu-install_{}_all.deb", self.classic_pkg_version()),
757                ],
758            },
759            // Update and install ROCm packages
760            PackageCommand {
761                program: "sudo".to_string(),
762                args: vec!["apt-get".to_string(), "update".to_string()],
763            },
764            // Install ROCm metapackage based on install type
765            self.apt_rocm_metapackage_command(),
766        ]
767    }
768
769    /// Get the apt metapackage install command for the selected install type.
770    fn apt_rocm_metapackage_command(&self) -> PackageCommand {
771        let metapkg = match self.config.install_type {
772            RocmInstallType::Standard => "rocm-libs",
773            RocmInstallType::Minimal => "rocm-core",
774            RocmInstallType::Full => "rocm-dev",
775            RocmInstallType::Custom => "rocm-libs",
776        };
777        PackageCommand {
778            program: "sudo".to_string(),
779            args: vec![
780                "apt-get".to_string(),
781                "install".to_string(),
782                "-y".to_string(),
783                metapkg.to_string(),
784            ],
785        }
786    }
787
788    /// Construct the dnf install commands for ROCm.
789    pub fn dnf_install_commands(&self, rhel_version: &str) -> Vec<PackageCommand> {
790        debug_assert!(
791            !self.config.channel.is_therock(),
792            "TheRock channels use therock_*_commands"
793        );
794        let rpm_url = self.amdgpu_install_rpm_url(rhel_version);
795        vec![
796            PackageCommand {
797                program: "sudo".to_string(),
798                args: vec![
799                    "dnf".to_string(),
800                    "install".to_string(),
801                    "-y".to_string(),
802                    rpm_url,
803                ],
804            },
805            self.dnf_rocm_metapackage_command(),
806        ]
807    }
808
809    /// Get the dnf metapackage install command.
810    fn dnf_rocm_metapackage_command(&self) -> PackageCommand {
811        let metapkg = match self.config.install_type {
812            RocmInstallType::Standard => "rocm-libs",
813            RocmInstallType::Minimal => "rocm-core",
814            RocmInstallType::Full => "rocm-dev",
815            RocmInstallType::Custom => "rocm-libs",
816        };
817        PackageCommand {
818            program: "sudo".to_string(),
819            args: vec![
820                "dnf".to_string(),
821                "install".to_string(),
822                "-y".to_string(),
823                metapkg.to_string(),
824            ],
825        }
826    }
827
828    /// Construct the pacman install commands for ROCm on Arch.
829    ///
830    /// Arch uses AUR packages (yay/paru) for ROCm.
831    /// Build the pacman/AUR (`yay`) install command for ROCm.
832    ///
833    /// `yay` MUST run as the user — makepkg refuses root and `yay` warns
834    /// "Avoid running yay as root/sudo". This therefore returns a SINGLE
835    /// user-space command, never wrapped in `sudo`. (A bare `sudo yay` with no
836    /// operation defaults to `yay -Syu`, i.e. a full system upgrade, which is
837    /// what previously aborted ROCm installs.) The caller feeds yay's internal
838    /// `sudo pacman` non-interactively — e.g. `SUDO_ASKPASS` +
839    /// `--sudoflags=-A`, as the installer's Arch path does.
840    pub fn pacman_install_commands(&self, aur_helper: &str) -> Vec<PackageCommand> {
841        let packages = self.pacman_rocm_packages();
842        let mut args = vec![
843            "-S".to_string(),
844            "--needed".to_string(),
845            "--noconfirm".to_string(),
846        ];
847        args.extend(packages);
848
849        vec![PackageCommand {
850            program: aur_helper.to_string(),
851            args,
852        }]
853    }
854
855    /// Get the list of ROCm AUR packages for pacman-based installs.
856    pub fn pacman_rocm_packages(&self) -> Vec<String> {
857        let mut pkgs = vec![
858            "rocm-hip-sdk".to_string(),
859            "rocminfo".to_string(),
860            "rocm-smi-lib".to_string(),
861            "hipblas".to_string(),
862            "rocblas".to_string(),
863            "hipsparselt".to_string(),
864        ];
865        // Add extra packages for Stable/Latest channels
866        if self.config.channel != RocmChannel::Legacy {
867            pkgs.push("rocm-opencl-sdk".to_string());
868            pkgs.push("rccl".to_string());
869            // rocwmma: RDNA3+ WMMA flash-attention backend (used by Rusty Llama's
870            // fattn-wmma-f16). Optional at build time (the fork gates on it), but
871            // install it so the WMMA FA path is available out of the box.
872            pkgs.push("rocwmma".to_string());
873        }
874        pkgs
875    }
876
877    /// Construct the zypper install commands for ROCm on SUSE.
878    pub fn zypper_install_commands(&self) -> Vec<PackageCommand> {
879        debug_assert!(
880            !self.config.channel.is_therock(),
881            "TheRock channels use therock_*_commands"
882        );
883        let rpm_url = self.amdgpu_install_sles_rpm_url();
884        vec![
885            PackageCommand {
886                program: "sudo".to_string(),
887                args: vec![
888                    "zypper".to_string(),
889                    "--non-interactive".to_string(),
890                    "install".to_string(),
891                    rpm_url,
892                ],
893            },
894            PackageCommand {
895                program: "sudo".to_string(),
896                args: vec![
897                    "zypper".to_string(),
898                    "--non-interactive".to_string(),
899                    "install".to_string(),
900                    "-y".to_string(),
901                    "rocm-libs".to_string(),
902                ],
903            },
904        ]
905    }
906
907    /// Get the repository configuration for apt-based distros.
908    pub fn apt_repo_config(&self, distro: &DistroFacade) -> RepoConfig {
909        let codename = if distro.codename().is_empty() {
910            "noble"
911        } else {
912            distro.codename()
913        };
914        let major_minor = self.config.channel.major_minor();
915        RepoConfig {
916            repo_type: "apt".to_string(),
917            url: format!("deb [signed-by=/etc/apt/keyrings/rocm.gpg] https://repo.radeon.com/rocm/apt/{major_minor} {codename} main"),
918            gpg_key_url: "https://repo.radeon.com/rocm/rocm.gpg.key".to_string(),
919            file_path: "/etc/apt/sources.list.d/rocm.list".to_string(),
920        }
921    }
922
923    /// Get the amdgpu repo configuration for apt-based distros.
924    pub fn amdgpu_apt_repo_config(&self, distro: &DistroFacade) -> RepoConfig {
925        let codename = if distro.codename().is_empty() {
926            "noble"
927        } else {
928            distro.codename()
929        };
930        let major_minor = self.config.channel.major_minor();
931        RepoConfig {
932            repo_type: "apt".to_string(),
933            url: format!("deb [signed-by=/etc/apt/keyrings/rocm.gpg] https://repo.radeon.com/amdgpu/{major_minor}/ubuntu/{codename} {codename} main"),
934            gpg_key_url: "https://repo.radeon.com/rocm/rocm.gpg.key".to_string(),
935            file_path: "/etc/apt/sources.list.d/amdgpu.list".to_string(),
936        }
937    }
938
939    /// Get the ROCm preference pin configuration for apt.
940    pub fn apt_pin_config(&self) -> (String, String) {
941        (
942            "Package: *\nPin: release o=repo.radeon.com\nPin-Priority: 600\n".to_string(),
943            "/etc/apt/preferences.d/rocm-pin-600".to_string(),
944        )
945    }
946
947    /// Build a command to check if a single package is installed on Arch/pacman.
948    ///
949    /// Returns a `PackageCommand` that runs `pacman -Qi <package>`.
950    /// Exit code 0 means installed, non-zero means not installed.
951    pub fn build_pacman_check_installed_command(package: &str) -> PackageCommand {
952        PackageCommand {
953            program: "pacman".to_string(),
954            args: vec!["-Qi".to_string(), package.to_string()],
955        }
956    }
957
958    /// Filter out packages that are already installed on Arch/pacman systems.
959    ///
960    /// For each package in `packages`, runs `pacman -Qi <pkg>` to check if
961    /// it's installed. Returns only the packages that are NOT yet installed.
962    /// In dry-run mode, returns all packages (no checks performed).
963    pub fn filter_already_installed_pacman(packages: &[String]) -> Vec<String> {
964        use std::process::Command;
965
966        packages
967            .iter()
968            .filter(|pkg| {
969                let status = Command::new("pacman").args(["-Qi", pkg.as_str()]).output();
970                match status {
971                    Ok(output) => !output.status.success(),
972                    Err(_) => true, // If pacman fails to run, assume not installed
973                }
974            })
975            .cloned()
976            .collect()
977    }
978
979    /// Check if command output indicates a package is already up to date.
980    ///
981    /// yay/pacman output patterns that should be treated as success:
982    /// - `warning: <pkg> is up to date -- skipping`
983    /// - `<pkg> is up to date -- skipping`
984    /// - `there is nothing to do`
985    /// - `already installed`
986    /// - `.rc files restored` (post-install message, not an error)
987    pub fn is_up_to_date_output(output: &str) -> bool {
988        let lower = output.to_lowercase();
989        // yay/pacman "up to date" patterns
990        lower.contains("up to date -- skipping")
991            || lower.contains("up to date, skipping")
992            || lower.contains("up to date -- reinstalling")
993            || lower.contains("there is nothing to do")
994            || lower.contains("already installed")
995            // pacman -S on already-installed package
996            || lower.contains("warning:") && lower.contains("up to date")
997    }
998}
999
1000// ===========================================================================
1001// Tests
1002// ===========================================================================
1003
1004#[cfg(test)]
1005mod tests {
1006    use super::*;
1007    use crate::platform::detection::{DistroFamily, DistroInfo, PackageManager};
1008
1009    fn ubuntu_distro() -> DistroFacade {
1010        DistroFacade::from_info(DistroInfo {
1011            id: "ubuntu".to_string(),
1012            name: "Ubuntu 24.04 LTS".to_string(),
1013            version: "24.04".to_string(),
1014            codename: "noble".to_string(),
1015            family: DistroFamily::Debian,
1016            pkg_manager: PackageManager::Apt,
1017            id_like: "debian".to_string(),
1018        })
1019    }
1020
1021    fn fedora_distro() -> DistroFacade {
1022        DistroFacade::from_info(DistroInfo {
1023            id: "fedora".to_string(),
1024            name: "Fedora Linux 41".to_string(),
1025            version: "41".to_string(),
1026            codename: String::new(),
1027            family: DistroFamily::Rhel,
1028            pkg_manager: PackageManager::Dnf,
1029            id_like: "fedora".to_string(),
1030        })
1031    }
1032
1033    fn rhel9_distro() -> DistroFacade {
1034        DistroFacade::from_info(DistroInfo {
1035            id: "rhel".to_string(),
1036            name: "Red Hat Enterprise Linux 9".to_string(),
1037            version: "9.3".to_string(),
1038            codename: String::new(),
1039            family: DistroFamily::Rhel,
1040            pkg_manager: PackageManager::Dnf,
1041            id_like: "rhel".to_string(),
1042        })
1043    }
1044
1045    fn suse_distro() -> DistroFacade {
1046        DistroFacade::from_info(DistroInfo {
1047            id: "opensuse-leap".to_string(),
1048            name: "openSUSE Leap 15.7".to_string(),
1049            version: "15.7".to_string(),
1050            codename: String::new(),
1051            family: DistroFamily::Suse,
1052            pkg_manager: PackageManager::Zypper,
1053            id_like: "suse".to_string(),
1054        })
1055    }
1056
1057    // --- VAL-INSTALL-002: ROCm channel selection correct version pins ---
1058
1059    #[test]
1060    fn test_channel_versions() {
1061        assert_eq!(RocmChannel::Legacy.version(), "7.2.4");
1062        assert_eq!(RocmChannel::Legacy.pkg_version(), "7.2.4.70204-1");
1063        assert_eq!(RocmChannel::Legacy.major_minor(), "7.2");
1064        assert_eq!(RocmChannel::Stable.version(), "7.14.0");
1065        assert_eq!(RocmChannel::Latest.version(), "7.14.1");
1066        assert_eq!(RocmChannel::Stable.major_minor(), "7.14");
1067        assert_eq!(RocmChannel::Preview.version(), "10.1.0");
1068        assert_eq!(RocmChannel::Preview.major_minor(), "10.1");
1069        assert!(!RocmChannel::Legacy.is_therock());
1070        assert!(RocmChannel::Preview.is_therock());
1071    }
1072
1073    #[test]
1074    fn test_channel_from_choice() {
1075        assert_eq!(RocmChannel::from_choice(1), Some(RocmChannel::Legacy));
1076        assert_eq!(RocmChannel::from_choice(2), Some(RocmChannel::Stable));
1077        assert_eq!(RocmChannel::from_choice(3), Some(RocmChannel::Latest));
1078        assert_eq!(RocmChannel::from_choice(4), Some(RocmChannel::Preview));
1079        assert_eq!(RocmChannel::from_choice(0), None);
1080        assert_eq!(RocmChannel::from_choice(5), None);
1081    }
1082
1083    #[test]
1084    fn test_channel_parse_and_for_version() {
1085        assert_eq!(RocmChannel::parse("Preview"), Some(RocmChannel::Preview));
1086        assert_eq!(RocmChannel::parse("bogus"), None);
1087        assert_eq!(RocmChannel::for_version("10.1.0"), RocmChannel::Preview);
1088        assert_eq!(RocmChannel::for_version("7.14.1"), RocmChannel::Latest);
1089        assert_eq!(RocmChannel::for_version("7.14.0"), RocmChannel::Stable);
1090        assert_eq!(RocmChannel::for_version("7.2.4"), RocmChannel::Legacy);
1091        assert_eq!(RocmChannel::for_version("6.4.3"), RocmChannel::Legacy);
1092    }
1093
1094    #[test]
1095    fn test_legacy_fallback_723() {
1096        let i = RocmInstaller::new(RocmConfig {
1097            channel: RocmChannel::Legacy,
1098            legacy_fallback: true,
1099            ..Default::default()
1100        });
1101        assert_eq!(i.effective_version(), "7.2.3");
1102        assert!(i
1103            .amdgpu_install_deb_url()
1104            .contains("7.2.3/ubuntu/noble/amdgpu-install_7.2.3.70203-1_all.deb"));
1105    }
1106
1107    #[test]
1108    fn test_therock_urls_and_package() {
1109        let i = RocmInstaller::new(RocmConfig {
1110            channel: RocmChannel::Stable,
1111            gfx_arch: "gfx1100".into(),
1112            ..Default::default()
1113        });
1114        assert_eq!(i.therock_package(), "amdrocm7.14-gfx1100");
1115        assert_eq!(i.pip_requirement(), "rocm[libraries,device-all]==7.14.0");
1116        assert_eq!(
1117            i.pip_index_url(),
1118            Some("https://repo.amd.com/rocm/whl-multi-arch/")
1119        );
1120        assert_eq!(
1121            i.tarball_url().unwrap(),
1122            "https://repo.amd.com/rocm/tarball-multi-arch/therock-dist-linux-gfx110X-all-7.14.0.tar.gz"
1123        );
1124        assert_eq!(
1125            i.runfile_url(),
1126            "https://repo.radeon.com/installer/rocm-runfile-installer/rocm-rel-7.14/rocm-installer-7.14.0-7.run"
1127        );
1128    }
1129
1130    #[test]
1131    fn test_therock_apt_commands_latest() {
1132        let i = RocmInstaller::new(RocmConfig {
1133            channel: RocmChannel::Latest,
1134            ..Default::default()
1135        });
1136        let cmds = i.therock_apt_commands(&ubuntu_distro()).unwrap();
1137        let all: String = cmds
1138            .iter()
1139            .map(|c| c.to_command_string())
1140            .collect::<Vec<_>>()
1141            .join("\n");
1142        assert!(all.contains("packages-multi-arch/gpg/rocm.gpg"));
1143        assert!(all.contains("packages-multi-arch/ubuntu2404 stable main"));
1144        assert!(all.contains("amdrocm7.14-gfx1100=7.14.1-*"));
1145    }
1146
1147    #[test]
1148    fn test_therock_rpm_and_distro_dir() {
1149        let i = RocmInstaller::new(RocmConfig {
1150            channel: RocmChannel::Latest,
1151            ..Default::default()
1152        });
1153        assert_eq!(
1154            RocmInstaller::therock_distro_dir(&ubuntu_distro()).as_deref(),
1155            Some("ubuntu2404")
1156        );
1157        assert_eq!(
1158            RocmInstaller::therock_distro_dir(&rhel9_distro()).as_deref(),
1159            Some("rhel9")
1160        );
1161        let cmds = i.therock_rpm_commands(&rhel9_distro()).unwrap();
1162        assert!(cmds
1163            .iter()
1164            .any(|c| c.to_command_string().contains("amdrocm7.14-gfx1100-7.14.1")));
1165    }
1166
1167    #[test]
1168    fn test_therock_unknown_gpu_and_distro_error() {
1169        let i = RocmInstaller::new(RocmConfig {
1170            channel: RocmChannel::Latest,
1171            gfx_arch: "gfx9999".into(),
1172            ..Default::default()
1173        });
1174        assert!(i.tarball_url().is_err());
1175        assert_eq!(RocmInstaller::therock_family("gfx9999"), None);
1176    }
1177
1178    #[test]
1179    fn test_therock_apt_pins_gpg_fingerprint() {
1180        let i = RocmInstaller::new(RocmConfig {
1181            channel: RocmChannel::Latest,
1182            ..Default::default()
1183        });
1184        let all: String = i
1185            .therock_apt_commands(&ubuntu_distro())
1186            .unwrap()
1187            .iter()
1188            .map(|c| c.to_command_string())
1189            .collect();
1190        assert!(all.contains(THEROCK_GPG_FINGERPRINT));
1191    }
1192
1193    #[test]
1194    fn test_pkg_version_classification_matches_for_version() {
1195        for v in ["8.0.0", "9.1.0", "7.2.4", "7.13.0"] {
1196            assert!(!RocmChannel::for_version(v).is_therock(), "{v}");
1197        }
1198        for v in ["7.14.0", "7.14.1", "10.1.0"] {
1199            assert!(RocmChannel::for_version(v).is_therock(), "{v}");
1200        }
1201    }
1202
1203    #[test]
1204    fn test_preview_channel_consistency() {
1205        let ch = RocmChannel::from_choice(4).unwrap();
1206        assert_eq!(ch, RocmChannel::Preview);
1207        assert_eq!(ch.version(), "10.1.0");
1208        assert_eq!(ch.major_minor(), "10.1");
1209        let i = RocmInstaller::new(RocmConfig {
1210            channel: ch,
1211            ..Default::default()
1212        });
1213        assert_eq!(i.pip_requirement(), "rocm[libraries,device-all]==10.1.0");
1214    }
1215
1216    #[test]
1217    fn test_preview_pip_index() {
1218        let i = RocmInstaller::new(RocmConfig {
1219            channel: RocmChannel::Preview,
1220            ..Default::default()
1221        });
1222        assert_eq!(
1223            i.pip_index_url(),
1224            Some("https://stable.repo.amd.com/rocm/whl-next/")
1225        );
1226        assert_eq!(i.pip_requirement(), "rocm[libraries,device-all]==10.1.0");
1227    }
1228
1229    #[test]
1230    fn test_pkg_version_from_manifest_latest() {
1231        let manifest = crate::core::manifest::Manifest {
1232            schema_version: 2,
1233            sequence: 1,
1234            generated_at: "2026-05-31T00:00:00Z".to_string(),
1235            expires_at: None,
1236            min_runtime_version: String::new(),
1237            components: vec![crate::core::manifest::ManifestComponent {
1238                id: "rocm".to_string(),
1239                version: "7.2.4".to_string(),
1240                script: "scripts/install_rocm.sh".to_string(),
1241                category: crate::core::Category::Foundation,
1242                validation_tier: crate::core::ValidationTier::Validated,
1243                min_rocm_version: String::new(),
1244                dependencies: vec![],
1245                compatible_channels: vec!["latest".to_string()],
1246                exclusive_group: String::new(),
1247            }],
1248            signature: None,
1249        };
1250
1251        assert_eq!(
1252            RocmChannel::pkg_version_from_manifest(&manifest),
1253            "7.2.4.70204-1"
1254        );
1255    }
1256
1257    #[test]
1258    fn test_pkg_version_from_manifest_legacy() {
1259        let manifest = crate::core::manifest::Manifest {
1260            schema_version: 2,
1261            sequence: 1,
1262            generated_at: "2026-05-31T00:00:00Z".to_string(),
1263            expires_at: None,
1264            min_runtime_version: String::new(),
1265            components: vec![crate::core::manifest::ManifestComponent {
1266                id: "rocm".to_string(),
1267                version: "6.4.3".to_string(),
1268                script: "scripts/install_rocm.sh".to_string(),
1269                category: crate::core::Category::Foundation,
1270                validation_tier: crate::core::ValidationTier::Validated,
1271                min_rocm_version: String::new(),
1272                dependencies: vec![],
1273                compatible_channels: vec!["legacy".to_string()],
1274                exclusive_group: String::new(),
1275            }],
1276            signature: None,
1277        };
1278
1279        assert_eq!(
1280            RocmChannel::pkg_version_from_manifest(&manifest),
1281            "6.4.60403-1"
1282        );
1283    }
1284
1285    // --- VAL-INSTALL-001: ROCm installer correct package commands ---
1286
1287    #[test]
1288    fn test_apt_install_commands_latest() {
1289        let installer = RocmInstaller::new(RocmConfig {
1290            channel: RocmChannel::Legacy,
1291            install_type: RocmInstallType::Standard,
1292            ..Default::default()
1293        });
1294        let cmds = installer.apt_install_commands();
1295        assert_eq!(cmds.len(), 4);
1296
1297        // wget download
1298        assert_eq!(cmds[0].program, "wget");
1299        assert!(cmds[0]
1300            .args
1301            .iter()
1302            .any(|a| a.contains("7.2.4/ubuntu/noble")));
1303        assert!(cmds[0].args.iter().any(|a| a.contains("7.2.4.70204-1")));
1304        assert_eq!(cmds[1].program, "sudo");
1305        assert!(cmds[1].args.contains(&"dpkg".to_string()));
1306
1307        // apt-get update
1308        assert_eq!(cmds[2].program, "sudo");
1309        assert!(cmds[2].args.contains(&"update".to_string()));
1310
1311        // rocm-libs metapackage
1312        assert_eq!(cmds[3].program, "sudo");
1313        assert!(cmds[3].args.contains(&"rocm-libs".to_string()));
1314    }
1315
1316    #[test]
1317    fn test_apt_install_commands_full() {
1318        let installer = RocmInstaller::new(RocmConfig {
1319            channel: RocmChannel::Legacy,
1320            install_type: RocmInstallType::Full,
1321            ..Default::default()
1322        });
1323        let cmds = installer.apt_install_commands();
1324        let meta_cmd = &cmds[3];
1325        assert!(meta_cmd.args.contains(&"rocm-dev".to_string()));
1326    }
1327
1328    #[test]
1329    fn test_dnf_install_commands() {
1330        let installer = RocmInstaller::new(RocmConfig {
1331            channel: RocmChannel::Legacy,
1332            install_type: RocmInstallType::Standard,
1333            ..Default::default()
1334        });
1335        let cmds = installer.dnf_install_commands("9");
1336        assert_eq!(cmds.len(), 2);
1337
1338        // dnf install RPM
1339        assert_eq!(cmds[0].program, "sudo");
1340        assert!(cmds[0].args.iter().any(|a| a.contains("7.2.4/rhel/9")));
1341        assert!(cmds[0].args.iter().any(|a| a.contains("7.2.4.70204-1")));
1342        assert!(cmds[0].args.iter().any(|a| a.contains("el9")));
1343
1344        // rocm-libs metapackage
1345        assert_eq!(cmds[1].program, "sudo");
1346        assert!(cmds[1].args.contains(&"rocm-libs".to_string()));
1347    }
1348
1349    #[test]
1350    fn test_pacman_install_commands() {
1351        let installer = RocmInstaller::new(RocmConfig {
1352            channel: RocmChannel::Latest,
1353            install_type: RocmInstallType::Standard,
1354            ..Default::default()
1355        });
1356        let cmds = installer.pacman_install_commands("yay");
1357        // Single user-space `yay` command — never a bare `sudo yay` (which
1358        // defaults to `yay -Syu`, a full system upgrade as root). Regression
1359        // guard for the malformed two-command [sudo yay, yay …] that aborted
1360        // ROCm installs on Arch.
1361        assert_eq!(
1362            cmds.len(),
1363            1,
1364            "pacman install must be a single user-space yay command"
1365        );
1366        assert_eq!(cmds[0].program, "yay");
1367        assert!(
1368            !cmds.iter().any(|c| c.program == "sudo"),
1369            "yay must not run under sudo"
1370        );
1371        assert!(cmds[0].args.contains(&"-S".to_string()));
1372        assert!(cmds[0].args.contains(&"--needed".to_string()));
1373        assert!(cmds[0].args.contains(&"--noconfirm".to_string()));
1374        // Should have packages for Latest channel
1375        let pkgs = installer.pacman_rocm_packages();
1376        assert!(pkgs.contains(&"rocm-hip-sdk".to_string()));
1377        assert!(pkgs.contains(&"rocminfo".to_string()));
1378        assert!(pkgs.contains(&"rocm-opencl-sdk".to_string())); // Latest gets extra
1379        assert!(pkgs.contains(&"rccl".to_string())); // Latest gets extra
1380        assert!(pkgs.contains(&"rocwmma".to_string())); // Latest: WMMA FA backend
1381        for p in &pkgs {
1382            assert!(cmds[0].args.contains(p), "yay args missing package {p}");
1383        }
1384    }
1385
1386    #[test]
1387    fn test_pacman_legacy_no_extras() {
1388        let installer = RocmInstaller::new(RocmConfig {
1389            channel: RocmChannel::Legacy,
1390            install_type: RocmInstallType::Standard,
1391            ..Default::default()
1392        });
1393        let pkgs = installer.pacman_rocm_packages();
1394        assert!(!pkgs.contains(&"rocm-opencl-sdk".to_string()));
1395        assert!(!pkgs.contains(&"rccl".to_string()));
1396        assert!(!pkgs.contains(&"rocwmma".to_string())); // Legacy (RDNA2): no WMMA
1397    }
1398
1399    #[test]
1400    fn test_zypper_install_commands() {
1401        let installer = RocmInstaller::new(RocmConfig {
1402            channel: RocmChannel::Legacy,
1403            install_type: RocmInstallType::Standard,
1404            ..Default::default()
1405        });
1406        let cmds = installer.zypper_install_commands();
1407        assert_eq!(cmds.len(), 2);
1408        assert_eq!(cmds[0].program, "sudo");
1409        assert!(cmds[0].args.iter().any(|a| a.contains("zypper")));
1410        assert!(cmds[0].args.iter().any(|a| a.contains("7.2.4/sle/15.7")));
1411        assert!(cmds[0].args.iter().any(|a| a.contains("7.2.4.70204-1")));
1412    }
1413
1414    // --- Repo URL construction ---
1415
1416    #[test]
1417    fn test_repo_base_url_ubuntu() {
1418        let installer = RocmInstaller::new(RocmConfig {
1419            channel: RocmChannel::Legacy,
1420            ..Default::default()
1421        });
1422        let distro = ubuntu_distro();
1423        let url = installer.repo_base_url(&distro);
1424        assert_eq!(url, "https://repo.radeon.com/amdgpu/7.2/ubuntu/noble");
1425    }
1426
1427    #[test]
1428    fn test_repo_base_url_rhel() {
1429        let installer = RocmInstaller::new(RocmConfig {
1430            channel: RocmChannel::Legacy,
1431            ..Default::default()
1432        });
1433        let distro = rhel9_distro();
1434        let url = installer.repo_base_url(&distro);
1435        assert_eq!(url, "https://repo.radeon.com/amdgpu/7.2/rhel/9");
1436    }
1437
1438    #[test]
1439    fn test_repo_base_url_fedora() {
1440        let installer = RocmInstaller::new(RocmConfig {
1441            channel: RocmChannel::Legacy,
1442            ..Default::default()
1443        });
1444        let distro = fedora_distro();
1445        let url = installer.repo_base_url(&distro);
1446        // Fedora version "41" doesn't match 8/9/10, defaults to 9
1447        assert_eq!(url, "https://repo.radeon.com/amdgpu/7.2/rhel/9");
1448    }
1449
1450    #[test]
1451    fn test_repo_base_url_suse() {
1452        let installer = RocmInstaller::new(RocmConfig {
1453            channel: RocmChannel::Legacy,
1454            ..Default::default()
1455        });
1456        let distro = suse_distro();
1457        let url = installer.repo_base_url(&distro);
1458        assert_eq!(url, "https://repo.radeon.com/amdgpu/7.2/sle/15.7");
1459    }
1460
1461    #[test]
1462    fn test_amdgpu_install_deb_url() {
1463        let installer = RocmInstaller::new(RocmConfig {
1464            channel: RocmChannel::Legacy,
1465            ..Default::default()
1466        });
1467        let url = installer.amdgpu_install_deb_url();
1468        assert!(url.contains("7.2.4/ubuntu/noble"));
1469        assert!(url.contains("7.2.4.70204-1"));
1470        assert!(url.contains("ubuntu/noble"));
1471        assert!(url.ends_with(".deb"));
1472    }
1473
1474    #[test]
1475    fn test_amdgpu_install_rpm_url() {
1476        let installer = RocmInstaller::new(RocmConfig {
1477            channel: RocmChannel::Legacy,
1478            ..Default::default()
1479        });
1480        let url = installer.amdgpu_install_rpm_url("9");
1481        assert!(url.contains("7.2.4/rhel/9"));
1482        assert!(url.contains("7.2.4.70204-1"));
1483        assert!(url.contains("rhel/9"));
1484        assert!(url.ends_with(".noarch.rpm"));
1485    }
1486
1487    #[test]
1488    fn test_apt_repo_config() {
1489        let installer = RocmInstaller::new(RocmConfig {
1490            channel: RocmChannel::Legacy,
1491            ..Default::default()
1492        });
1493        let distro = ubuntu_distro();
1494        let repo = installer.apt_repo_config(&distro);
1495        assert_eq!(repo.file_path, "/etc/apt/sources.list.d/rocm.list");
1496        assert!(repo.url.contains("rocm/apt/7.2"));
1497        assert!(repo.url.contains("noble"));
1498    }
1499
1500    #[test]
1501    fn test_amdgpu_apt_repo_config() {
1502        let installer = RocmInstaller::new(RocmConfig {
1503            channel: RocmChannel::Legacy,
1504            ..Default::default()
1505        });
1506        let distro = ubuntu_distro();
1507        let repo = installer.amdgpu_apt_repo_config(&distro);
1508        assert_eq!(repo.file_path, "/etc/apt/sources.list.d/amdgpu.list");
1509        assert!(repo.url.contains("amdgpu/7.2"));
1510    }
1511
1512    #[test]
1513    fn test_apt_pin_config() {
1514        let installer = RocmInstaller::with_defaults();
1515        let (content, path) = installer.apt_pin_config();
1516        assert_eq!(path, "/etc/apt/preferences.d/rocm-pin-600");
1517        assert!(content.contains("Pin-Priority: 600"));
1518    }
1519
1520    // --- Cross-channel parametric tests ---
1521
1522    #[test]
1523    fn test_all_channels_version_consistency() {
1524        for choice in [1u8, 2, 3] {
1525            let ch = RocmChannel::from_choice(choice).unwrap();
1526            let config = RocmConfig {
1527                channel: ch,
1528                ..Default::default()
1529            };
1530            let installer = RocmInstaller::new(config);
1531
1532            // Verify deb URL contains correct package version
1533            let deb_url = installer.amdgpu_install_deb_url();
1534            assert!(
1535                deb_url.contains(ch.pkg_version()),
1536                "deb URL for {:?} should contain pkg_version {}",
1537                ch,
1538                ch.pkg_version()
1539            );
1540
1541            // Verify repo URL contains major_minor
1542            let distro = ubuntu_distro();
1543            let repo_url = installer.repo_base_url(&distro);
1544            assert!(
1545                repo_url.contains(ch.major_minor()),
1546                "repo URL for {:?} should contain major_minor {}",
1547                ch,
1548                ch.major_minor()
1549            );
1550        }
1551    }
1552
1553    #[test]
1554    fn test_install_type_metapackages() {
1555        for (itype, expected_pkg) in [
1556            (RocmInstallType::Standard, "rocm-libs"),
1557            (RocmInstallType::Minimal, "rocm-core"),
1558            (RocmInstallType::Full, "rocm-dev"),
1559            (RocmInstallType::Custom, "rocm-libs"),
1560        ] {
1561            let installer = RocmInstaller::new(RocmConfig {
1562                channel: RocmChannel::Legacy,
1563                install_type: itype,
1564                ..Default::default()
1565            });
1566            let cmds = installer.apt_install_commands();
1567            let meta_cmd = &cmds[3];
1568            assert!(
1569                meta_cmd.args.contains(&expected_pkg.to_string()),
1570                "Install type {:?} should use metapackage {}",
1571                itype,
1572                expected_pkg
1573            );
1574        }
1575    }
1576
1577    // --- Up-to-date output detection ---
1578
1579    #[test]
1580    fn test_is_up_to_date_yay_warning() {
1581        assert!(RocmInstaller::is_up_to_date_output(
1582            "warning: rocminfo is up to date -- skipping"
1583        ));
1584    }
1585
1586    #[test]
1587    fn test_is_up_to_date_yay_warning_case_insensitive() {
1588        assert!(RocmInstaller::is_up_to_date_output(
1589            "WARNING: rocminfo is UP TO DATE -- skipping"
1590        ));
1591    }
1592
1593    #[test]
1594    fn test_is_up_to_date_nothing_to_do() {
1595        assert!(RocmInstaller::is_up_to_date_output(
1596            " there is nothing to do\n"
1597        ));
1598    }
1599
1600    #[test]
1601    fn test_is_up_to_date_already_installed() {
1602        assert!(RocmInstaller::is_up_to_date_output(
1603            "package already installed"
1604        ));
1605    }
1606
1607    #[test]
1608    fn test_is_up_to_date_normal_output_is_false() {
1609        assert!(!RocmInstaller::is_up_to_date_output(
1610            "resolving dependencies...\nlooking for conflicting packages...\n"
1611        ));
1612    }
1613
1614    #[test]
1615    fn test_is_up_to_date_error_output_is_false() {
1616        assert!(!RocmInstaller::is_up_to_date_output(
1617            "error: failed to prepare transaction\nerror: target not found: rocminfo"
1618        ));
1619    }
1620
1621    #[test]
1622    fn test_is_up_to_date_empty_output_is_false() {
1623        assert!(!RocmInstaller::is_up_to_date_output(""));
1624    }
1625
1626    #[test]
1627    fn test_is_up_to_date_pacman_skipping() {
1628        assert!(RocmInstaller::is_up_to_date_output(
1629            "warning: rocm-hip-sdk is up to date -- skipping"
1630        ));
1631    }
1632
1633    // --- Pacman check-installed command construction ---
1634
1635    #[test]
1636    fn test_build_pacman_check_installed_command() {
1637        let cmd = RocmInstaller::build_pacman_check_installed_command("rocminfo");
1638        assert_eq!(cmd.program, "pacman");
1639        assert_eq!(cmd.args, vec!["-Qi", "rocminfo"]);
1640    }
1641
1642    #[test]
1643    fn test_build_pacman_check_installed_command_complex_pkg() {
1644        let cmd = RocmInstaller::build_pacman_check_installed_command("rocm-hip-sdk");
1645        assert_eq!(cmd.program, "pacman");
1646        assert!(cmd.args.contains(&"rocm-hip-sdk".to_string()));
1647    }
1648}
1649
1650#[cfg(test)]
1651mod channel_env_tests {
1652    use super::RocmChannel;
1653
1654    #[test]
1655    fn parses_plain_guarded_and_fish() {
1656        let f = RocmChannel::channel_from_env_body;
1657        assert_eq!(f("export ROCM_CHANNEL=stable\n"), Some(RocmChannel::Stable));
1658        assert_eq!(
1659            f("if [ -z \"${ROCM_CHANNEL:-}\" ]; then export ROCM_CHANNEL=legacy; fi"),
1660            Some(RocmChannel::Legacy)
1661        );
1662        assert_eq!(f("set -q ROCM_CHANNEL; or set -gx ROCM_CHANNEL preview"), Some(RocmChannel::Preview));
1663        assert_eq!(f("# export ROCM_CHANNEL=stable"), None);
1664        assert_eq!(f("export ROCM_CHANNEL=bogus"), None);
1665        // Spaces around `=` (plain and quoted values) are tolerated.
1666        assert_eq!(f("export ROCM_CHANNEL = stable"), Some(RocmChannel::Stable));
1667        assert_eq!(f("ROCM_CHANNEL = \"latest\""), Some(RocmChannel::Latest));
1668        // Mentions that are not assignments stay unrecognized.
1669        assert_eq!(f("export ROCM_CHANNEL_X=stable"), None);
1670        assert_eq!(f("grep ROCM_CHANNEL env"), None);
1671    }
1672
1673    #[test]
1674    fn channel_env_last_invalid_assignment_wins() {
1675        assert_eq!(
1676            RocmChannel::channel_from_env_body("export ROCM_CHANNEL=stable\nexport ROCM_CHANNEL=bogus\n"),
1677            None
1678        );
1679    }
1680
1681    #[test]
1682    fn invalid_assignment_is_distinct_from_missing() {
1683        assert_eq!(RocmChannel::channel_assignment("export ROCM_CHANNEL=bogus\n"), Some(None));
1684        assert_eq!(RocmChannel::channel_assignment("echo hi\n"), None);
1685    }
1686
1687    #[test]
1688    fn last_assignment_wins() {
1689        assert_eq!(
1690            RocmChannel::channel_from_env_body("export ROCM_CHANNEL=stable\necho ROCM_CHANNEL=latest\n"),
1691            Some(RocmChannel::Stable)
1692        );
1693        assert_eq!(
1694            RocmChannel::channel_from_env_body("export ROCM_CHANNEL=stable\nexport ROCM_CHANNEL=\"latest\"\n"),
1695            Some(RocmChannel::Latest)
1696        );
1697    }
1698
1699    #[test]
1700    fn version_round_trips_to_its_channel() {
1701        // Every channel's pinned version maps back to itself (update-apply
1702        // parity: the planned target selects the confirmed channel again).
1703        for channel in [
1704            RocmChannel::Legacy,
1705            RocmChannel::Stable,
1706            RocmChannel::Latest,
1707            RocmChannel::Preview,
1708        ] {
1709            assert_eq!(
1710                RocmChannel::for_version_to_channel(channel.version()),
1711                Some(channel)
1712            );
1713        }
1714        // Unpinned and unparsable inputs yield None (no channel guessed).
1715        assert_eq!(RocmChannel::for_version_to_channel("9.9.9"), None);
1716        assert_eq!(RocmChannel::for_version_to_channel("latest"), None);
1717        assert_eq!(RocmChannel::for_version_to_channel("  7.14.1  "), Some(RocmChannel::Latest));
1718    }
1719}