Skip to main content

rusty_stack/installers/components/
llama_cpp.rs

1//! llama.cpp-turboquant-hip installer — native Rust CMake source build.
2//!
3//! Clones the private fork `scooter-lacroix/llama.cpp-turboquant-hip`,
4//! builds with HIP/ROCm CMake flags per channel policy, and installs
5//! binaries to `~/.mlstack/components/llama-cpp/bin/`.
6//!
7//! # Detection Contract
8//!
9//! Detection and verification both operate on the same binary contract:
10//! `llama-cli --help` must succeed (exit code 0). This ensures:
11//! - Partial clone artifacts do not count as installed
12//! - Broken executables do not count as installed
13//! - Detection and verification use the same executable
14//!
15//! # ROCm Channel Policy
16//!
17//! | Channel | GPU Targets | WMMA FA | Notes |
18//! |---------|-------------|---------|-------|
19//! | Legacy  | gfx1030     | No      | RDNA2 only |
20//! | Stable  | gfx1030;gfx1100;gfx1101 | Yes | RDNA2+RDNA3 |
21//! | Latest  | gfx1030;gfx1100;gfx1101;gfx1200 | Yes | RDNA2+RDNA3+RDNA4 |
22//!
23//! # Validation Assertions
24//!
25//! - **VAL-INTEGRATION-002**: Native installer route accepts llama-cpp
26//! - **VAL-INTEGRATION-003**: Installed detection succeeds with functional command
27//! - **VAL-INTEGRATION-004**: Installed detection fails when command is absent
28//! - **VAL-INTEGRATION-005**: Broken executable does not count as installed
29//! - **VAL-INTEGRATION-006**: Partial filesystem artifacts do not count as success
30//! - **VAL-CROSS-001**: Channel selection determines llama.cpp build flags
31//! - **VAL-CROSS-003**: Install path and detection strategy are consistent
32//! - **VAL-CROSS-012**: Detection and verification operate on the same binary contract
33
34use crate::installers::common::{
35    log_warn, submit_build_report, BuildReport, BuildReportStatus, SealedToken,
36};
37use serde::Deserialize;
38use sha2::{Digest, Sha256};
39use std::fs;
40use std::io::{Read, Write};
41use std::path::{Path, PathBuf};
42use std::time::Instant;
43use tempfile::NamedTempFile;
44
45// ===========================================================================
46// Constants
47// ===========================================================================
48
49/// Default repo URL (HTTPS). Can be overridden via `LLAMA_CPP_REPO_URL` env var.
50const DEFAULT_REPO_URL: &str = "https://github.com/scooter-lacroix/llama.cpp-turboquant-hip";
51
52/// Default branch to clone.
53const DEFAULT_BRANCH: &str = "main";
54
55/// Build directory for CMake source builds.
56const DEFAULT_BUILD_DIR: &str = "/tmp/llama-cpp-rocm-build";
57
58/// Install prefix for llama.cpp binaries.
59const DEFAULT_INSTALL_PREFIX: &str = ".mlstack/components/llama-cpp";
60
61/// Binary targets to build.
62const BUILD_TARGETS: &[&str] = &["llama-cli", "llama-bench", "llama-server"];
63
64/// The primary detection binary.
65pub const DETECTION_BINARY: &str = "llama-cli";
66
67/// The detection subcommand that must succeed for installed status.
68pub const DETECTION_SUBCOMMAND: &str = "--help";
69
70// ===========================================================================
71// Types
72// ===========================================================================
73
74/// GPU architecture specification for HIP builds.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum HipArchs {
77    /// gfx1030 family (RDNA2).
78    Gfx1030,
79    /// gfx1100 family (gfx1100-gfx1101, RDNA3).
80    Gfx1100,
81    /// gfx1200 family (gfx1200-gfx1201, RDNA4).
82    Gfx1200,
83    /// Default fallback (gfx1030 — conservative).
84    Default,
85}
86
87impl HipArchs {
88    /// Detect HIP architecture from a GPU arch string.
89    pub fn from_gpu_arch(gpu_arch: &str) -> Self {
90        if gpu_arch.starts_with("gfx103") {
91            HipArchs::Gfx1030
92        } else if gpu_arch.starts_with("gfx110") {
93            HipArchs::Gfx1100
94        } else if gpu_arch.starts_with("gfx120") {
95            HipArchs::Gfx1200
96        } else {
97            HipArchs::Default
98        }
99    }
100
101    /// Get the GPU_TARGETS string for a given ROCm channel.
102    ///
103    /// Channel policy:
104    /// - Legacy: only gfx1030 (RDNA2)
105    /// - Stable: gfx1030 + gfx1100/1101 (RDNA2+RDNA3)
106    /// - Latest: gfx1030 + gfx1100/1101 + gfx1200 (RDNA2+RDNA3+RDNA4)
107    ///
108    /// Unknown/indeterminate hardware degrades to conservative gfx1030.
109    pub fn gpu_targets_for_channel(&self, channel: &str) -> String {
110        match (self, channel) {
111            // Legacy channel: RDNA2 only regardless of detected GPU
112            (_, "legacy") => "gfx1030".to_string(),
113            // Stable channel: RDNA2 + RDNA3
114            (HipArchs::Gfx1030, "stable") => "gfx1030".to_string(),
115            (HipArchs::Gfx1100, "stable") => "gfx1030;gfx1100;gfx1101".to_string(),
116            (HipArchs::Gfx1200, "stable") => "gfx1030;gfx1100;gfx1101".to_string(),
117            (HipArchs::Default, "stable") => "gfx1030".to_string(),
118            // Latest channel: RDNA2 + RDNA3 + RDNA4
119            (HipArchs::Gfx1030, _) => "gfx1030".to_string(),
120            (HipArchs::Gfx1100, _) => "gfx1030;gfx1100;gfx1101".to_string(),
121            (HipArchs::Gfx1200, _) => "gfx1030;gfx1100;gfx1101;gfx1200".to_string(),
122            (HipArchs::Default, _) => "gfx1030".to_string(),
123        }
124    }
125}
126
127/// ROCm channel for CMake flag selection.
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub enum RocmChannel {
130    Legacy,
131    Stable,
132    Latest,
133}
134
135impl RocmChannel {
136    /// Parse from a channel string (case-insensitive).
137    pub fn parse_channel(channel: &str) -> Self {
138        match channel.to_lowercase().as_str() {
139            "legacy" => RocmChannel::Legacy,
140            "stable" => RocmChannel::Stable,
141            _ => RocmChannel::Latest,
142        }
143    }
144
145    /// Whether WMMA flash attention should be enabled for this channel.
146    pub fn enable_wmma_fa(&self) -> bool {
147        matches!(self, RocmChannel::Stable | RocmChannel::Latest)
148    }
149
150    /// The channel label string.
151    pub fn label(&self) -> &'static str {
152        match self {
153            RocmChannel::Legacy => "legacy",
154            RocmChannel::Stable => "stable",
155            RocmChannel::Latest => "latest",
156        }
157    }
158}
159
160/// A constructed shell command.
161#[derive(Debug, Clone, PartialEq, Eq)]
162pub struct ShellCommand {
163    /// The program to run.
164    pub program: String,
165    /// Arguments to pass.
166    pub args: Vec<String>,
167    /// Environment variables to set.
168    pub env: Vec<(String, String)>,
169    /// Working directory for the command.
170    pub working_dir: Option<PathBuf>,
171}
172
173/// Configuration for the llama.cpp installer.
174#[derive(Debug, Clone)]
175pub struct LlamaCppConfig {
176    /// GPU architecture string (e.g., "gfx1100").
177    pub gpu_arch: String,
178    /// ROCm channel (legacy/stable/latest).
179    pub channel: String,
180    /// Repo URL override (defaults to DEFAULT_REPO_URL).
181    pub repo_url: Option<String>,
182    /// Branch to clone (defaults to DEFAULT_BRANCH).
183    pub branch: String,
184    /// Build directory override.
185    pub build_dir: Option<String>,
186    /// Install prefix override (relative to $HOME).
187    pub install_prefix: Option<String>,
188    /// Whether to run in dry-run mode.
189    pub dry_run: bool,
190}
191
192#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
193#[allow(dead_code)]
194pub struct ReleaseAsset {
195    pub url: String,
196    pub sha256: String,
197}
198
199#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
200#[serde(deny_unknown_fields)]
201pub struct ReleaseManifest {
202    pub version: String,
203    pub archs: ReleaseArchs,
204}
205
206#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
207#[serde(deny_unknown_fields)]
208pub struct ReleaseArchs {
209    pub gfx1030: ReleaseAsset,
210    pub gfx1100: ReleaseAsset,
211    pub gfx1200: ReleaseAsset,
212}
213
214#[derive(Debug, Clone, PartialEq, Eq)]
215pub struct PrebuiltInstallPlan {
216    pub manifest_version: String,
217    pub arch: String,
218    pub url: String,
219    pub sha256: String,
220    pub install_prefix: String,
221    pub binary_path: PathBuf,
222}
223
224#[derive(Debug, Clone, PartialEq, Eq)]
225pub struct SourceInstallPlan {
226    pub reason: String,
227    pub build_commands: Vec<ShellCommand>,
228    pub install_prefix: String,
229    pub binary_path: PathBuf,
230}
231
232#[derive(Debug, Clone, PartialEq, Eq)]
233pub enum InstallStrategy {
234    Prebuilt(PrebuiltInstallPlan),
235    Source(SourceInstallPlan),
236}
237
238impl Default for LlamaCppConfig {
239    fn default() -> Self {
240        Self {
241            gpu_arch: "gfx1100".to_string(),
242            channel: "latest".to_string(),
243            repo_url: None,
244            branch: DEFAULT_BRANCH.to_string(),
245            build_dir: None,
246            install_prefix: None,
247            dry_run: false,
248        }
249    }
250}
251
252impl LlamaCppConfig {
253    /// Get the effective repo URL.
254    pub fn repo_url(&self) -> &str {
255        self.repo_url.as_deref().unwrap_or(DEFAULT_REPO_URL)
256    }
257
258    /// Get the effective build directory.
259    pub fn build_dir(&self) -> &str {
260        self.build_dir.as_deref().unwrap_or(DEFAULT_BUILD_DIR)
261    }
262
263    /// Get the effective install prefix (relative to $HOME).
264    pub fn install_prefix(&self) -> &str {
265        self.install_prefix
266            .as_deref()
267            .unwrap_or(DEFAULT_INSTALL_PREFIX)
268    }
269
270    /// Get the full install bin directory path.
271    pub fn install_bin_dir(&self, home: &str) -> PathBuf {
272        PathBuf::from(home).join(self.install_prefix()).join("bin")
273    }
274
275    /// Get the path to the detection binary.
276    pub fn detection_binary_path(&self, home: &str) -> PathBuf {
277        self.install_bin_dir(home).join(DETECTION_BINARY)
278    }
279}
280
281/// The llama.cpp installer.
282#[derive(Debug, Clone)]
283pub struct LlamaCppInstaller {
284    config: LlamaCppConfig,
285}
286
287impl Default for LlamaCppInstaller {
288    fn default() -> Self {
289        Self::new(LlamaCppConfig::default())
290    }
291}
292
293impl LlamaCppInstaller {
294    /// Create a new installer with the given config.
295    pub fn new(config: LlamaCppConfig) -> Self {
296        Self { config }
297    }
298
299    /// Create with default config.
300    pub fn with_defaults() -> Self {
301        Self::new(LlamaCppConfig::default())
302    }
303
304    // -------------------------------------------------------------------
305    // Dependencies
306    // -------------------------------------------------------------------
307
308    /// Get the list of required dependencies.
309    ///
310    /// llama.cpp depends on ROCm.
311    pub fn dependencies(&self) -> &[&str] {
312        &["rocm"]
313    }
314
315    /// Fetch the latest release manifest from GitHub.
316    ///
317    /// Returns `None` for HTTP/API failures, including 404.
318    pub fn check_latest_release(&self) -> Option<ReleaseManifest> {
319        let mut token = SealedToken::from_env();
320        let result = self.fetch_latest_release_manifest(token.as_str());
321        token.purge();
322        result
323    }
324
325    pub fn release_asset_for_arch<'a>(
326        &self,
327        manifest: &'a ReleaseManifest,
328        gpu_arch: &str,
329    ) -> Option<&'a ReleaseAsset> {
330        match gpu_arch {
331            "gfx1030" => Some(&manifest.archs.gfx1030),
332            "gfx1100" | "gfx1101" => Some(&manifest.archs.gfx1100),
333            "gfx1200" | "gfx1201" => Some(&manifest.archs.gfx1200),
334            _ => None,
335        }
336    }
337
338    // -------------------------------------------------------------------
339    // Auth Validation
340    // -------------------------------------------------------------------
341
342    /// Validate the git clone step for private repo access.
343    ///
344    /// Returns `Ok(())` if the repo is accessible (public or private with valid auth).
345    /// Returns `Err(String)` with an actionable error message if auth fails.
346    pub fn validate_repo_access(&self) -> Result<(), String> {
347        let mut github_token = Some(SealedToken::from_env());
348
349        // Try to clone a single file to validate access
350        let temp_dir = tempfile::tempdir().map_err(|e| e.to_string())?;
351        let temp_path = temp_dir
352            .path()
353            .to_str()
354            .ok_or("Failed to create temp dir")?
355            .to_string();
356
357        // Build the git command
358        let mut cmd = if let Some(ref token) = github_token {
359            // Use credential helper for authentication — token never appears in command line
360            let credential_helper = r#"!sh -c "echo username=git; echo password=$GITHUB_TOKEN""#;
361            let mut git_cmd = std::process::Command::new("git");
362            git_cmd
363                .arg("clone")
364                .arg("--depth")
365                .arg("1")
366                .arg("--branch")
367                .arg(&self.config.branch)
368                .arg(self.config.repo_url())
369                .arg(&temp_path)
370                .arg("-c")
371                .arg(format!("credential.helper={}", credential_helper))
372                .env("GITHUB_TOKEN", token.as_str())
373                .env("GIT_TERMINAL_PROMPT", "0");
374            git_cmd
375        } else {
376            // Use unauthenticated clone
377            let mut git_cmd = std::process::Command::new("git");
378            git_cmd
379                .arg("clone")
380                .arg("--depth")
381                .arg("1")
382                .arg("--branch")
383                .arg(&self.config.branch)
384                .arg(self.config.repo_url())
385                .arg(&temp_path);
386            git_cmd
387        };
388
389        let output = match cmd.output() {
390            Ok(output) => output,
391            Err(e) => {
392                if let Some(token) = github_token.as_mut() {
393                    token.purge();
394                }
395                return Err(e.to_string());
396            }
397        };
398
399        if !output.status.success() {
400            let stderr = String::from_utf8_lossy(&output.stderr);
401            if let Some(token) = github_token.as_mut() {
402                token.purge();
403            }
404            if stderr.contains("authentication") || stderr.contains("403") || stderr.contains("401")
405            {
406                return Err(
407                    "authentication failed for private repo. If you have access, set the build-time GitHub token.".to_string(),
408                );
409            } else {
410                return Err("Failed to clone repository".to_string());
411            }
412        }
413
414        if let Some(token) = github_token.as_mut() {
415            token.purge();
416        }
417        Ok(())
418    }
419
420    fn fetch_latest_release_manifest(&self, token: &str) -> Option<ReleaseManifest> {
421        let url =
422            "https://api.github.com/repos/scooter-lacroix/llama.cpp-turboquant-hip/releases/latest";
423        let response = ureq::get(url)
424            .header("Authorization", format!("Bearer {}", token))
425            .header("Accept", "application/vnd.github+json")
426            .header("X-GitHub-Api-Version", "2022-11-28")
427            .header("User-Agent", "rusty-stack")
428            .call();
429
430        let mut response = match response {
431            Ok(response) => response,
432            Err(_) => return None,
433        };
434
435        if response.status() == 404 {
436            return None;
437        }
438
439        if !response.status().is_success() {
440            return None;
441        }
442
443        response.body_mut().read_json::<ReleaseManifest>().ok()
444    }
445
446    // -------------------------------------------------------------------
447    // CMake flag generation
448    // -------------------------------------------------------------------
449
450    /// Build the CMake flags for the configured channel and GPU arch.
451    ///
452    /// Returns a list of CMake cache variable flags.
453    pub fn cmake_flags(&self) -> Vec<String> {
454        let hip_archs = HipArchs::from_gpu_arch(&self.config.gpu_arch);
455        let channel = RocmChannel::parse_channel(&self.config.channel);
456        let gpu_targets = hip_archs.gpu_targets_for_channel(channel.label());
457
458        let mut flags = vec![
459            "-DGGML_HIP=ON".to_string(),
460            format!("-DGPU_TARGETS={}", gpu_targets),
461            // VAL-GUARD-001: force ROCm/HIP-only backend selection.
462            "-DGGML_CUDA=OFF".to_string(),
463            "-DGGML_VULKAN=OFF".to_string(),
464            "-DGGML_METAL=OFF".to_string(),
465        ];
466
467        // RDNA3 probes and WMMA flash attention are enabled for stable/latest only
468        if channel.enable_wmma_fa() {
469            flags.push("-DGGML_HIP_RDNA3_PROBES=ON".to_string());
470            flags.push("-DGGML_HIP_ROCWMMA_FATTN=ON".to_string());
471        }
472
473        flags
474    }
475
476    fn expected_gpu_targets(&self) -> String {
477        let hip_archs = HipArchs::from_gpu_arch(&self.config.gpu_arch);
478        let channel = RocmChannel::parse_channel(&self.config.channel);
479        hip_archs.gpu_targets_for_channel(channel.label())
480    }
481
482    // -------------------------------------------------------------------
483    // Command sequence
484    // -------------------------------------------------------------------
485
486    /// Build the sequence of commands to install llama.cpp with ROCm support.
487    pub fn build_commands(&self, home: &str) -> Vec<ShellCommand> {
488        self.build_commands_with_auth(home, None)
489    }
490
491    /// Build source install commands, passing installer token auth when available.
492    fn build_source_commands_from_env(&self, home: &str) -> Vec<ShellCommand> {
493        let mut token = SealedToken::from_env();
494        if token.as_str().is_empty() {
495            token.purge();
496            self.build_commands_with_auth(home, None)
497        } else {
498            self.build_commands_with_auth(home, Some(token))
499        }
500    }
501
502    /// Resolve the install strategy for the detected GPU and latest release manifest.
503    pub fn resolve_install_strategy(
504        &self,
505        home: &str,
506        manifest: Option<&ReleaseManifest>,
507    ) -> InstallStrategy {
508        let install_prefix = format!("{}/{}", home, self.config.install_prefix());
509        let binary_path = self.config.detection_binary_path(home);
510        if let Some(manifest) = manifest {
511            if let Some(asset) = self.release_asset_for_arch(manifest, &self.config.gpu_arch) {
512                return InstallStrategy::Prebuilt(PrebuiltInstallPlan {
513                    manifest_version: manifest.version.clone(),
514                    arch: self.config.gpu_arch.clone(),
515                    url: asset.url.clone(),
516                    sha256: asset.sha256.clone(),
517                    install_prefix,
518                    binary_path,
519                });
520            }
521        }
522
523        let reason = if manifest.is_some() {
524            format!(
525                "No pre-built binary available for {}; falling back to source compile.",
526                self.config.gpu_arch
527            )
528        } else {
529            format!(
530                "Unable to fetch release manifest or match {}; falling back to source compile.",
531                self.config.gpu_arch
532            )
533        };
534
535        InstallStrategy::Source(SourceInstallPlan {
536            reason,
537            build_commands: self.build_source_commands_from_env(home),
538            install_prefix,
539            binary_path,
540        })
541    }
542
543    fn verify_sha256(path: &std::path::Path, expected: &str) -> Result<bool, String> {
544        let mut file = fs::File::open(path).map_err(|e| e.to_string())?;
545        let mut hasher = Sha256::new();
546        let mut buffer = [0u8; 8192];
547        loop {
548            let count = file.read(&mut buffer).map_err(|e| e.to_string())?;
549            if count == 0 {
550                break;
551            }
552            hasher.update(&buffer[..count]);
553        }
554        let actual = Self::sha256_hex_bytes(&hasher.finalize());
555        Ok(actual.eq_ignore_ascii_case(expected))
556    }
557
558    pub fn download_prebuilt_binary(&self, plan: &PrebuiltInstallPlan) -> Result<(), String> {
559        let mut temp = NamedTempFile::new().map_err(|e| e.to_string())?;
560        let response = match ureq::get(&plan.url).call() {
561            Ok(response) => response,
562            Err(err) => {
563                log_warn(&format!(
564                    "Prebuilt llama.cpp download failed; falling back to source compile: {}",
565                    err
566                ));
567                return Err("download failed".to_string());
568            }
569        };
570
571        if !response.status().is_success() {
572            log_warn("Prebuilt llama.cpp download failed; falling back to source compile.");
573            return Err("download failed".to_string());
574        }
575
576        let downloaded = response
577            .into_body()
578            .read_to_vec()
579            .map_err(|e| e.to_string())?;
580        temp.write_all(&downloaded).map_err(|e| e.to_string())?;
581        temp.flush().map_err(|e| e.to_string())?;
582
583        if !Self::verify_sha256(temp.path(), &plan.sha256)? {
584            let message = format!(
585                "Checksum mismatch for {} binary: expected {}, got {}. Download may be corrupted. Falling back to source build.",
586                plan.arch,
587                plan.sha256,
588                Self::sha256_hex(temp.path())?
589            );
590            log_warn(&message);
591            return Err(message);
592        }
593
594        fs::create_dir_all(&plan.install_prefix).map_err(|e| e.to_string())?;
595        let status = std::process::Command::new("tar")
596            .arg("-xzf")
597            .arg(temp.path())
598            .arg("-C")
599            .arg(&plan.install_prefix)
600            .status()
601            .map_err(|e| e.to_string())?;
602
603        if !status.success() {
604            return Err("failed to extract prebuilt archive".to_string());
605        }
606
607        // VAL-GUARD-004: Post-build linkage verification rejects CUDA-linked binaries
608        // VAL-GUARD-005: prebuilt binaries must prove ROCm linkage before success.
609        verify_binary_linkage(&plan.binary_path)?;
610
611        temp.close().map_err(|e| e.to_string())?;
612        Ok(())
613    }
614
615    fn sha256_hex(path: &std::path::Path) -> Result<String, String> {
616        let mut file = fs::File::open(path).map_err(|e| e.to_string())?;
617        let mut hasher = Sha256::new();
618        let mut buffer = [0u8; 8192];
619        loop {
620            let count = file.read(&mut buffer).map_err(|e| e.to_string())?;
621            if count == 0 {
622                break;
623            }
624            hasher.update(&buffer[..count]);
625        }
626        Ok(Self::sha256_hex_bytes(&hasher.finalize()))
627    }
628
629    fn sha256_hex_bytes(bytes: &[u8]) -> String {
630        bytes.iter().map(|byte| format!("{:02x}", byte)).collect()
631    }
632
633    /// Purge source build artifacts after a successful install.
634    ///
635    /// Removes repo metadata, git credential cache/store entries, and the build tree.
636    /// Returns Ok(()) when the purge is already complete or if the binary was placed
637    /// but a cleanup step fails. Returns Err when cleanup fails before installation
638    /// has produced the binary.
639    pub fn purge_source_artifacts(&self, home: &str, binary_placed: bool) -> Result<(), String> {
640        let build_dir = self.config.build_dir().to_string();
641        let install_bin = self.config.detection_binary_path(home);
642        let mut had_error = None::<String>;
643        let purge_plan = [
644            (
645                "source repository metadata",
646                std::process::Command::new("find")
647                    .args([
648                        format!("{}/.mlstack", home),
649                        "-type".to_string(),
650                        "d".to_string(),
651                        "-name".to_string(),
652                        ".git".to_string(),
653                        "-prune".to_string(),
654                        "-exec".to_string(),
655                        "rm".to_string(),
656                        "-rf".to_string(),
657                        "{}".to_string(),
658                        ";".to_string(),
659                    ])
660                    .output(),
661            ),
662            (
663                "git credential store",
664                std::process::Command::new("git")
665                    .args([
666                        "credential-store".to_string(),
667                        "erase".to_string(),
668                        "--file".to_string(),
669                        format!("{}/.git-credentials", home),
670                    ])
671                    .output(),
672            ),
673            (
674                "git credential cache",
675                std::process::Command::new("git")
676                    .args(["credential-cache".to_string(), "exit".to_string()])
677                    .output(),
678            ),
679            (
680                "source build tree",
681                std::process::Command::new("rm")
682                    .args(["-rf".to_string(), build_dir.clone()])
683                    .output(),
684            ),
685        ];
686
687        for (label, output) in purge_plan {
688            match output {
689                Ok(output) if output.status.success() => {}
690                Ok(output) => {
691                    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
692                    had_error = Some(if stderr.is_empty() {
693                        format!("{} purge failed", label)
694                    } else {
695                        stderr
696                    });
697                    break;
698                }
699                Err(err) => {
700                    had_error = Some(format!("{} purge failed: {}", label, err));
701                    break;
702                }
703            }
704        }
705
706        if let Some(err) = had_error {
707            if binary_placed && install_bin.exists() {
708                log_warn(&format!(
709                    "llama.cpp source purge completed with warnings: {}",
710                    err
711                ));
712                return Ok(());
713            }
714            return Err(format!(
715                "Failed to purge llama.cpp source artifacts; please remove {} and retry: {}",
716                build_dir, err
717            ));
718        }
719
720        Ok(())
721    }
722
723    /// Build the sequence of commands to install llama.cpp with ROCm support, handling private repo auth.
724    pub fn build_commands_with_auth(
725        &self,
726        home: &str,
727        token: Option<SealedToken>,
728    ) -> Vec<ShellCommand> {
729        let build_dir = self.config.build_dir().to_string();
730        let install_prefix = format!("{}/{}", home, self.config.install_prefix());
731
732        // Build the clone command with auth if available
733        let mut commands = Vec::new();
734        commands.push(self.mkdir_build_dir(&build_dir));
735
736        if let Some(mut token) = token {
737            commands.push(self.git_clone_with_auth(&build_dir, token.as_str()));
738            token.purge();
739        } else {
740            commands.push(self.git_clone(&build_dir));
741        }
742
743        commands.push(self.git_checkout(&build_dir));
744        commands.push(self.cmake_configure(&build_dir, &install_prefix));
745        commands.push(self.cmake_build(&build_dir));
746        commands.push(self.cmake_install(&build_dir));
747        commands.push(self.purge_source_artifacts_command(home));
748
749        commands
750    }
751
752    pub fn install(&self, home: &str, manifest: Option<&ReleaseManifest>) -> Result<(), String> {
753        let start = Instant::now();
754        match self.resolve_install_strategy(home, manifest) {
755            InstallStrategy::Prebuilt(plan) => match self.download_prebuilt_binary(&plan) {
756                Ok(()) => {
757                    self.submit_telemetry(home, start, true, Some(&plan.binary_path))
758                        .ok();
759                    Ok(())
760                }
761                Err(err) if err.contains("checksum mismatch") => {
762                    log_warn(&format!("{err} Falling back to source compile."));
763                    let result = self.run_source_install(home);
764                    if result.is_ok() {
765                        self.submit_telemetry(home, start, false, None).ok();
766                    }
767                    result
768                }
769                Err(_) => {
770                    log_warn("Prebuilt download unavailable; using source compile.");
771                    let result = self.run_source_install(home);
772                    if result.is_ok() {
773                        self.submit_telemetry(home, start, false, None).ok();
774                    }
775                    result
776                }
777            },
778            InstallStrategy::Source(plan) => {
779                let result = self.run_source_install(home);
780                if result.is_ok() {
781                    self.submit_telemetry(home, start, false, Some(&plan.binary_path))
782                        .ok();
783                }
784                result
785            }
786        }
787    }
788
789    pub fn run_source_install(&self, home: &str) -> Result<(), String> {
790        // VAL-GUARD-002: CUDA toolkit presence is warning-only; build stays HIP-only.
791        let cuda_artifacts = detect_cuda_toolkit_presence();
792        if !cuda_artifacts.is_empty() {
793            log_warn(&format!(
794                "NVIDIA CUDA toolkit detected on this system ({}). Rusty Llama builds exclusively with ROCm/HIP; CUDA will not be used.",
795                cuda_artifacts.join(", ")
796            ));
797        }
798
799        let commands = self.build_source_commands_from_env(home);
800        if commands.is_empty() {
801            return Err("no source install commands available".to_string());
802        }
803
804        for command in commands {
805            let is_cmake_configure = command.program == "cmake"
806                && command.args.iter().any(|arg| arg == "-B")
807                && command.args.iter().any(|arg| arg == "-S");
808            let is_cmake_install =
809                command.program == "cmake" && command.args.iter().any(|arg| arg == "--install");
810            let command_working_dir = command.working_dir.clone();
811
812            let mut process = std::process::Command::new(&command.program);
813            process.args(&command.args);
814            for (key, value) in &command.env {
815                process.env(key, value);
816            }
817            if let Some(dir) = &command.working_dir {
818                process.current_dir(dir);
819            }
820
821            let output = process.output().map_err(|err| {
822                format!(
823                    "failed to run source install command '{}': {}",
824                    command.program, err
825                )
826            })?;
827
828            if !output.status.success() {
829                let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
830                let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
831                let mut details = String::new();
832                if !stderr.is_empty() {
833                    details.push_str(&stderr);
834                }
835                if !stdout.is_empty() {
836                    if !details.is_empty() {
837                        details.push_str(" | ");
838                    }
839                    details.push_str(&stdout);
840                }
841
842                return Err(if details.is_empty() {
843                    format!("source install command '{}' failed", command.program)
844                } else {
845                    format!(
846                        "source install command '{}' failed: {}",
847                        command.program, details
848                    )
849                });
850            }
851
852            if is_cmake_configure {
853                let cache_path = command_working_dir
854                    .as_deref()
855                    .unwrap_or_else(|| Path::new("."))
856                    .join("build")
857                    .join("CMakeCache.txt");
858                validate_cmake_cache(&cache_path, &self.expected_gpu_targets())?;
859            }
860
861            if is_cmake_install {
862                // VAL-GUARD-004: Post-build linkage verification rejects CUDA-linked binaries
863                // VAL-GUARD-005: source builds must prove ROCm linkage before success.
864                let bin_path = resolve_install_bin_dir(home).join(DETECTION_BINARY);
865                verify_binary_linkage(&bin_path)?;
866            }
867        }
868
869        Ok(())
870    }
871
872    fn purge_source_artifacts_command(&self, home: &str) -> ShellCommand {
873        let build_dir = self.config.build_dir().to_string();
874        let mlstack_dir = format!("{}/.mlstack", home);
875        let credentials_file = format!("{}/.git-credentials", home);
876        ShellCommand {
877            program: "sh".to_string(),
878            args: vec![
879                "-c".to_string(),
880                format!(
881                    "find '{}' -type d -name .git -prune -exec rm -rf '{{}}' ';' ; git credential-store erase --file '{}' ; git credential-cache exit ; rm -rf '{}'",
882                    mlstack_dir, credentials_file, build_dir
883                ),
884            ],
885            env: vec![],
886            working_dir: None,
887        }
888    }
889
890    /// Build a git clone command with HTTPS authentication using the provided token.
891    ///
892    /// Uses git's credential helper to avoid exposing the token in command-line arguments.
893    /// The token is passed via GITHUB_TOKEN environment variable, and git invokes
894    /// a shell helper that outputs the credentials.
895    fn git_clone_with_auth(&self, build_dir: &str, token: &str) -> ShellCommand {
896        // Use standard HTTPS URL — token never appears in args or logs
897        let credential_helper = r#"!sh -c "echo username=git; echo password=$GITHUB_TOKEN""#;
898        ShellCommand {
899            program: "git".to_string(),
900            args: vec![
901                "clone".to_string(),
902                "--depth".to_string(),
903                "1".to_string(),
904                "--branch".to_string(),
905                self.config.branch.clone(),
906                self.config.repo_url().to_string(),
907                build_dir.to_string(),
908                "-c".to_string(),
909                format!("credential.helper={}", credential_helper),
910            ],
911            env: vec![
912                ("GITHUB_TOKEN".to_string(), token.to_string()),
913                ("GIT_TERMINAL_PROMPT".to_string(), "0".to_string()),
914            ],
915            working_dir: None,
916        }
917    }
918
919    fn mkdir_build_dir(&self, build_dir: &str) -> ShellCommand {
920        ShellCommand {
921            program: "mkdir".to_string(),
922            args: vec!["-p".to_string(), build_dir.to_string()],
923            env: vec![],
924            working_dir: None,
925        }
926    }
927
928    fn git_clone(&self, build_dir: &str) -> ShellCommand {
929        ShellCommand {
930            program: "git".to_string(),
931            args: vec![
932                "clone".to_string(),
933                "--depth".to_string(),
934                "1".to_string(),
935                "--branch".to_string(),
936                self.config.branch.clone(),
937                self.config.repo_url().to_string(),
938                build_dir.to_string(),
939            ],
940            env: vec![],
941            working_dir: None,
942        }
943    }
944
945    fn git_checkout(&self, build_dir: &str) -> ShellCommand {
946        ShellCommand {
947            program: "git".to_string(),
948            args: vec!["checkout".to_string(), self.config.branch.clone()],
949            env: vec![],
950            working_dir: Some(PathBuf::from(build_dir)),
951        }
952    }
953
954    fn cmake_configure(&self, build_dir: &str, install_prefix: &str) -> ShellCommand {
955        let mut args = vec![
956            "-B".to_string(),
957            "build".to_string(),
958            "-S".to_string(),
959            ".".to_string(),
960        ];
961        args.extend(self.cmake_flags());
962        args.push(format!("-DCMAKE_INSTALL_PREFIX={}", install_prefix));
963
964        ShellCommand {
965            program: "cmake".to_string(),
966            args,
967            env: vec![],
968            working_dir: Some(PathBuf::from(build_dir)),
969        }
970    }
971
972    fn cmake_build(&self, build_dir: &str) -> ShellCommand {
973        let mut args = vec!["--build".to_string(), "build".to_string(), "-j".to_string()];
974        // Add build targets
975        for target in BUILD_TARGETS {
976            args.push("--target".to_string());
977            args.push(target.to_string());
978        }
979
980        ShellCommand {
981            program: "cmake".to_string(),
982            args,
983            env: vec![],
984            working_dir: Some(PathBuf::from(build_dir)),
985        }
986    }
987
988    fn cmake_install(&self, build_dir: &str) -> ShellCommand {
989        ShellCommand {
990            program: "cmake".to_string(),
991            args: vec!["--install".to_string(), "build".to_string()],
992            env: vec![],
993            working_dir: Some(PathBuf::from(build_dir)),
994        }
995    }
996
997    fn submit_telemetry(
998        &self,
999        home: &str,
1000        build_start: Instant,
1001        was_prebuilt: bool,
1002        binary_path: Option<&std::path::Path>,
1003    ) -> Result<(), String> {
1004        let gpu = crate::hardware::detect_hardware()
1005            .map(|state| state.gpu)
1006            .unwrap_or_default();
1007        let report = BuildReport {
1008            gpu_arch: if gpu.architecture.is_empty() {
1009                self.config.gpu_arch.clone()
1010            } else {
1011                gpu.architecture
1012            },
1013            gpu_count: gpu.gpu_count,
1014            gpu_name: gpu.model,
1015            rocm_version: gpu.rocm_version,
1016            os: std::env::consts::OS.to_string(),
1017            os_distro: std::env::var("ID").unwrap_or_else(|_| "unknown".to_string()),
1018            build_duration_seconds: build_start.elapsed().as_secs(),
1019            git_commit: Self::current_git_commit().unwrap_or_else(|| "unknown".to_string()),
1020            build_status: BuildReportStatus::Success,
1021            install_path: binary_path
1022                .map(|p| p.display().to_string())
1023                .unwrap_or_else(|| {
1024                    self.config
1025                        .detection_binary_path(home)
1026                        .display()
1027                        .to_string()
1028                }),
1029            cmake_flags: self.cmake_flags(),
1030            verification_path: self
1031                .config
1032                .detection_binary_path(home)
1033                .display()
1034                .to_string(),
1035            rdna3_validation_passed: false,
1036            wmma_available: false,
1037            shared_memory_ok: false,
1038            tokens_per_second_wmma: None,
1039            tokens_per_second_fallback: None,
1040            binary_version: self.config.branch.clone(),
1041            was_prebuilt,
1042        };
1043        submit_build_report(report);
1044        Ok(())
1045    }
1046
1047    fn current_git_commit() -> Option<String> {
1048        let output = std::process::Command::new("git")
1049            .args(["rev-parse", "HEAD"])
1050            .output()
1051            .ok()?;
1052        if !output.status.success() {
1053            return None;
1054        }
1055        Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
1056    }
1057}
1058
1059// ===========================================================================
1060// Detection helpers (shared with component_status.rs)
1061// ===========================================================================
1062
1063/// Resolve the install bin directory for llama-cpp.
1064///
1065/// Returns `~/.mlstack/components/llama-cpp/bin/`.
1066pub fn resolve_install_bin_dir(home: &str) -> PathBuf {
1067    PathBuf::from(home).join(DEFAULT_INSTALL_PREFIX).join("bin")
1068}
1069
1070/// Check if the llama-cli binary is functional at the expected install path.
1071///
1072/// This is the single detection contract used by both detection and verification.
1073/// Returns `true` only if the binary exists AND `llama-cli --help` succeeds.
1074pub fn is_llama_cli_functional(home: &str) -> bool {
1075    let bin_path = resolve_install_bin_dir(home).join(DETECTION_BINARY);
1076    if !bin_path.exists() {
1077        return false;
1078    }
1079    // Run the detection subcommand to verify the binary is functional
1080    std::process::Command::new(bin_path)
1081        .arg(DETECTION_SUBCOMMAND)
1082        .output()
1083        .map(|o| o.status.success())
1084        .unwrap_or(false)
1085}
1086
1087/// Check if llama-cli is on PATH and functional.
1088///
1089/// Used as a fallback when the install-path check fails.
1090pub fn is_llama_cli_on_path() -> bool {
1091    std::process::Command::new(DETECTION_BINARY)
1092        .arg(DETECTION_SUBCOMMAND)
1093        .output()
1094        .map(|o| o.status.success())
1095        .unwrap_or(false)
1096}
1097
1098/// Check if partial artifacts exist (clone/build dirs) without a functional binary.
1099///
1100/// Returns `true` if artifacts exist but the binary is NOT functional.
1101/// Used to distinguish partial installs from complete ones.
1102pub fn has_partial_artifacts(home: &str) -> bool {
1103    let build_dir = PathBuf::from(DEFAULT_BUILD_DIR);
1104    let install_dir = PathBuf::from(home).join(DEFAULT_INSTALL_PREFIX);
1105
1106    let artifacts_exist = build_dir.exists() || install_dir.exists();
1107    let binary_functional = is_llama_cli_functional(home);
1108
1109    artifacts_exist && !binary_functional
1110}
1111
1112/// Check if the installed llama-cli binary has ROCm/HIP linkage.
1113///
1114/// Verify that the installed binary has ROCm/HIP linkage and NOT CUDA linkage.
1115///
1116/// Uses `ldd` to check for:
1117/// - REQUIRED: `amdhip64` or `hipblas` or `libhip` (ROCm present)
1118/// - FORBIDDEN: `libcuda` or `libnvidia` or `libcudart` (CUDA contamination)
1119///
1120/// # Validation
1121///
1122/// - **VAL-GUARD-004**: Post-build linkage verification rejects CUDA-linked binaries
1123/// - **VAL-GUARD-005**: Post-build linkage verification requires ROCm linkage
1124fn verify_binary_linkage(binary_path: &Path) -> Result<(), String> {
1125    if !binary_path.exists() {
1126        return Err(format!(
1127            "Binary not found at {} — install may have failed",
1128            binary_path.display()
1129        ));
1130    }
1131
1132    let output = std::process::Command::new("ldd")
1133        .arg(binary_path)
1134        .output()
1135        .map_err(|e| format!("Failed to run ldd on {}: {}", binary_path.display(), e))?;
1136
1137    if !output.status.success() {
1138        return Err(format!(
1139            "ldd failed on {}: {}",
1140            binary_path.display(),
1141            String::from_utf8_lossy(&output.stderr)
1142        ));
1143    }
1144
1145    let ldd_output = String::from_utf8_lossy(&output.stdout);
1146
1147    // Check for CUDA contamination
1148    let cuda_markers = ["libcuda.so", "libnvidia", "libcudart"];
1149    for marker in &cuda_markers {
1150        if ldd_output.contains(marker) {
1151            return Err(format!(
1152                "CUDA contamination detected: {} links against '{}'. \
1153                 The binary was built with CUDA instead of ROCm/HIP. \
1154                 This should not happen — check CMake configuration.",
1155                binary_path.display(),
1156                marker
1157            ));
1158        }
1159    }
1160
1161    // Check for ROCm linkage
1162    let has_rocm = ldd_output.contains("amdhip64")
1163        || ldd_output.contains("hipblas")
1164        || ldd_output.contains("libhip");
1165
1166    if !has_rocm {
1167        return Err(format!(
1168            "ROCm/HIP linkage not found in {}. The binary may be a CPU-only build \
1169             or was built without GPU acceleration. Expected linkage to amdhip64 or hipblas.",
1170            binary_path.display()
1171        ));
1172    }
1173
1174    Ok(())
1175}
1176
1177/// Check if NVIDIA CUDA toolkit artifacts are present on the system.
1178///
1179/// VAL-GUARD-002: detection is informational and never blocks HIP builds.
1180fn detect_cuda_toolkit_presence() -> Vec<String> {
1181    let mut found = Vec::new();
1182    let cuda_indicators = [
1183        ("nvcc", "NVIDIA CUDA compiler"),
1184        ("nvidia-smi", "NVIDIA system management interface"),
1185    ];
1186
1187    for (binary, description) in cuda_indicators {
1188        if std::process::Command::new("which")
1189            .arg(binary)
1190            .output()
1191            .map(|output| output.status.success())
1192            .unwrap_or(false)
1193        {
1194            found.push(format!("{} ({})", binary, description));
1195        }
1196    }
1197
1198    if Path::new("/usr/local/cuda").exists() {
1199        found.push("/usr/local/cuda directory".to_string());
1200    }
1201
1202    found
1203}
1204
1205/// Validate generated CMake cache after configure.
1206///
1207/// VAL-GUARD-003: cache must confirm HIP ON, CUDA OFF, expected GPU targets.
1208fn validate_cmake_cache(cache_path: &Path, expected_gpu_targets: &str) -> Result<(), String> {
1209    let contents = fs::read_to_string(cache_path).map_err(|err| {
1210        format!(
1211            "failed to read CMake cache at {}: {}",
1212            cache_path.display(),
1213            err
1214        )
1215    })?;
1216
1217    let mut hip_on = false;
1218    let mut cuda_off = false;
1219    let mut gpu_targets_seen = false;
1220
1221    for line in contents.lines() {
1222        let trimmed = line.trim();
1223        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("//") {
1224            continue;
1225        }
1226
1227        if trimmed == "GGML_HIP:BOOL=ON" {
1228            hip_on = true;
1229        } else if trimmed == "GGML_CUDA:BOOL=OFF" {
1230            cuda_off = true;
1231        } else if let Some(value) = trimmed.strip_prefix("GPU_TARGETS:STRING=") {
1232            gpu_targets_seen = true;
1233            if value != expected_gpu_targets {
1234                return Err(format!(
1235                    "GPU_TARGETS mismatch in CMakeCache.txt: expected '{}', found '{}'",
1236                    expected_gpu_targets, value
1237                ));
1238            }
1239        }
1240    }
1241
1242    if !hip_on {
1243        return Err("GGML_HIP is not ON in CMakeCache.txt; build is not using ROCm".to_string());
1244    }
1245    if !cuda_off {
1246        return Err("GGML_CUDA is not OFF in CMakeCache.txt; CUDA contamination risk".to_string());
1247    }
1248    if !gpu_targets_seen {
1249        return Err("GPU_TARGETS is missing from CMakeCache.txt".to_string());
1250    }
1251
1252    Ok(())
1253}
1254
1255// ===========================================================================
1256// Post-install verification (follow-up hardening)
1257// ===========================================================================
1258
1259/// Result of stronger-than---help post-install verification.
1260#[derive(Debug, Clone, PartialEq)]
1261pub struct PostInstallVerification {
1262    /// Whether the basic --help check passed.
1263    pub help_check_passed: bool,
1264    /// Whether a stronger verification (llama-bench) was attempted.
1265    pub stronger_check_attempted: bool,
1266    /// Whether the stronger verification passed.
1267    pub stronger_check_passed: bool,
1268    /// Prefill tokens/second from llama-bench (if run).
1269    pub bench_prefill_tps: Option<f64>,
1270    /// Decode tokens/second from llama-bench (if run).
1271    pub bench_decode_tps: Option<f64>,
1272    /// Whether RDNA3 validation was attempted.
1273    pub rdna3_check_attempted: bool,
1274    /// Whether RDNA3 validation passed (is_rdna3 = YES).
1275    pub rdna3_is_valid: Option<bool>,
1276    /// WMMA throughput from rdna3_validation.
1277    pub rdna3_wmma_tps: Option<f64>,
1278    /// Whether the GPU is RDNA3.
1279    pub is_rdna3_device: Option<bool>,
1280    /// Whether WMMA is supported on the detected GPU.
1281    pub wmma_supported: Option<bool>,
1282    /// Path to the binary that was verified.
1283    pub verified_binary_path: String,
1284    /// Any human-readable summary of the verification.
1285    pub summary: String,
1286}
1287
1288/// Default verification model search paths relative to home.
1289const VERIFICATION_MODEL_SEARCH_PATHS: &[&str] = &[
1290    "models/Qwen3-0.6B-F16.gguf",
1291    ".mlstack/models/Qwen3-0.6B-F16.gguf",
1292    "llama.cpp-turboquant-hip/models/Qwen3-0.6B-F16.gguf",
1293];
1294
1295/// Locate a model file for llama-bench verification.
1296fn find_verification_model(home: &str, fork_dir: &str) -> Option<PathBuf> {
1297    for relative in VERIFICATION_MODEL_SEARCH_PATHS {
1298        let candidate = PathBuf::from(home)
1299            .join("Documents/Product/Stan-s-ML-Stack/Fork")
1300            .join(relative.trim_start_matches("llama.cpp-turboquant-hip/"));
1301        if candidate.exists() {
1302            return Some(candidate);
1303        }
1304    }
1305    // Also check the fork_dir directly
1306    for relative in VERIFICATION_MODEL_SEARCH_PATHS {
1307        let candidate =
1308            PathBuf::from(fork_dir).join(relative.trim_start_matches("llama.cpp-turboquant-hip/"));
1309        if candidate.exists() {
1310            return Some(candidate);
1311        }
1312    }
1313    None
1314}
1315
1316/// Performs stronger-than---help post-install verification on the installed binary.
1317///
1318/// Runs from the installed path (VAL-CROSS-009, VAL-CROSS-011).
1319/// Attempts llama-bench with a small model for VAL-CROSS-010.
1320/// Runs rdna3_validation if available for RDNA3 proof (VAL-CROSS-011).
1321pub fn verify_installed_binary(home: &str, fork_dir: &str) -> PostInstallVerification {
1322    let bin_dir = resolve_install_bin_dir(home);
1323    let cli_path = bin_dir.join(DETECTION_BINARY);
1324    let bench_path = bin_dir.join("llama-bench");
1325    let rdna3_path = bin_dir.join("rdna3_validation");
1326
1327    let mut result = PostInstallVerification {
1328        help_check_passed: false,
1329        stronger_check_attempted: false,
1330        stronger_check_passed: false,
1331        bench_prefill_tps: None,
1332        bench_decode_tps: None,
1333        rdna3_check_attempted: false,
1334        rdna3_is_valid: None,
1335        rdna3_wmma_tps: None,
1336        is_rdna3_device: None,
1337        wmma_supported: None,
1338        verified_binary_path: cli_path.display().to_string(),
1339        summary: String::new(),
1340    };
1341
1342    // ── Step 1: Basic --help check ──────────────────────────────────
1343    if cli_path.exists() {
1344        match std::process::Command::new(&cli_path)
1345            .arg(DETECTION_SUBCOMMAND)
1346            .output()
1347        {
1348            Ok(out) => {
1349                result.help_check_passed = out.status.success();
1350            }
1351            Err(_) => {
1352                result.help_check_passed = false;
1353            }
1354        }
1355    }
1356
1357    // ── Step 2: Stronger check via llama-bench (if available) ──────
1358    if bench_path.exists() {
1359        result.stronger_check_attempted = true;
1360        if let Some(model_path) = find_verification_model(home, fork_dir) {
1361            if let Ok(out) = std::process::Command::new(&bench_path)
1362                .arg("-m")
1363                .arg(model_path.to_str().unwrap_or(""))
1364                .arg("-p")
1365                .arg("128")
1366                .arg("-n")
1367                .arg("32")
1368                .arg("-ngl")
1369                .arg("33")
1370                .output()
1371            {
1372                if out.status.success() {
1373                    result.stronger_check_passed = true;
1374                    // Parse prefill and decode t/s from output
1375                    let stdout = String::from_utf8_lossy(&out.stdout);
1376                    for line in stdout.lines() {
1377                        if line.contains("pp128") {
1378                            // Extract the t/s value (last numeric column)
1379                            if let Some(tps) = parse_bench_tps(line) {
1380                                result.bench_prefill_tps = Some(tps);
1381                            }
1382                        } else if line.contains("tg32") {
1383                            if let Some(tps) = parse_bench_tps(line) {
1384                                result.bench_decode_tps = Some(tps);
1385                            }
1386                        }
1387                    }
1388                }
1389            }
1390        }
1391    }
1392
1393    // ── Step 3: RDNA3 proof (if available) ─────────────────────────
1394    if rdna3_path.exists() {
1395        result.rdna3_check_attempted = true;
1396        if let Ok(out) = std::process::Command::new(&rdna3_path).output() {
1397            if out.status.success() {
1398                let stdout = String::from_utf8_lossy(&out.stdout);
1399                result.is_rdna3_device = Some(stdout.contains("Is RDNA3: YES"));
1400                result.wmma_supported = Some(stdout.contains("WMMA Supported: YES"));
1401                result.rdna3_is_valid = result.is_rdna3_device;
1402
1403                // Extract WMMA throughput
1404                for line in stdout.lines() {
1405                    if line.starts_with("WMMA Throughput:") {
1406                        if let Some(val) = line.split_whitespace().nth(2) {
1407                            if let Ok(tps) = val.parse::<f64>() {
1408                                result.rdna3_wmma_tps = Some(tps);
1409                            }
1410                        }
1411                    }
1412                }
1413            }
1414        }
1415    }
1416
1417    // ── Build summary ──────────────────────────────────────────────
1418    let mut parts: Vec<String> = vec![];
1419    if result.help_check_passed {
1420        parts.push("basic check PASSED".to_string());
1421    } else {
1422        parts.push("basic check FAILED".to_string());
1423    }
1424    if result.stronger_check_attempted {
1425        if result.stronger_check_passed {
1426            parts.push(format!(
1427                "llama-bench PASSED (prefill={:.0} t/s, decode={:.0} t/s)",
1428                result.bench_prefill_tps.unwrap_or(0.0),
1429                result.bench_decode_tps.unwrap_or(0.0)
1430            ));
1431        } else {
1432            parts.push("llama-bench FAILED".to_string());
1433        }
1434    } else {
1435        parts.push("llama-bench SKIPPED (binary unavailable)".to_string());
1436    }
1437    if result.rdna3_check_attempted {
1438        if result.rdna3_is_valid.unwrap_or(false) {
1439            parts.push(format!(
1440                "RDNA3 proof PASSED (WMMA={:.0} t/s)",
1441                result.rdna3_wmma_tps.unwrap_or(0.0)
1442            ));
1443        } else {
1444            parts.push("RDNA3 proof: not an RDNA3 device".to_string());
1445        }
1446    } else {
1447        parts.push("RDNA3 proof SKIPPED (rdna3_validation unavailable)".to_string());
1448    }
1449
1450    result.summary = parts.join("; ");
1451    result
1452}
1453
1454/// Parse tokens/second from a llama-bench output line.
1455/// Lines look like: `| qwen3 0.6B F16 | 1.11 GiB | 596.05 M | ROCm | 33 | pp128 | 6254.94 ± 1534.82 |`
1456fn parse_bench_tps(line: &str) -> Option<f64> {
1457    let parts: Vec<&str> = line.split('|').collect();
1458    if parts.len() >= 8 {
1459        let tps_cell = parts[7].trim();
1460        // Extract the number before any ±
1461        tps_cell
1462            .split('±')
1463            .next()
1464            .and_then(|s| s.trim().parse::<f64>().ok())
1465    } else {
1466        None
1467    }
1468}
1469
1470// ===========================================================================
1471// Tests
1472// ===========================================================================
1473
1474#[cfg(test)]
1475mod tests {
1476    use super::*;
1477
1478    #[test]
1479    fn test_hip_archs_from_gpu_arch() {
1480        assert_eq!(HipArchs::from_gpu_arch("gfx1030"), HipArchs::Gfx1030);
1481        assert_eq!(HipArchs::from_gpu_arch("gfx1100"), HipArchs::Gfx1100);
1482        assert_eq!(HipArchs::from_gpu_arch("gfx1101"), HipArchs::Gfx1100);
1483        assert_eq!(HipArchs::from_gpu_arch("gfx1200"), HipArchs::Gfx1200);
1484        assert_eq!(HipArchs::from_gpu_arch("gfx1201"), HipArchs::Gfx1200);
1485        assert_eq!(HipArchs::from_gpu_arch("gfx000"), HipArchs::Default);
1486        assert_eq!(HipArchs::from_gpu_arch("unknown"), HipArchs::Default);
1487    }
1488
1489    #[test]
1490    fn test_gpu_targets_legacy_channel() {
1491        // Legacy channel always returns gfx1030 regardless of detected GPU
1492        assert_eq!(
1493            HipArchs::Gfx1100.gpu_targets_for_channel("legacy"),
1494            "gfx1030"
1495        );
1496        assert_eq!(
1497            HipArchs::Gfx1200.gpu_targets_for_channel("legacy"),
1498            "gfx1030"
1499        );
1500        assert_eq!(
1501            HipArchs::Gfx1030.gpu_targets_for_channel("legacy"),
1502            "gfx1030"
1503        );
1504        assert_eq!(
1505            HipArchs::Default.gpu_targets_for_channel("legacy"),
1506            "gfx1030"
1507        );
1508    }
1509
1510    #[test]
1511    fn test_gpu_targets_stable_channel() {
1512        assert_eq!(
1513            HipArchs::Gfx1030.gpu_targets_for_channel("stable"),
1514            "gfx1030"
1515        );
1516        assert_eq!(
1517            HipArchs::Gfx1100.gpu_targets_for_channel("stable"),
1518            "gfx1030;gfx1100;gfx1101"
1519        );
1520        // RDNA4 on stable degrades to RDNA2+RDNA3 (no gfx1200)
1521        assert_eq!(
1522            HipArchs::Gfx1200.gpu_targets_for_channel("stable"),
1523            "gfx1030;gfx1100;gfx1101"
1524        );
1525        assert_eq!(
1526            HipArchs::Default.gpu_targets_for_channel("stable"),
1527            "gfx1030"
1528        );
1529    }
1530
1531    #[test]
1532    fn test_gpu_targets_latest_channel() {
1533        assert_eq!(
1534            HipArchs::Gfx1030.gpu_targets_for_channel("latest"),
1535            "gfx1030"
1536        );
1537        assert_eq!(
1538            HipArchs::Gfx1100.gpu_targets_for_channel("latest"),
1539            "gfx1030;gfx1100;gfx1101"
1540        );
1541        assert_eq!(
1542            HipArchs::Gfx1200.gpu_targets_for_channel("latest"),
1543            "gfx1030;gfx1100;gfx1101;gfx1200"
1544        );
1545    }
1546
1547    #[test]
1548    fn test_rocm_channel_wmma_fa() {
1549        assert!(!RocmChannel::parse_channel("legacy").enable_wmma_fa());
1550        assert!(RocmChannel::parse_channel("stable").enable_wmma_fa());
1551        assert!(RocmChannel::parse_channel("latest").enable_wmma_fa());
1552    }
1553
1554    #[test]
1555    fn test_cmake_flags_legacy() {
1556        let config = LlamaCppConfig {
1557            gpu_arch: "gfx1100".to_string(),
1558            channel: "legacy".to_string(),
1559            ..Default::default()
1560        };
1561        let installer = LlamaCppInstaller::new(config);
1562        let flags = installer.cmake_flags();
1563
1564        assert!(flags.contains(&"-DGGML_HIP=ON".to_string()));
1565        assert!(flags.contains(&"-DGPU_TARGETS=gfx1030".to_string()));
1566        // Legacy should NOT have WMMA FA enabled
1567        assert!(!flags.iter().any(|f| f.contains("RDNA3_PROBES")));
1568        assert!(!flags.iter().any(|f| f.contains("ROCWMMA_FATTN")));
1569    }
1570
1571    #[test]
1572    fn test_cmake_flags_always_disable_non_rocm_backends() {
1573        // VAL-GUARD-001: every channel must explicitly disable non-ROCm backends.
1574        for channel in ["legacy", "stable", "latest"] {
1575            let config = LlamaCppConfig {
1576                gpu_arch: "gfx1100".to_string(),
1577                channel: channel.to_string(),
1578                ..Default::default()
1579            };
1580            let installer = LlamaCppInstaller::new(config);
1581            let flags = installer.cmake_flags();
1582
1583            assert!(
1584                flags.contains(&"-DGGML_CUDA=OFF".to_string()),
1585                "channel {channel} must set GGML_CUDA=OFF"
1586            );
1587            assert!(
1588                flags.contains(&"-DGGML_VULKAN=OFF".to_string()),
1589                "channel {channel} must set GGML_VULKAN=OFF"
1590            );
1591            assert!(
1592                flags.contains(&"-DGGML_METAL=OFF".to_string()),
1593                "channel {channel} must set GGML_METAL=OFF"
1594            );
1595        }
1596    }
1597
1598    #[test]
1599    fn test_cmake_flags_stable() {
1600        let config = LlamaCppConfig {
1601            gpu_arch: "gfx1100".to_string(),
1602            channel: "stable".to_string(),
1603            ..Default::default()
1604        };
1605        let installer = LlamaCppInstaller::new(config);
1606        let flags = installer.cmake_flags();
1607
1608        assert!(flags.contains(&"-DGGML_HIP=ON".to_string()));
1609        assert!(flags.contains(&"-DGPU_TARGETS=gfx1030;gfx1100;gfx1101".to_string()));
1610        assert!(flags.contains(&"-DGGML_HIP_RDNA3_PROBES=ON".to_string()));
1611        assert!(flags.contains(&"-DGGML_HIP_ROCWMMA_FATTN=ON".to_string()));
1612    }
1613
1614    #[test]
1615    fn test_cmake_flags_latest_with_rdna4() {
1616        let config = LlamaCppConfig {
1617            gpu_arch: "gfx1200".to_string(),
1618            channel: "latest".to_string(),
1619            ..Default::default()
1620        };
1621        let installer = LlamaCppInstaller::new(config);
1622        let flags = installer.cmake_flags();
1623
1624        assert!(flags.contains(&"-DGGML_HIP=ON".to_string()));
1625        assert!(flags.contains(&"-DGPU_TARGETS=gfx1030;gfx1100;gfx1101;gfx1200".to_string()));
1626        assert!(flags.contains(&"-DGGML_HIP_RDNA3_PROBES=ON".to_string()));
1627        assert!(flags.contains(&"-DGGML_HIP_ROCWMMA_FATTN=ON".to_string()));
1628    }
1629
1630    #[test]
1631    fn test_rocm_channel_rdna3_gating() {
1632        let legacy = LlamaCppInstaller::new(LlamaCppConfig {
1633            gpu_arch: "gfx1100".to_string(),
1634            channel: "legacy".to_string(),
1635            ..Default::default()
1636        });
1637        let stable = LlamaCppInstaller::new(LlamaCppConfig {
1638            gpu_arch: "gfx1100".to_string(),
1639            channel: "stable".to_string(),
1640            ..Default::default()
1641        });
1642        let latest = LlamaCppInstaller::new(LlamaCppConfig {
1643            gpu_arch: "gfx1100".to_string(),
1644            channel: "latest".to_string(),
1645            ..Default::default()
1646        });
1647
1648        let legacy_flags = legacy.cmake_flags();
1649        let stable_flags = stable.cmake_flags();
1650        let latest_flags = latest.cmake_flags();
1651
1652        assert!(!legacy_flags
1653            .iter()
1654            .any(|flag| flag.contains("RDNA3_PROBES") || flag.contains("ROCWMMA_FATTN")));
1655        assert!(stable_flags.contains(&"-DGGML_HIP_RDNA3_PROBES=ON".to_string()));
1656        assert!(stable_flags.contains(&"-DGGML_HIP_ROCWMMA_FATTN=ON".to_string()));
1657        assert!(latest_flags.contains(&"-DGGML_HIP_RDNA3_PROBES=ON".to_string()));
1658        assert!(latest_flags.contains(&"-DGGML_HIP_ROCWMMA_FATTN=ON".to_string()));
1659    }
1660
1661    #[test]
1662    fn test_cmake_flags_unknown_gpu_conservative() {
1663        let config = LlamaCppConfig {
1664            gpu_arch: "unknown".to_string(),
1665            channel: "latest".to_string(),
1666            ..Default::default()
1667        };
1668        let installer = LlamaCppInstaller::new(config);
1669        let flags = installer.cmake_flags();
1670
1671        // Unknown GPU should degrade to conservative gfx1030
1672        assert!(flags.contains(&"-DGPU_TARGETS=gfx1030".to_string()));
1673    }
1674
1675    #[test]
1676    fn test_detect_cuda_toolkit_presence_no_panic() {
1677        // VAL-GUARD-002: detection must be safe whether CUDA exists or not.
1678        let result = detect_cuda_toolkit_presence();
1679        assert!(result.len() <= 3);
1680    }
1681
1682    #[test]
1683    fn test_validate_cmake_cache_accepts_rocm_only_config() {
1684        // VAL-GUARD-003: valid cache confirms HIP ON, CUDA OFF, expected GPU targets.
1685        let temp = tempfile::NamedTempFile::new().unwrap();
1686        fs::write(
1687            temp.path(),
1688            "GGML_HIP:BOOL=ON\nGGML_CUDA:BOOL=OFF\nGPU_TARGETS:STRING=gfx1030;gfx1100;gfx1101\n",
1689        )
1690        .unwrap();
1691
1692        assert!(validate_cmake_cache(temp.path(), "gfx1030;gfx1100;gfx1101").is_ok());
1693    }
1694
1695    #[test]
1696    fn test_validate_cmake_cache_rejects_cuda_on() {
1697        // VAL-GUARD-003: CUDA ON is contamination risk.
1698        let temp = tempfile::NamedTempFile::new().unwrap();
1699        fs::write(
1700            temp.path(),
1701            "GGML_HIP:BOOL=ON\nGGML_CUDA:BOOL=ON\nGPU_TARGETS:STRING=gfx1030\n",
1702        )
1703        .unwrap();
1704
1705        let err = validate_cmake_cache(temp.path(), "gfx1030").unwrap_err();
1706        assert!(err.contains("GGML_CUDA is not OFF"));
1707    }
1708
1709    #[test]
1710    fn test_validate_cmake_cache_rejects_gpu_target_mismatch() {
1711        // VAL-GUARD-003: GPU_TARGETS must match channel policy exactly.
1712        let temp = tempfile::NamedTempFile::new().unwrap();
1713        fs::write(
1714            temp.path(),
1715            "GGML_HIP:BOOL=ON\nGGML_CUDA:BOOL=OFF\nGPU_TARGETS:STRING=gfx1030\n",
1716        )
1717        .unwrap();
1718
1719        let err = validate_cmake_cache(temp.path(), "gfx1030;gfx1100;gfx1101").unwrap_err();
1720        assert!(err.contains("GPU_TARGETS mismatch"));
1721    }
1722
1723    #[test]
1724    fn test_build_commands_sequence() {
1725        let config = LlamaCppConfig {
1726            gpu_arch: "gfx1100".to_string(),
1727            channel: "latest".to_string(),
1728            ..Default::default()
1729        };
1730        let installer = LlamaCppInstaller::new(config);
1731        let commands = installer.build_commands("/home/testuser");
1732
1733        // Should have 7 commands: mkdir, clone, checkout, cmake configure, build, install, purge
1734        assert_eq!(commands.len(), 7);
1735        assert_eq!(commands[0].program, "mkdir");
1736        assert_eq!(commands[1].program, "git");
1737        assert_eq!(commands[2].program, "git");
1738        assert_eq!(commands[3].program, "cmake");
1739        assert_eq!(commands[4].program, "cmake");
1740        assert_eq!(commands[5].program, "cmake");
1741        assert_eq!(commands[6].program, "sh");
1742    }
1743
1744    #[test]
1745    fn test_detection_binary_path() {
1746        let config = LlamaCppConfig::default();
1747        let path = config.detection_binary_path("/home/testuser");
1748        assert_eq!(
1749            path,
1750            PathBuf::from("/home/testuser/.mlstack/components/llama-cpp/bin/llama-cli")
1751        );
1752    }
1753
1754    #[test]
1755    fn test_resolve_install_bin_dir() {
1756        let dir = resolve_install_bin_dir("/home/testuser");
1757        assert_eq!(
1758            dir,
1759            PathBuf::from("/home/testuser/.mlstack/components/llama-cpp/bin")
1760        );
1761    }
1762
1763    #[test]
1764    fn test_is_llama_cli_functional_absent() {
1765        // With a non-existent home, the binary should not be found
1766        assert!(!is_llama_cli_functional(
1767            "/nonexistent/path/that/does/not/exist"
1768        ));
1769    }
1770
1771    #[test]
1772    fn test_has_partial_artifacts_no_artifacts() {
1773        // Serialize: has_partial_artifacts checks the GLOBAL /tmp/llama-cpp-rocm-build
1774        // path; test_purge_source_artifacts_is_idempotent creates it. The global env
1775        // lock serializes all such global-state mutations.
1776        let _env = crate::test_support::lock_env();
1777        // With a non-existent home, no artifacts should exist.
1778        // Use a path starting with /tmp-nonexistent- prefix to avoid
1779        // races with other tests that may create /tmp/llama-cpp-rocm-build.
1780        assert!(!has_partial_artifacts(
1781            "/nonexistent-path-DOES-NOT-exist-f94a1f2a3b5c/test-home"
1782        ));
1783    }
1784
1785    #[test]
1786    fn test_dependencies() {
1787        let installer = LlamaCppInstaller::with_defaults();
1788        assert!(installer.dependencies().contains(&"rocm"));
1789    }
1790
1791    #[test]
1792    fn test_repo_url_default() {
1793        let config = LlamaCppConfig::default();
1794        assert_eq!(config.repo_url(), DEFAULT_REPO_URL);
1795    }
1796
1797    #[test]
1798    fn test_repo_url_override() {
1799        let config = LlamaCppConfig {
1800            repo_url: Some("https://example.com/fork.git".to_string()),
1801            ..Default::default()
1802        };
1803        assert_eq!(config.repo_url(), "https://example.com/fork.git");
1804    }
1805
1806    #[test]
1807    fn test_rocm_channel_from_str() {
1808        assert_eq!(RocmChannel::parse_channel("legacy"), RocmChannel::Legacy);
1809        assert_eq!(RocmChannel::parse_channel("LEGACY"), RocmChannel::Legacy);
1810        assert_eq!(RocmChannel::parse_channel("stable"), RocmChannel::Stable);
1811        assert_eq!(RocmChannel::parse_channel("latest"), RocmChannel::Latest);
1812        assert_eq!(RocmChannel::parse_channel("unknown"), RocmChannel::Latest);
1813    }
1814
1815    #[test]
1816    fn test_rocm_channel_label() {
1817        assert_eq!(RocmChannel::Legacy.label(), "legacy");
1818        assert_eq!(RocmChannel::Stable.label(), "stable");
1819        assert_eq!(RocmChannel::Latest.label(), "latest");
1820    }
1821
1822    #[test]
1823    fn test_build_commands_with_auth_token_handling() {
1824        // Save any pre-existing GITHUB_TOKEN value to restore later
1825        let _original_token = std::env::var("GITHUB_TOKEN").ok();
1826
1827        // --- Branch 1: with GITHUB_TOKEN set ---
1828        let config = LlamaCppConfig::default();
1829        let installer = LlamaCppInstaller::new(config);
1830        let commands_with_token = installer
1831            .build_commands_with_auth("/home/testuser", Some(SealedToken::new("mock_token")));
1832
1833        // The clone command is at index 1 (after mkdir)
1834        let clone_cmd = &commands_with_token[1];
1835        // Token must NOT appear directly in args (security requirement)
1836        assert!(!clone_cmd.args.iter().any(|arg| arg.contains("mock_token")));
1837        // Plain HTTPS repo URL must be present
1838        assert!(clone_cmd.args.iter().any(|arg| arg == DEFAULT_REPO_URL));
1839        // Credential helper flag must be present
1840        assert!(clone_cmd
1841            .args
1842            .iter()
1843            .any(|arg| arg.starts_with("credential.helper=")));
1844        // Env must contain GITHUB_TOKEN for git auth and GIT_TERMINAL_PROMPT
1845        assert!(clone_cmd
1846            .env
1847            .iter()
1848            .any(|(k, v)| k == "GITHUB_TOKEN" && v == "mock_token"));
1849        assert!(clone_cmd
1850            .env
1851            .iter()
1852            .any(|(k, v)| k == "GIT_TERMINAL_PROMPT" && v == "0"));
1853    }
1854
1855    #[test]
1856    fn test_release_manifest_parse_and_lookup() {
1857        let json = r#"{
1858            "version": "v0.3.2",
1859            "archs": {
1860                "gfx1030": {"url": "https://example.com/a", "sha256": "aaa"},
1861                "gfx1100": {"url": "https://example.com/b", "sha256": "bbb"},
1862                "gfx1200": {"url": "https://example.com/c", "sha256": "ccc"}
1863            }
1864        }"#;
1865
1866        let manifest: ReleaseManifest = serde_json::from_str(json).unwrap();
1867        let installer = LlamaCppInstaller::with_defaults();
1868        assert_eq!(manifest.version, "v0.3.2");
1869        assert_eq!(
1870            installer
1871                .release_asset_for_arch(&manifest, "gfx1030")
1872                .unwrap()
1873                .url,
1874            "https://example.com/a"
1875        );
1876        assert_eq!(
1877            installer
1878                .release_asset_for_arch(&manifest, "gfx1100")
1879                .unwrap()
1880                .sha256,
1881            "bbb"
1882        );
1883        assert!(installer
1884            .release_asset_for_arch(&manifest, "gfx9999")
1885            .is_none());
1886    }
1887
1888    #[test]
1889    fn test_release_manifest_rejects_unknown_fields() {
1890        let json = r#"{
1891            "version": "v0.3.2",
1892            "unexpected": true,
1893            "archs": {
1894                "gfx1030": {"url": "https://example.com/a", "sha256": "aaa"},
1895                "gfx1100": {"url": "https://example.com/b", "sha256": "bbb"},
1896                "gfx1200": {"url": "https://example.com/c", "sha256": "ccc"}
1897            }
1898        }"#;
1899
1900        assert!(serde_json::from_str::<ReleaseManifest>(json).is_err());
1901    }
1902
1903    #[test]
1904    fn test_verify_sha256_matches() {
1905        let temp = tempfile::NamedTempFile::new().unwrap();
1906        fs::write(temp.path(), b"hello").unwrap();
1907        let expected = Sha256::digest(b"hello")
1908            .iter()
1909            .map(|byte| format!("{:02x}", byte))
1910            .collect::<String>();
1911        assert!(LlamaCppInstaller::verify_sha256(temp.path(), &expected).unwrap());
1912    }
1913
1914    #[test]
1915    fn test_verify_sha256_mismatch() {
1916        let temp = tempfile::NamedTempFile::new().unwrap();
1917        fs::write(temp.path(), b"hello").unwrap();
1918        assert!(!LlamaCppInstaller::verify_sha256(temp.path(), "deadbeef").unwrap());
1919    }
1920
1921    #[test]
1922    fn test_download_failure_falls_back_to_source() {
1923        let installer = LlamaCppInstaller::with_defaults();
1924        let plan = PrebuiltInstallPlan {
1925            manifest_version: "v1".into(),
1926            arch: "gfx1100".into(),
1927            url: "http://127.0.0.1:9/nope".into(),
1928            sha256: "deadbeef".into(),
1929            install_prefix: tempfile::tempdir().unwrap().path().display().to_string(),
1930            binary_path: PathBuf::from("/tmp/llama-cli"),
1931        };
1932        assert!(installer.download_prebuilt_binary(&plan).is_err());
1933    }
1934
1935    #[test]
1936    fn test_purge_source_artifacts_is_idempotent() {
1937        // Serialize: this test creates the GLOBAL /tmp/llama-cpp-rocm-build path
1938        // that test_has_partial_artifacts_no_artifacts checks. Global lock +
1939        // explicit cleanup make the suite deterministic.
1940        let _env = crate::test_support::lock_env();
1941        let installer = LlamaCppInstaller::with_defaults();
1942        let temp_home = tempfile::tempdir().unwrap();
1943        let home = temp_home.path().to_string_lossy().to_string();
1944
1945        std::fs::create_dir_all(format!("{home}/.mlstack/components/llama-cpp")).unwrap();
1946        std::fs::create_dir_all(format!("{home}/.mlstack/components/llama-cpp/.git")).unwrap();
1947        std::fs::create_dir_all("/tmp/llama-cpp-rocm-build").unwrap();
1948        std::fs::create_dir_all(format!("{home}/.mlstack/components/llama-cpp/bin")).unwrap();
1949        std::fs::write(
1950            format!("{home}/.mlstack/components/llama-cpp/bin/llama-cli"),
1951            "#!/bin/sh\nexit 0\n",
1952        )
1953        .unwrap();
1954
1955        assert!(installer.purge_source_artifacts(&home, true).is_ok());
1956        assert!(installer.purge_source_artifacts(&home, true).is_ok());
1957
1958        // Determinism: remove the global build dir so no later test sees a
1959        // leftover (purge targets home-relative paths, not this global constant).
1960        let _ = std::fs::remove_dir_all("/tmp/llama-cpp-rocm-build");
1961    }
1962
1963    #[test]
1964    fn test_git_clone_with_auth() {
1965        let config = LlamaCppConfig::default();
1966        let installer = LlamaCppInstaller::new(config);
1967        let command = installer.git_clone_with_auth("/tmp/test", "mock_token");
1968
1969        // Token must NOT appear directly in args (security requirement)
1970        assert!(!command.args.iter().any(|arg| arg.contains("mock_token")));
1971        // Plain HTTPS repo URL must be present
1972        assert!(command.args.iter().any(|arg| arg == DEFAULT_REPO_URL));
1973        // Credential helper flag must be present
1974        assert!(command.args.iter().any(|arg| arg == "-c"));
1975        assert!(command
1976            .args
1977            .iter()
1978            .any(|arg| arg.starts_with("credential.helper=")));
1979        // Env must contain GITHUB_TOKEN for git auth and GIT_TERMINAL_PROMPT
1980        assert!(command
1981            .env
1982            .iter()
1983            .any(|(k, v)| k == "GITHUB_TOKEN" && v == "mock_token"));
1984        assert!(command
1985            .env
1986            .iter()
1987            .any(|(k, v)| k == "GIT_TERMINAL_PROMPT" && v == "0"));
1988    }
1989
1990    // Note: `validate_repo_access` is not tested here because it requires real git/network access.
1991    // It is tested indirectly via integration tests or manual validation.
1992
1993    // ── Post-install verification tests (VAL-CROSS-008/009/010/011) ──
1994
1995    #[test]
1996    fn test_post_install_verification_no_binaries() {
1997        let result =
1998            verify_installed_binary("/nonexistent/path/that/does/not/exist", "/nonexistent/fork");
1999        assert!(!result.help_check_passed);
2000        assert!(!result.stronger_check_attempted);
2001        assert!(!result.rdna3_check_attempted);
2002        assert!(!result.summary.is_empty());
2003        assert!(result.summary.contains("FAILED"));
2004    }
2005
2006    #[test]
2007    fn test_post_install_verification_struct_fields() {
2008        let result =
2009            verify_installed_binary("/nonexistent/path/that/does/not/exist", "/nonexistent/fork");
2010        assert!(!result.help_check_passed);
2011        assert!(!result.stronger_check_attempted);
2012        assert!(!result.stronger_check_passed);
2013        assert_eq!(result.bench_prefill_tps, None);
2014        assert_eq!(result.bench_decode_tps, None);
2015        assert!(!result.rdna3_check_attempted);
2016        assert_eq!(result.rdna3_is_valid, None);
2017        assert_eq!(result.rdna3_wmma_tps, None);
2018        assert_eq!(result.is_rdna3_device, None);
2019        assert_eq!(result.wmma_supported, None);
2020        assert!(result.verified_binary_path.contains("llama-cli"));
2021    }
2022
2023    #[test]
2024    fn test_post_install_verification_cli_only() {
2025        let tmp = tempfile::tempdir().unwrap();
2026        let bin_dir = tmp.path().join(".mlstack/components/llama-cpp/bin");
2027        std::fs::create_dir_all(&bin_dir).unwrap();
2028        std::fs::write(
2029            bin_dir.join("llama-cli"),
2030            "#!/bin/sh\necho 'usage: llama-cli'\nexit 0\n",
2031        )
2032        .unwrap();
2033        #[cfg(unix)]
2034        {
2035            use std::os::unix::fs::PermissionsExt;
2036            std::fs::set_permissions(
2037                bin_dir.join("llama-cli"),
2038                std::fs::Permissions::from_mode(0o755),
2039            )
2040            .unwrap();
2041        }
2042
2043        let result = verify_installed_binary(tmp.path().to_str().unwrap(), "/nonexistent/fork");
2044        assert!(result.help_check_passed);
2045        assert!(!result.stronger_check_attempted);
2046        assert!(!result.rdna3_check_attempted);
2047        assert!(result.summary.contains("PASSED"));
2048        assert!(result.summary.contains("SKIPPED"));
2049    }
2050
2051    #[test]
2052    fn test_parse_bench_tps_valid() {
2053        let line =
2054            "| qwen3 0.6B F16 | 1.11 GiB | 596.05 M | ROCm | 33 | pp128 | 6254.94 ± 1534.82 |";
2055        let tps = parse_bench_tps(line);
2056        assert!(tps.is_some());
2057        assert!((tps.unwrap() - 6254.94).abs() < 0.01);
2058    }
2059
2060    #[test]
2061    fn test_parse_bench_tps_no_uncertainty() {
2062        let line = "| qwen3 0.6B F16 | 1.11 GiB | 596.05 M | ROCm | 33 | tg32 | 152.17 |";
2063        let tps = parse_bench_tps(line);
2064        assert!(tps.is_some());
2065        assert!((tps.unwrap() - 152.17).abs() < 0.01);
2066    }
2067
2068    #[test]
2069    fn test_parse_bench_tps_invalid_line() {
2070        assert_eq!(parse_bench_tps("not a bench line"), None);
2071        assert_eq!(parse_bench_tps(""), None);
2072        assert_eq!(parse_bench_tps("| col1 | col2 |"), None);
2073    }
2074
2075    #[test]
2076    fn test_verify_binary_linkage_missing_binary() {
2077        // VAL-GUARD-004, VAL-GUARD-005
2078        let result = verify_binary_linkage(std::path::Path::new("/nonexistent/binary"));
2079        assert!(result.is_err());
2080        assert!(result.unwrap_err().contains("not found"));
2081    }
2082
2083    #[test]
2084    fn test_verify_binary_linkage_no_panic() {
2085        // Test with a known binary (bash) — should fail on ROCm linkage check
2086        // but should NOT panic
2087        let result = verify_binary_linkage(std::path::Path::new("/usr/bin/bash"));
2088        // bash won't have ROCm linkage, so this should be an Err
2089        assert!(result.is_err());
2090    }
2091}