luaupm 0.2.0

The Luau package manager: dependencies, tools and scripts for Luau and Roblox projects
/*! GitHub-released binaries a project pins by version. this module stores
and installs them; `archive` picks and unpacks the right release asset,
`shim` maps an alias to one of the stored binaries. */

pub mod archive;
pub mod shim;

use crate::error::Error;
use crate::net::github::GithubAPI;
use crate::net::http;
use crate::net::http::responses::Release;
use crate::project::manifest::Tool;
use crate::sys::paths;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};

/// tools addable by bare name; anything else must be "owner/repo".
const SHORTHANDS: &[(&str, &str)] = &[
    ("darklua", "seaofvoices/darklua"),
    ("lest", "luau-lest/lest"),
    ("luau-lsp", "johnnymorganz/luau-lsp"),
    ("rojo", "rojo-rbx/rojo"),
    ("stylua", "johnnymorganz/stylua"),
];

/// shorthand to full repo name; unknown names pass through as "owner/repo".
pub fn expand_shorthand(name: &str) -> String {
    SHORTHANDS
        .iter()
        .find(|(short, _)| *short == name)
        .map_or_else(|| name.to_string(), |(_, full)| full.to_string())
}

/// the repo behind a shorthand, for matching manifest entries by value, not alias key.
pub fn shorthand_repository(name: &str) -> Option<&'static str> {
    SHORTHANDS
        .iter()
        .find(|(short, _)| *short == name)
        .map(|(_, full)| *full)
}

/** ~/.lpm/tools/{owner}_{repo}/{version}. GitHub owner names can't contain
'_', so the first '_' always marks the owner/repo split when reading the
folder name back. */
pub fn storage_dir(repository: &str, version: &str) -> Result<PathBuf, Error> {
    Ok(paths::tools_dir()?
        .join(repository.replace('/', "_"))
        .join(version))
}

/// where a tool's executable sits once installed.
fn stored_executable(repository: &str, version: &str) -> Result<PathBuf, Error> {
    Ok(storage_dir(repository, version)?.join(executable_name(repo_short_name(repository))))
}

/** Installs a manifest tool, skipping the network when the version is
already stored. true = downloaded, false = cache reused; the bin shim
gets refreshed either way. */
pub fn install_tool(alias: &str, tool: &Tool, github: &GithubAPI) -> Result<bool, Error> {
    let stored = stored_executable(&tool.repository, &tool.version)?;
    if stored.exists() {
        shim::write(&shim::path(alias)?)?;
        return Ok(false);
    }

    let release = github.get_release(&tool.repository, &tool.version)?;
    install_release(alias, &tool.repository, &release)
}

/** Downloads the platform asset of a fetched release, extracts it, stores
the executable under `storage_dir`, and writes a bin shim named after
the alias. true = downloaded, false = version already stored; the shim
gets refreshed either way. */
pub fn install_release(alias: &str, repository: &str, release: &Release) -> Result<bool, Error> {
    let version = release.tag_name.trim_start_matches('v');
    let storage = storage_dir(repository, version)?;
    let repo = repo_short_name(repository);

    let stored = storage.join(executable_name(repo));
    if stored.exists() {
        shim::write(&shim::path(alias)?)?;
        return Ok(false);
    }

    let asset = archive::select_asset(&release.assets, env::consts::OS, env::consts::ARCH)
        .ok_or_else(|| Error::NoMatchingAsset {
            tool: repository.to_string(),
            version: version.to_string(),
        })?;
    let bytes = http::get_bytes(&asset.browser_download_url, &[])?;

    /* extract into a staging sibling first so a failed install never leaves
    a half-written storage dir behind (same dance as install.rs). */
    let staging = paths::with_suffix(&storage, ".tmp");
    if staging.exists() {
        fs::remove_dir_all(&staging)?;
    }
    fs::create_dir_all(&staging)?;

    let target = staging.join(executable_name(repo));
    archive::extract(&asset.name, &bytes, &staging, &target)?;

    let mut files = Vec::new();
    archive::collect_files(&staging, &mut files)?;
    let found = archive::pick_executable(&files, repo, alias, env::consts::EXE_SUFFIX)
        .ok_or_else(|| Error::NoExecutableInAsset(alias.to_string()))?;
    if found != target {
        if target.exists() {
            fs::remove_file(&target)?;
        }
        fs::rename(&found, &target)?;
    }

    // move staging into place; same filesystem, so it's a rename.
    if storage.exists() {
        fs::remove_dir_all(&storage)?;
    }
    fs::create_dir_all(storage.parent().expect("storage dir has a parent"))?;
    fs::rename(&staging, &storage)?;

    let stored = storage.join(executable_name(repo));
    make_executable(&stored)?;
    shim::write(&shim::path(alias)?)?;
    Ok(true)
}

/// the "repo" half of "owner/repo"; names the stored binary.
fn repo_short_name(repository: &str) -> &str {
    repository
        .split_once('/')
        .map_or(repository, |(_, repo)| repo)
}

/// "rojo.exe" on windows, "rojo" elsewhere.
fn executable_name(repo: &str) -> String {
    format!("{repo}{}", env::consts::EXE_SUFFIX)
}

/// chmod 755. no-op on windows, where the .exe extension is what makes it executable.
#[cfg(unix)]
fn make_executable(path: &Path) -> Result<(), Error> {
    use std::os::unix::fs::PermissionsExt;
    fs::set_permissions(path, fs::Permissions::from_mode(0o755))?;
    Ok(())
}

#[cfg(not(unix))]
fn make_executable(_path: &Path) -> Result<(), Error> {
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn storage_paths_include_owner_repo_and_version() {
        let dir = storage_dir("rojo-rbx/rojo", "7.4.4").unwrap();
        assert!(dir.starts_with(paths::tools_dir().unwrap()));
        assert!(dir.ends_with(Path::new("tools").join("rojo-rbx_rojo").join("7.4.4")));
    }

    #[test]
    fn expands_known_shorthands_only() {
        assert_eq!(expand_shorthand("rojo"), "rojo-rbx/rojo");
        assert_eq!(expand_shorthand("acme/tool"), "acme/tool");
        assert_eq!(shorthand_repository("stylua"), Some("johnnymorganz/stylua"));
        assert_eq!(shorthand_repository("acme/tool"), None);
    }
}