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";
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))
}
}
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)
}
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()
}
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}");
}
}