Skip to main content

lux_lib/operations/
dist_bin.rs

1use std::{
2    io,
3    path::{Path, PathBuf},
4    process::Stdio,
5};
6
7use bon::Builder;
8use itertools::Itertools;
9use thiserror::Error;
10use walkdir::WalkDir;
11
12use crate::{
13    build::utils::c_dylib_extension,
14    config::Config,
15    fs,
16    lua_installation::{LuaInstallation, LuaInstallationError},
17    lua_rockspec::LuaModule,
18    operations::{InstallProject, InstallProjectError},
19    project::{project_toml::LocalProjectTomlValidationError, Project},
20    rockspec::Rockspec,
21    tree::{InstallTree, TreeError},
22};
23
24/// Compile a Lux project and all its dependencies into a single
25/// static binary that does not require a Lua installation.
26///
27/// Projects with native Lua modules additionally ship those modules as
28/// shared libraries copied next to the binary, which resolves them via its
29/// rpath at runtime.
30///
31/// Based on [luastatic](https://github.com/ers35/luastatic)
32#[derive(Builder)]
33#[builder(start_fn = new, finish_fn(name = _build, vis = ""))]
34pub struct DistProjectBin<'a, T>
35where
36    T: InstallTree,
37{
38    /// The project to compile.
39    project: &'a Project,
40
41    config: &'a Config,
42
43    /// Tree in which to install the project before compiling.
44    tree: &'a T,
45
46    /// Destination path for the compiled binary.
47    /// Defaults to `<cwd>/<package>[.exe]`.
48    output: Option<PathBuf>,
49}
50
51use miette::Diagnostic;
52#[derive(Error, Debug, Diagnostic)]
53#[non_exhaustive]
54pub enum DistProjectBinError {
55    #[error("error installing project:\n{0}")]
56    #[diagnostic(forward(0))]
57    InstallProject(#[from] InstallProjectError),
58    #[error(transparent)]
59    #[diagnostic(transparent)]
60    LocalProjectTomlValidation(#[from] LocalProjectTomlValidationError),
61    #[error(transparent)]
62    #[diagnostic(transparent)]
63    Tree(#[from] TreeError),
64    #[cfg(not(target_os = "linux"))]
65    #[error(
66        r#"Lua binary libraries are only linkable on Linux.
67Cannot link the following binaries:
68{0}"#
69    )]
70    CannotLinkBinaryLibs(String),
71    #[error(transparent)]
72    #[diagnostic(transparent)]
73    LuaInstallation(#[from] LuaInstallationError),
74    #[error(transparent)]
75    CC(#[from] cc::Error),
76    #[error(transparent)]
77    Io(#[from] io::Error),
78    #[error(transparent)]
79    #[diagnostic(transparent)]
80    Fs(#[from] fs::FsError),
81    #[error("C compilation failed (exit {status}):\nstdout: {stdout}\nstderr: {stderr}")]
82    CompilationFailed {
83        status: std::process::ExitStatus,
84        stdout: String,
85        stderr: String,
86    },
87}
88
89impl<T, State> DistProjectBinBuilder<'_, T, State>
90where
91    T: InstallTree + Sync + Send + Clone + 'static,
92    State: dist_project_bin_builder::State + dist_project_bin_builder::IsComplete,
93{
94    pub async fn compile(self) -> Result<PathBuf, DistProjectBinError> {
95        do_dist_project_bin(self._build()).await
96    }
97}
98
99/// Files collected from an installed tree for binary compilation.
100#[derive(Debug)]
101struct InstalledFiles {
102    /// Lua source files (.lua) to embed.
103    src: Vec<(LuaModule, PathBuf)>,
104    /// Native Lua modules (.so/.dll/.dylib) to link.
105    lib: Vec<PathBuf>,
106}
107
108#[tracing::instrument(name = "Distributing binary", skip_all)]
109
110async fn do_dist_project_bin<T>(args: DistProjectBin<'_, T>) -> Result<PathBuf, DistProjectBinError>
111where
112    T: InstallTree + Sync + Send + Clone + 'static,
113{
114    let package = InstallProject::new()
115        .project(args.project)
116        .config(args.config)
117        .tree(args.tree)
118        .build()
119        .await?;
120
121    let files = collect_installed_files(args.tree)?;
122
123    let project_toml = args.project.toml().into_local()?;
124    let pkg_name = project_toml.package().to_string();
125
126    let entrypoint_module = project_toml
127        .run()
128        .and_then(|r| r.current_platform().args.as_ref())
129        .map(|args| args.first().as_str())
130        .map(entrypoint_stem)
131        .unwrap_or_else(|| pkg_name.clone());
132
133    let layout = args.tree.installed_rock_layout(&package)?;
134
135    let lua = LuaInstallation::new_from_config(args.config).await?;
136
137    let output = match args.output {
138        Some(output) => output,
139        None => {
140            let mut p = PathBuf::from(&pkg_name);
141            if cfg!(target_env = "msvc") {
142                p.set_extension("exe");
143            }
144            p
145        }
146    };
147    let output = if output.is_absolute() {
148        output
149    } else {
150        std::env::current_dir()?.join(output)
151    };
152
153    let lib_root = layout.lib.clone();
154    let c_src = generate_c_source(&entrypoint_module, &files, &lib_root).await?;
155
156    let work_dir = fs::tempfile::tempdir()?;
157    let c_path = work_dir.path().join(format!("{pkg_name}.static.c"));
158    fs::tokio::write(&c_path, &c_src).await?;
159
160    compile_binary(&c_path, &output, &lua, &files.lib, &work_dir, args.config).await?;
161
162    Ok(output)
163}
164
165#[allow(clippy::result_large_err)]
166fn collect_installed_files(tree: &impl InstallTree) -> Result<InstalledFiles, DistProjectBinError> {
167    let mut lua_sources = Vec::new();
168    let mut native_modules = Vec::new();
169    let c_dylib_ext = c_dylib_extension();
170
171    for package in tree.list()?.values().flatten() {
172        let layout = tree.installed_rock_layout(package)?;
173
174        if layout.src.is_dir() {
175            let src_canonical = layout.src.canonicalize().unwrap_or(layout.src.clone());
176            for path in WalkDir::new(&src_canonical)
177                .into_iter()
178                .filter_map(|e| e.ok())
179                .map(|e| e.into_path())
180                .filter(|p| p.is_file() && p.extension().is_some_and(|ext| ext == "lua"))
181            {
182                let rel = path
183                    .strip_prefix(&src_canonical)
184                    .unwrap_or(&path)
185                    .with_extension("");
186                if let Ok(module) = LuaModule::from_pathbuf(rel) {
187                    lua_sources.push((module, path));
188                }
189            }
190        }
191
192        if layout.lib.is_dir() {
193            let lib_canononical = layout.lib.canonicalize().unwrap_or(layout.lib.clone());
194            for path in WalkDir::new(&lib_canononical)
195                .into_iter()
196                .filter_map(|e| e.ok())
197                .map(|e| e.into_path())
198                .filter(|p| p.is_file() && p.extension().is_some_and(|ext| ext == c_dylib_ext))
199            {
200                native_modules.push(path);
201            }
202        }
203    }
204    #[cfg(not(target_os = "linux"))]
205    if !native_modules.is_empty() {
206        return Err(DistProjectBinError::CannotLinkBinaryLibs(
207            native_modules
208                .iter()
209                .unique()
210                .map(|p| p.to_string_lossy())
211                .join("\n"),
212        ));
213    }
214
215    // NOTE: A `FlatDistTree` can produce duplicates, as all modules share the same `src`.
216    Ok(InstalledFiles {
217        src: lua_sources.into_iter().unique().collect(),
218        lib: native_modules.into_iter().unique().collect(),
219    })
220}
221
222/// Derive a module stem from a run-spec arg like `"src/main.lua"` -> `"main"`.
223fn entrypoint_stem(arg: &str) -> String {
224    PathBuf::from(arg)
225        .file_stem()
226        .map(|s| s.to_string_lossy().into_owned())
227        .unwrap_or_else(|| arg.to_owned())
228}
229
230/// Derive the Lua module name from an installed lib path.
231/// e.g. `<tree>/lib/foo/bar.so` -> `("foo.bar", "foo_bar")`.
232fn module_names(lib_root: &Path, path: &Path) -> (String, String) {
233    let rel = path.strip_prefix(lib_root).unwrap_or(path);
234    let dotpath = rel
235        .with_extension("")
236        .to_string_lossy()
237        .replace(std::path::MAIN_SEPARATOR, ".");
238    let underscore = dotpath.replace(['.', '-'], "_");
239    (dotpath, underscore)
240}
241
242/// Generate a C source file embedding all Lua sources.
243async fn generate_c_source(
244    entrypoint_module: &str,
245    files: &InstalledFiles,
246    lib_root: &Path,
247) -> Result<String, fs::FsError> {
248    let mut out = String::from(C_PREAMBLE);
249
250    out.push_str("#ifdef __cplusplus\nextern \"C\" {\n#endif\n");
251    for path in &files.lib {
252        let (_, underscore) = module_names(lib_root, path);
253        out.push_str(&format!("int luaopen_{underscore}(lua_State *L);\n"));
254    }
255    out.push_str("#ifdef __cplusplus\n}\n#endif\n\n");
256
257    for (i, (_, path)) in files.src.iter().enumerate() {
258        let bytes = fs::tokio::read(path).await?;
259        let hex = bytes_to_hex(&bytes);
260        out.push_str(&format!(
261            "static const unsigned char lua_src_{i}[] = {{{hex}}};\n"
262        ));
263    }
264
265    let loader_with_entrypoint = format!(
266        "{LUA_LOADER_SOURCE}local func = lua_loader(\"{entrypoint_module}\")\n\
267         if type(func) == \"function\" then\n\
268         \tfunc(unpack(arg))\n\
269         else\n\
270         \terror(func, 0)\n\
271         end\n"
272    );
273    let loader_hex = bytes_to_hex(loader_with_entrypoint.as_bytes());
274    out.push_str(&format!(
275        "static const unsigned char lua_loader_program[] = {{{loader_hex}}};\n\n"
276    ));
277
278    out.push_str("int main(int argc, char *argv[]) {\n");
279    out.push_str("  lua_State *L = luaL_newstate();\n");
280    out.push_str("  luaL_openlibs(L);\n");
281    out.push_str("  createargtable(L, argv, argc, 0);\n\n");
282
283    out.push_str(&format!(
284        "  if (luaL_loadbuffer(L, (const char*)lua_loader_program, sizeof(lua_loader_program), \"{entrypoint_module}\") != LUA_OK) {{\n"
285    ));
286    out.push_str("    fprintf(stderr, \"luaL_loadbuffer: %s\\n\", lua_tostring(L, -1));\n");
287    out.push_str("    lua_close(L); return 1;\n  }\n\n");
288
289    out.push_str("  /* lua_bundle */\n  lua_newtable(L);\n");
290
291    for (i, (module, _)) in files.src.iter().enumerate() {
292        out.push_str(&format!(
293            "  lua_pushlstring(L, (const char*)lua_src_{i}, sizeof(lua_src_{i}));\n"
294        ));
295        out.push_str(&format!("  lua_setfield(L, -2, \"{module}\");\n"));
296    }
297
298    for path in &files.lib {
299        let (dotpath, underscore) = module_names(lib_root, path);
300        out.push_str(&format!("  lua_pushcfunction(L, luaopen_{underscore});\n"));
301        out.push_str(&format!("  lua_setfield(L, -2, \"{dotpath}\");\n"));
302    }
303
304    out.push_str("\n  if (docall(L, 1, LUA_MULTRET)) {\n");
305    out.push_str("    const char *msg = lua_tostring(L, 1);\n");
306    out.push_str("    if (msg) fprintf(stderr, \"%s\\n\", msg);\n");
307    out.push_str("    lua_close(L); return 1;\n  }\n");
308    out.push_str("  lua_close(L);\n  return 0;\n}\n");
309
310    Ok(out)
311}
312
313fn bytes_to_hex(bytes: &[u8]) -> String {
314    bytes
315        .iter()
316        .map(|b| format!("0x{b:02x}"))
317        .collect::<Vec<_>>()
318        .join(", ")
319}
320
321async fn compile_binary(
322    c_path: &Path,
323    output: &Path,
324    lua: &LuaInstallation,
325    native_modules: &[PathBuf],
326    work_dir: &tempfile::TempDir,
327    config: &Config,
328) -> Result<(), DistProjectBinError> {
329    let mut build = cc::Build::new();
330    let host = target_lexicon::Triple::host().to_string();
331
332    let intermediate_dir = fs::tempfile::tempdir()?;
333    build
334        .cargo_output(false)
335        .cargo_metadata(false)
336        .cargo_warnings(false)
337        .warnings(config.verbose())
338        .host(&host)
339        .target(&host)
340        .opt_level(config.build_profile().opt_level())
341        .out_dir(&intermediate_dir);
342
343    let compiler = build.try_get_compiler()?;
344
345    let is_msvc = compiler.is_like_msvc();
346    // Suppress all warnings
347    if is_msvc {
348        build.flag("-W0");
349    } else {
350        build.flag("-w");
351    }
352
353    let mut cmd: tokio::process::Command = compiler.to_command().into();
354    cmd.current_dir(work_dir.path());
355    cmd.arg(c_path);
356
357    for include in lua.includes() {
358        cmd.arg(format!("-I{}", include.display()));
359    }
360
361    // Native modules have to be linked dynamically.
362    // We copy them next to the output binary and link them by basename,
363    // so the binary can find them via its rpath at runtime.
364    let output_dir = output.parent().unwrap_or_else(|| Path::new("."));
365    if !native_modules.is_empty() {
366        fs::tokio::create_dir_all(output_dir).await?;
367        cmd.arg(format!("-L{}", output_dir.display()));
368        for module in native_modules {
369            let file_name = module
370                .file_name()
371                .ok_or_else(|| {
372                    io::Error::new(
373                        io::ErrorKind::InvalidInput,
374                        format!("native module path '{}' has no file name", module.display()),
375                    )
376                })?
377                .to_string_lossy();
378            fs::tokio::copy(module, output_dir.join(&*file_name)).await?;
379            cmd.arg(format!("-l:{file_name}"));
380        }
381    }
382    cmd.arg("-o").arg(output);
383    cmd.args(lua.lib_link_args(&compiler));
384
385    #[cfg(not(target_env = "msvc"))]
386    {
387        cmd.arg("-rdynamic");
388        cmd.arg("-lm");
389        // Link with libdl because liblua was built with support loading
390        // shared objects and the operating system depends on it.
391        #[cfg(target_family = "unix")]
392        cmd.arg("-ldl");
393    }
394
395    #[cfg(target_family = "unix")]
396    if !native_modules.is_empty() {
397        cmd.arg(if cfg!(target_os = "macos") {
398            "-Wl,-rpath,@loader_path"
399        } else {
400            "-Wl,-rpath,$ORIGIN"
401        });
402    }
403
404    let out = cmd
405        .stdout(Stdio::piped())
406        .stderr(Stdio::piped())
407        .output()
408        .await?;
409
410    if !out.status.success() {
411        return Err(DistProjectBinError::CompilationFailed {
412            status: out.status,
413            stdout: String::from_utf8_lossy(&out.stdout).into(),
414            stderr: String::from_utf8_lossy(&out.stderr).into(),
415        });
416    }
417
418    Ok(())
419}
420
421const C_PREAMBLE: &str = r#"
422#ifdef __cplusplus
423extern "C" {
424#endif
425#include <lauxlib.h>
426#include <lua.h>
427#include <lualib.h>
428#ifdef __cplusplus
429}
430#endif
431#include <signal.h>
432#include <stdio.h>
433#include <stdlib.h>
434#include <string.h>
435
436#if LUA_VERSION_NUM == 501
437  #define LUA_OK 0
438#endif
439
440static lua_State *globalL = NULL;
441
442static void lstop(lua_State *L, lua_Debug *ar) {
443  (void)ar;
444  lua_sethook(L, NULL, 0, 0);
445  luaL_error(L, "interrupted!");
446}
447
448static void laction(int i) {
449  signal(i, SIG_DFL);
450  lua_sethook(globalL, lstop, LUA_MASKCALL | LUA_MASKRET | LUA_MASKCOUNT, 1);
451}
452
453static void createargtable(lua_State *L, char **argv, int argc, int script) {
454  int i, narg;
455  if (script == argc) script = 0;
456  narg = argc - (script + 1);
457  lua_createtable(L, narg, script + 1);
458  for (i = 0; i < argc; i++) {
459    lua_pushstring(L, argv[i]);
460    lua_rawseti(L, -2, i - script);
461  }
462  lua_setglobal(L, "arg");
463}
464
465static int msghandler(lua_State *L) {
466  const char *msg = lua_tostring(L, 1);
467  if (msg == NULL) {
468    if (luaL_callmeta(L, 1, "__tostring") && lua_type(L, -1) == LUA_TSTRING)
469      return 1;
470    msg = lua_pushfstring(L, "(error object is a %s value)", luaL_typename(L, 1));
471  }
472  lua_getglobal(L, "debug");
473  lua_getfield(L, -1, "traceback");
474  lua_remove(L, -2);
475  lua_pushstring(L, msg);
476  lua_remove(L, -3);
477  lua_pushinteger(L, 2);
478  lua_call(L, 2, 1);
479  return 1;
480}
481
482static int docall(lua_State *L, int narg, int nres) {
483  int status;
484  int base = lua_gettop(L) - narg;
485  lua_pushcfunction(L, msghandler);
486  lua_insert(L, base);
487  globalL = L;
488  signal(SIGINT, laction);
489  status = lua_pcall(L, narg, nres, base);
490  signal(SIGINT, SIG_DFL);
491  lua_remove(L, base);
492  return status;
493}
494
495"#;
496
497const LUA_LOADER_SOURCE: &str = r#"local args = {...}
498local lua_bundle = args[1]
499
500local function load_string(str, name)
501	if _VERSION == "Lua 5.1" then
502		return loadstring(str, name)
503	else
504		return load(str, name)
505	end
506end
507
508local function lua_loader(name)
509	local separator = package.config:sub(1, 1)
510	name = name:gsub(separator, ".")
511	local mod = lua_bundle[name] or lua_bundle[name .. ".init"]
512	if mod then
513		if type(mod) == "string" then
514			local chunk, errstr = load_string(mod, name)
515			if chunk then
516				return chunk
517			else
518				error(
519					("error loading module '%s' from static Lua bundle:\n\t%s"):format(name, errstr),
520					0
521				)
522			end
523		elseif type(mod) == "function" then
524			return mod
525		end
526	else
527		return ("\n\tno module '%s' in static Lua bundle"):format(name)
528	end
529end
530table.insert(package.loaders or package.searchers, 2, lua_loader)
531
532local unpack = unpack or table.unpack
533"#;
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538
539    use assert_fs::fixture::PathCopy;
540    #[cfg(target_os = "linux")]
541    use assert_fs::prelude::{PathChild, PathCreateDir};
542    use assert_fs::TempDir;
543
544    use crate::lua_installation::detect_installed_lua_version;
545    use crate::{config::ConfigBuilder, lua_version::LuaVersion, tree::FlatDistTree};
546    #[cfg(target_os = "linux")]
547    use crate::{
548        fs,
549        lockfile::{LocalPackage, LocalPackageHashes, LockConstraint},
550        package::PackageSpec,
551        remote_package_source::RemotePackageSource,
552        rockspec::RockBinaries,
553    };
554
555    #[cfg(target_os = "linux")]
556    fn mk_dummy_package(spec: PackageSpec) -> LocalPackage {
557        let hashes = LocalPackageHashes {
558            rockspec: "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
559                .parse()
560                .unwrap(),
561            source: "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
562                .parse()
563                .unwrap(),
564        };
565        LocalPackage::from(
566            &spec,
567            LockConstraint::Unconstrained,
568            RockBinaries::default(),
569            RemotePackageSource::Test,
570            None,
571            hashes,
572        )
573    }
574
575    #[cfg(target_os = "linux")]
576    #[tokio::test]
577    async fn test_collect_installed_files() {
578        let staging = TempDir::new().unwrap();
579        let config = ConfigBuilder::new()
580            .unwrap()
581            .lua_version(Some(LuaVersion::Lua51))
582            .build()
583            .unwrap();
584        let tree = FlatDistTree::new(staging.to_path_buf(), LuaVersion::Lua51, &config).unwrap();
585
586        let pkg_a = mk_dummy_package(PackageSpec::new("foo".into(), "1.0.0-1".parse().unwrap()));
587        let layout_a = tree.entrypoint(&pkg_a).unwrap();
588        staging
589            .child(layout_a.src.strip_prefix(staging.path()).unwrap())
590            .create_dir_all()
591            .unwrap();
592        fs::tokio::write(layout_a.src.join("foo.lua"), "return {}")
593            .await
594            .unwrap();
595
596        let pkg_b = mk_dummy_package(PackageSpec::new("bar".into(), "2.0.0-1".parse().unwrap()));
597        let layout_b = tree.entrypoint(&pkg_b).unwrap();
598        staging
599            .child(layout_b.src.strip_prefix(staging.path()).unwrap())
600            .create_dir_all()
601            .unwrap();
602        fs::tokio::write(layout_b.src.join("bar.lua"), "return {}")
603            .await
604            .unwrap();
605        staging
606            .child(layout_b.lib.strip_prefix(staging.path()).unwrap())
607            .create_dir_all()
608            .unwrap();
609        fs::tokio::write(
610            layout_b.lib.join(format!("bar.{}", c_dylib_extension())),
611            "",
612        )
613        .await
614        .unwrap();
615
616        {
617            let lockfile = tree.lockfile().unwrap();
618            let mut lockfile = lockfile.write_guard();
619            lockfile.add_entrypoint(&pkg_a);
620            lockfile.add_entrypoint(&pkg_b);
621        }
622
623        let tree = FlatDistTree::new(staging.to_path_buf(), LuaVersion::Lua51, &config).unwrap();
624        let files = collect_installed_files(&tree).unwrap();
625
626        assert_eq!(files.src.len(), 2);
627        assert!(files
628            .src
629            .iter()
630            .all(|(_, p)| p.extension().is_some_and(|e| e == "lua")));
631        assert_eq!(files.lib.len(), 1);
632        assert!(files.lib[0]
633            .extension()
634            .is_some_and(|e| e == c_dylib_extension()));
635    }
636
637    #[tokio::test]
638    async fn test_collect_installed_files_from_sample_project() {
639        let sample = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
640            .join("resources/test/sample-projects/only-src/");
641        let project_dir = TempDir::new().unwrap();
642        project_dir.copy_from(&sample, &["**"]).unwrap();
643
644        let project = Project::from_exact(project_dir.path()).unwrap().unwrap();
645        let lua_version = detect_installed_lua_version().or(Some(LuaVersion::Lua51));
646        let config = ConfigBuilder::new()
647            .unwrap()
648            .lua_version(lua_version)
649            .build()
650            .unwrap();
651
652        let staging = TempDir::new().unwrap();
653        let tree = FlatDistTree::new(
654            staging.to_path_buf(),
655            config.lua_version().cloned().unwrap(),
656            &config,
657        )
658        .unwrap();
659
660        InstallProject::new()
661            .project(&project)
662            .config(&config)
663            .tree(&tree)
664            .build()
665            .await
666            .unwrap();
667
668        let files = collect_installed_files(&tree).unwrap();
669
670        let module_keys: Vec<&str> = files.src.iter().map(|(m, _)| m.as_str()).collect();
671
672        assert!(
673            module_keys.contains(&"main"),
674            "expected 'main' in {module_keys:?}"
675        );
676        assert!(
677            module_keys.contains(&"foo"),
678            "expected 'foo' in {module_keys:?}"
679        );
680    }
681
682    #[tokio::test]
683    async fn test_dist_bin_from_lua_source_compiles_and_runs() {
684        test_dist_bin_compiles_and_runs("resources/test/sample-projects/only-src/", "1").await
685    }
686
687    #[tokio::test]
688    #[cfg(target_os = "linux")]
689    async fn test_dist_bin_from_c_source_compiles_and_runs() {
690        test_dist_bin_compiles_and_runs("resources/test/sample-projects/c-src/", "OK").await
691    }
692
693    async fn test_dist_bin_compiles_and_runs(sample_project_path: &str, expected_output: &str) {
694        let sample = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(sample_project_path);
695        let project_dir = TempDir::new().unwrap();
696        project_dir.copy_from(&sample, &["**"]).unwrap();
697
698        let project = Project::from_exact(project_dir.path()).unwrap().unwrap();
699        let lua_version = detect_installed_lua_version().or(Some(LuaVersion::Lua51));
700        let config = ConfigBuilder::new()
701            .unwrap()
702            .lua_version(lua_version)
703            .build()
704            .unwrap();
705
706        let staging = TempDir::new().unwrap();
707        let tree = FlatDistTree::new(
708            staging.to_path_buf(),
709            config.lua_version().cloned().unwrap(),
710            &config,
711        )
712        .unwrap();
713
714        let out_dir = TempDir::new().unwrap();
715        let binary = out_dir.path().join(if cfg!(target_env = "msvc") {
716            "sample-project.exe"
717        } else {
718            "sample-project"
719        });
720
721        DistProjectBin::new()
722            .project(&project)
723            .config(&config)
724            .tree(&tree)
725            .output(binary.clone())
726            .compile()
727            .await
728            .unwrap();
729
730        assert!(binary.is_file(), "binary not produced");
731
732        drop(staging);
733
734        let out = tokio::process::Command::new(&binary)
735            .output()
736            .await
737            .unwrap();
738
739        assert!(out.status.success(), "binary exited non-zero:\n{:?}", out);
740        assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), expected_output);
741    }
742}