luaupm 0.1.0

The Luau package manager: dependencies, tools and scripts for Luau and Roblox projects
use std::{collections::BTreeMap, env::consts::EXE_SUFFIX, fs};

use crate::{
    error::Error,
    net::github::GithubAPI,
    project::manifest::{
        Tool,
        edit::{ManifestDoc, Scope},
    },
    sys::paths,
    tools, ui,
};
use clap::Subcommand;
use semver::Version;

#[derive(Subcommand, Debug)]
pub enum ToolCommand {
    /// Use a tool in the current project
    Add {
        /// Name of the tool (e.g. owner/repo)
        name: String,

        /// Specific version to add
        #[arg(short, long)]
        version: Option<String>,

        /// Add to the global tools file, usable anywhere
        #[arg(short, long, alias = "g")]
        global: bool,
    },

    /// Remove a tool from the current project
    Remove {
        /// Name of the tool
        name: String,

        /// Remove from the global tools file instead of this project
        #[arg(short, long, alias = "g")]
        global: bool,
    },

    /// Update a tool
    Update,

    /// List installed and pinned tools
    List,

    /// Delete a tool's binaries from your system (its manifest pins remain)
    Delete {
        /// Name of the tool (e.g. owner/repo)
        name: String,

        /// Specific version to delete
        #[arg(short, long)]
        version: Option<String>,
    },
}

pub fn run(command: ToolCommand) -> Result<(), Error> {
    match command {
        ToolCommand::Add {
            name,
            version,
            global,
        } => add(name, version, global),
        ToolCommand::Remove { name, global } => remove(name, global),
        ToolCommand::Update => update(),
        ToolCommand::Delete { name, version } => delete(name, version),
        ToolCommand::List => list(),
    }
}

fn add(name: String, version: Option<String>, global: bool) -> Result<(), Error> {
    let github = GithubAPI::new();
    let name = tools::expand_shorthand(&name);
    let (owner, repo) = Tool::split_repository(&name)?;

    // resolve the release first so a bad name/version never touches the file
    let release = match &version {
        Some(version) => github.get_release(&name, version.trim_start_matches('v'))?,
        None => github.get_latest_release(&name)?,
    };

    /* global tools live in ~/.lpm/tools.toml (created on first use) and
    resolve in any directory; project tools only inside their project */
    let mut manifest = ManifestDoc::open_or_create(Scope::from_global(global))?;

    /* alias key defaults to the repo short name; the bin shim takes the
    same name, which is what `delete` relies on when removing it */
    let version = release.tag_name.trim_start_matches('v');
    manifest.table_or_create("tools")?[repo] =
        toml_edit::value(format!("{owner}/{repo}@{version}"));
    manifest.save()?;

    if global {
        ui::print_success(&format!("Added global tool {name}@{version}"));
    } else {
        ui::print_success(&format!("Added tool {name}@{version} to lpm.toml"));
    }
    println!("Run `lpm install` to install it");

    Ok(())
}

fn remove(name: String, global: bool) -> Result<(), Error> {
    let scope = Scope::from_global(global);

    // no global tools file at all means nothing was ever pinned globally
    let mut manifest = match ManifestDoc::open(scope) {
        Err(Error::ManifestMissing) if global => return Err(Error::ToolMissing(name)),
        opened => opened?,
    };
    let table = manifest.require_table("tools")?;

    /* try the exact alias key first; keys are short names, so a shorthand
    or full "owner/repo" falls back to matching table values by repository */
    let mut removed = table.remove(&name).is_some();
    if !removed {
        let repository = tools::shorthand_repository(&name).unwrap_or(&name);
        let prefix = format!("{repository}@");

        // github repo names are case-insensitive, so match ignoring case
        let key = table.iter().find_map(|(key, item)| {
            item.as_str()
                .and_then(|spec| spec.trim().get(..prefix.len()))
                .is_some_and(|head| head.eq_ignore_ascii_case(&prefix))
                .then(|| key.to_string())
        });

        if let Some(key) = key {
            removed = table.remove(&key).is_some();
        }
    }

    if !removed {
        return Err(Error::ToolMissing(name));
    }

    manifest.drop_if_empty("tools");
    manifest.save()?;
    ui::print_success(&format!(
        "Successfully removed tool {name} from {}",
        scope.label()
    ));

    Ok(())
}

/** true when `latest` is newer than `current`. compared as semver when
possible; otherwise any plain string difference counts as outdated. */
fn is_outdated(current: &str, latest: &str) -> bool {
    match (Version::parse(current), Version::parse(latest)) {
        (Ok(current), Ok(latest)) => latest > current,
        _ => current != latest,
    }
}

fn update() -> Result<(), Error> {
    let github = GithubAPI::new();
    let mut manifest = ManifestDoc::open(Scope::Project)?;

    let Some(table) = manifest.table("tools")? else {
        println!("No tools to update");
        return Ok(());
    };

    // snapshot the entries up front so the table can be edited while looping
    let entries = table
        .iter()
        .map(|(alias, item)| {
            item.as_str()
                .map(|spec| (alias.to_string(), spec.to_string()))
                .ok_or_else(|| {
                    Error::ManifestInvalid(format!("[tools] entry '{alias}' is not a string"))
                })
        })
        .collect::<Result<Vec<_>, Error>>()?;

    if entries.is_empty() {
        println!("No tools to update");
        return Ok(());
    }

    let mut updated = 0;
    for (alias, spec) in entries {
        let tool = Tool::parse(&spec)?;

        let release = github.get_latest_release(&tool.repository)?;
        let latest = release.tag_name.trim_start_matches('v');

        if !is_outdated(&tool.version, latest) {
            continue;
        }

        table[alias.as_str()] = toml_edit::value(format!("{}@{latest}", tool.repository));
        println!("{}: {} -> {latest}", tool.repository, tool.version);
        updated += 1;
    }

    if updated == 0 {
        println!("All tools are up to date");
        return Ok(());
    }

    manifest.save()?;
    ui::print_success(&format!(
        "Updated {updated} tool{} in lpm.toml",
        if updated == 1 { "" } else { "s" }
    ));
    println!("Run `lpm install` to install the updated tools");

    Ok(())
}

fn list() -> Result<(), Error> {
    /* everything known about one version of a tool: whether its binaries are
    stored, and which manifests pin it. pins without storage still show up
    (as "not installed") so a fresh `tool add` never looks like a ghost */
    #[derive(Default)]
    struct VersionState {
        installed: bool,
        project: bool,
        global: bool,
    }

    /* keyed by lowercased "owner/repo": manifests may spell the repository
    with different casing than the storage folder was created with */
    type Seen = BTreeMap<String, (String, BTreeMap<String, VersionState>)>;
    fn state_of<'a>(seen: &'a mut Seen, name: &str, version: String) -> &'a mut VersionState {
        let entry = seen
            .entry(name.to_ascii_lowercase())
            .or_insert_with(|| (name.to_string(), BTreeMap::new()));
        entry.1.entry(version).or_default()
    }

    let mut seen: Seen = BTreeMap::new();

    // what's stored on disk
    let tools_dir = paths::tools_dir()?;
    if tools_dir.is_dir() {
        for entry in fs::read_dir(&tools_dir)? {
            let entry = entry?;
            if !entry.file_type()?.is_dir() {
                continue;
            }

            /* github owners can never contain '_', so the first '_' in the
            directory name splits owner from repo */
            let dir_name = entry.file_name().to_string_lossy().into_owned();
            let Some((owner, repo)) = dir_name.split_once('_') else {
                continue;
            };
            let name = format!("{owner}/{repo}");

            for version in fs::read_dir(entry.path())? {
                let version = version?;
                if version.path().is_dir() {
                    let version = version.file_name().to_string_lossy().into_owned();
                    state_of(&mut seen, &name, version).installed = true;
                }
            }
        }
    }

    // what the surrounding project and the global tools file pin
    let project = tools::shim::project_tools(&std::env::current_dir()?)?;
    let global = tools::shim::global_tools()?;
    for (tools, is_global) in [(&project, false), (&global, true)] {
        for tool in tools.values() {
            let state = state_of(&mut seen, &tool.repository, tool.version.clone());
            if is_global {
                state.global = true;
            } else {
                state.project = true;
            }
        }
    }

    if seen.is_empty() {
        println!("No tools installed or pinned");
        return Ok(());
    }

    /* one accent check line ("name@version") per tool, matching install's
    output; multiple versions share the line, each annotated with its scopes */
    for (name, versions) in seen.into_values() {
        let mut versions: Vec<_> = versions.into_iter().collect();
        // order versions semver-aware where possible
        versions.sort_by(
            |(a, _), (b, _)| match (Version::parse(a), Version::parse(b)) {
                (Ok(a), Ok(b)) => a.cmp(&b),
                _ => a.cmp(b),
            },
        );

        let versions = versions
            .into_iter()
            .map(|(version, state)| {
                let mut notes = Vec::new();
                if state.project {
                    notes.push("project");
                }
                if state.global {
                    notes.push("global");
                }
                if !state.installed {
                    notes.push("not installed");
                }
                if notes.is_empty() {
                    version
                } else {
                    format!("{version} ({})", notes.join(", "))
                }
            })
            .collect::<Vec<_>>()
            .join(", ");
        ui::print_success(&format!("{name}@{versions}"));
    }

    Ok(())
}

fn delete(name: String, version: Option<String>) -> Result<(), Error> {
    let name = tools::expand_shorthand(&name);
    let (owner, repo) = Tool::split_repository(&name)?;
    let tool_dir = paths::tools_dir()?.join(format!("{owner}_{repo}"));
    let version = version
        .as_deref()
        .map(|version| version.trim_start_matches('v'));

    // with --version only remove that version's subdir, otherwise the tool
    let target = match version {
        Some(version) => tool_dir.join(version),
        None => tool_dir.clone(),
    };
    if !target.exists() {
        return Err(Error::ToolMissing(name.to_string()));
    }
    fs::remove_dir_all(&target)?;

    /* when the whole tool (or its last version) is gone, drop the shim too.
    manifest pins stay: `tool list` shows it as "not installed" and the
    next `lpm install` puts it back */
    let tool_gone = version.is_none()
        || fs::read_dir(&tool_dir)
            .map(|mut dir| dir.next().is_none())
            .unwrap_or(true);
    if tool_gone {
        let _ = fs::remove_dir(&tool_dir);
        if let Ok(bin_dir) = paths::bin_dir() {
            let _ = fs::remove_file(bin_dir.join(format!("{repo}{EXE_SUFFIX}")));
        }
    }

    let message = match version {
        Some(version) => format!("Deleted tool {name}@{version}"),
        None => format!("Deleted tool {name}"),
    };
    ui::print_success(&message);

    Ok(())
}