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/// ROCm release channel.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21pub enum RocmChannel {
22    /// ROCm 6.4.3 — conservative compatibility target (Legacy).
23    Legacy,
24    /// ROCm 7.2.3 — prior production target for RDNA 3/4 (Stable).
25    Stable,
26    /// ROCm 7.2.4 — current production release (Latest, default).
27    Latest,
28}
29
30impl fmt::Display for RocmChannel {
31    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32        match self {
33            RocmChannel::Legacy => write!(f, "legacy"),
34            RocmChannel::Stable => write!(f, "stable"),
35            RocmChannel::Latest => write!(f, "latest"),
36        }
37    }
38}
39
40impl RocmChannel {
41    /// Parse from a choice number (1-3) as used in the original script.
42    pub fn from_choice(choice: u8) -> Option<Self> {
43        match choice {
44            1 => Some(RocmChannel::Legacy),
45            2 => Some(RocmChannel::Stable),
46            3 => Some(RocmChannel::Latest),
47            _ => None,
48        }
49    }
50
51    /// Get the ROCm version string for this channel.
52    pub fn version(&self) -> &'static str {
53        match self {
54            RocmChannel::Legacy => "6.4.3",
55            RocmChannel::Stable => "7.2.3",
56            RocmChannel::Latest => "7.2.4",
57        }
58    }
59
60    /// Get the ROCm directory path component for this channel.
61    pub fn dir_path(&self) -> &'static str {
62        match self {
63            RocmChannel::Legacy => "6.4.3",
64            RocmChannel::Stable => "7.2.3",
65            RocmChannel::Latest => "7.2.4",
66        }
67    }
68
69    /// Get the package version string used in installer package names.
70    pub fn pkg_version(&self) -> &'static str {
71        match self {
72            RocmChannel::Legacy => "6.4.60403-1",
73            RocmChannel::Stable => "7.2.3.70203-1",
74            RocmChannel::Latest => "7.2.4.70204-1",
75        }
76    }
77
78    /// Get the major.minor version (e.g., "7.2") for repo URLs.
79    pub fn major_minor(&self) -> &'static str {
80        match self {
81            RocmChannel::Legacy => "6.4",
82            RocmChannel::Stable => "7.2",
83            RocmChannel::Latest => "7.2",
84        }
85    }
86
87    /// Get the version for this channel, with optional manifest override.
88    ///
89    /// If a manifest is loaded, uses the manifest version for the "rocm" component.
90    /// Falls back to the hardcoded version if no manifest or manifest doesn't contain rocm.
91    pub fn version_from_manifest(manifest: &crate::core::manifest::Manifest) -> String {
92        manifest
93            .components
94            .iter()
95            .find(|c| c.id == "rocm")
96            .map(|c| c.version.clone())
97            .unwrap_or_else(|| RocmChannel::Latest.version().to_string())
98    }
99
100    /// Get the package version for this channel, with optional manifest override.
101    ///
102    /// Constructs a package version string from the manifest version if available.
103    pub fn pkg_version_from_manifest(manifest: &crate::core::manifest::Manifest) -> String {
104        let version = Self::version_from_manifest(manifest);
105        if version == "6.4.3" {
106            return "6.4.60403-1".to_string();
107        }
108
109        // Convert "7.2.4" to "7.2.4.70204-1" format.
110        let parts: Vec<&str> = version.split('.').collect();
111        if parts.len() >= 3 {
112            let major: u64 = parts[0].parse().unwrap_or(7);
113            let minor: u64 = parts[1].parse().unwrap_or(2);
114            let patch: u64 = parts[2].parse().unwrap_or(0);
115            let build = format!("{major}{minor:02}{patch:02}");
116            format!("{}.{build}-1", version)
117        } else {
118            RocmChannel::Latest.pkg_version().to_string()
119        }
120    }
121}
122
123/// Installation type selection.
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum RocmInstallType {
126    /// ROCm runtime + basic tools.
127    Standard,
128    /// ROCm runtime only.
129    Minimal,
130    /// ROCm + development tools + libraries.
131    Full,
132    /// Select specific components.
133    Custom,
134}
135
136impl fmt::Display for RocmInstallType {
137    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138        match self {
139            RocmInstallType::Standard => write!(f, "standard"),
140            RocmInstallType::Minimal => write!(f, "minimal"),
141            RocmInstallType::Full => write!(f, "full"),
142            RocmInstallType::Custom => write!(f, "custom"),
143        }
144    }
145}
146
147/// Configuration for the ROCm installer.
148#[derive(Debug, Clone)]
149pub struct RocmConfig {
150    /// Selected ROCm channel.
151    pub channel: RocmChannel,
152    /// Installation type.
153    pub install_type: RocmInstallType,
154    /// Whether to force reinstall.
155    pub force_reinstall: bool,
156    /// Whether to run in dry-run mode.
157    pub dry_run: bool,
158}
159
160impl Default for RocmConfig {
161    fn default() -> Self {
162        Self {
163            channel: RocmChannel::Latest,
164            install_type: RocmInstallType::Standard,
165            force_reinstall: false,
166            dry_run: false,
167        }
168    }
169}
170
171/// Constructed command for a package manager operation.
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct PackageCommand {
174    /// The program to run (e.g., "sudo", "apt-get").
175    pub program: String,
176    /// Arguments to pass.
177    pub args: Vec<String>,
178}
179
180impl PackageCommand {
181    /// Format as a shell command string.
182    pub fn to_command_string(&self) -> String {
183        if self.args.is_empty() {
184            self.program.clone()
185        } else {
186            format!("{} {}", self.program, self.args.join(" "))
187        }
188    }
189}
190
191/// Constructed repository configuration for ROCm.
192#[derive(Debug, Clone)]
193pub struct RepoConfig {
194    /// Repository type (e.g., "ubuntu", "rhel", "sle").
195    pub repo_type: String,
196    /// Repository URL.
197    pub url: String,
198    /// GPG key URL.
199    pub gpg_key_url: String,
200    /// Target file path for the repo config.
201    pub file_path: String,
202}
203
204/// The ROCm installer.
205pub struct RocmInstaller {
206    config: RocmConfig,
207}
208
209impl RocmInstaller {
210    /// Create a new ROCm installer with the given config.
211    pub fn new(config: RocmConfig) -> Self {
212        Self { config }
213    }
214
215    /// Create with default config (Latest channel, Standard install).
216    pub fn with_defaults() -> Self {
217        Self::new(RocmConfig::default())
218    }
219
220    /// Get the ROCm channel.
221    pub fn channel(&self) -> RocmChannel {
222        self.config.channel
223    }
224
225    // -----------------------------------------------------------------------
226    // Repository URL construction (VAL-INSTALL-002)
227    // -----------------------------------------------------------------------
228
229    /// Get the amdgpu/ROCm repo base URL for the given distro and channel.
230    ///
231    /// For apt-based distros, the URL is:
232    /// `https://repo.radeon.com/amdgpu/{major_minor}/ubuntu/{codename}`
233    ///
234    /// For RHEL-based distros:
235    /// `https://repo.radeon.com/amdgpu/{major_minor}/rhel/{rhel_version}`
236    ///
237    /// For SUSE:
238    /// `https://repo.radeon.com/amdgpu/{major_minor}/sle/{sles_version}`
239    pub fn repo_base_url(&self, distro: &DistroFacade) -> String {
240        let major_minor = self.config.channel.major_minor();
241        if distro.uses_apt() {
242            let codename = if distro.codename().is_empty() {
243                "noble"
244            } else {
245                distro.codename()
246            };
247            format!("https://repo.radeon.com/amdgpu/{major_minor}/ubuntu/{codename}")
248        } else if distro.uses_dnf() || distro.uses_yum() {
249            let rhel_version = Self::rhel_version(distro);
250            format!("https://repo.radeon.com/amdgpu/{major_minor}/rhel/{rhel_version}")
251        } else if distro.uses_zypper() {
252            format!("https://repo.radeon.com/amdgpu/{major_minor}/sle/15.7")
253        } else {
254            // Fallback for Arch and others
255            format!("https://repo.radeon.com/amdgpu/{major_minor}/ubuntu/noble")
256        }
257    }
258
259    /// Get the ROCm core repo URL for apt-based distros.
260    pub fn rocm_core_repo_url(&self, _distro: &DistroFacade) -> String {
261        let major_minor = self.config.channel.major_minor();
262        format!("https://repo.radeon.com/rocm/apt/{major_minor}")
263    }
264
265    /// Get the GPG key URL.
266    pub fn gpg_key_url(&self) -> &'static str {
267        "https://repo.radeon.com/rocm/rocm.gpg.key"
268    }
269
270    /// Derive the RHEL major version from distro info.
271    fn rhel_version(distro: &DistroFacade) -> &'static str {
272        let v = distro.version();
273        if v.starts_with("10") {
274            "10"
275        } else if v.starts_with("9") {
276            "9"
277        } else if v.starts_with("8") {
278            "8"
279        } else {
280            "9"
281        }
282    }
283
284    // -----------------------------------------------------------------------
285    // Package command construction (VAL-INSTALL-001)
286    // -----------------------------------------------------------------------
287
288    /// Construct the amdgpu-install package download URL for apt-based distros.
289    pub fn amdgpu_install_deb_url(&self) -> String {
290        let release_dir = self.config.channel.dir_path();
291        let pkg_ver = self.config.channel.pkg_version();
292        format!(
293            "https://repo.radeon.com/amdgpu-install/{release_dir}/ubuntu/noble/amdgpu-install_{pkg_ver}_all.deb"
294        )
295    }
296
297    /// Construct the amdgpu-install RPM download URL.
298    pub fn amdgpu_install_rpm_url(&self, rhel_version: &str) -> String {
299        let release_dir = self.config.channel.dir_path();
300        let pkg_ver = self.config.channel.pkg_version();
301        format!(
302            "https://repo.radeon.com/amdgpu-install/{release_dir}/rhel/{rhel_version}/amdgpu-install-{pkg_ver}.el{rhel_version}.noarch.rpm"
303        )
304    }
305
306    /// Construct the amdgpu-install SLES RPM download URL.
307    pub fn amdgpu_install_sles_rpm_url(&self) -> String {
308        let release_dir = self.config.channel.dir_path();
309        let pkg_ver = self.config.channel.pkg_version();
310        format!(
311            "https://repo.radeon.com/amdgpu-install/{release_dir}/sle/15.7/amdgpu-install-{pkg_ver}.noarch.rpm"
312        )
313    }
314
315    /// Construct the apt-get install command for amdgpu-install.
316    pub fn apt_install_commands(&self) -> Vec<PackageCommand> {
317        let deb_url = self.amdgpu_install_deb_url();
318        vec![
319            // Download the .deb
320            PackageCommand {
321                program: "wget".to_string(),
322                args: vec!["-q".to_string(), deb_url.clone()],
323            },
324            // Install it
325            PackageCommand {
326                program: "sudo".to_string(),
327                args: vec![
328                    "dpkg".to_string(),
329                    "-i".to_string(),
330                    format!(
331                        "amdgpu-install_{}_all.deb",
332                        self.config.channel.pkg_version()
333                    ),
334                ],
335            },
336            // Update and install ROCm packages
337            PackageCommand {
338                program: "sudo".to_string(),
339                args: vec!["apt-get".to_string(), "update".to_string()],
340            },
341            // Install ROCm metapackage based on install type
342            self.apt_rocm_metapackage_command(),
343        ]
344    }
345
346    /// Get the apt metapackage install command for the selected install type.
347    fn apt_rocm_metapackage_command(&self) -> PackageCommand {
348        let metapkg = match self.config.install_type {
349            RocmInstallType::Standard => "rocm-libs",
350            RocmInstallType::Minimal => "rocm-core",
351            RocmInstallType::Full => "rocm-dev",
352            RocmInstallType::Custom => "rocm-libs",
353        };
354        PackageCommand {
355            program: "sudo".to_string(),
356            args: vec![
357                "apt-get".to_string(),
358                "install".to_string(),
359                "-y".to_string(),
360                metapkg.to_string(),
361            ],
362        }
363    }
364
365    /// Construct the dnf install commands for ROCm.
366    pub fn dnf_install_commands(&self, rhel_version: &str) -> Vec<PackageCommand> {
367        let rpm_url = self.amdgpu_install_rpm_url(rhel_version);
368        vec![
369            PackageCommand {
370                program: "sudo".to_string(),
371                args: vec![
372                    "dnf".to_string(),
373                    "install".to_string(),
374                    "-y".to_string(),
375                    rpm_url,
376                ],
377            },
378            self.dnf_rocm_metapackage_command(),
379        ]
380    }
381
382    /// Get the dnf metapackage install command.
383    fn dnf_rocm_metapackage_command(&self) -> PackageCommand {
384        let metapkg = match self.config.install_type {
385            RocmInstallType::Standard => "rocm-libs",
386            RocmInstallType::Minimal => "rocm-core",
387            RocmInstallType::Full => "rocm-dev",
388            RocmInstallType::Custom => "rocm-libs",
389        };
390        PackageCommand {
391            program: "sudo".to_string(),
392            args: vec![
393                "dnf".to_string(),
394                "install".to_string(),
395                "-y".to_string(),
396                metapkg.to_string(),
397            ],
398        }
399    }
400
401    /// Construct the pacman install commands for ROCm on Arch.
402    ///
403    /// Arch uses AUR packages (yay/paru) for ROCm.
404    /// Build the pacman/AUR (`yay`) install command for ROCm.
405    ///
406    /// `yay` MUST run as the user — makepkg refuses root and `yay` warns
407    /// "Avoid running yay as root/sudo". This therefore returns a SINGLE
408    /// user-space command, never wrapped in `sudo`. (A bare `sudo yay` with no
409    /// operation defaults to `yay -Syu`, i.e. a full system upgrade, which is
410    /// what previously aborted ROCm installs.) The caller feeds yay's internal
411    /// `sudo pacman` non-interactively — e.g. `SUDO_ASKPASS` +
412    /// `--sudoflags=-A`, as the installer's Arch path does.
413    pub fn pacman_install_commands(&self, aur_helper: &str) -> Vec<PackageCommand> {
414        let packages = self.pacman_rocm_packages();
415        let mut args = vec![
416            "-S".to_string(),
417            "--needed".to_string(),
418            "--noconfirm".to_string(),
419        ];
420        args.extend(packages);
421
422        vec![PackageCommand {
423            program: aur_helper.to_string(),
424            args,
425        }]
426    }
427
428    /// Get the list of ROCm AUR packages for pacman-based installs.
429    pub fn pacman_rocm_packages(&self) -> Vec<String> {
430        let mut pkgs = vec![
431            "rocm-hip-sdk".to_string(),
432            "rocminfo".to_string(),
433            "rocm-smi-lib".to_string(),
434            "hipblas".to_string(),
435            "rocblas".to_string(),
436            "hipsparselt".to_string(),
437        ];
438        // Add extra packages for Stable/Latest channels
439        if self.config.channel != RocmChannel::Legacy {
440            pkgs.push("rocm-opencl-sdk".to_string());
441            pkgs.push("rccl".to_string());
442            // rocwmma: RDNA3+ WMMA flash-attention backend (used by Rusty Llama's
443            // fattn-wmma-f16). Optional at build time (the fork gates on it), but
444            // install it so the WMMA FA path is available out of the box.
445            pkgs.push("rocwmma".to_string());
446        }
447        pkgs
448    }
449
450    /// Construct the zypper install commands for ROCm on SUSE.
451    pub fn zypper_install_commands(&self) -> Vec<PackageCommand> {
452        let rpm_url = self.amdgpu_install_sles_rpm_url();
453        vec![
454            PackageCommand {
455                program: "sudo".to_string(),
456                args: vec![
457                    "zypper".to_string(),
458                    "--non-interactive".to_string(),
459                    "install".to_string(),
460                    rpm_url,
461                ],
462            },
463            PackageCommand {
464                program: "sudo".to_string(),
465                args: vec![
466                    "zypper".to_string(),
467                    "--non-interactive".to_string(),
468                    "install".to_string(),
469                    "-y".to_string(),
470                    "rocm-libs".to_string(),
471                ],
472            },
473        ]
474    }
475
476    /// Get the repository configuration for apt-based distros.
477    pub fn apt_repo_config(&self, distro: &DistroFacade) -> RepoConfig {
478        let codename = if distro.codename().is_empty() {
479            "noble"
480        } else {
481            distro.codename()
482        };
483        let major_minor = self.config.channel.major_minor();
484        RepoConfig {
485            repo_type: "apt".to_string(),
486            url: format!("deb [signed-by=/etc/apt/keyrings/rocm.gpg] https://repo.radeon.com/rocm/apt/{major_minor} {codename} main"),
487            gpg_key_url: "https://repo.radeon.com/rocm/rocm.gpg.key".to_string(),
488            file_path: "/etc/apt/sources.list.d/rocm.list".to_string(),
489        }
490    }
491
492    /// Get the amdgpu repo configuration for apt-based distros.
493    pub fn amdgpu_apt_repo_config(&self, distro: &DistroFacade) -> RepoConfig {
494        let codename = if distro.codename().is_empty() {
495            "noble"
496        } else {
497            distro.codename()
498        };
499        let major_minor = self.config.channel.major_minor();
500        RepoConfig {
501            repo_type: "apt".to_string(),
502            url: format!("deb [signed-by=/etc/apt/keyrings/rocm.gpg] https://repo.radeon.com/amdgpu/{major_minor}/ubuntu/{codename} {codename} main"),
503            gpg_key_url: "https://repo.radeon.com/rocm/rocm.gpg.key".to_string(),
504            file_path: "/etc/apt/sources.list.d/amdgpu.list".to_string(),
505        }
506    }
507
508    /// Get the ROCm preference pin configuration for apt.
509    pub fn apt_pin_config(&self) -> (String, String) {
510        (
511            "Package: *\nPin: release o=repo.radeon.com\nPin-Priority: 600\n".to_string(),
512            "/etc/apt/preferences.d/rocm-pin-600".to_string(),
513        )
514    }
515
516    /// Build a command to check if a single package is installed on Arch/pacman.
517    ///
518    /// Returns a `PackageCommand` that runs `pacman -Qi <package>`.
519    /// Exit code 0 means installed, non-zero means not installed.
520    pub fn build_pacman_check_installed_command(package: &str) -> PackageCommand {
521        PackageCommand {
522            program: "pacman".to_string(),
523            args: vec!["-Qi".to_string(), package.to_string()],
524        }
525    }
526
527    /// Filter out packages that are already installed on Arch/pacman systems.
528    ///
529    /// For each package in `packages`, runs `pacman -Qi <pkg>` to check if
530    /// it's installed. Returns only the packages that are NOT yet installed.
531    /// In dry-run mode, returns all packages (no checks performed).
532    pub fn filter_already_installed_pacman(packages: &[String]) -> Vec<String> {
533        use std::process::Command;
534
535        packages
536            .iter()
537            .filter(|pkg| {
538                let status = Command::new("pacman").args(["-Qi", pkg.as_str()]).output();
539                match status {
540                    Ok(output) => !output.status.success(),
541                    Err(_) => true, // If pacman fails to run, assume not installed
542                }
543            })
544            .cloned()
545            .collect()
546    }
547
548    /// Check if command output indicates a package is already up to date.
549    ///
550    /// yay/pacman output patterns that should be treated as success:
551    /// - `warning: <pkg> is up to date -- skipping`
552    /// - `<pkg> is up to date -- skipping`
553    /// - `there is nothing to do`
554    /// - `already installed`
555    /// - `.rc files restored` (post-install message, not an error)
556    pub fn is_up_to_date_output(output: &str) -> bool {
557        let lower = output.to_lowercase();
558        // yay/pacman "up to date" patterns
559        lower.contains("up to date -- skipping")
560            || lower.contains("up to date, skipping")
561            || lower.contains("up to date -- reinstalling")
562            || lower.contains("there is nothing to do")
563            || lower.contains("already installed")
564            // pacman -S on already-installed package
565            || lower.contains("warning:") && lower.contains("up to date")
566    }
567}
568
569// ===========================================================================
570// Tests
571// ===========================================================================
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576    use crate::platform::detection::{DistroFamily, DistroInfo, PackageManager};
577
578    fn ubuntu_distro() -> DistroFacade {
579        DistroFacade::from_info(DistroInfo {
580            id: "ubuntu".to_string(),
581            name: "Ubuntu 24.04 LTS".to_string(),
582            version: "24.04".to_string(),
583            codename: "noble".to_string(),
584            family: DistroFamily::Debian,
585            pkg_manager: PackageManager::Apt,
586            id_like: "debian".to_string(),
587        })
588    }
589
590    fn fedora_distro() -> DistroFacade {
591        DistroFacade::from_info(DistroInfo {
592            id: "fedora".to_string(),
593            name: "Fedora Linux 41".to_string(),
594            version: "41".to_string(),
595            codename: String::new(),
596            family: DistroFamily::Rhel,
597            pkg_manager: PackageManager::Dnf,
598            id_like: "fedora".to_string(),
599        })
600    }
601
602    fn rhel9_distro() -> DistroFacade {
603        DistroFacade::from_info(DistroInfo {
604            id: "rhel".to_string(),
605            name: "Red Hat Enterprise Linux 9".to_string(),
606            version: "9.3".to_string(),
607            codename: String::new(),
608            family: DistroFamily::Rhel,
609            pkg_manager: PackageManager::Dnf,
610            id_like: "rhel".to_string(),
611        })
612    }
613
614    fn suse_distro() -> DistroFacade {
615        DistroFacade::from_info(DistroInfo {
616            id: "opensuse-leap".to_string(),
617            name: "openSUSE Leap 15.7".to_string(),
618            version: "15.7".to_string(),
619            codename: String::new(),
620            family: DistroFamily::Suse,
621            pkg_manager: PackageManager::Zypper,
622            id_like: "suse".to_string(),
623        })
624    }
625
626    // --- VAL-INSTALL-002: ROCm channel selection correct version pins ---
627
628    #[test]
629    fn test_channel_legacy_version() {
630        let ch = RocmChannel::Legacy;
631        assert_eq!(ch.version(), "6.4.3");
632        assert_eq!(ch.pkg_version(), "6.4.60403-1");
633        assert_eq!(ch.major_minor(), "6.4");
634    }
635
636    #[test]
637    fn test_channel_stable_version() {
638        let ch = RocmChannel::Stable;
639        assert_eq!(ch.version(), "7.2.3");
640        assert_eq!(ch.pkg_version(), "7.2.3.70203-1");
641        assert_eq!(ch.major_minor(), "7.2");
642    }
643
644    #[test]
645    fn test_channel_latest_version() {
646        let ch = RocmChannel::Latest;
647        assert_eq!(ch.version(), "7.2.4");
648        assert_eq!(ch.pkg_version(), "7.2.4.70204-1");
649        assert_eq!(ch.major_minor(), "7.2");
650    }
651
652    #[test]
653    fn test_channel_from_choice() {
654        assert_eq!(RocmChannel::from_choice(1), Some(RocmChannel::Legacy));
655        assert_eq!(RocmChannel::from_choice(2), Some(RocmChannel::Stable));
656        assert_eq!(RocmChannel::from_choice(3), Some(RocmChannel::Latest));
657        assert_eq!(RocmChannel::from_choice(0), None);
658        assert_eq!(RocmChannel::from_choice(4), None);
659    }
660
661    #[test]
662    fn test_pkg_version_from_manifest_latest() {
663        let manifest = crate::core::manifest::Manifest {
664            schema_version: 2,
665            sequence: 1,
666            generated_at: "2026-05-31T00:00:00Z".to_string(),
667            expires_at: None,
668            min_runtime_version: String::new(),
669            components: vec![crate::core::manifest::ManifestComponent {
670                id: "rocm".to_string(),
671                version: "7.2.4".to_string(),
672                script: "scripts/install_rocm.sh".to_string(),
673                category: crate::core::Category::Foundation,
674                validation_tier: crate::core::ValidationTier::Validated,
675                min_rocm_version: String::new(),
676                dependencies: vec![],
677                compatible_channels: vec!["latest".to_string()],
678                exclusive_group: String::new(),
679            }],
680            signature: None,
681        };
682
683        assert_eq!(
684            RocmChannel::pkg_version_from_manifest(&manifest),
685            "7.2.4.70204-1"
686        );
687    }
688
689    #[test]
690    fn test_pkg_version_from_manifest_legacy() {
691        let manifest = crate::core::manifest::Manifest {
692            schema_version: 2,
693            sequence: 1,
694            generated_at: "2026-05-31T00:00:00Z".to_string(),
695            expires_at: None,
696            min_runtime_version: String::new(),
697            components: vec![crate::core::manifest::ManifestComponent {
698                id: "rocm".to_string(),
699                version: "6.4.3".to_string(),
700                script: "scripts/install_rocm.sh".to_string(),
701                category: crate::core::Category::Foundation,
702                validation_tier: crate::core::ValidationTier::Validated,
703                min_rocm_version: String::new(),
704                dependencies: vec![],
705                compatible_channels: vec!["legacy".to_string()],
706                exclusive_group: String::new(),
707            }],
708            signature: None,
709        };
710
711        assert_eq!(
712            RocmChannel::pkg_version_from_manifest(&manifest),
713            "6.4.60403-1"
714        );
715    }
716
717    // --- VAL-INSTALL-001: ROCm installer correct package commands ---
718
719    #[test]
720    fn test_apt_install_commands_latest() {
721        let installer = RocmInstaller::new(RocmConfig {
722            channel: RocmChannel::Latest,
723            install_type: RocmInstallType::Standard,
724            ..Default::default()
725        });
726        let cmds = installer.apt_install_commands();
727        assert_eq!(cmds.len(), 4);
728
729        // wget download
730        assert_eq!(cmds[0].program, "wget");
731        assert!(cmds[0]
732            .args
733            .iter()
734            .any(|a| a.contains("7.2.4/ubuntu/noble")));
735        assert!(cmds[0].args.iter().any(|a| a.contains("7.2.4.70204-1")));
736        assert_eq!(cmds[1].program, "sudo");
737        assert!(cmds[1].args.contains(&"dpkg".to_string()));
738
739        // apt-get update
740        assert_eq!(cmds[2].program, "sudo");
741        assert!(cmds[2].args.contains(&"update".to_string()));
742
743        // rocm-libs metapackage
744        assert_eq!(cmds[3].program, "sudo");
745        assert!(cmds[3].args.contains(&"rocm-libs".to_string()));
746    }
747
748    #[test]
749    fn test_apt_install_commands_full() {
750        let installer = RocmInstaller::new(RocmConfig {
751            channel: RocmChannel::Stable,
752            install_type: RocmInstallType::Full,
753            ..Default::default()
754        });
755        let cmds = installer.apt_install_commands();
756        let meta_cmd = &cmds[3];
757        assert!(meta_cmd.args.contains(&"rocm-dev".to_string()));
758    }
759
760    #[test]
761    fn test_dnf_install_commands() {
762        let installer = RocmInstaller::new(RocmConfig {
763            channel: RocmChannel::Latest,
764            install_type: RocmInstallType::Standard,
765            ..Default::default()
766        });
767        let cmds = installer.dnf_install_commands("9");
768        assert_eq!(cmds.len(), 2);
769
770        // dnf install RPM
771        assert_eq!(cmds[0].program, "sudo");
772        assert!(cmds[0].args.iter().any(|a| a.contains("7.2.4/rhel/9")));
773        assert!(cmds[0].args.iter().any(|a| a.contains("7.2.4.70204-1")));
774        assert!(cmds[0].args.iter().any(|a| a.contains("el9")));
775
776        // rocm-libs metapackage
777        assert_eq!(cmds[1].program, "sudo");
778        assert!(cmds[1].args.contains(&"rocm-libs".to_string()));
779    }
780
781    #[test]
782    fn test_pacman_install_commands() {
783        let installer = RocmInstaller::new(RocmConfig {
784            channel: RocmChannel::Latest,
785            install_type: RocmInstallType::Standard,
786            ..Default::default()
787        });
788        let cmds = installer.pacman_install_commands("yay");
789        // Single user-space `yay` command — never a bare `sudo yay` (which
790        // defaults to `yay -Syu`, a full system upgrade as root). Regression
791        // guard for the malformed two-command [sudo yay, yay …] that aborted
792        // ROCm installs on Arch.
793        assert_eq!(
794            cmds.len(),
795            1,
796            "pacman install must be a single user-space yay command"
797        );
798        assert_eq!(cmds[0].program, "yay");
799        assert!(
800            !cmds.iter().any(|c| c.program == "sudo"),
801            "yay must not run under sudo"
802        );
803        assert!(cmds[0].args.contains(&"-S".to_string()));
804        assert!(cmds[0].args.contains(&"--needed".to_string()));
805        assert!(cmds[0].args.contains(&"--noconfirm".to_string()));
806        // Should have packages for Latest channel
807        let pkgs = installer.pacman_rocm_packages();
808        assert!(pkgs.contains(&"rocm-hip-sdk".to_string()));
809        assert!(pkgs.contains(&"rocminfo".to_string()));
810        assert!(pkgs.contains(&"rocm-opencl-sdk".to_string())); // Latest gets extra
811        assert!(pkgs.contains(&"rccl".to_string())); // Latest gets extra
812        assert!(pkgs.contains(&"rocwmma".to_string())); // Latest: WMMA FA backend
813        for p in &pkgs {
814            assert!(cmds[0].args.contains(p), "yay args missing package {p}");
815        }
816    }
817
818    #[test]
819    fn test_pacman_legacy_no_extras() {
820        let installer = RocmInstaller::new(RocmConfig {
821            channel: RocmChannel::Legacy,
822            install_type: RocmInstallType::Standard,
823            ..Default::default()
824        });
825        let pkgs = installer.pacman_rocm_packages();
826        assert!(!pkgs.contains(&"rocm-opencl-sdk".to_string()));
827        assert!(!pkgs.contains(&"rccl".to_string()));
828        assert!(!pkgs.contains(&"rocwmma".to_string())); // Legacy (RDNA2): no WMMA
829    }
830
831    #[test]
832    fn test_zypper_install_commands() {
833        let installer = RocmInstaller::new(RocmConfig {
834            channel: RocmChannel::Latest,
835            install_type: RocmInstallType::Standard,
836            ..Default::default()
837        });
838        let cmds = installer.zypper_install_commands();
839        assert_eq!(cmds.len(), 2);
840        assert_eq!(cmds[0].program, "sudo");
841        assert!(cmds[0].args.iter().any(|a| a.contains("zypper")));
842        assert!(cmds[0].args.iter().any(|a| a.contains("7.2.4/sle/15.7")));
843        assert!(cmds[0].args.iter().any(|a| a.contains("7.2.4.70204-1")));
844    }
845
846    // --- Repo URL construction ---
847
848    #[test]
849    fn test_repo_base_url_ubuntu() {
850        let installer = RocmInstaller::new(RocmConfig {
851            channel: RocmChannel::Latest,
852            ..Default::default()
853        });
854        let distro = ubuntu_distro();
855        let url = installer.repo_base_url(&distro);
856        assert_eq!(url, "https://repo.radeon.com/amdgpu/7.2/ubuntu/noble");
857    }
858
859    #[test]
860    fn test_repo_base_url_rhel() {
861        let installer = RocmInstaller::new(RocmConfig {
862            channel: RocmChannel::Latest,
863            ..Default::default()
864        });
865        let distro = rhel9_distro();
866        let url = installer.repo_base_url(&distro);
867        assert_eq!(url, "https://repo.radeon.com/amdgpu/7.2/rhel/9");
868    }
869
870    #[test]
871    fn test_repo_base_url_fedora() {
872        let installer = RocmInstaller::new(RocmConfig {
873            channel: RocmChannel::Stable,
874            ..Default::default()
875        });
876        let distro = fedora_distro();
877        let url = installer.repo_base_url(&distro);
878        // Fedora version "41" doesn't match 8/9/10, defaults to 9
879        assert_eq!(url, "https://repo.radeon.com/amdgpu/7.2/rhel/9");
880    }
881
882    #[test]
883    fn test_repo_base_url_suse() {
884        let installer = RocmInstaller::new(RocmConfig {
885            channel: RocmChannel::Latest,
886            ..Default::default()
887        });
888        let distro = suse_distro();
889        let url = installer.repo_base_url(&distro);
890        assert_eq!(url, "https://repo.radeon.com/amdgpu/7.2/sle/15.7");
891    }
892
893    #[test]
894    fn test_amdgpu_install_deb_url() {
895        let installer = RocmInstaller::new(RocmConfig {
896            channel: RocmChannel::Latest,
897            ..Default::default()
898        });
899        let url = installer.amdgpu_install_deb_url();
900        assert!(url.contains("7.2.4/ubuntu/noble"));
901        assert!(url.contains("7.2.4.70204-1"));
902        assert!(url.contains("ubuntu/noble"));
903        assert!(url.ends_with(".deb"));
904    }
905
906    #[test]
907    fn test_amdgpu_install_rpm_url() {
908        let installer = RocmInstaller::new(RocmConfig {
909            channel: RocmChannel::Latest,
910            ..Default::default()
911        });
912        let url = installer.amdgpu_install_rpm_url("9");
913        assert!(url.contains("7.2.4/rhel/9"));
914        assert!(url.contains("7.2.4.70204-1"));
915        assert!(url.contains("rhel/9"));
916        assert!(url.ends_with(".noarch.rpm"));
917    }
918
919    #[test]
920    fn test_apt_repo_config() {
921        let installer = RocmInstaller::new(RocmConfig {
922            channel: RocmChannel::Latest,
923            ..Default::default()
924        });
925        let distro = ubuntu_distro();
926        let repo = installer.apt_repo_config(&distro);
927        assert_eq!(repo.file_path, "/etc/apt/sources.list.d/rocm.list");
928        assert!(repo.url.contains("rocm/apt/7.2"));
929        assert!(repo.url.contains("noble"));
930    }
931
932    #[test]
933    fn test_amdgpu_apt_repo_config() {
934        let installer = RocmInstaller::new(RocmConfig {
935            channel: RocmChannel::Latest,
936            ..Default::default()
937        });
938        let distro = ubuntu_distro();
939        let repo = installer.amdgpu_apt_repo_config(&distro);
940        assert_eq!(repo.file_path, "/etc/apt/sources.list.d/amdgpu.list");
941        assert!(repo.url.contains("amdgpu/7.2"));
942    }
943
944    #[test]
945    fn test_apt_pin_config() {
946        let installer = RocmInstaller::with_defaults();
947        let (content, path) = installer.apt_pin_config();
948        assert_eq!(path, "/etc/apt/preferences.d/rocm-pin-600");
949        assert!(content.contains("Pin-Priority: 600"));
950    }
951
952    // --- Cross-channel parametric tests ---
953
954    #[test]
955    fn test_all_channels_version_consistency() {
956        for choice in [1u8, 2, 3] {
957            let ch = RocmChannel::from_choice(choice).unwrap();
958            let config = RocmConfig {
959                channel: ch,
960                ..Default::default()
961            };
962            let installer = RocmInstaller::new(config);
963
964            // Verify deb URL contains correct package version
965            let deb_url = installer.amdgpu_install_deb_url();
966            assert!(
967                deb_url.contains(ch.pkg_version()),
968                "deb URL for {:?} should contain pkg_version {}",
969                ch,
970                ch.pkg_version()
971            );
972
973            // Verify repo URL contains major_minor
974            let distro = ubuntu_distro();
975            let repo_url = installer.repo_base_url(&distro);
976            assert!(
977                repo_url.contains(ch.major_minor()),
978                "repo URL for {:?} should contain major_minor {}",
979                ch,
980                ch.major_minor()
981            );
982        }
983    }
984
985    #[test]
986    fn test_install_type_metapackages() {
987        for (itype, expected_pkg) in [
988            (RocmInstallType::Standard, "rocm-libs"),
989            (RocmInstallType::Minimal, "rocm-core"),
990            (RocmInstallType::Full, "rocm-dev"),
991            (RocmInstallType::Custom, "rocm-libs"),
992        ] {
993            let installer = RocmInstaller::new(RocmConfig {
994                install_type: itype,
995                ..Default::default()
996            });
997            let cmds = installer.apt_install_commands();
998            let meta_cmd = &cmds[3];
999            assert!(
1000                meta_cmd.args.contains(&expected_pkg.to_string()),
1001                "Install type {:?} should use metapackage {}",
1002                itype,
1003                expected_pkg
1004            );
1005        }
1006    }
1007
1008    // --- Up-to-date output detection ---
1009
1010    #[test]
1011    fn test_is_up_to_date_yay_warning() {
1012        assert!(RocmInstaller::is_up_to_date_output(
1013            "warning: rocminfo is up to date -- skipping"
1014        ));
1015    }
1016
1017    #[test]
1018    fn test_is_up_to_date_yay_warning_case_insensitive() {
1019        assert!(RocmInstaller::is_up_to_date_output(
1020            "WARNING: rocminfo is UP TO DATE -- skipping"
1021        ));
1022    }
1023
1024    #[test]
1025    fn test_is_up_to_date_nothing_to_do() {
1026        assert!(RocmInstaller::is_up_to_date_output(
1027            " there is nothing to do\n"
1028        ));
1029    }
1030
1031    #[test]
1032    fn test_is_up_to_date_already_installed() {
1033        assert!(RocmInstaller::is_up_to_date_output(
1034            "package already installed"
1035        ));
1036    }
1037
1038    #[test]
1039    fn test_is_up_to_date_normal_output_is_false() {
1040        assert!(!RocmInstaller::is_up_to_date_output(
1041            "resolving dependencies...\nlooking for conflicting packages...\n"
1042        ));
1043    }
1044
1045    #[test]
1046    fn test_is_up_to_date_error_output_is_false() {
1047        assert!(!RocmInstaller::is_up_to_date_output(
1048            "error: failed to prepare transaction\nerror: target not found: rocminfo"
1049        ));
1050    }
1051
1052    #[test]
1053    fn test_is_up_to_date_empty_output_is_false() {
1054        assert!(!RocmInstaller::is_up_to_date_output(""));
1055    }
1056
1057    #[test]
1058    fn test_is_up_to_date_pacman_skipping() {
1059        assert!(RocmInstaller::is_up_to_date_output(
1060            "warning: rocm-hip-sdk is up to date -- skipping"
1061        ));
1062    }
1063
1064    // --- Pacman check-installed command construction ---
1065
1066    #[test]
1067    fn test_build_pacman_check_installed_command() {
1068        let cmd = RocmInstaller::build_pacman_check_installed_command("rocminfo");
1069        assert_eq!(cmd.program, "pacman");
1070        assert_eq!(cmd.args, vec!["-Qi", "rocminfo"]);
1071    }
1072
1073    #[test]
1074    fn test_build_pacman_check_installed_command_complex_pkg() {
1075        let cmd = RocmInstaller::build_pacman_check_installed_command("rocm-hip-sdk");
1076        assert_eq!(cmd.program, "pacman");
1077        assert!(cmd.args.contains(&"rocm-hip-sdk".to_string()));
1078    }
1079}