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        }
443        pkgs
444    }
445
446    /// Construct the zypper install commands for ROCm on SUSE.
447    pub fn zypper_install_commands(&self) -> Vec<PackageCommand> {
448        let rpm_url = self.amdgpu_install_sles_rpm_url();
449        vec![
450            PackageCommand {
451                program: "sudo".to_string(),
452                args: vec![
453                    "zypper".to_string(),
454                    "--non-interactive".to_string(),
455                    "install".to_string(),
456                    rpm_url,
457                ],
458            },
459            PackageCommand {
460                program: "sudo".to_string(),
461                args: vec![
462                    "zypper".to_string(),
463                    "--non-interactive".to_string(),
464                    "install".to_string(),
465                    "-y".to_string(),
466                    "rocm-libs".to_string(),
467                ],
468            },
469        ]
470    }
471
472    /// Get the repository configuration for apt-based distros.
473    pub fn apt_repo_config(&self, distro: &DistroFacade) -> RepoConfig {
474        let codename = if distro.codename().is_empty() {
475            "noble"
476        } else {
477            distro.codename()
478        };
479        let major_minor = self.config.channel.major_minor();
480        RepoConfig {
481            repo_type: "apt".to_string(),
482            url: format!("deb [signed-by=/etc/apt/keyrings/rocm.gpg] https://repo.radeon.com/rocm/apt/{major_minor} {codename} main"),
483            gpg_key_url: "https://repo.radeon.com/rocm/rocm.gpg.key".to_string(),
484            file_path: "/etc/apt/sources.list.d/rocm.list".to_string(),
485        }
486    }
487
488    /// Get the amdgpu repo configuration for apt-based distros.
489    pub fn amdgpu_apt_repo_config(&self, distro: &DistroFacade) -> RepoConfig {
490        let codename = if distro.codename().is_empty() {
491            "noble"
492        } else {
493            distro.codename()
494        };
495        let major_minor = self.config.channel.major_minor();
496        RepoConfig {
497            repo_type: "apt".to_string(),
498            url: format!("deb [signed-by=/etc/apt/keyrings/rocm.gpg] https://repo.radeon.com/amdgpu/{major_minor}/ubuntu/{codename} {codename} main"),
499            gpg_key_url: "https://repo.radeon.com/rocm/rocm.gpg.key".to_string(),
500            file_path: "/etc/apt/sources.list.d/amdgpu.list".to_string(),
501        }
502    }
503
504    /// Get the ROCm preference pin configuration for apt.
505    pub fn apt_pin_config(&self) -> (String, String) {
506        (
507            "Package: *\nPin: release o=repo.radeon.com\nPin-Priority: 600\n".to_string(),
508            "/etc/apt/preferences.d/rocm-pin-600".to_string(),
509        )
510    }
511
512    /// Build a command to check if a single package is installed on Arch/pacman.
513    ///
514    /// Returns a `PackageCommand` that runs `pacman -Qi <package>`.
515    /// Exit code 0 means installed, non-zero means not installed.
516    pub fn build_pacman_check_installed_command(package: &str) -> PackageCommand {
517        PackageCommand {
518            program: "pacman".to_string(),
519            args: vec!["-Qi".to_string(), package.to_string()],
520        }
521    }
522
523    /// Filter out packages that are already installed on Arch/pacman systems.
524    ///
525    /// For each package in `packages`, runs `pacman -Qi <pkg>` to check if
526    /// it's installed. Returns only the packages that are NOT yet installed.
527    /// In dry-run mode, returns all packages (no checks performed).
528    pub fn filter_already_installed_pacman(packages: &[String]) -> Vec<String> {
529        use std::process::Command;
530
531        packages
532            .iter()
533            .filter(|pkg| {
534                let status = Command::new("pacman").args(["-Qi", pkg.as_str()]).output();
535                match status {
536                    Ok(output) => !output.status.success(),
537                    Err(_) => true, // If pacman fails to run, assume not installed
538                }
539            })
540            .cloned()
541            .collect()
542    }
543
544    /// Check if command output indicates a package is already up to date.
545    ///
546    /// yay/pacman output patterns that should be treated as success:
547    /// - `warning: <pkg> is up to date -- skipping`
548    /// - `<pkg> is up to date -- skipping`
549    /// - `there is nothing to do`
550    /// - `already installed`
551    /// - `.rc files restored` (post-install message, not an error)
552    pub fn is_up_to_date_output(output: &str) -> bool {
553        let lower = output.to_lowercase();
554        // yay/pacman "up to date" patterns
555        lower.contains("up to date -- skipping")
556            || lower.contains("up to date, skipping")
557            || lower.contains("up to date -- reinstalling")
558            || lower.contains("there is nothing to do")
559            || lower.contains("already installed")
560            // pacman -S on already-installed package
561            || lower.contains("warning:") && lower.contains("up to date")
562    }
563}
564
565// ===========================================================================
566// Tests
567// ===========================================================================
568
569#[cfg(test)]
570mod tests {
571    use super::*;
572    use crate::platform::detection::{DistroFamily, DistroInfo, PackageManager};
573
574    fn ubuntu_distro() -> DistroFacade {
575        DistroFacade::from_info(DistroInfo {
576            id: "ubuntu".to_string(),
577            name: "Ubuntu 24.04 LTS".to_string(),
578            version: "24.04".to_string(),
579            codename: "noble".to_string(),
580            family: DistroFamily::Debian,
581            pkg_manager: PackageManager::Apt,
582            id_like: "debian".to_string(),
583        })
584    }
585
586    fn fedora_distro() -> DistroFacade {
587        DistroFacade::from_info(DistroInfo {
588            id: "fedora".to_string(),
589            name: "Fedora Linux 41".to_string(),
590            version: "41".to_string(),
591            codename: String::new(),
592            family: DistroFamily::Rhel,
593            pkg_manager: PackageManager::Dnf,
594            id_like: "fedora".to_string(),
595        })
596    }
597
598    fn rhel9_distro() -> DistroFacade {
599        DistroFacade::from_info(DistroInfo {
600            id: "rhel".to_string(),
601            name: "Red Hat Enterprise Linux 9".to_string(),
602            version: "9.3".to_string(),
603            codename: String::new(),
604            family: DistroFamily::Rhel,
605            pkg_manager: PackageManager::Dnf,
606            id_like: "rhel".to_string(),
607        })
608    }
609
610    fn suse_distro() -> DistroFacade {
611        DistroFacade::from_info(DistroInfo {
612            id: "opensuse-leap".to_string(),
613            name: "openSUSE Leap 15.7".to_string(),
614            version: "15.7".to_string(),
615            codename: String::new(),
616            family: DistroFamily::Suse,
617            pkg_manager: PackageManager::Zypper,
618            id_like: "suse".to_string(),
619        })
620    }
621
622    // --- VAL-INSTALL-002: ROCm channel selection correct version pins ---
623
624    #[test]
625    fn test_channel_legacy_version() {
626        let ch = RocmChannel::Legacy;
627        assert_eq!(ch.version(), "6.4.3");
628        assert_eq!(ch.pkg_version(), "6.4.60403-1");
629        assert_eq!(ch.major_minor(), "6.4");
630    }
631
632    #[test]
633    fn test_channel_stable_version() {
634        let ch = RocmChannel::Stable;
635        assert_eq!(ch.version(), "7.2.3");
636        assert_eq!(ch.pkg_version(), "7.2.3.70203-1");
637        assert_eq!(ch.major_minor(), "7.2");
638    }
639
640    #[test]
641    fn test_channel_latest_version() {
642        let ch = RocmChannel::Latest;
643        assert_eq!(ch.version(), "7.2.4");
644        assert_eq!(ch.pkg_version(), "7.2.4.70204-1");
645        assert_eq!(ch.major_minor(), "7.2");
646    }
647
648    #[test]
649    fn test_channel_from_choice() {
650        assert_eq!(RocmChannel::from_choice(1), Some(RocmChannel::Legacy));
651        assert_eq!(RocmChannel::from_choice(2), Some(RocmChannel::Stable));
652        assert_eq!(RocmChannel::from_choice(3), Some(RocmChannel::Latest));
653        assert_eq!(RocmChannel::from_choice(0), None);
654        assert_eq!(RocmChannel::from_choice(4), None);
655    }
656
657    #[test]
658    fn test_pkg_version_from_manifest_latest() {
659        let manifest = crate::core::manifest::Manifest {
660            schema_version: 2,
661            sequence: 1,
662            generated_at: "2026-05-31T00:00:00Z".to_string(),
663            expires_at: None,
664            min_runtime_version: String::new(),
665            components: vec![crate::core::manifest::ManifestComponent {
666                id: "rocm".to_string(),
667                version: "7.2.4".to_string(),
668                script: "scripts/install_rocm.sh".to_string(),
669                category: crate::core::Category::Foundation,
670                validation_tier: crate::core::ValidationTier::Validated,
671                min_rocm_version: String::new(),
672                dependencies: vec![],
673                compatible_channels: vec!["latest".to_string()],
674            }],
675            signature: None,
676        };
677
678        assert_eq!(
679            RocmChannel::pkg_version_from_manifest(&manifest),
680            "7.2.4.70204-1"
681        );
682    }
683
684    #[test]
685    fn test_pkg_version_from_manifest_legacy() {
686        let manifest = crate::core::manifest::Manifest {
687            schema_version: 2,
688            sequence: 1,
689            generated_at: "2026-05-31T00:00:00Z".to_string(),
690            expires_at: None,
691            min_runtime_version: String::new(),
692            components: vec![crate::core::manifest::ManifestComponent {
693                id: "rocm".to_string(),
694                version: "6.4.3".to_string(),
695                script: "scripts/install_rocm.sh".to_string(),
696                category: crate::core::Category::Foundation,
697                validation_tier: crate::core::ValidationTier::Validated,
698                min_rocm_version: String::new(),
699                dependencies: vec![],
700                compatible_channels: vec!["legacy".to_string()],
701            }],
702            signature: None,
703        };
704
705        assert_eq!(
706            RocmChannel::pkg_version_from_manifest(&manifest),
707            "6.4.60403-1"
708        );
709    }
710
711    // --- VAL-INSTALL-001: ROCm installer correct package commands ---
712
713    #[test]
714    fn test_apt_install_commands_latest() {
715        let installer = RocmInstaller::new(RocmConfig {
716            channel: RocmChannel::Latest,
717            install_type: RocmInstallType::Standard,
718            ..Default::default()
719        });
720        let cmds = installer.apt_install_commands();
721        assert_eq!(cmds.len(), 4);
722
723        // wget download
724        assert_eq!(cmds[0].program, "wget");
725        assert!(cmds[0]
726            .args
727            .iter()
728            .any(|a| a.contains("7.2.4/ubuntu/noble")));
729        assert!(cmds[0].args.iter().any(|a| a.contains("7.2.4.70204-1")));
730        assert_eq!(cmds[1].program, "sudo");
731        assert!(cmds[1].args.contains(&"dpkg".to_string()));
732
733        // apt-get update
734        assert_eq!(cmds[2].program, "sudo");
735        assert!(cmds[2].args.contains(&"update".to_string()));
736
737        // rocm-libs metapackage
738        assert_eq!(cmds[3].program, "sudo");
739        assert!(cmds[3].args.contains(&"rocm-libs".to_string()));
740    }
741
742    #[test]
743    fn test_apt_install_commands_full() {
744        let installer = RocmInstaller::new(RocmConfig {
745            channel: RocmChannel::Stable,
746            install_type: RocmInstallType::Full,
747            ..Default::default()
748        });
749        let cmds = installer.apt_install_commands();
750        let meta_cmd = &cmds[3];
751        assert!(meta_cmd.args.contains(&"rocm-dev".to_string()));
752    }
753
754    #[test]
755    fn test_dnf_install_commands() {
756        let installer = RocmInstaller::new(RocmConfig {
757            channel: RocmChannel::Latest,
758            install_type: RocmInstallType::Standard,
759            ..Default::default()
760        });
761        let cmds = installer.dnf_install_commands("9");
762        assert_eq!(cmds.len(), 2);
763
764        // dnf install RPM
765        assert_eq!(cmds[0].program, "sudo");
766        assert!(cmds[0].args.iter().any(|a| a.contains("7.2.4/rhel/9")));
767        assert!(cmds[0].args.iter().any(|a| a.contains("7.2.4.70204-1")));
768        assert!(cmds[0].args.iter().any(|a| a.contains("el9")));
769
770        // rocm-libs metapackage
771        assert_eq!(cmds[1].program, "sudo");
772        assert!(cmds[1].args.contains(&"rocm-libs".to_string()));
773    }
774
775    #[test]
776    fn test_pacman_install_commands() {
777        let installer = RocmInstaller::new(RocmConfig {
778            channel: RocmChannel::Latest,
779            install_type: RocmInstallType::Standard,
780            ..Default::default()
781        });
782        let cmds = installer.pacman_install_commands("yay");
783        // Single user-space `yay` command — never a bare `sudo yay` (which
784        // defaults to `yay -Syu`, a full system upgrade as root). Regression
785        // guard for the malformed two-command [sudo yay, yay …] that aborted
786        // ROCm installs on Arch.
787        assert_eq!(
788            cmds.len(),
789            1,
790            "pacman install must be a single user-space yay command"
791        );
792        assert_eq!(cmds[0].program, "yay");
793        assert!(
794            !cmds.iter().any(|c| c.program == "sudo"),
795            "yay must not run under sudo"
796        );
797        assert!(cmds[0].args.contains(&"-S".to_string()));
798        assert!(cmds[0].args.contains(&"--needed".to_string()));
799        assert!(cmds[0].args.contains(&"--noconfirm".to_string()));
800        // Should have packages for Latest channel
801        let pkgs = installer.pacman_rocm_packages();
802        assert!(pkgs.contains(&"rocm-hip-sdk".to_string()));
803        assert!(pkgs.contains(&"rocminfo".to_string()));
804        assert!(pkgs.contains(&"rocm-opencl-sdk".to_string())); // Latest gets extra
805        assert!(pkgs.contains(&"rccl".to_string())); // Latest gets extra
806        for p in &pkgs {
807            assert!(cmds[0].args.contains(p), "yay args missing package {p}");
808        }
809    }
810
811    #[test]
812    fn test_pacman_legacy_no_extras() {
813        let installer = RocmInstaller::new(RocmConfig {
814            channel: RocmChannel::Legacy,
815            install_type: RocmInstallType::Standard,
816            ..Default::default()
817        });
818        let pkgs = installer.pacman_rocm_packages();
819        assert!(!pkgs.contains(&"rocm-opencl-sdk".to_string()));
820        assert!(!pkgs.contains(&"rccl".to_string()));
821    }
822
823    #[test]
824    fn test_zypper_install_commands() {
825        let installer = RocmInstaller::new(RocmConfig {
826            channel: RocmChannel::Latest,
827            install_type: RocmInstallType::Standard,
828            ..Default::default()
829        });
830        let cmds = installer.zypper_install_commands();
831        assert_eq!(cmds.len(), 2);
832        assert_eq!(cmds[0].program, "sudo");
833        assert!(cmds[0].args.iter().any(|a| a.contains("zypper")));
834        assert!(cmds[0].args.iter().any(|a| a.contains("7.2.4/sle/15.7")));
835        assert!(cmds[0].args.iter().any(|a| a.contains("7.2.4.70204-1")));
836    }
837
838    // --- Repo URL construction ---
839
840    #[test]
841    fn test_repo_base_url_ubuntu() {
842        let installer = RocmInstaller::new(RocmConfig {
843            channel: RocmChannel::Latest,
844            ..Default::default()
845        });
846        let distro = ubuntu_distro();
847        let url = installer.repo_base_url(&distro);
848        assert_eq!(url, "https://repo.radeon.com/amdgpu/7.2/ubuntu/noble");
849    }
850
851    #[test]
852    fn test_repo_base_url_rhel() {
853        let installer = RocmInstaller::new(RocmConfig {
854            channel: RocmChannel::Latest,
855            ..Default::default()
856        });
857        let distro = rhel9_distro();
858        let url = installer.repo_base_url(&distro);
859        assert_eq!(url, "https://repo.radeon.com/amdgpu/7.2/rhel/9");
860    }
861
862    #[test]
863    fn test_repo_base_url_fedora() {
864        let installer = RocmInstaller::new(RocmConfig {
865            channel: RocmChannel::Stable,
866            ..Default::default()
867        });
868        let distro = fedora_distro();
869        let url = installer.repo_base_url(&distro);
870        // Fedora version "41" doesn't match 8/9/10, defaults to 9
871        assert_eq!(url, "https://repo.radeon.com/amdgpu/7.2/rhel/9");
872    }
873
874    #[test]
875    fn test_repo_base_url_suse() {
876        let installer = RocmInstaller::new(RocmConfig {
877            channel: RocmChannel::Latest,
878            ..Default::default()
879        });
880        let distro = suse_distro();
881        let url = installer.repo_base_url(&distro);
882        assert_eq!(url, "https://repo.radeon.com/amdgpu/7.2/sle/15.7");
883    }
884
885    #[test]
886    fn test_amdgpu_install_deb_url() {
887        let installer = RocmInstaller::new(RocmConfig {
888            channel: RocmChannel::Latest,
889            ..Default::default()
890        });
891        let url = installer.amdgpu_install_deb_url();
892        assert!(url.contains("7.2.4/ubuntu/noble"));
893        assert!(url.contains("7.2.4.70204-1"));
894        assert!(url.contains("ubuntu/noble"));
895        assert!(url.ends_with(".deb"));
896    }
897
898    #[test]
899    fn test_amdgpu_install_rpm_url() {
900        let installer = RocmInstaller::new(RocmConfig {
901            channel: RocmChannel::Latest,
902            ..Default::default()
903        });
904        let url = installer.amdgpu_install_rpm_url("9");
905        assert!(url.contains("7.2.4/rhel/9"));
906        assert!(url.contains("7.2.4.70204-1"));
907        assert!(url.contains("rhel/9"));
908        assert!(url.ends_with(".noarch.rpm"));
909    }
910
911    #[test]
912    fn test_apt_repo_config() {
913        let installer = RocmInstaller::new(RocmConfig {
914            channel: RocmChannel::Latest,
915            ..Default::default()
916        });
917        let distro = ubuntu_distro();
918        let repo = installer.apt_repo_config(&distro);
919        assert_eq!(repo.file_path, "/etc/apt/sources.list.d/rocm.list");
920        assert!(repo.url.contains("rocm/apt/7.2"));
921        assert!(repo.url.contains("noble"));
922    }
923
924    #[test]
925    fn test_amdgpu_apt_repo_config() {
926        let installer = RocmInstaller::new(RocmConfig {
927            channel: RocmChannel::Latest,
928            ..Default::default()
929        });
930        let distro = ubuntu_distro();
931        let repo = installer.amdgpu_apt_repo_config(&distro);
932        assert_eq!(repo.file_path, "/etc/apt/sources.list.d/amdgpu.list");
933        assert!(repo.url.contains("amdgpu/7.2"));
934    }
935
936    #[test]
937    fn test_apt_pin_config() {
938        let installer = RocmInstaller::with_defaults();
939        let (content, path) = installer.apt_pin_config();
940        assert_eq!(path, "/etc/apt/preferences.d/rocm-pin-600");
941        assert!(content.contains("Pin-Priority: 600"));
942    }
943
944    // --- Cross-channel parametric tests ---
945
946    #[test]
947    fn test_all_channels_version_consistency() {
948        for choice in [1u8, 2, 3] {
949            let ch = RocmChannel::from_choice(choice).unwrap();
950            let config = RocmConfig {
951                channel: ch,
952                ..Default::default()
953            };
954            let installer = RocmInstaller::new(config);
955
956            // Verify deb URL contains correct package version
957            let deb_url = installer.amdgpu_install_deb_url();
958            assert!(
959                deb_url.contains(ch.pkg_version()),
960                "deb URL for {:?} should contain pkg_version {}",
961                ch,
962                ch.pkg_version()
963            );
964
965            // Verify repo URL contains major_minor
966            let distro = ubuntu_distro();
967            let repo_url = installer.repo_base_url(&distro);
968            assert!(
969                repo_url.contains(ch.major_minor()),
970                "repo URL for {:?} should contain major_minor {}",
971                ch,
972                ch.major_minor()
973            );
974        }
975    }
976
977    #[test]
978    fn test_install_type_metapackages() {
979        for (itype, expected_pkg) in [
980            (RocmInstallType::Standard, "rocm-libs"),
981            (RocmInstallType::Minimal, "rocm-core"),
982            (RocmInstallType::Full, "rocm-dev"),
983            (RocmInstallType::Custom, "rocm-libs"),
984        ] {
985            let installer = RocmInstaller::new(RocmConfig {
986                install_type: itype,
987                ..Default::default()
988            });
989            let cmds = installer.apt_install_commands();
990            let meta_cmd = &cmds[3];
991            assert!(
992                meta_cmd.args.contains(&expected_pkg.to_string()),
993                "Install type {:?} should use metapackage {}",
994                itype,
995                expected_pkg
996            );
997        }
998    }
999
1000    // --- Up-to-date output detection ---
1001
1002    #[test]
1003    fn test_is_up_to_date_yay_warning() {
1004        assert!(RocmInstaller::is_up_to_date_output(
1005            "warning: rocminfo is up to date -- skipping"
1006        ));
1007    }
1008
1009    #[test]
1010    fn test_is_up_to_date_yay_warning_case_insensitive() {
1011        assert!(RocmInstaller::is_up_to_date_output(
1012            "WARNING: rocminfo is UP TO DATE -- skipping"
1013        ));
1014    }
1015
1016    #[test]
1017    fn test_is_up_to_date_nothing_to_do() {
1018        assert!(RocmInstaller::is_up_to_date_output(
1019            " there is nothing to do\n"
1020        ));
1021    }
1022
1023    #[test]
1024    fn test_is_up_to_date_already_installed() {
1025        assert!(RocmInstaller::is_up_to_date_output(
1026            "package already installed"
1027        ));
1028    }
1029
1030    #[test]
1031    fn test_is_up_to_date_normal_output_is_false() {
1032        assert!(!RocmInstaller::is_up_to_date_output(
1033            "resolving dependencies...\nlooking for conflicting packages...\n"
1034        ));
1035    }
1036
1037    #[test]
1038    fn test_is_up_to_date_error_output_is_false() {
1039        assert!(!RocmInstaller::is_up_to_date_output(
1040            "error: failed to prepare transaction\nerror: target not found: rocminfo"
1041        ));
1042    }
1043
1044    #[test]
1045    fn test_is_up_to_date_empty_output_is_false() {
1046        assert!(!RocmInstaller::is_up_to_date_output(""));
1047    }
1048
1049    #[test]
1050    fn test_is_up_to_date_pacman_skipping() {
1051        assert!(RocmInstaller::is_up_to_date_output(
1052            "warning: rocm-hip-sdk is up to date -- skipping"
1053        ));
1054    }
1055
1056    // --- Pacman check-installed command construction ---
1057
1058    #[test]
1059    fn test_build_pacman_check_installed_command() {
1060        let cmd = RocmInstaller::build_pacman_check_installed_command("rocminfo");
1061        assert_eq!(cmd.program, "pacman");
1062        assert_eq!(cmd.args, vec!["-Qi", "rocminfo"]);
1063    }
1064
1065    #[test]
1066    fn test_build_pacman_check_installed_command_complex_pkg() {
1067        let cmd = RocmInstaller::build_pacman_check_installed_command("rocm-hip-sdk");
1068        assert_eq!(cmd.program, "pacman");
1069        assert!(cmd.args.contains(&"rocm-hip-sdk".to_string()));
1070    }
1071}