lux-lib 0.67.3

Library for the lux package manager for Lua
Documentation
//! Support for running [tiniest](https://github.com/dphfox/tiniest) test suites.

use std::path::{Path, PathBuf};

use itertools::Itertools;
use miette::Diagnostic;
use path_slash::PathExt;
use pathdiff::diff_paths;
use thiserror::Error;
use walkdir::WalkDir;

use crate::{
    build::{BuildBehaviour, BuildError},
    config::Config,
    fs,
    lua_installation::{LuaInstallation, LuaInstallationError},
    lua_rockspec::RemoteLuaRockspec,
    lua_version::LuaVersion,
    package::PackageReq,
    pipeline::build_local::Build,
    tree::{self, InstallTree, RockMatches, Tree, TreeError},
    workspace::LUX_DIR_NAME,
};

const TINIEST_VERSION: &str = "0.0.0-1";

/// Pin of `github:dphfox/tiniest@main`.
///
/// Upstream has no tagged releases, so we track a commit instead.
/// `lx test` reinstalls tiniest when the installed version no longer matches it.
fn tiniest_rockspec() -> String {
    format!(
        r#"rockspec_format = "3.0"
package = "tiniest"
version = "{TINIEST_VERSION}"
source = {{
   url = "git+https://github.com/dphfox/tiniest",
   tag = "ba9d9ef462180b7ffc23d3a4e35f3dca06a05047",
}}
"#
    )
}

const TEST_COLLECTOR_TEMPLATE: &str = r#"-- This file is auto-generated by Lux. Do not modify it.
--!nocheck

local tiniest_src = "{{TINIEST_MODULE}}"

local tiniest_expect = require(`{tiniest_src}/tiniest_expect`)
local tiniest_time = require(`{tiniest_src}/tiniest_time`)
local tiniest_pretty = require(`{tiniest_src}/tiniest_pretty`)
local tiniest_core = require(`{tiniest_src}/tiniest`)

local time_plugin = tiniest_time.configure({
	get_timestamp = os.clock,
})

local pretty_plugin = tiniest_pretty.configure({
	plugins = { time_plugin },
})

local tiniest = tiniest_core.configure({
	plugins = { time_plugin, pretty_plugin },
})
tiniest.expect = tiniest_expect.expect

local tests = tiniest.collect_tests(function()
	local describe = tiniest.describe
{{REQUIRES}}
end)

local result = tiniest.run_tests(tests, {})

if result.status_tally.fail > 0 then
	error(string.format("%d test(s) failed", result.status_tally.fail))
end
"#;

#[derive(Error, Debug, Diagnostic)]
pub enum TiniestError {
    #[error("failed to install tiniest")]
    #[diagnostic(transparent)]
    Build(Box<BuildError>),
    #[error(transparent)]
    #[diagnostic(transparent)]
    LuaInstallation(#[from] LuaInstallationError),
    #[error(transparent)]
    #[diagnostic(transparent)]
    Fs(#[from] fs::FsError),
    #[error(transparent)]
    #[diagnostic(transparent)]
    Tree(#[from] TreeError),
}

impl From<BuildError> for TiniestError {
    fn from(source: BuildError) -> Self {
        Self::Build(Box::new(source))
    }
}

/// Returns the `src` directory of tiniest installed in the given tree,
/// installing it first if necessary.
pub(crate) async fn ensure_installed(
    tree: &Tree,
    config: &Config,
) -> Result<PathBuf, TiniestError> {
    let tiniest =
        unsafe { PackageReq::new_unchecked("tiniest".into(), Some(TINIEST_VERSION.into())) };
    if let Some(src_dir) = find_installed_src(tree, &tiniest)? {
        return Ok(src_dir);
    }
    let lua = LuaInstallation::new(&LuaVersion::Luau, config).await?;
    let rockspec = RemoteLuaRockspec::new(&tiniest_rockspec()).unwrap_or_else(|err| {
        unreachable!("failed to parse the embedded tiniest rockspec: {err:?}")
    });
    let package = Build::new()
        .rockspec(&rockspec)
        .lua(&lua)
        .tree(tree)
        .entry_type(tree::EntryType::Entrypoint)
        .config(config)
        .behaviour(BuildBehaviour::Force)
        .build()
        .await?;
    let mut lockfile = tree.lockfile()?.write_guard();
    lockfile.add_entrypoint(&package);
    Ok(tree.layout_for(package.spec()).src)
}

fn find_installed_src(tree: &Tree, req: &PackageReq) -> Result<Option<PathBuf>, TreeError> {
    let packages = match tree.match_rocks(req)? {
        RockMatches::NotFound(_) => return Ok(None),
        RockMatches::Single(id) => vec![id],
        RockMatches::Many(ids) => ids.into_iter().collect(),
    };
    let lockfile = tree.lockfile()?;
    for package_id in packages {
        if let Some(package) = lockfile.get(&package_id) {
            let layout = tree.layout_for(package.spec());
            if layout.src.is_dir() {
                return Ok(Some(layout.src));
            }
        }
    }
    Ok(None)
}

/// Discovers tiniest spec files in the project's `test/` and `tests/` directories.
pub(crate) fn discover_spec_files(project_root: &Path) -> Vec<PathBuf> {
    vec!["test", "tests"]
        .into_iter()
        .map(|dir_name| project_root.join(dir_name))
        .filter(|dir| dir.is_dir())
        .flat_map(|dir| {
            WalkDir::new(dir)
                .sort_by_file_name()
                .into_iter()
                .filter_map(Result::ok)
                .filter(|entry| entry.file_type().is_file())
                .map(walkdir::DirEntry::into_path)
        })
        .filter(|path| {
            path.file_name()
                .map(|name| name.to_string_lossy())
                .is_some_and(|name| name.ends_with(".spec.luau") || name.ends_with(".spec.lua"))
        })
        .sorted()
        .dedup()
        .collect_vec()
}

/// Generates the spec collector script that runs the discovered spec files.
///
/// The collector lives under the workspace root and requires tiniest by a
/// relative path into the install tree.
pub(crate) async fn write_collector(
    workspace_root: &Path,
    spec_files: &[PathBuf],
    tiniest_src: &Path,
) -> Result<PathBuf, TiniestError> {
    let collector_dir = workspace_root.join(LUX_DIR_NAME);
    let requires = spec_files
        .iter()
        .map(|spec_file| {
            let label = spec_label(spec_file);
            let module_path = module_path(&spec_file.with_extension(""), &collector_dir);
            format!(
                "\tdescribe(\"{label}\", function()\n\t\trequire(\"{module_path}\")(tiniest)\n\tend)\n"
            )
        })
        .collect::<String>();
    let content = TEST_COLLECTOR_TEMPLATE
        .replace(
            "{{TINIEST_MODULE}}",
            &module_path(tiniest_src, &collector_dir),
        )
        .replace("{{REQUIRES}}", &requires);
    let collector_path = collector_dir.join("tiniest-main.luau");
    fs::tokio::create_dir_all(&collector_dir).await?;
    fs::tokio::write(&collector_path, content).await?;
    Ok(collector_path)
}

fn module_path(path: &Path, collector_dir: &Path) -> String {
    let rel_path = diff_paths(path, collector_dir)
        .unwrap_or_else(|| path.to_path_buf())
        .to_slash_lossy()
        .to_string();
    if rel_path.starts_with('.') {
        rel_path
    } else {
        format!("./{rel_path}")
    }
}

fn spec_label(spec_file: &Path) -> String {
    spec_file
        .file_stem()
        .map(|stem| stem.to_string_lossy().trim_end_matches(".spec").to_string())
        .unwrap_or_default()
}

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

    #[test]
    fn test_discover_spec_files() {
        let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .join("resources/test/sample-projects/hello-luau");
        let mut spec_files = discover_spec_files(&project_root);
        spec_files.retain(|path| path.ends_with("tests/greeting.spec.luau"));
        assert_eq!(spec_files.len(), 1);
    }

    #[tokio::test]
    async fn test_write_collector() {
        let temp = tempfile::tempdir().unwrap();
        let workspace_root = temp.path();
        std::fs::create_dir_all(workspace_root.join("tests")).unwrap();
        let spec_file = workspace_root.join("tests").join("greeting.spec.luau");
        std::fs::write(&spec_file, b"return function() end").unwrap();

        let tiniest_src = workspace_root.join("tree/luau/test_dependencies/x-tiniest@0.0.0-1/src");
        let collector_path = write_collector(workspace_root, &[spec_file], &tiniest_src)
            .await
            .unwrap();
        assert_eq!(
            collector_path,
            workspace_root.join(".lux").join("tiniest-main.luau")
        );
        let content = std::fs::read_to_string(collector_path).unwrap();
        assert!(
            content.contains(
                r#"local tiniest_src = "../tree/luau/test_dependencies/x-tiniest@0.0.0-1/src""#
            ),
            "{content}"
        );
        assert!(
            content.contains("require(`{tiniest_src}/tiniest`)"),
            "{content}"
        );
        assert!(
            content.contains("require(\"../tests/greeting.spec\")"),
            "{content}"
        );
        assert!(content.contains("describe(\"greeting\""), "{content}");
        assert!(content.contains("status_tally.fail > 0"), "{content}");
    }
}