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 from_str(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::from_str(&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::from_str(&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            match 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                Ok(out) => {
1373                    if out.status.success() {
1374                        result.stronger_check_passed = true;
1375                        // Parse prefill and decode t/s from output
1376                        let stdout = String::from_utf8_lossy(&out.stdout);
1377                        for line in stdout.lines() {
1378                            if line.contains("pp128") {
1379                                // Extract the t/s value (last numeric column)
1380                                if let Some(tps) = parse_bench_tps(line) {
1381                                    result.bench_prefill_tps = Some(tps);
1382                                }
1383                            } else if line.contains("tg32") {
1384                                if let Some(tps) = parse_bench_tps(line) {
1385                                    result.bench_decode_tps = Some(tps);
1386                                }
1387                            }
1388                        }
1389                    }
1390                }
1391                Err(_) => {}
1392            }
1393        }
1394    }
1395
1396    // ── Step 3: RDNA3 proof (if available) ─────────────────────────
1397    if rdna3_path.exists() {
1398        result.rdna3_check_attempted = true;
1399        match std::process::Command::new(&rdna3_path).output() {
1400            Ok(out) => {
1401                if out.status.success() {
1402                    let stdout = String::from_utf8_lossy(&out.stdout);
1403                    result.is_rdna3_device = Some(stdout.contains("Is RDNA3: YES"));
1404                    result.wmma_supported = Some(stdout.contains("WMMA Supported: YES"));
1405                    result.rdna3_is_valid = result.is_rdna3_device;
1406
1407                    // Extract WMMA throughput
1408                    for line in stdout.lines() {
1409                        if line.starts_with("WMMA Throughput:") {
1410                            if let Some(val) = line.split_whitespace().nth(2) {
1411                                if let Ok(tps) = val.parse::<f64>() {
1412                                    result.rdna3_wmma_tps = Some(tps);
1413                                }
1414                            }
1415                        }
1416                    }
1417                }
1418            }
1419            Err(_) => {}
1420        }
1421    }
1422
1423    // ── Build summary ──────────────────────────────────────────────
1424    let mut parts: Vec<String> = vec![];
1425    if result.help_check_passed {
1426        parts.push("basic check PASSED".to_string());
1427    } else {
1428        parts.push("basic check FAILED".to_string());
1429    }
1430    if result.stronger_check_attempted {
1431        if result.stronger_check_passed {
1432            parts.push(format!(
1433                "llama-bench PASSED (prefill={:.0} t/s, decode={:.0} t/s)",
1434                result.bench_prefill_tps.unwrap_or(0.0),
1435                result.bench_decode_tps.unwrap_or(0.0)
1436            ));
1437        } else {
1438            parts.push("llama-bench FAILED".to_string());
1439        }
1440    } else {
1441        parts.push("llama-bench SKIPPED (binary unavailable)".to_string());
1442    }
1443    if result.rdna3_check_attempted {
1444        if result.rdna3_is_valid.unwrap_or(false) {
1445            parts.push(format!(
1446                "RDNA3 proof PASSED (WMMA={:.0} t/s)",
1447                result.rdna3_wmma_tps.unwrap_or(0.0)
1448            ));
1449        } else {
1450            parts.push("RDNA3 proof: not an RDNA3 device".to_string());
1451        }
1452    } else {
1453        parts.push("RDNA3 proof SKIPPED (rdna3_validation unavailable)".to_string());
1454    }
1455
1456    result.summary = parts.join("; ");
1457    result
1458}
1459
1460/// Parse tokens/second from a llama-bench output line.
1461/// Lines look like: `| qwen3 0.6B F16 | 1.11 GiB | 596.05 M | ROCm | 33 | pp128 | 6254.94 ± 1534.82 |`
1462fn parse_bench_tps(line: &str) -> Option<f64> {
1463    let parts: Vec<&str> = line.split('|').collect();
1464    if parts.len() >= 8 {
1465        let tps_cell = parts[7].trim();
1466        // Extract the number before any ±
1467        tps_cell
1468            .split('±')
1469            .next()
1470            .and_then(|s| s.trim().parse::<f64>().ok())
1471    } else {
1472        None
1473    }
1474}
1475
1476// ===========================================================================
1477// Tests
1478// ===========================================================================
1479
1480#[cfg(test)]
1481mod tests {
1482    use super::*;
1483
1484    #[test]
1485    fn test_hip_archs_from_gpu_arch() {
1486        assert_eq!(HipArchs::from_gpu_arch("gfx1030"), HipArchs::Gfx1030);
1487        assert_eq!(HipArchs::from_gpu_arch("gfx1100"), HipArchs::Gfx1100);
1488        assert_eq!(HipArchs::from_gpu_arch("gfx1101"), HipArchs::Gfx1100);
1489        assert_eq!(HipArchs::from_gpu_arch("gfx1200"), HipArchs::Gfx1200);
1490        assert_eq!(HipArchs::from_gpu_arch("gfx1201"), HipArchs::Gfx1200);
1491        assert_eq!(HipArchs::from_gpu_arch("gfx000"), HipArchs::Default);
1492        assert_eq!(HipArchs::from_gpu_arch("unknown"), HipArchs::Default);
1493    }
1494
1495    #[test]
1496    fn test_gpu_targets_legacy_channel() {
1497        // Legacy channel always returns gfx1030 regardless of detected GPU
1498        assert_eq!(
1499            HipArchs::Gfx1100.gpu_targets_for_channel("legacy"),
1500            "gfx1030"
1501        );
1502        assert_eq!(
1503            HipArchs::Gfx1200.gpu_targets_for_channel("legacy"),
1504            "gfx1030"
1505        );
1506        assert_eq!(
1507            HipArchs::Gfx1030.gpu_targets_for_channel("legacy"),
1508            "gfx1030"
1509        );
1510        assert_eq!(
1511            HipArchs::Default.gpu_targets_for_channel("legacy"),
1512            "gfx1030"
1513        );
1514    }
1515
1516    #[test]
1517    fn test_gpu_targets_stable_channel() {
1518        assert_eq!(
1519            HipArchs::Gfx1030.gpu_targets_for_channel("stable"),
1520            "gfx1030"
1521        );
1522        assert_eq!(
1523            HipArchs::Gfx1100.gpu_targets_for_channel("stable"),
1524            "gfx1030;gfx1100;gfx1101"
1525        );
1526        // RDNA4 on stable degrades to RDNA2+RDNA3 (no gfx1200)
1527        assert_eq!(
1528            HipArchs::Gfx1200.gpu_targets_for_channel("stable"),
1529            "gfx1030;gfx1100;gfx1101"
1530        );
1531        assert_eq!(
1532            HipArchs::Default.gpu_targets_for_channel("stable"),
1533            "gfx1030"
1534        );
1535    }
1536
1537    #[test]
1538    fn test_gpu_targets_latest_channel() {
1539        assert_eq!(
1540            HipArchs::Gfx1030.gpu_targets_for_channel("latest"),
1541            "gfx1030"
1542        );
1543        assert_eq!(
1544            HipArchs::Gfx1100.gpu_targets_for_channel("latest"),
1545            "gfx1030;gfx1100;gfx1101"
1546        );
1547        assert_eq!(
1548            HipArchs::Gfx1200.gpu_targets_for_channel("latest"),
1549            "gfx1030;gfx1100;gfx1101;gfx1200"
1550        );
1551    }
1552
1553    #[test]
1554    fn test_rocm_channel_wmma_fa() {
1555        assert!(!RocmChannel::from_str("legacy").enable_wmma_fa());
1556        assert!(RocmChannel::from_str("stable").enable_wmma_fa());
1557        assert!(RocmChannel::from_str("latest").enable_wmma_fa());
1558    }
1559
1560    #[test]
1561    fn test_cmake_flags_legacy() {
1562        let config = LlamaCppConfig {
1563            gpu_arch: "gfx1100".to_string(),
1564            channel: "legacy".to_string(),
1565            ..Default::default()
1566        };
1567        let installer = LlamaCppInstaller::new(config);
1568        let flags = installer.cmake_flags();
1569
1570        assert!(flags.contains(&"-DGGML_HIP=ON".to_string()));
1571        assert!(flags.contains(&"-DGPU_TARGETS=gfx1030".to_string()));
1572        // Legacy should NOT have WMMA FA enabled
1573        assert!(!flags.iter().any(|f| f.contains("RDNA3_PROBES")));
1574        assert!(!flags.iter().any(|f| f.contains("ROCWMMA_FATTN")));
1575    }
1576
1577    #[test]
1578    fn test_cmake_flags_always_disable_non_rocm_backends() {
1579        // VAL-GUARD-001: every channel must explicitly disable non-ROCm backends.
1580        for channel in ["legacy", "stable", "latest"] {
1581            let config = LlamaCppConfig {
1582                gpu_arch: "gfx1100".to_string(),
1583                channel: channel.to_string(),
1584                ..Default::default()
1585            };
1586            let installer = LlamaCppInstaller::new(config);
1587            let flags = installer.cmake_flags();
1588
1589            assert!(
1590                flags.contains(&"-DGGML_CUDA=OFF".to_string()),
1591                "channel {channel} must set GGML_CUDA=OFF"
1592            );
1593            assert!(
1594                flags.contains(&"-DGGML_VULKAN=OFF".to_string()),
1595                "channel {channel} must set GGML_VULKAN=OFF"
1596            );
1597            assert!(
1598                flags.contains(&"-DGGML_METAL=OFF".to_string()),
1599                "channel {channel} must set GGML_METAL=OFF"
1600            );
1601        }
1602    }
1603
1604    #[test]
1605    fn test_cmake_flags_stable() {
1606        let config = LlamaCppConfig {
1607            gpu_arch: "gfx1100".to_string(),
1608            channel: "stable".to_string(),
1609            ..Default::default()
1610        };
1611        let installer = LlamaCppInstaller::new(config);
1612        let flags = installer.cmake_flags();
1613
1614        assert!(flags.contains(&"-DGGML_HIP=ON".to_string()));
1615        assert!(flags.contains(&"-DGPU_TARGETS=gfx1030;gfx1100;gfx1101".to_string()));
1616        assert!(flags.contains(&"-DGGML_HIP_RDNA3_PROBES=ON".to_string()));
1617        assert!(flags.contains(&"-DGGML_HIP_ROCWMMA_FATTN=ON".to_string()));
1618    }
1619
1620    #[test]
1621    fn test_cmake_flags_latest_with_rdna4() {
1622        let config = LlamaCppConfig {
1623            gpu_arch: "gfx1200".to_string(),
1624            channel: "latest".to_string(),
1625            ..Default::default()
1626        };
1627        let installer = LlamaCppInstaller::new(config);
1628        let flags = installer.cmake_flags();
1629
1630        assert!(flags.contains(&"-DGGML_HIP=ON".to_string()));
1631        assert!(flags.contains(&"-DGPU_TARGETS=gfx1030;gfx1100;gfx1101;gfx1200".to_string()));
1632        assert!(flags.contains(&"-DGGML_HIP_RDNA3_PROBES=ON".to_string()));
1633        assert!(flags.contains(&"-DGGML_HIP_ROCWMMA_FATTN=ON".to_string()));
1634    }
1635
1636    #[test]
1637    fn test_rocm_channel_rdna3_gating() {
1638        let legacy = LlamaCppInstaller::new(LlamaCppConfig {
1639            gpu_arch: "gfx1100".to_string(),
1640            channel: "legacy".to_string(),
1641            ..Default::default()
1642        });
1643        let stable = LlamaCppInstaller::new(LlamaCppConfig {
1644            gpu_arch: "gfx1100".to_string(),
1645            channel: "stable".to_string(),
1646            ..Default::default()
1647        });
1648        let latest = LlamaCppInstaller::new(LlamaCppConfig {
1649            gpu_arch: "gfx1100".to_string(),
1650            channel: "latest".to_string(),
1651            ..Default::default()
1652        });
1653
1654        let legacy_flags = legacy.cmake_flags();
1655        let stable_flags = stable.cmake_flags();
1656        let latest_flags = latest.cmake_flags();
1657
1658        assert!(!legacy_flags
1659            .iter()
1660            .any(|flag| flag.contains("RDNA3_PROBES") || flag.contains("ROCWMMA_FATTN")));
1661        assert!(stable_flags.contains(&"-DGGML_HIP_RDNA3_PROBES=ON".to_string()));
1662        assert!(stable_flags.contains(&"-DGGML_HIP_ROCWMMA_FATTN=ON".to_string()));
1663        assert!(latest_flags.contains(&"-DGGML_HIP_RDNA3_PROBES=ON".to_string()));
1664        assert!(latest_flags.contains(&"-DGGML_HIP_ROCWMMA_FATTN=ON".to_string()));
1665    }
1666
1667    #[test]
1668    fn test_cmake_flags_unknown_gpu_conservative() {
1669        let config = LlamaCppConfig {
1670            gpu_arch: "unknown".to_string(),
1671            channel: "latest".to_string(),
1672            ..Default::default()
1673        };
1674        let installer = LlamaCppInstaller::new(config);
1675        let flags = installer.cmake_flags();
1676
1677        // Unknown GPU should degrade to conservative gfx1030
1678        assert!(flags.contains(&"-DGPU_TARGETS=gfx1030".to_string()));
1679    }
1680
1681    #[test]
1682    fn test_detect_cuda_toolkit_presence_no_panic() {
1683        // VAL-GUARD-002: detection must be safe whether CUDA exists or not.
1684        let result = detect_cuda_toolkit_presence();
1685        assert!(result.len() <= 3);
1686    }
1687
1688    #[test]
1689    fn test_validate_cmake_cache_accepts_rocm_only_config() {
1690        // VAL-GUARD-003: valid cache confirms HIP ON, CUDA OFF, expected GPU targets.
1691        let temp = tempfile::NamedTempFile::new().unwrap();
1692        fs::write(
1693            temp.path(),
1694            "GGML_HIP:BOOL=ON\nGGML_CUDA:BOOL=OFF\nGPU_TARGETS:STRING=gfx1030;gfx1100;gfx1101\n",
1695        )
1696        .unwrap();
1697
1698        assert!(validate_cmake_cache(temp.path(), "gfx1030;gfx1100;gfx1101").is_ok());
1699    }
1700
1701    #[test]
1702    fn test_validate_cmake_cache_rejects_cuda_on() {
1703        // VAL-GUARD-003: CUDA ON is contamination risk.
1704        let temp = tempfile::NamedTempFile::new().unwrap();
1705        fs::write(
1706            temp.path(),
1707            "GGML_HIP:BOOL=ON\nGGML_CUDA:BOOL=ON\nGPU_TARGETS:STRING=gfx1030\n",
1708        )
1709        .unwrap();
1710
1711        let err = validate_cmake_cache(temp.path(), "gfx1030").unwrap_err();
1712        assert!(err.contains("GGML_CUDA is not OFF"));
1713    }
1714
1715    #[test]
1716    fn test_validate_cmake_cache_rejects_gpu_target_mismatch() {
1717        // VAL-GUARD-003: GPU_TARGETS must match channel policy exactly.
1718        let temp = tempfile::NamedTempFile::new().unwrap();
1719        fs::write(
1720            temp.path(),
1721            "GGML_HIP:BOOL=ON\nGGML_CUDA:BOOL=OFF\nGPU_TARGETS:STRING=gfx1030\n",
1722        )
1723        .unwrap();
1724
1725        let err = validate_cmake_cache(temp.path(), "gfx1030;gfx1100;gfx1101").unwrap_err();
1726        assert!(err.contains("GPU_TARGETS mismatch"));
1727    }
1728
1729    #[test]
1730    fn test_build_commands_sequence() {
1731        let config = LlamaCppConfig {
1732            gpu_arch: "gfx1100".to_string(),
1733            channel: "latest".to_string(),
1734            ..Default::default()
1735        };
1736        let installer = LlamaCppInstaller::new(config);
1737        let commands = installer.build_commands("/home/testuser");
1738
1739        // Should have 7 commands: mkdir, clone, checkout, cmake configure, build, install, purge
1740        assert_eq!(commands.len(), 7);
1741        assert_eq!(commands[0].program, "mkdir");
1742        assert_eq!(commands[1].program, "git");
1743        assert_eq!(commands[2].program, "git");
1744        assert_eq!(commands[3].program, "cmake");
1745        assert_eq!(commands[4].program, "cmake");
1746        assert_eq!(commands[5].program, "cmake");
1747        assert_eq!(commands[6].program, "sh");
1748    }
1749
1750    #[test]
1751    fn test_detection_binary_path() {
1752        let config = LlamaCppConfig::default();
1753        let path = config.detection_binary_path("/home/testuser");
1754        assert_eq!(
1755            path,
1756            PathBuf::from("/home/testuser/.mlstack/components/llama-cpp/bin/llama-cli")
1757        );
1758    }
1759
1760    #[test]
1761    fn test_resolve_install_bin_dir() {
1762        let dir = resolve_install_bin_dir("/home/testuser");
1763        assert_eq!(
1764            dir,
1765            PathBuf::from("/home/testuser/.mlstack/components/llama-cpp/bin")
1766        );
1767    }
1768
1769    #[test]
1770    fn test_is_llama_cli_functional_absent() {
1771        // With a non-existent home, the binary should not be found
1772        assert!(!is_llama_cli_functional(
1773            "/nonexistent/path/that/does/not/exist"
1774        ));
1775    }
1776
1777    #[test]
1778    fn test_has_partial_artifacts_no_artifacts() {
1779        // With a non-existent home, no artifacts should exist.
1780        // Use a path starting with /tmp-nonexistent- prefix to avoid
1781        // races with other tests that may create /tmp/llama-cpp-rocm-build.
1782        assert!(!has_partial_artifacts(
1783            "/nonexistent-path-DOES-NOT-exist-f94a1f2a3b5c/test-home"
1784        ));
1785    }
1786
1787    #[test]
1788    fn test_dependencies() {
1789        let installer = LlamaCppInstaller::with_defaults();
1790        assert!(installer.dependencies().contains(&"rocm"));
1791    }
1792
1793    #[test]
1794    fn test_repo_url_default() {
1795        let config = LlamaCppConfig::default();
1796        assert_eq!(config.repo_url(), DEFAULT_REPO_URL);
1797    }
1798
1799    #[test]
1800    fn test_repo_url_override() {
1801        let config = LlamaCppConfig {
1802            repo_url: Some("https://example.com/fork.git".to_string()),
1803            ..Default::default()
1804        };
1805        assert_eq!(config.repo_url(), "https://example.com/fork.git");
1806    }
1807
1808    #[test]
1809    fn test_rocm_channel_from_str() {
1810        assert_eq!(RocmChannel::from_str("legacy"), RocmChannel::Legacy);
1811        assert_eq!(RocmChannel::from_str("LEGACY"), RocmChannel::Legacy);
1812        assert_eq!(RocmChannel::from_str("stable"), RocmChannel::Stable);
1813        assert_eq!(RocmChannel::from_str("latest"), RocmChannel::Latest);
1814        assert_eq!(RocmChannel::from_str("unknown"), RocmChannel::Latest);
1815    }
1816
1817    #[test]
1818    fn test_rocm_channel_label() {
1819        assert_eq!(RocmChannel::Legacy.label(), "legacy");
1820        assert_eq!(RocmChannel::Stable.label(), "stable");
1821        assert_eq!(RocmChannel::Latest.label(), "latest");
1822    }
1823
1824    #[test]
1825    fn test_build_commands_with_auth_token_handling() {
1826        // Save any pre-existing GITHUB_TOKEN value to restore later
1827        let _original_token = std::env::var("GITHUB_TOKEN").ok();
1828
1829        // --- Branch 1: with GITHUB_TOKEN set ---
1830        let config = LlamaCppConfig::default();
1831        let installer = LlamaCppInstaller::new(config);
1832        let commands_with_token = installer
1833            .build_commands_with_auth("/home/testuser", Some(SealedToken::new("mock_token")));
1834
1835        // The clone command is at index 1 (after mkdir)
1836        let clone_cmd = &commands_with_token[1];
1837        // Token must NOT appear directly in args (security requirement)
1838        assert!(!clone_cmd.args.iter().any(|arg| arg.contains("mock_token")));
1839        // Plain HTTPS repo URL must be present
1840        assert!(clone_cmd.args.iter().any(|arg| arg == DEFAULT_REPO_URL));
1841        // Credential helper flag must be present
1842        assert!(clone_cmd
1843            .args
1844            .iter()
1845            .any(|arg| arg.starts_with("credential.helper=")));
1846        // Env must contain GITHUB_TOKEN for git auth and GIT_TERMINAL_PROMPT
1847        assert!(clone_cmd
1848            .env
1849            .iter()
1850            .any(|(k, v)| k == "GITHUB_TOKEN" && v == "mock_token"));
1851        assert!(clone_cmd
1852            .env
1853            .iter()
1854            .any(|(k, v)| k == "GIT_TERMINAL_PROMPT" && v == "0"));
1855    }
1856
1857    #[test]
1858    fn test_release_manifest_parse_and_lookup() {
1859        let json = r#"{
1860            "version": "v0.3.2",
1861            "archs": {
1862                "gfx1030": {"url": "https://example.com/a", "sha256": "aaa"},
1863                "gfx1100": {"url": "https://example.com/b", "sha256": "bbb"},
1864                "gfx1200": {"url": "https://example.com/c", "sha256": "ccc"}
1865            }
1866        }"#;
1867
1868        let manifest: ReleaseManifest = serde_json::from_str(json).unwrap();
1869        let installer = LlamaCppInstaller::with_defaults();
1870        assert_eq!(manifest.version, "v0.3.2");
1871        assert_eq!(
1872            installer
1873                .release_asset_for_arch(&manifest, "gfx1030")
1874                .unwrap()
1875                .url,
1876            "https://example.com/a"
1877        );
1878        assert_eq!(
1879            installer
1880                .release_asset_for_arch(&manifest, "gfx1100")
1881                .unwrap()
1882                .sha256,
1883            "bbb"
1884        );
1885        assert!(installer
1886            .release_asset_for_arch(&manifest, "gfx9999")
1887            .is_none());
1888    }
1889
1890    #[test]
1891    fn test_release_manifest_rejects_unknown_fields() {
1892        let json = r#"{
1893            "version": "v0.3.2",
1894            "unexpected": true,
1895            "archs": {
1896                "gfx1030": {"url": "https://example.com/a", "sha256": "aaa"},
1897                "gfx1100": {"url": "https://example.com/b", "sha256": "bbb"},
1898                "gfx1200": {"url": "https://example.com/c", "sha256": "ccc"}
1899            }
1900        }"#;
1901
1902        assert!(serde_json::from_str::<ReleaseManifest>(json).is_err());
1903    }
1904
1905    #[test]
1906    fn test_verify_sha256_matches() {
1907        let temp = tempfile::NamedTempFile::new().unwrap();
1908        fs::write(temp.path(), b"hello").unwrap();
1909        let expected = Sha256::digest(b"hello")
1910            .iter()
1911            .map(|byte| format!("{:02x}", byte))
1912            .collect::<String>();
1913        assert!(LlamaCppInstaller::verify_sha256(temp.path(), &expected).unwrap());
1914    }
1915
1916    #[test]
1917    fn test_verify_sha256_mismatch() {
1918        let temp = tempfile::NamedTempFile::new().unwrap();
1919        fs::write(temp.path(), b"hello").unwrap();
1920        assert!(!LlamaCppInstaller::verify_sha256(temp.path(), "deadbeef").unwrap());
1921    }
1922
1923    #[test]
1924    fn test_download_failure_falls_back_to_source() {
1925        let installer = LlamaCppInstaller::with_defaults();
1926        let plan = PrebuiltInstallPlan {
1927            manifest_version: "v1".into(),
1928            arch: "gfx1100".into(),
1929            url: "http://127.0.0.1:9/nope".into(),
1930            sha256: "deadbeef".into(),
1931            install_prefix: tempfile::tempdir().unwrap().path().display().to_string(),
1932            binary_path: PathBuf::from("/tmp/llama-cli"),
1933        };
1934        assert!(installer.download_prebuilt_binary(&plan).is_err());
1935    }
1936
1937    #[test]
1938    fn test_purge_source_artifacts_is_idempotent() {
1939        let installer = LlamaCppInstaller::with_defaults();
1940        let temp_home = tempfile::tempdir().unwrap();
1941        let home = temp_home.path().to_string_lossy().to_string();
1942
1943        std::fs::create_dir_all(format!("{home}/.mlstack/components/llama-cpp")).unwrap();
1944        std::fs::create_dir_all(format!("{home}/.mlstack/components/llama-cpp/.git")).unwrap();
1945        std::fs::create_dir_all("/tmp/llama-cpp-rocm-build").unwrap();
1946        std::fs::create_dir_all(format!("{home}/.mlstack/components/llama-cpp/bin")).unwrap();
1947        std::fs::write(
1948            format!("{home}/.mlstack/components/llama-cpp/bin/llama-cli"),
1949            "#!/bin/sh\nexit 0\n",
1950        )
1951        .unwrap();
1952
1953        assert!(installer.purge_source_artifacts(&home, true).is_ok());
1954        assert!(installer.purge_source_artifacts(&home, true).is_ok());
1955    }
1956
1957    #[test]
1958    fn test_git_clone_with_auth() {
1959        let config = LlamaCppConfig::default();
1960        let installer = LlamaCppInstaller::new(config);
1961        let command = installer.git_clone_with_auth("/tmp/test", "mock_token");
1962
1963        // Token must NOT appear directly in args (security requirement)
1964        assert!(!command.args.iter().any(|arg| arg.contains("mock_token")));
1965        // Plain HTTPS repo URL must be present
1966        assert!(command.args.iter().any(|arg| arg == DEFAULT_REPO_URL));
1967        // Credential helper flag must be present
1968        assert!(command.args.iter().any(|arg| arg == "-c"));
1969        assert!(command
1970            .args
1971            .iter()
1972            .any(|arg| arg.starts_with("credential.helper=")));
1973        // Env must contain GITHUB_TOKEN for git auth and GIT_TERMINAL_PROMPT
1974        assert!(command
1975            .env
1976            .iter()
1977            .any(|(k, v)| k == "GITHUB_TOKEN" && v == "mock_token"));
1978        assert!(command
1979            .env
1980            .iter()
1981            .any(|(k, v)| k == "GIT_TERMINAL_PROMPT" && v == "0"));
1982    }
1983
1984    // Note: `validate_repo_access` is not tested here because it requires real git/network access.
1985    // It is tested indirectly via integration tests or manual validation.
1986
1987    // ── Post-install verification tests (VAL-CROSS-008/009/010/011) ──
1988
1989    #[test]
1990    fn test_post_install_verification_no_binaries() {
1991        let result =
1992            verify_installed_binary("/nonexistent/path/that/does/not/exist", "/nonexistent/fork");
1993        assert!(!result.help_check_passed);
1994        assert!(!result.stronger_check_attempted);
1995        assert!(!result.rdna3_check_attempted);
1996        assert!(!result.summary.is_empty());
1997        assert!(result.summary.contains("FAILED"));
1998    }
1999
2000    #[test]
2001    fn test_post_install_verification_struct_fields() {
2002        let result =
2003            verify_installed_binary("/nonexistent/path/that/does/not/exist", "/nonexistent/fork");
2004        assert!(!result.help_check_passed);
2005        assert!(!result.stronger_check_attempted);
2006        assert!(!result.stronger_check_passed);
2007        assert_eq!(result.bench_prefill_tps, None);
2008        assert_eq!(result.bench_decode_tps, None);
2009        assert!(!result.rdna3_check_attempted);
2010        assert_eq!(result.rdna3_is_valid, None);
2011        assert_eq!(result.rdna3_wmma_tps, None);
2012        assert_eq!(result.is_rdna3_device, None);
2013        assert_eq!(result.wmma_supported, None);
2014        assert!(result.verified_binary_path.contains("llama-cli"));
2015    }
2016
2017    #[test]
2018    fn test_post_install_verification_cli_only() {
2019        let tmp = tempfile::tempdir().unwrap();
2020        let bin_dir = tmp.path().join(".mlstack/components/llama-cpp/bin");
2021        std::fs::create_dir_all(&bin_dir).unwrap();
2022        std::fs::write(
2023            bin_dir.join("llama-cli"),
2024            "#!/bin/sh\necho 'usage: llama-cli'\nexit 0\n",
2025        )
2026        .unwrap();
2027        #[cfg(unix)]
2028        {
2029            use std::os::unix::fs::PermissionsExt;
2030            std::fs::set_permissions(
2031                bin_dir.join("llama-cli"),
2032                std::fs::Permissions::from_mode(0o755),
2033            )
2034            .unwrap();
2035        }
2036
2037        let result = verify_installed_binary(tmp.path().to_str().unwrap(), "/nonexistent/fork");
2038        assert!(result.help_check_passed);
2039        assert!(!result.stronger_check_attempted);
2040        assert!(!result.rdna3_check_attempted);
2041        assert!(result.summary.contains("PASSED"));
2042        assert!(result.summary.contains("SKIPPED"));
2043    }
2044
2045    #[test]
2046    fn test_parse_bench_tps_valid() {
2047        let line =
2048            "| qwen3 0.6B F16 | 1.11 GiB | 596.05 M | ROCm | 33 | pp128 | 6254.94 ± 1534.82 |";
2049        let tps = parse_bench_tps(line);
2050        assert!(tps.is_some());
2051        assert!((tps.unwrap() - 6254.94).abs() < 0.01);
2052    }
2053
2054    #[test]
2055    fn test_parse_bench_tps_no_uncertainty() {
2056        let line = "| qwen3 0.6B F16 | 1.11 GiB | 596.05 M | ROCm | 33 | tg32 | 152.17 |";
2057        let tps = parse_bench_tps(line);
2058        assert!(tps.is_some());
2059        assert!((tps.unwrap() - 152.17).abs() < 0.01);
2060    }
2061
2062    #[test]
2063    fn test_parse_bench_tps_invalid_line() {
2064        assert_eq!(parse_bench_tps("not a bench line"), None);
2065        assert_eq!(parse_bench_tps(""), None);
2066        assert_eq!(parse_bench_tps("| col1 | col2 |"), None);
2067    }
2068
2069    #[test]
2070    fn test_verify_binary_linkage_missing_binary() {
2071        // VAL-GUARD-004, VAL-GUARD-005
2072        let result = verify_binary_linkage(std::path::Path::new("/nonexistent/binary"));
2073        assert!(result.is_err());
2074        assert!(result.unwrap_err().contains("not found"));
2075    }
2076
2077    #[test]
2078    fn test_verify_binary_linkage_no_panic() {
2079        // Test with a known binary (bash) — should fail on ROCm linkage check
2080        // but should NOT panic
2081        let result = verify_binary_linkage(std::path::Path::new("/usr/bin/bash"));
2082        // bash won't have ROCm linkage, so this should be an Err
2083        assert!(result.is_err());
2084    }
2085}