luaupm 0.1.0-beta.1

The Luau package manager: dependencies, tools and scripts for Luau and Roblox projects
//! How a tool alias becomes a running binary. Shims are copies of lpm named
//! after their alias (aftman-style); invoked under such a name, lpm looks up
//! which repo@version the surrounding project pins the alias to and hands off
//! to the stored binary. That lookup is what scopes tools to their projects.

use super::stored_executable;
use crate::error::Error;
use crate::project::manifest::{MANIFEST_FILE, Tool};
use crate::sys::paths;
use crate::sys::process;
use serde::Deserialize;
use std::collections::BTreeMap;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};

/// The alias this process was invoked under, or None when running as the real
/// lpm CLI. "lpm" itself and "lpm-*" stems count as the CLI because release
/// assets are named "lpm-{os}-{arch}".
pub fn shim_alias() -> Option<String> {
    let exe = env::current_exe().ok()?;
    alias_from_stem(exe.file_stem()?.to_str()?)
}

fn alias_from_stem(stem: &str) -> Option<String> {
    let lower = stem.to_ascii_lowercase();
    if lower == "lpm" || lower.starts_with("lpm-") {
        None
    } else {
        Some(stem.to_string())
    }
}

/// Runs `alias` as a tool shim. Only returns the tool's exit code on platforms
/// without exec (Windows); on unix the tool replaces this process.
pub fn run(alias: &str) -> Result<i32, Error> {
    let tool = resolve_alias(alias, &env::current_dir()?, &paths::global_tools_file()?)?;
    let stored = stored_executable(&tool.repository, &tool.version)?;
    if !stored.exists() {
        return Err(Error::ToolNotInstalled(alias.to_string()));
    }

    let mut command = std::process::Command::new(&stored);
    command.args(env::args_os().skip(1));
    process::exec(command)
}

/// Path of the shim an alias is invoked through, e.g. ~/.lpm/bin/stylua.
pub fn path(alias: &str) -> Result<PathBuf, Error> {
    Ok(paths::bin_dir()?.join(format!("{alias}{}", env::consts::EXE_SUFFIX)))
}

/// Writes the alias shim: a copy of the running lpm executable.
pub fn write(shim: &Path) -> Result<(), Error> {
    fs::create_dir_all(shim.parent().expect("bin dir has a parent"))?;
    fs::copy(env::current_exe()?, shim)?;
    super::make_executable(shim)
}

/// The executable `alias` currently resolves to on PATH when that is NOT our
/// shim — i.e. another toolchain manager (aftman, rokit, foreman) or a stray
/// copy that shadows the lpm-managed tool, producing that manager's errors
/// instead of running what lpm installed.
pub fn shadowing_executable(alias: &str) -> Option<PathBuf> {
    let bin = paths::bin_dir().ok()?;
    let file = format!("{alias}{}", env::consts::EXE_SUFFIX);
    for dir in env::split_paths(&env::var_os("PATH")?) {
        if dir.as_os_str().is_empty() {
            continue;
        }
        let candidate = dir.join(&file);
        if candidate.is_file() {
            // The first hit wins, mirroring OS command lookup
            return if paths::same_file(&dir, &bin) || dir == bin {
                None
            } else {
                Some(candidate)
            };
        }
    }
    None
}

/// Finds the tool an alias refers to: the nearest lpm.toml walking up from
/// `start` whose [tools] lists the alias wins, then the global tools file.
fn resolve_alias(alias: &str, start: &Path, global: &Path) -> Result<Tool, Error> {
    for dir in start.ancestors() {
        if let Some(tools) = tools_in(&dir.join(MANIFEST_FILE))?
            && let Some(tool) = get_tool(&tools, alias)
        {
            return Ok(tool.clone());
        }
    }
    if let Some(tools) = tools_in(global)?
        && let Some(tool) = get_tool(&tools, alias)
    {
        return Ok(tool.clone());
    }
    Err(Error::ToolNotManaged(alias.to_string()))
}

/// Tools visible from `start` through project manifests: the union over
/// ancestor lpm.tomls, the nearest manifest winning per alias — the same
/// scope `run` resolves aliases against.
pub fn project_tools(start: &Path) -> Result<BTreeMap<String, Tool>, Error> {
    let mut merged = BTreeMap::new();
    for dir in start.ancestors() {
        if let Some(tools) = tools_in(&dir.join(MANIFEST_FILE))? {
            for (alias, tool) in tools {
                merged.entry(alias).or_insert(tool);
            }
        }
    }
    Ok(merged)
}

/// Tools pinned by the global tools file; empty when it does not exist yet.
pub fn global_tools() -> Result<BTreeMap<String, Tool>, Error> {
    Ok(tools_in(&paths::global_tools_file()?)?.unwrap_or_default())
}

/// Just the [tools] table of a manifest, parsed leniently so the shim can
/// read both project manifests and the global tools file (which has no
/// [package] section).
#[derive(Deserialize)]
struct ToolsTable {
    #[serde(default)]
    tools: BTreeMap<String, Tool>,
}

fn tools_in(path: &Path) -> Result<Option<BTreeMap<String, Tool>>, Error> {
    if !path.exists() {
        return Ok(None);
    }
    let table: ToolsTable = toml::from_str(&fs::read_to_string(path)?)?;
    Ok(Some(table.tools))
}

/// Alias lookup, exact key first; Windows resolves commands case-insensitively
/// so a shim can be invoked as "Rojo" while the manifest key is "rojo".
fn get_tool<'a>(tools: &'a BTreeMap<String, Tool>, alias: &str) -> Option<&'a Tool> {
    tools.get(alias).or_else(|| {
        tools
            .iter()
            .find_map(|(key, tool)| key.eq_ignore_ascii_case(alias).then_some(tool))
    })
}

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

    #[test]
    fn shim_aliases_exclude_lpm_itself() {
        assert_eq!(alias_from_stem("lpm"), None);
        assert_eq!(alias_from_stem("LPM"), None);
        // Release assets run before `self install` renames them.
        assert_eq!(alias_from_stem("lpm-windows-x86_64"), None);
        assert_eq!(alias_from_stem("rojo"), Some("rojo".to_string()));
        assert_eq!(alias_from_stem("StyLua"), Some("StyLua".to_string()));
    }

    #[test]
    fn resolves_aliases_from_nearest_manifest_then_global() {
        let base = std::env::temp_dir().join("lpm-test-resolve-alias");
        let _ = fs::remove_dir_all(&base);

        let project = base.join("project");
        let nested = project.join("a").join("b");
        fs::create_dir_all(&nested).unwrap();
        fs::write(
            project.join(MANIFEST_FILE),
            "[package]\nname = \"scope/name\"\nversion = \"0.1.0\"\n\n[tools]\nrojo = \"rojo-rbx/rojo@7.4.4\"\n",
        )
        .unwrap();
        let global = base.join("tools.toml");
        fs::write(
            &global,
            "[tools]\nstylua = \"johnnymorganz/stylua@2.0.0\"\n",
        )
        .unwrap();

        // Project manifests win, found from any directory below them.
        let tool = resolve_alias("rojo", &nested, &global).unwrap();
        assert_eq!(tool.repository, "rojo-rbx/rojo");
        assert_eq!(tool.version, "7.4.4");

        // Aliases the project does not list fall back to the global file.
        let tool = resolve_alias("stylua", &nested, &global).unwrap();
        assert_eq!(tool.repository, "johnnymorganz/stylua");

        // Windows command lookup is case-insensitive; resolution must be too.
        assert!(resolve_alias("Rojo", &nested, &global).is_ok());

        assert!(matches!(
            resolve_alias("wally", &nested, &global),
            Err(Error::ToolNotManaged(_))
        ));

        let _ = fs::remove_dir_all(&base);
    }

    #[test]
    fn project_tools_merge_with_nearest_manifest_winning() {
        let base = std::env::temp_dir().join("lpm-test-project-tools");
        let _ = fs::remove_dir_all(&base);

        let inner = base.join("project");
        let start = inner.join("src");
        fs::create_dir_all(&start).unwrap();
        fs::write(
            base.join(MANIFEST_FILE),
            "[tools]\nrojo = \"rojo-rbx/rojo@7.4.4\"\nstylua = \"johnnymorganz/stylua@2.0.0\"\n",
        )
        .unwrap();
        fs::write(
            inner.join(MANIFEST_FILE),
            "[tools]\nrojo = \"rojo-rbx/rojo@7.5.1\"\n",
        )
        .unwrap();

        let tools = project_tools(&start).unwrap();
        assert_eq!(tools["rojo"].version, "7.5.1");
        assert_eq!(tools["stylua"].version, "2.0.0");

        let _ = fs::remove_dir_all(&base);
    }
}