luaupm 0.2.0

The Luau package manager: dependencies, tools and scripts for Luau and Roblox projects
use crate::error::Error;
use crate::project::manifest::{Environment, MANIFEST_FILE, Manifest};
use flate2::Compression;
use flate2::write::GzEncoder;
use std::path::{Path, PathBuf};

/// Never published; matched by name at any depth.
const SKIP_NAMES: [&str; 5] = [".git", ".lpm-staging", "lpm.lock", "target", "node_modules"];

/// Files to publish, relative to `root`, sorted so the archive is
/// deterministic. Skips packages-out folders and `SKIP_NAMES`; without
/// `includes` under [target] that walk is the whole selection. Include/exclude
/// entries are relative paths (a directory covers its subtree) or globs like
/// `src/*`; `excludes` subtracts from the selection, and lpm.toml always
/// ships.
pub fn packed_files(root: &Path, manifest: &Manifest) -> Result<Vec<PathBuf>, Error> {
    let out_dirs: Vec<PathBuf> = Environment::ALL
        .into_iter()
        .map(|environment| manifest.packages_out(environment))
        .collect();

    let mut files = Vec::new();
    walk(root, root, &out_dirs, &mut files)?;

    let empty = Vec::new();
    let (includes, excludes) = match &manifest.target {
        Some(target) => (&target.includes, &target.excludes),
        None => (&empty, &empty),
    };
    let includes = PathFilter::new(includes)?;
    let excludes = PathFilter::new(excludes)?;
    files.retain(|file| {
        if file.as_path() == Path::new(MANIFEST_FILE) {
            return true;
        }
        if !includes.is_empty() && !includes.matches(file) {
            return false;
        }
        !excludes.matches(file)
    });

    files.sort();
    Ok(files)
}

/** One includes/excludes list, compiled: entries with glob metacharacters
match as globs, plain entries as path prefixes (a bare directory name covers
its subtree). */
struct PathFilter {
    paths: Vec<PathBuf>,
    globs: globset::GlobSet,
}

impl PathFilter {
    fn new(entries: &[String]) -> Result<Self, Error> {
        let mut paths = Vec::new();
        let mut globs = globset::GlobSetBuilder::new();
        for entry in entries {
            if entry.contains(['*', '?', '[', '{']) {
                // literal_separator: a `src/` + `*` glob means src's files
                // only, not the subtree; use a double star (or just `src`).
                let glob = globset::GlobBuilder::new(entry)
                    .literal_separator(true)
                    .build()
                    .map_err(|error| {
                        Error::ManifestInvalid(format!(
                            "invalid includes/excludes glob '{entry}': {error}"
                        ))
                    })?;
                globs.add(glob);
            } else {
                paths.push(PathBuf::from(entry));
            }
        }
        let globs = globs
            .build()
            .map_err(|error| Error::ManifestInvalid(error.to_string()))?;
        Ok(PathFilter { paths, globs })
    }

    fn is_empty(&self) -> bool {
        self.paths.is_empty() && self.globs.is_empty()
    }

    fn matches(&self, file: &Path) -> bool {
        self.paths.iter().any(|path| file.starts_with(path)) || self.globs.is_match(file)
    }
}

/** Packs the selected files into a gzipped tar. Entry paths are
forward-slash relative, no leading "./". The lpm.toml entry is serialized
from `manifest`, not copied from disk: publish rewrites workspace deps into
registry ones in memory, and the archive is where that rewrite has to land
(the on-disk file stays untouched), same as pesde. */
pub fn pack(root: &Path, manifest: &Manifest) -> Result<Vec<u8>, Error> {
    let files = packed_files(root, manifest)?;
    let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default()));
    for file in &files {
        if file.as_path() == Path::new(MANIFEST_FILE) {
            let contents = toml::to_string(manifest)?;
            let mut header = tar::Header::new_gnu();
            header.set_size(contents.len() as u64);
            header.set_mode(0o644);
            header.set_cksum();
            builder.append_data(&mut header, MANIFEST_FILE, contents.as_bytes())?;
        } else {
            builder.append_path_with_name(root.join(file), tar_path(file))?;
        }
    }
    Ok(builder.into_inner()?.finish()?)
}

fn walk(
    dir: &Path,
    root: &Path,
    out_dirs: &[PathBuf],
    files: &mut Vec<PathBuf>,
) -> Result<(), Error> {
    for entry in std::fs::read_dir(dir)? {
        let entry = entry?;
        let name = entry.file_name();
        if SKIP_NAMES.iter().any(|skip| name == *skip) {
            continue;
        }

        let path = entry.path();
        let relative = path
            .strip_prefix(root)
            .expect("walked path is under root")
            .to_path_buf();
        if out_dirs
            .iter()
            .any(|out| starts_with_ignore_case(&relative, out))
        {
            continue;
        }

        let file_type = entry.file_type()?;
        if file_type.is_dir() {
            walk(&path, root, out_dirs, files)?;
        } else if file_type.is_file() {
            files.push(relative);
        }
    }
    Ok(())
}

/** Component-wise, ASCII-case-insensitive `Path::starts_with`. Windows and
macOS filesystems are case-insensitive, so the on-disk folder can be cased
differently than the configured packages-out path and still be the same
directory. */
fn starts_with_ignore_case(path: &Path, prefix: &Path) -> bool {
    let mut components = path.components();
    prefix.components().all(|expected| {
        components.next().is_some_and(|actual| {
            actual
                .as_os_str()
                .eq_ignore_ascii_case(expected.as_os_str())
        })
    })
}

fn tar_path(file: &Path) -> String {
    file.iter()
        .map(|part| part.to_string_lossy())
        .collect::<Vec<_>>()
        .join("/")
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::BTreeMap;
    use std::fs;
    use std::io::Read as _;

    fn parse_manifest(toml_text: &str) -> Manifest {
        toml::from_str(toml_text).unwrap()
    }

    fn write(root: &Path, file: &str, contents: &str) {
        let path = root.join(file);
        fs::create_dir_all(path.parent().unwrap()).unwrap();
        fs::write(path, contents).unwrap();
    }

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

        write(&base, "lpm.toml", "");
        write(&base, "src/init.luau", "return {}");
        write(&base, ".git/HEAD", "ref: refs/heads/master");
        write(&base, ".lpm-staging/rocket.tar.gz", "");
        write(&base, "lpm.lock", "version = 1");
        write(&base, "target/debug/lpm", "");
        write(&base, "node_modules/left-pad/index.js", "");
        write(&base, "packages/luau/Core.luau", "");
        write(&base, "Packages/Chief.luau", "");

        let manifest = parse_manifest(
            r#"
            [package]
            name = "acme/rocket"
            version = "1.0.0"

            [config]
            shared-packages-out = "Packages"
            "#,
        );

        let files = packed_files(&base, &manifest).unwrap();
        assert_eq!(
            files,
            vec![PathBuf::from("lpm.toml"), PathBuf::from("src/init.luau")]
        );

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

    #[test]
    fn out_dir_matching_ignores_case_per_component() {
        assert!(starts_with_ignore_case(
            Path::new("packages/Chief.luau"),
            Path::new("Packages")
        ));
        assert!(starts_with_ignore_case(
            Path::new("Packages/luau/Core.luau"),
            Path::new("packages/Luau")
        ));
        assert!(!starts_with_ignore_case(
            Path::new("packages-old/x.luau"),
            Path::new("packages")
        ));
        assert!(!starts_with_ignore_case(
            Path::new("src/init.luau"),
            Path::new("Packages")
        ));
    }

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

        write(&base, "lpm.toml", "");
        write(&base, "README.md", "");
        write(&base, "notes.txt", "");
        write(&base, "src/init.luau", "");
        write(&base, "src/tests/spec.luau", "");

        let manifest = parse_manifest(
            r#"
            [package]
            name = "acme/rocket"
            version = "1.0.0"

            [target]
            environment = "luau"
            includes = ["src", "README.md"]
            excludes = ["src/tests"]
            "#,
        );

        let files = packed_files(&base, &manifest).unwrap();
        assert_eq!(
            files,
            vec![
                PathBuf::from("README.md"),
                PathBuf::from("lpm.toml"),
                PathBuf::from("src/init.luau"),
            ]
        );
        // Same tree, same list.
        assert_eq!(packed_files(&base, &manifest).unwrap(), files);

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

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

        write(&base, "lpm.toml", "[package]\nname = \"acme/rocket\"\n");
        write(&base, "src/init.luau", "return 1\n");
        write(&base, "docs/guide.md", "# rocket\n");

        let manifest = parse_manifest("[package]\nname = \"acme/rocket\"\nversion = \"1.0.0\"");

        let bytes = pack(&base, &manifest).unwrap();
        assert!(bytes.starts_with(&[0x1f, 0x8b]), "output must be gzipped");

        let mut unpacked = BTreeMap::new();
        let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(bytes.as_slice()));
        for entry in archive.entries().unwrap() {
            let mut entry = entry.unwrap();
            let path = String::from_utf8(entry.path_bytes().into_owned()).unwrap();
            assert!(!path.starts_with("./"), "unexpected ./ prefix in {path}");
            assert!(!path.contains('\\'), "backslash in tar path {path}");
            let mut contents = String::new();
            entry.read_to_string(&mut contents).unwrap();
            unpacked.insert(path, contents);
        }

        assert_eq!(unpacked.len(), 3);
        /* The archive's manifest comes from memory (that's where publish's
        workspace-dep rewrite lives), not from disk. */
        assert_eq!(unpacked["lpm.toml"], toml::to_string(&manifest).unwrap());
        assert_eq!(unpacked["src/init.luau"], "return 1\n");
        assert_eq!(unpacked["docs/guide.md"], "# rocket\n");

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