shuck-run 0.0.30

Managed shell interpreter resolution for shuck
Documentation
use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
use std::time::{Duration, SystemTime};

use anyhow::{Context, Result, anyhow};
use serde::Deserialize;
use tempfile::NamedTempFile;

use crate::download::fetch_url_to_path;
use crate::{AvailableShell, Environment, Shell, Version, VersionConstraint};

const REGISTRY_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);

#[derive(Debug, Deserialize)]
pub(crate) struct RegistryIndex {
    #[serde(rename = "version")]
    _version: u64,
    pub(crate) shells: BTreeMap<String, RegistryShell>,
}

#[derive(Debug, Deserialize)]
pub(crate) struct RegistryShell {
    pub(crate) versions: BTreeMap<String, RegistryVersion>,
}

#[derive(Debug, Deserialize)]
pub(crate) struct RegistryVersion {
    pub(crate) platforms: BTreeMap<String, RegistryArtifact>,
}

#[derive(Debug, Deserialize)]
pub(crate) struct RegistryArtifact {
    pub(crate) url: String,
    pub(crate) sha256: String,
}

pub(crate) fn available_shells(
    registry: &RegistryIndex,
    shell: Option<Shell>,
) -> Vec<AvailableShell> {
    let shells = shell.into_iter().collect::<Vec<_>>();
    let names = if shells.is_empty() {
        registry
            .shells
            .keys()
            .filter_map(|name| Shell::from_name(name))
            .collect::<Vec<_>>()
    } else {
        shells
    };

    let mut available = names
        .into_iter()
        .filter_map(|shell| {
            registry.shells.get(shell.as_str()).map(|entry| {
                let mut versions = entry
                    .versions
                    .keys()
                    .filter_map(|version| Version::parse(version).ok())
                    .collect::<Vec<_>>();
                versions.sort();
                versions.reverse();
                AvailableShell { shell, versions }
            })
        })
        .collect::<Vec<_>>();
    available.sort_by_key(|entry| entry.shell);
    available
}

pub(crate) fn select_version_for_platform(
    registry: &RegistryIndex,
    shell: Shell,
    constraint: &VersionConstraint,
    platform: &str,
) -> Result<Version> {
    let shell_entry = shell_entry(registry, shell)?;
    let mut versions = parsed_versions(shell_entry)?;
    versions.sort();

    let mut matched_constraint = false;
    for version in versions.into_iter().rev() {
        if !constraint.matches(&version) {
            continue;
        }
        matched_constraint = true;
        if shell_entry
            .versions
            .get(version.as_str())
            .is_some_and(|entry| entry.platforms.contains_key(platform))
        {
            return Ok(version);
        }
    }

    if matched_constraint {
        Err(anyhow!(
            "{shell} {} does not have a prebuilt binary for {platform}.",
            constraint.describe()
        ))
    } else {
        Err(version_unavailable_error(shell, constraint))
    }
}

pub(crate) fn load_registry(
    environment: &Environment,
    refresh: bool,
    verbose: bool,
) -> Result<RegistryIndex> {
    fs::create_dir_all(&environment.shells_root)
        .with_context(|| format!("create {}", environment.shells_root.display()))?;
    let index_path = environment.shells_root.join("index.json");

    let should_refresh =
        refresh || !index_path.exists() || index_is_stale(&index_path).unwrap_or(true);
    if should_refresh {
        match refresh_registry_index(environment, &index_path, verbose) {
            Ok(registry) => return Ok(registry),
            Err(_err) if index_path.exists() && !refresh => {}
            Err(err) => return Err(err),
        }
    }

    read_registry_index(&index_path)
}

fn index_is_stale(path: &Path) -> Result<bool> {
    let modified = fs::metadata(path)
        .with_context(|| format!("stat {}", path.display()))?
        .modified()
        .with_context(|| format!("read mtime for {}", path.display()))?;
    let age = SystemTime::now()
        .duration_since(modified)
        .unwrap_or_default();
    Ok(age > REGISTRY_MAX_AGE)
}

fn refresh_registry_index(
    environment: &Environment,
    index_path: &Path,
    verbose: bool,
) -> Result<RegistryIndex> {
    let temp_file = NamedTempFile::new_in(&environment.shells_root).with_context(|| {
        format!(
            "create temp registry in {}",
            environment.shells_root.display()
        )
    })?;
    fetch_url_to_path(&environment.registry_url, temp_file.path(), verbose)
        .with_context(|| format!("fetch {}", environment.registry_url))?;
    let registry = read_registry_index(temp_file.path())?;
    match temp_file.persist(index_path) {
        Ok(_) => Ok(registry),
        Err(err) => Err(err.error).with_context(|| format!("replace {}", index_path.display())),
    }
}

fn read_registry_index(path: &Path) -> Result<RegistryIndex> {
    let source = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
    serde_json::from_str(&source).with_context(|| format!("parse {}", path.display()))
}

fn shell_entry(registry: &RegistryIndex, shell: Shell) -> Result<&RegistryShell> {
    registry.shells.get(shell.as_str()).ok_or_else(|| {
        anyhow!(
            "{} is not available. Run `shuck install --list` to see available shells.",
            shell
        )
    })
}

fn parsed_versions(shell_entry: &RegistryShell) -> Result<Vec<Version>> {
    shell_entry
        .versions
        .keys()
        .map(|version| Version::parse(version))
        .collect()
}

fn version_unavailable_error(shell: Shell, constraint: &VersionConstraint) -> anyhow::Error {
    anyhow!(
        "{shell} {} is not available. Run `shuck install --list {shell}` to see available versions.",
        constraint.describe()
    )
}