rialo-build-lib 0.11.2

Shared library for Rialo program building logic
Documentation
// Copyright (c) Subzero Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

//! Toolchain management for building Rialo programs
//!
//! This module provides a generic interface for downloading, installing,
//! and validating toolchains needed to build different types of programs.

use std::{
    fs::{self, File},
    io::{self, Write},
    path::{Path, PathBuf},
};

use anyhow::{Context, Result};
use flate2::read::GzDecoder;
use serde::Deserialize;
use sha2::{Digest, Sha256};
use tar::Archive;

mod gnu_riscv;
mod http_backend;
mod rialo_rust;
pub mod s3_backend;
pub mod source_builder;

pub use gnu_riscv::{GnuRiscvToolchain, DEFAULT_GNU_RISCV_VERSION};
pub use http_backend::HttpToolchainClient;
pub use rialo_rust::{
    ResolvedToolchainVersion, RialoRustToolchain, ToolchainSource, RUST_COMMIT_HASH,
    RUST_NIGHTLY_VERSION,
};
pub use s3_backend::S3StorageBackend;
pub use source_builder::{
    BuildSystemConfig, RustSourceBuilder, SourceBuildConfig, SourceBuildable,
};

/// Type of toolchain
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolchainType {
    /// GNU RISC-V toolchain (gcc, binutils) for C/C++ programs
    GnuRiscv,
    /// Rialo custom Rust toolchain for Rust programs
    RialoRust,
}

/// Download source for toolchains
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DownloadSource {
    /// Prefer S3, fallback to GitHub if unavailable
    PreferS3WithFallback,
    /// S3 only (fail if S3 unavailable)
    S3,
    /// GitHub releases only
    GitHub,
}

/// Configuration for a toolchain
#[derive(Debug, Clone)]
pub struct ToolchainConfig {
    /// Name of the toolchain (e.g., "gnu-riscv", "rialo-rust")
    pub name: String,
    /// Version of the toolchain
    pub version: String,
    /// URL to download the toolchain from
    pub download_url: String,
    /// Path where the toolchain should be installed
    pub install_path: PathBuf,
    /// Optional SHA256 checksum for verification
    pub checksum: Option<String>,
}

/// Trait for managing toolchains
pub trait Toolchain {
    /// Check if the toolchain is installed
    fn is_installed(&self) -> Result<bool>;

    /// Install the toolchain
    fn install(&self) -> Result<()>;

    /// Validate that the toolchain is correctly installed and functional
    fn validate(&self) -> Result<()>;

    /// Get the path to the toolchain's bin directory
    fn get_bin_path(&self) -> Result<PathBuf>;

    /// Get the toolchain configuration
    fn get_config(&self) -> &ToolchainConfig;
}

/// Get the toolchain root directory
///
/// Returns the path where toolchains are installed:
/// 1. Custom location via `RIALO_BUILD_TOOLCHAIN_HOME` environment variable (if set)
/// 2. Default location: `~/.local/share/rialo/toolchains`
///
/// # Examples
///
/// ```no_run
/// use rialo_build_lib::toolchain::get_toolchain_root;
///
/// let toolchain_root = get_toolchain_root()?;
/// println!("Using toolchain at: {}", toolchain_root.display());
/// # Ok::<(), anyhow::Error>(())
/// ```
pub fn get_toolchain_root() -> Result<PathBuf> {
    use std::env;

    // Check for custom location
    if let Ok(custom_path) = env::var("RIALO_BUILD_TOOLCHAIN_HOME") {
        log::debug!("Using custom toolchain location: {}", custom_path);
        return Ok(PathBuf::from(custom_path));
    }

    // Default location
    let home = dirs::home_dir().context("Failed to get home directory")?;
    let path = home.join(".local/share/rialo/toolchains");
    log::debug!("Using toolchain location: {}", path.display());
    Ok(path)
}

/// Barebones release manifest used by Rialoman. Used to detect the associated
/// Rust toolchain version for a given release.
#[derive(Debug, Deserialize)]
struct MinimalManifest {
    manifest_version: Option<u32>,
    rust_toolchain: Option<String>,
}

/// Find the Rialoman release manifest path relative to the current executable.
///
/// Returns `Some(path)` if manifest exists, `None` if not (standalone mode).
///
/// # Path Structure
/// - Release binaries live at: `$RIALO_HOME/releases/{channel}/{version}/bin/rialo-build`
/// - Manifest lives at:        `$RIALO_HOME/releases/{channel}/{version}/manifest.json`
fn find_rialoman_release_manifest_path() -> Option<PathBuf> {
    let exe = std::env::current_exe().ok()?;
    let manifest = exe.parent()?.parent()?.join("manifest.json");

    manifest.exists().then_some(manifest)
}

/// Detect toolchain version from release manifest, if present.
///
/// Returns:
/// - `Ok(Some(version))` if manifest specifies rust_toolchain
/// - `Ok(None)` if no manifest or rust_toolchain not specified
pub fn detect_rialoman_release_toolchain_version() -> Result<Option<String>> {
    let Some(path) = find_rialoman_release_manifest_path() else {
        log::debug!("No release manifest found (standalone mode)");
        return Ok(None);
    };

    let content = std::fs::read_to_string(&path)
        .with_context(|| format!("Failed to read {}", path.display()))?;

    let manifest: MinimalManifest = serde_json::from_str(&content)
        .with_context(|| format!("Failed to parse {}", path.display()))?;

    // If rust_toolchain is specified, use it regardless of version
    if let Some(tc) = manifest.rust_toolchain {
        log::debug!("Found rust_toolchain: {tc}");
        return Ok(Some(tc));
    }

    // No rust_toolchain - warn if manifest version suggests we should have one
    let version = manifest.manifest_version.unwrap_or(1);
    if version > 1 {
        log::warn!("Manifest v{version} has no rust_toolchain field - using default toolchain",);
    }

    Ok(None)
}

/// Download a file from a URL
pub fn download_file(url: &str, dest: &Path) -> Result<()> {
    log::info!("Downloading from {}", url);

    let response =
        reqwest::blocking::get(url).with_context(|| format!("Failed to download from {url}"))?;

    if !response.status().is_success() {
        return Err(anyhow::anyhow!(
            "Download failed with status: {}",
            response.status()
        ));
    }

    let mut dest_file = File::create(dest)
        .with_context(|| format!("Failed to create file at {}", dest.display()))?;

    let content = response.bytes().context("Failed to read response bytes")?;

    dest_file
        .write_all(&content)
        .with_context(|| format!("Failed to write to {}", dest.display()))?;

    log::debug!("Downloaded {} bytes", content.len());
    Ok(())
}

/// Verify the SHA256 checksum of a file
pub fn verify_checksum(file_path: &Path, expected_checksum: &str) -> Result<()> {
    log::debug!("Verifying checksum for {}", file_path.display());

    let mut file = File::open(file_path)
        .with_context(|| format!("Failed to open file {}", file_path.display()))?;

    let mut hasher = Sha256::new();
    io::copy(&mut file, &mut hasher)
        .with_context(|| format!("Failed to read file {}", file_path.display()))?;

    let hash = hasher.finalize();
    let hash_str = hex::encode(hash);

    if hash_str != expected_checksum {
        return Err(anyhow::anyhow!(
            "Checksum mismatch: expected {}, got {}",
            expected_checksum,
            hash_str
        ));
    }

    log::debug!("Checksum verified");
    Ok(())
}

/// Extract a tar.gz archive
pub fn extract_tar_gz(archive_path: &Path, dest_dir: &Path) -> Result<()> {
    log::info!("Extracting archive to {}", dest_dir.display());

    let tar_gz = File::open(archive_path)
        .with_context(|| format!("Failed to open archive {}", archive_path.display()))?;

    let tar = GzDecoder::new(tar_gz);
    let mut archive = Archive::new(tar);

    archive
        .unpack(dest_dir)
        .with_context(|| format!("Failed to extract archive to {}", dest_dir.display()))?;

    log::info!("Extraction complete");
    Ok(())
}

/// Get the current platform as a string
pub fn get_platform() -> Result<String> {
    let os = std::env::consts::OS;
    let arch = std::env::consts::ARCH;

    match (os, arch) {
        ("macos", "x86_64") => Ok("x86_64-apple-darwin".to_string()),
        ("macos", "aarch64") => Ok("aarch64-apple-darwin".to_string()),
        ("linux", "x86_64") => Ok("x86_64-unknown-linux-gnu".to_string()),
        ("linux", "aarch64") => Ok("aarch64-unknown-linux-gnu".to_string()),
        _ => Err(anyhow::anyhow!("Unsupported platform: {os}-{arch}")),
    }
}

/// Check if a command exists in PATH or at a specific location
pub fn command_exists(command: &str) -> bool {
    which::which(command).is_ok()
}

/// Execute download with fallback strategy based on environment configuration
///
/// The download source preference is controlled by the `RIALO_TOOLCHAIN_SOURCE` environment variable.
///
/// # Arguments
///
/// * `s3_download` - Closure to download from S3
/// * `github_download` - Closure to download from GitHub
///
/// # Errors
///
/// Returns an error if all attempted download methods fail.
pub fn download_with_fallback_strategy<S, G>(s3_download: S, github_download: G) -> Result<()>
where
    S: FnOnce() -> Result<()>,
    G: FnOnce() -> Result<()>,
{
    let source = determine_download_source_from_env();

    match source {
        DownloadSource::S3 => {
            log::info!("Attempting download from S3 (RIALO_TOOLCHAIN_SOURCE=s3)");
            s3_download().context(
                "S3 download failed. Set RIALO_TOOLCHAIN_SOURCE=github to use GitHub instead.",
            )?;
            log::info!("Successfully downloaded from S3");
        }
        DownloadSource::GitHub => {
            log::info!("Attempting download from GitHub (RIALO_TOOLCHAIN_SOURCE=github)");
            github_download().context("GitHub download failed")?;
            log::info!("Successfully downloaded from GitHub");
        }
        DownloadSource::PreferS3WithFallback => {
            log::info!("Attempting download from S3 (will fallback to GitHub if unavailable)");
            if let Err(e) = s3_download() {
                log::warn!("S3 download failed: {}", e);
                log::info!("Falling back to GitHub releases");
                github_download().context("Both S3 and GitHub downloads failed")?;
                log::info!("Successfully downloaded from GitHub");
            } else {
                log::info!("Successfully downloaded from S3");
            }
        }
    }

    Ok(())
}

/// Get the S3 bucket name for toolchain storage
///
/// Returns the bucket name from the `RIALO_TOOLCHAIN_S3_BUCKET` environment variable,
/// or the default bucket name if not set.
///
/// # Default
///
/// `rialo-artifacts` (unified bucket for all artifacts)
pub fn get_s3_bucket() -> String {
    std::env::var("RIALO_TOOLCHAIN_S3_BUCKET").unwrap_or_else(|_| "rialo-artifacts".to_string())
}

/// Determine the preferred download source from environment configuration
///
/// Reads the `RIALO_TOOLCHAIN_SOURCE` environment variable to determine
/// which download source to use.
///
/// # Environment Variable Values
///
/// - `s3` - Use S3 only
/// - `github` - Use GitHub releases only
/// - `auto` or unset - Try S3 first, fallback to GitHub
///
/// # Default
///
/// [`DownloadSource::PreferS3WithFallback`]
///
/// # Warnings
///
/// Logs a warning if an unrecognized value is provided, then defaults to PreferS3WithFallback.
pub fn determine_download_source_from_env() -> DownloadSource {
    let env_value = std::env::var("RIALO_TOOLCHAIN_SOURCE").unwrap_or_else(|_| "auto".to_string());

    match env_value.as_str() {
        "s3" => DownloadSource::S3,
        "github" => DownloadSource::GitHub,
        "auto" => DownloadSource::PreferS3WithFallback,
        _ => {
            eprintln!(
                "⚠️  Warning: Unrecognized RIALO_TOOLCHAIN_SOURCE value '{}'. \
                 Valid values are: 's3', 'github', 'auto'. \
                 Defaulting to 'auto' (prefer S3 with GitHub fallback).",
                env_value
            );
            DownloadSource::PreferS3WithFallback
        }
    }
}

/// List all installed toolchains
pub fn list_installed_toolchains() -> Result<Vec<(String, String)>> {
    let toolchain_root = get_toolchain_root()?;

    if !toolchain_root.exists() {
        return Ok(Vec::new());
    }

    let mut toolchains = Vec::new();

    for entry in fs::read_dir(&toolchain_root)
        .with_context(|| format!("Failed to read directory {}", toolchain_root.display()))?
    {
        let entry = entry?;
        let path = entry.path();

        if path.is_dir() {
            if let Some(name) = path.file_name() {
                let name_str = name.to_string_lossy().to_string();

                // Parse toolchain name and version from directory name
                // Expected format: <name>-<version> (e.g., rialo-rust-0.0.1, gnu-riscv-13.2.0)
                // Use rsplit_once to split on the LAST hyphen (not first)
                if let Some((toolchain_name, version)) = name_str.rsplit_once('-') {
                    toolchains.push((toolchain_name.to_string(), version.to_string()));
                }
            }
        }
    }

    Ok(toolchains)
}