rialoman 0.2.0

Rialo native toolchain manager
Documentation
//! Unix shim management for exposing the current release on PATH.
// Copyright (c) Subzero Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

use std::{
    collections::HashSet,
    fs,
    os::unix,
    path::{Path, PathBuf},
};

use anyhow::{Context, Result};

/// Manages symlinks in the global `bin` directory pointing to specific releases.
pub struct ShimManager {
    /// The directory where shims are created (e.g. `~/.local/share/rialo/bin`).
    bin_dir: PathBuf,
    /// The *absolute* root directory containing all releases (e.g. `$HOME/.local/share/rialo/releases`).
    ///
    /// Used to verify that a symlink belongs to us before deleting it.
    releases_dir: PathBuf,
}

impl ShimManager {
    pub fn new(bin_dir: PathBuf, releases_dir: PathBuf) -> Result<Self> {
        Ok(Self {
            bin_dir,
            releases_dir,
        })
    }

    pub fn rewrite_shims(&self, binaries: &[&str], target_dir: &Path) -> Result<()> {
        fs::create_dir_all(&self.bin_dir)
            .with_context(|| format!("failed to create bin dir at {}", self.bin_dir.display()))?;

        self.remove_shims_except(binaries)?;

        for &bin in binaries {
            // Make sure we never delete/overwrite ourselves
            if bin == "rialoman" {
                eprintln!("`rialoman` cannot be a managed shim, skipping");
                continue;
            }

            let shim_path = self.bin_dir.join(bin);
            let target = target_dir.join("bin").join(bin);

            // Symlinks cannot be overwritten atomically, so we use the rename trick
            let tmp_path = self.bin_dir.join(format!(".{bin}.tmp"));
            let _lingering_file_removed = fs::remove_file(&tmp_path);
            unix::fs::symlink(&target, &tmp_path).with_context(|| {
                format!(
                    "failed to create shim symlink {} -> {}",
                    tmp_path.display(),
                    target.display()
                )
            })?;
            fs::rename(&tmp_path, &shim_path)
                .with_context(|| format!("failed to update shim {}", shim_path.display()))?;
        }
        Ok(())
    }

    /// Removes all shims managed by the manager (pointing to `$RIALO_HOME/releases`).
    pub fn clear_all(&self) -> Result<()> {
        if !self.bin_dir.exists() {
            return Ok(());
        }

        self.remove_shims_except(&[])?;

        Ok(())
    }

    fn remove_shims_except(&self, keep: &[&str]) -> Result<()> {
        let keep_set: HashSet<_> = keep.iter().collect();

        for entry in fs::read_dir(&self.bin_dir)? {
            let entry = entry?;
            let name = entry.file_name();
            let Some(name) = name.to_str() else { continue };

            if keep_set.contains(&name) || name == "rialoman" {
                continue;
            }

            let path = entry.path();
            if self.is_managed_shim(&path) {
                fs::remove_file(&path)
                    .with_context(|| format!("failed to remove old shim {}", path.display()))?;
            }
        }
        Ok(())
    }

    /// Returns whether a path is a managed shim by us, i.e. it's a symlink and points to `$RIALO_HOME/releases`.
    fn is_managed_shim(&self, path: &Path) -> bool {
        match fs::read_link(path) {
            Ok(link) => link.starts_with(&self.releases_dir),
            Err(..) => false,
        }
    }
}