rialoman 0.2.0

Rialo native toolchain manager
Documentation
//! High-level orchestration for installing and switching Rialo releases.
// Copyright (c) Subzero Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

use std::{
    env::consts,
    fs::{self, File},
    io::BufReader,
    path::{Path, PathBuf},
};

use anyhow::{anyhow, bail, Context, Result};
use serde_json;
use sha2::{Digest, Sha256};
use tempfile::{Builder, NamedTempFile, TempDir};

use crate::{
    current::CurrentManager,
    dirs::RialoDirs,
    manifest::{Artifact, KnownPlatform, Manifest},
    remote::{extract_archive, RemoteClient},
    shims::ShimManager,
    spec::{InstallSpec, ReleaseId},
};

/// Coordinates layout/remote interactions to manage installs locally.
pub struct ReleaseService {
    dirs: RialoDirs,
    remote: RemoteClient,
}

impl ReleaseService {
    pub fn new(dirs: RialoDirs, remote: RemoteClient) -> Self {
        Self { dirs, remote }
    }

    /// Install the requested release and optionally set it as the default.
    pub fn install(&self, spec: &InstallSpec, set_default: bool) -> Result<ReleaseId> {
        eprintln!("Resolving manifest for {spec}");
        let manifest = self.remote.fetch_manifest(spec)?;
        eprintln!(
            "Resolved {spec} -> {}@{}",
            manifest.channel, manifest.version
        );

        let platform = match (consts::OS, consts::ARCH) {
            ("linux", "x86_64") => KnownPlatform::LinuxAmd64,
            ("linux", "aarch64") => KnownPlatform::LinuxArm64,
            ("macos", "aarch64") => KnownPlatform::DarwinArm64,
            _ => bail!("no artifact for this platform"),
        };

        let artifact = manifest
            .find_matching(platform)
            .ok_or_else(|| anyhow!("no artifact for this platform"))?;

        let release_id = ReleaseId::new(manifest.channel, manifest.version.clone());
        let target_dir = self.release_dir(&release_id);

        let archive_path = self.ensure_cached_artifact(artifact)?;
        let staging = self.create_staging_dir()?;
        extract_archive(&archive_path, staging.path())?;
        self.write_manifest_into(staging.path(), &manifest)?;
        self.swap_staging_into_place(staging, &target_dir)?;

        if set_default {
            let current_mgr = CurrentManager::new(self.dirs.current_file().clone());
            let current = current_mgr.load()?;
            if current.as_ref().is_some_and(|cur| cur == &release_id) {
                eprintln!("{release_id} is already the active release");
            } else {
                eprintln!("Activating {release_id} as current");
                self.update_current(&release_id, &manifest)?;
            }
        }

        Ok(release_id)
    }

    /// Switch shims/current pointer to an already-installed release.
    pub fn use_existing(&self, id: &ReleaseId) -> Result<()> {
        if !self.release_dir(id).exists() {
            bail!("release {} is not installed", id);
        }

        let manifest = self.load_manifest(id)?;
        self.update_current(id, &manifest)
    }

    /// Uninstall a release, optionally forcing removal if it is current.
    pub fn uninstall(&self, id: &ReleaseId, force: bool) -> Result<()> {
        let target_dir = self.release_dir(id);
        if !target_dir.exists() {
            bail!("release {} is not installed", id);
        }

        let current_mgr = CurrentManager::new(self.dirs.current_file().clone());
        let current = current_mgr.load()?;
        let is_current = current.as_ref().is_some_and(|cur| cur == id);
        if is_current && !force {
            bail!("{} is the active release; pass --force to remove it", id);
        }

        fs::remove_dir_all(&target_dir)
            .with_context(|| format!("failed to remove release dir {}", target_dir.display()))?;

        if is_current {
            current_mgr.clear()?;
            let shim_manager =
                ShimManager::new(self.dirs.bin().clone(), self.dirs.releases().clone())?;
            shim_manager.clear_all()?;
        }

        Ok(())
    }

    fn update_current(&self, id: &ReleaseId, manifest: &Manifest) -> Result<()> {
        let current = CurrentManager::new(self.dirs.current_file().clone());
        current.save(id)?;

        let release_binaries: Vec<_> = manifest
            .artifacts
            .iter()
            .flat_map(|a| &a.binaries)
            .map(|x| x.as_str())
            .collect();
        let release_dir = self.release_dir(id);

        let shim_manager = ShimManager::new(self.dirs.bin().clone(), self.dirs.releases().clone())?;
        shim_manager.rewrite_shims(&release_binaries, &release_dir)?;

        // Update rustup toolchain link if manifest specifies a toolchain
        if let Some(tc_version) = manifest.rust_toolchain_version() {
            self.update_rustup_link(tc_version)?;
        }

        Ok(())
    }

    /// Update the rustup 'rialo' toolchain link to point to the specified version.
    ///
    /// This ensures `cargo +rialo` uses the correct toolchain for the active release.
    fn update_rustup_link(&self, version: &str) -> Result<()> {
        use rialo_build_lib::{RialoRustToolchain, Toolchain};

        let toolchain = RialoRustToolchain::with_version(version)?;

        if toolchain.is_installed()? {
            eprintln!("Updating rustup link: cargo +rialo -> rialo-rust {version}");
            toolchain.register_with_rustup()?;
        } else {
            eprintln!("Toolchain rialo-rust {version} not installed, skipping rustup link update");
            eprintln!("Install it with: rialoman toolchain install rialo-rust --version {version}");
        }

        Ok(())
    }

    fn release_dir(&self, id: &ReleaseId) -> PathBuf {
        self.dirs
            .releases()
            .join(id.channel.as_str())
            .join(&id.version)
    }

    /// Load manifest for an installed release.
    pub fn load_manifest(&self, id: &ReleaseId) -> Result<Manifest> {
        let path = self.release_dir(id).join("manifest.json");
        let contents = fs::read_to_string(&path)
            .with_context(|| format!("failed to read manifest at {}", path.display()))?;
        serde_json::from_str(&contents).context("failed to parse manifest")
    }

    fn ensure_cached_artifact(&self, artifact: &Artifact) -> Result<PathBuf> {
        let cache_path = self.dirs.downloads().join(&artifact.archive);
        if cache_path.exists() {
            if Self::verify_sha256(&cache_path, &artifact.sha256)? {
                return Ok(cache_path);
            }
            fs::remove_file(&cache_path).with_context(|| {
                format!(
                    "failed to remove corrupt cached artifact {}",
                    cache_path.display()
                )
            })?;
        }

        self.download_into_cache(artifact, &cache_path)?;
        Ok(cache_path)
    }

    fn download_into_cache(&self, artifact: &Artifact, cache_path: &Path) -> Result<()> {
        let temp = NamedTempFile::new_in(self.dirs.downloads())
            .context("failed to create temp download file")?;
        eprintln!("Downloading {}", artifact.archive);
        self.remote
            .download_artifact(artifact, temp.path())
            .context("artifact download failed")?;
        eprintln!("Download complete: {}", artifact.archive);
        temp.persist(cache_path).map_err(|err| {
            anyhow!(
                "failed to persist verified archive into cache {}: {}",
                cache_path.display(),
                err.error
            )
        })?;
        Ok(())
    }

    fn verify_sha256(path: &Path, expected: &str) -> Result<bool> {
        let file = File::open(path)
            .with_context(|| format!("failed to open cached artifact {}", path.display()))?;
        let mut file = BufReader::new(file);

        let mut hasher = Sha256::new();
        std::io::copy(&mut file, &mut hasher)?;
        let digest = hex::encode(hasher.finalize());

        Ok(digest.eq_ignore_ascii_case(expected))
    }

    fn create_staging_dir(&self) -> Result<TempDir> {
        Builder::new()
            .prefix("rialoman-staging-")
            .tempdir_in(self.dirs.tmp())
            .context("failed to create staging directory")
    }

    fn write_manifest_into(&self, staging_root: &Path, manifest: &Manifest) -> Result<()> {
        let manifest_path = staging_root.join("manifest.json");
        let manifest_json = serde_json::to_vec_pretty(manifest)?;
        fs::write(&manifest_path, manifest_json).with_context(|| {
            format!(
                "failed to write manifest into staging dir {}",
                manifest_path.display()
            )
        })
    }

    fn swap_staging_into_place(&self, staging: TempDir, target_dir: &Path) -> Result<()> {
        let staging_path = staging.keep();
        if target_dir.exists() {
            fs::remove_dir_all(target_dir).with_context(|| {
                format!("failed to remove release dir {}", target_dir.display())
            })?;
        }
        fs::create_dir_all(target_dir)
            .with_context(|| format!("failed to create a directory {}", target_dir.display()))?;
        fs::rename(&staging_path, target_dir)
            .with_context(|| format!("failed to move release into {}", target_dir.display()))
    }
}