Skip to main content

lux_lib/build/
mod.rs

1use crate::build::backend::{BuildBackend, BuildInfo, RunBuildArgs};
2use crate::fs;
3use crate::lockfile::{LockfileError, RemotePackageSourceUrl};
4use crate::lua_installation::LuaInstallationError;
5use crate::lua_rockspec::LuaVersionError;
6use crate::lua_version::LuaVersion;
7use crate::operations::UnpackError;
8use crate::rockspec::Rockspec;
9use crate::tree::{EntryType, InstallTree, TreeError};
10use bon::Builder;
11use bytes::Bytes;
12use std::collections::HashMap;
13use std::fs::DirEntry;
14use std::path::PathBuf;
15use std::{io, path::Path};
16use tracing::Instrument;
17
18use crate::{
19    config::Config,
20    hash::HasIntegrity,
21    lockfile::{LockedPackage, LockedPackageSpec},
22    lua_installation::LuaInstallation,
23    lua_rockspec::BuildBackendSpec,
24    operations::FetchSrcError,
25};
26use builtin::BuiltinBuildError;
27use cmake::CMakeError;
28use command::CommandError;
29use external_dependency::{ExternalDependencyError, ExternalDependencyInfo};
30
31use itertools::Itertools;
32use luarocks::LuarocksBuildError;
33use make::MakeError;
34
35use miette::Diagnostic;
36use patch::{Patch, PatchError};
37use rust_binary::RustBinaryError;
38use rust_mlua::RustError;
39use source::SourceBuildError;
40use ssri::Integrity;
41use thiserror::Error;
42use treesitter_parser::TreesitterBuildError;
43use utils::{recursive_copy_dir, CompileCFilesError, InstallBinaryError};
44
45mod builtin;
46mod cmake;
47mod command;
48mod luarocks;
49#[cfg(feature = "luau-transpile")]
50mod luau;
51mod make;
52mod patch;
53mod rust_binary;
54mod rust_mlua;
55mod source;
56mod treesitter_parser;
57
58pub(crate) mod backend;
59pub(crate) mod utils;
60pub(crate) use builtin::autodetect_modules;
61
62pub mod external_dependency;
63
64#[derive(Debug)]
65pub(crate) enum RemotePackageSourceSpec {
66    RockSpec(Option<RemotePackageSourceUrl>),
67    SrcRock(SrcRockSource),
68}
69
70/// A packed .src.rock archive.
71#[derive(Debug)]
72pub(crate) struct SrcRockSource {
73    pub bytes: Bytes,
74    pub source_url: RemotePackageSourceUrl,
75}
76
77#[derive(Error, Debug, Diagnostic)]
78#[non_exhaustive]
79pub enum BuildError {
80    #[error("builtin build failed")]
81    #[diagnostic(forward(0))]
82    Builtin(#[from] BuiltinBuildError),
83    #[error("cmake build failed")]
84    #[diagnostic(forward(0))]
85    CMake(#[from] CMakeError),
86    #[error("make build failed")]
87    #[diagnostic(forward(0))]
88    Make(#[from] MakeError),
89    #[error("command build failed")]
90    #[diagnostic(forward(0))]
91    Command(#[from] CommandError),
92    #[error("rust-mlua build failed")]
93    #[diagnostic(forward(0))]
94    Rust(#[from] RustError),
95    #[error("rust-binary build failed")]
96    #[diagnostic(forward(0))]
97    RustBinary(#[from] RustBinaryError),
98    #[error("treesitter-parser build failed")]
99    #[diagnostic(forward(0))]
100    TreesitterBuild(#[from] TreesitterBuildError),
101    #[error("luarocks build failed")]
102    #[diagnostic(forward(0))]
103    LuarocksBuild(#[from] LuarocksBuildError),
104    #[error("building from rock source failed")]
105    #[diagnostic(forward(0))]
106    SourceBuild(#[from] Box<SourceBuildError>),
107    #[error("unsupported build backend for luau")]
108    #[diagnostic(help("lux can build luau packages with the following build backends: 'builtin', 'source' & 'none'"))]
109    LuauUnsupportedBuildBackend,
110    #[error("luau does not support native dependencies")]
111    #[diagnostic(help("the luau runtime cannot load C libraries. use pure-Luau packages or Lua"))]
112    LuauNativeDepsUnsupported,
113    #[error("IO operation failed")]
114    Io(#[from] io::Error),
115    #[error(transparent)]
116    #[diagnostic(transparent)]
117    Fs(#[from] fs::FsError),
118    #[error(transparent)]
119    #[diagnostic(transparent)]
120    Lockfile(#[from] LockfileError),
121    #[error(transparent)]
122    #[diagnostic(transparent)]
123    Tree(#[from] TreeError),
124
125    #[error(transparent)]
126    #[diagnostic(transparent)]
127    ExternalDependencyError(#[from] ExternalDependencyError),
128    #[error(transparent)]
129    #[diagnostic(transparent)]
130    PatchError(#[from] PatchError),
131    #[error(transparent)]
132    #[diagnostic(transparent)]
133    CompileCFiles(#[from] CompileCFilesError),
134    #[error(transparent)]
135    #[diagnostic(transparent)]
136    LuaVersion(#[from] LuaVersionError),
137    #[error(
138        r#"source integrity mismatch.
139- source: {src}
140- expected: {expected}
141- got: {actual}"#
142    )]
143    #[diagnostic(help(
144        r#"the source may have been modified or a tag may have been moved.
145check the source, then rerun the command with `--no-lock` to update the hash."#
146    ))]
147    SourceIntegrityMismatch {
148        src: String,
149        expected: Integrity,
150        actual: Integrity,
151    },
152    #[error("failed to unpack src.rock")]
153    #[diagnostic(forward(0))]
154    UnpackSrcRock(#[source] UnpackError),
155    #[error("failed to fetch rock source")]
156    #[diagnostic(forward(0))]
157    FetchSrcError(#[from] FetchSrcError),
158    #[error("failed to install binary '{file_name}'")]
159    InstallBinary {
160        file_name: String,
161        #[diagnostic_source]
162        source: InstallBinaryError,
163    },
164    #[error(transparent)]
165    #[diagnostic(transparent)]
166    LuaInstallation(#[from] LuaInstallationError),
167}
168
169impl From<SourceBuildError> for BuildError {
170    fn from(source: SourceBuildError) -> Self {
171        Self::SourceBuild(Box::new(source))
172    }
173}
174
175#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
176pub enum BuildBehaviour {
177    /// If a package with the same version is already installed, do nothing.
178    /// Otherwise, behave like [`BuildBehaviour::Conflict`].
179    Ignore,
180    /// Error if an entrypoint with the same name is already installed, even if
181    /// it is the same version.
182    Conflict,
183    /// Remove any installed entrypoint with the same name and rebuild.
184    Force,
185}
186
187#[tracing::instrument(level = "trace", skip_all)]
188async fn run_build<R: Rockspec + HasIntegrity, T: InstallTree + Sync>(
189    rockspec: &R,
190    args: RunBuildArgs<'_, T>,
191) -> Result<BuildInfo, BuildError> {
192    if args.tree.version() == &LuaVersion::Luau
193        && rockspec
194            .build()
195            .current_platform()
196            .build_backend
197            .as_ref()
198            .is_some_and(|backend| {
199                !matches!(
200                    backend,
201                    BuildBackendSpec::Builtin(_) | BuildBackendSpec::Source
202                )
203            })
204    {
205        Err(BuildError::LuauUnsupportedBuildBackend)
206    } else {
207        Ok(
208            match rockspec.build().current_platform().build_backend.to_owned() {
209                Some(BuildBackendSpec::Builtin(build_spec)) => build_spec.run(args).await?,
210                Some(BuildBackendSpec::Make(make_spec)) => make_spec.run(args).await?,
211                Some(BuildBackendSpec::CMake(cmake_spec)) => cmake_spec.run(args).await?,
212                Some(BuildBackendSpec::Command(command_spec)) => command_spec.run(args).await?,
213                Some(BuildBackendSpec::RustMlua(rust_mlua_spec)) => {
214                    rust_mlua_spec.run(args).await?
215                }
216                Some(BuildBackendSpec::RustBinary(rust_binary_spec)) => {
217                    rust_binary_spec.run(args).await?
218                }
219                Some(BuildBackendSpec::TreesitterParser(treesitter_parser_spec)) => {
220                    treesitter_parser_spec.run(args).await?
221                }
222                Some(BuildBackendSpec::LuaRock(build_backend_name)) => {
223                    luarocks::build(&build_backend_name, rockspec, args).await?
224                }
225                Some(BuildBackendSpec::Source) => source::build(args).await?,
226                None => BuildInfo::default(),
227            },
228        )
229    }
230}
231
232#[allow(clippy::too_many_arguments)]
233#[tracing::instrument(level = "trace", skip(rockspec, tree, config))]
234async fn install<R: Rockspec + HasIntegrity, T: InstallTree>(
235    rockspec: &R,
236    tree: &T,
237    package: &LockedPackageSpec,
238    lua: &LuaInstallation,
239    build_dir: &Path,
240    entry_type: &EntryType,
241    config: &Config,
242) -> Result<(), BuildError> {
243    let install_spec = &rockspec.build().current_platform().install;
244    let layout = tree.layout_for(package);
245    {
246        let span = tracing::info_span!("Copying Lua modules");
247        let _enter = span.enter();
248        for (target, source) in &install_spec.lua {
249            let _enter = span.enter();
250            let absolute_source = build_dir.join(source);
251            utils::copy_lua_to_module_path(&absolute_source, target, &layout.src)?;
252        }
253    }
254    {
255        let span = tracing::info_span!("Compiling C libraries");
256        let _enter = span.enter();
257        for (target, source) in &install_spec.lib {
258            let absolute_source = build_dir.join(source);
259            let resolved_target = layout.lib.join(target);
260            fs::tokio::copy(&absolute_source, &resolved_target)
261                .instrument(tracing::trace_span!("copying target"))
262                .await?;
263            utils::make_writable(&resolved_target).await?;
264        }
265    }
266    if entry_type.is_entrypoint() {
267        let span = tracing::info_span!("Installing binaries");
268        let _enter = span.enter();
269        let deploy_spec = rockspec.deploy().current_platform();
270        for (target, source) in &install_spec.bin {
271            utils::install_binary(
272                &build_dir.join(source),
273                target,
274                tree,
275                lua,
276                deploy_spec,
277                config,
278            )
279            .instrument(tracing::trace_span!("installing binary"))
280            .await
281            .map_err(|err| BuildError::InstallBinary {
282                file_name: target.clone(),
283                source: err,
284            })?;
285        }
286    }
287    if !install_spec.conf.is_empty() {
288        let span = tracing::info_span!("Copying configuration files");
289        let _enter = span.enter();
290        for (target, source) in &install_spec.conf {
291            let absolute_source = build_dir.join(source);
292            let target = layout.conf.join(target);
293            if let Some(parent_dir) = target.parent() {
294                fs::tokio::create_dir_all(parent_dir)
295                    .instrument(tracing::trace_span!("creating configuration directory"))
296                    .await?;
297            }
298            fs::tokio::copy(&absolute_source, &target)
299                .instrument(tracing::trace_span!("copying configuration file"))
300                .await?;
301            utils::make_writable(&target).await?;
302        }
303    }
304    Ok(())
305}
306
307// TODO(vhyrro): break apart deployment into a separate step (once we implement transactions)
308#[derive(Builder)]
309#[builder(start_fn = new, finish_fn(name = _build, vis = ""))]
310pub(crate) struct Deploy<'a, R: Rockspec + HasIntegrity, T: InstallTree + Sync> {
311    #[builder(start_fn)]
312    rockspec: &'a R,
313    #[builder(start_fn)]
314    tree: &'a T,
315    package: LockedPackage,
316    lua: &'a LuaInstallation,
317    source_root: &'a Path,
318    entry_type: EntryType,
319    config: &'a Config,
320    behaviour: BuildBehaviour,
321}
322
323impl<R, T, State> DeployBuilder<'_, R, T, State>
324where
325    R: Rockspec + HasIntegrity,
326    T: InstallTree + Sync,
327    State: deploy_builder::State + deploy_builder::IsComplete,
328{
329    pub(crate) async fn deploy(self) -> Result<LockedPackage, BuildError> {
330        do_deploy(self._build()).await
331    }
332}
333
334#[tracing::instrument(level = "trace", skip_all, fields(package = deploy.rockspec.package().to_string()))]
335async fn do_deploy<R, T>(deploy: Deploy<'_, R, T>) -> Result<LockedPackage, BuildError>
336where
337    R: Rockspec + HasIntegrity,
338    T: InstallTree + Sync,
339{
340    let Deploy {
341        rockspec,
342        tree,
343        package,
344        lua,
345        source_root,
346        entry_type,
347        config,
348        behaviour,
349    } = deploy;
350
351    if matches!(behaviour, BuildBehaviour::Ignore | BuildBehaviour::Conflict) {
352        if let Some(existing) = tree.lockfile()?.get(&package.id()) {
353            return Ok(existing.clone());
354        }
355    }
356
357    // TODO(vhyrro): When we implement transactionality make this a Drop guard
358    // or something else.
359    tree.prepare(&package.spec)?;
360    let layout = tree.layout_for(&package.spec);
361
362    let rock_source = rockspec.source().current_platform();
363    let build_dir = resolve_source_dir(
364        source_root,
365        rock_source.unpack_dir.as_deref(),
366        &rockspec.build().current_platform().copy_directories,
367    )?;
368
369    Patch::new(&build_dir, &rockspec.build().current_platform().patches).apply()?;
370
371    let external_dependencies = rockspec
372        .external_dependencies()
373        .current_platform()
374        .iter()
375        .map(|(name, dep)| {
376            ExternalDependencyInfo::probe(name, dep, config.external_deps())
377                .map(|info| (name.clone(), info))
378        })
379        .try_collect::<_, HashMap<_, _>, _>()?;
380
381    let output = run_build(
382        rockspec,
383        RunBuildArgs::new()
384            .package(&package.spec)
385            .no_install(false)
386            .lua(lua)
387            .external_dependencies(&external_dependencies)
388            .deploy(rockspec.deploy().current_platform())
389            .config(config)
390            .tree(tree)
391            .build_dir(&build_dir)
392            .build(),
393    )
394    .await?;
395
396    let mut binaries = rockspec.binaries();
397    binaries.extend(output.binaries);
398    tree.lockfile()?
399        .write_guard()
400        .set_binaries(&package, binaries);
401
402    install(
403        rockspec,
404        tree,
405        &package.spec,
406        lua,
407        &build_dir,
408        &entry_type,
409        config,
410    )
411    .await?;
412
413    for directory in rockspec
414        .build()
415        .current_platform()
416        .copy_directories
417        .iter()
418        .filter(|dir| {
419            dir.file_name()
420                .is_some_and(|name| name != "doc" && name != "docs")
421        })
422    {
423        recursive_copy_dir(&build_dir.join(directory), &layout.etc.join(directory)).await?;
424    }
425
426    recursive_copy_doc_dir(&layout.doc, &build_dir).await?;
427
428    if let Ok(rockspec_str) = rockspec.to_lua_remote_rockspec_string() {
429        fs::sync::write(layout.rockspec_path(), rockspec_str)?;
430    }
431
432    tree.finalize(&package, entry_type)?;
433
434    Ok(package)
435}
436
437fn is_source_or_etc_dir(dir: &DirEntry, copy_dirs: &[PathBuf]) -> bool {
438    let dir_name = dir.file_name().to_string_lossy().to_string();
439    matches!(dir_name.as_str(), "lua" | "src")
440        || copy_dirs
441            .iter()
442            .any(|copy_dir_name| copy_dir_name == &PathBuf::from(&dir_name))
443}
444
445/// Resolve the directory containing a rock's sources within an extracted source archive.
446/// When the rockspec has no `source.dir`, fall back to the archive's single top-level directory.
447pub(crate) fn resolve_source_dir(
448    source_root: &Path,
449    unpack_dir: Option<&Path>,
450    copy_dirs: &[PathBuf],
451) -> Result<PathBuf, fs::FsError> {
452    if let Some(unpack_dir) = unpack_dir {
453        return Ok(source_root.join(unpack_dir));
454    }
455    // Some older/off-spec rockspecs don't specify a `source.dir`.
456    // After unpacking the archive, if
457    //
458    //   - there exist no Lua or C sources
459    //   - there exists a single subdirectory that is not a source
460    //     or etc directory
461    //
462    // we assume it's the `source.dir`.
463    // Unlike the LuaRocks implementation - which filters when fetching sources -
464    // we only infer `source.dir` if the directory name is not 'src', 'lua'
465    // or one of the `build.copy_directories`.
466    // This allows us to build local projects with only a `src` directory.
467    //
468    // LuaRocks implementation:
469    // https://github.com/luarocks/luarocks/blob/4188fdb235aca66530d274c782374cf6afba09b8/src/luarocks/fetch.tl?plain=1#L526
470    let has_lua_or_c_sources = fs::sync::read_dir(source_root)?
471        .filter_map(Result::ok)
472        .filter(|entry| entry.path().is_file())
473        .any(|entry| {
474            entry.path().extension().is_some_and(|ext| {
475                matches!(
476                    ext.to_string_lossy().to_string().as_str(),
477                    "lua" | "luau" | "c"
478                )
479            })
480        });
481    if has_lua_or_c_sources {
482        return Ok(source_root.to_path_buf());
483    }
484    let dir_entries = fs::sync::read_dir(source_root)?
485        .filter_map(Result::ok)
486        .filter(|entry| entry.path().is_dir())
487        .collect_vec();
488    if dir_entries.len() == 1 && !is_source_or_etc_dir(&dir_entries[0], copy_dirs) {
489        Ok(dir_entries[0].path())
490    } else {
491        Ok(source_root.to_path_buf())
492    }
493}
494
495#[tracing::instrument(level = "trace")]
496async fn recursive_copy_doc_dir(target_doc_dir: &Path, build_dir: &Path) -> Result<(), BuildError> {
497    let mut doc_dir = build_dir.join("doc");
498    if !doc_dir.exists() {
499        doc_dir = build_dir.join("docs");
500    }
501    recursive_copy_dir(&doc_dir, target_doc_dir).await?;
502    Ok(())
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508    use predicates::prelude::*;
509    use std::path::PathBuf;
510
511    use assert_fs::{
512        assert::PathAssert,
513        prelude::{PathChild, PathCopy},
514    };
515
516    use is_executable::IsExecutable;
517
518    use crate::{
519        config::ConfigBuilder,
520        lockfile::{LockConstraint, LockedPackageHashes, LockedPackageSpec, OptState, PinnedState},
521        lua_installation::{detect_installed_lua_version, LuaInstallation},
522        lua_rockspec::RemoteLuaRockspec,
523        lua_version::LuaVersion,
524        package::PackageSpec,
525        project::Project,
526        remote_package_source::RemotePackageSource,
527    };
528    #[cfg(feature = "impure_tests")]
529    use crate::{
530        operations::{unpack_rockspec, DownloadedPackedRockBytes},
531        pipeline::build_local::Build,
532        tree::Tree,
533    };
534
535    #[tokio::test]
536    async fn test_builtin_build() {
537        let lua_version = detect_installed_lua_version().or(Some(LuaVersion::Lua51));
538        let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
539            .join("resources/test/sample-projects/no-build-spec/");
540        let tree_dir = assert_fs::TempDir::new().unwrap();
541        let config = ConfigBuilder::new()
542            .unwrap()
543            .lua_version(lua_version)
544            .user_tree(Some(tree_dir.to_path_buf()))
545            .build()
546            .unwrap();
547        let build_dir = assert_fs::TempDir::new().unwrap();
548        build_dir.copy_from(&project_root, &["**"]).unwrap();
549        let tree = config
550            .user_tree(config.lua_version().cloned().unwrap())
551            .unwrap();
552        let lua_version = config.lua_version().unwrap_or(&LuaVersion::Lua51);
553        let lua = LuaInstallation::new(lua_version, &config).await.unwrap();
554        let project = Project::from_exact(&project_root).unwrap().unwrap();
555        let rockspec = project.toml().into_remote(None).unwrap();
556        let package = LockedPackage::from(
557            &PackageSpec::new(rockspec.package().clone(), rockspec.version().clone()),
558            LockConstraint::Unconstrained,
559            RemotePackageSource::Test,
560            None,
561            LockedPackageHashes {
562                rockspec: "sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="
563                    .parse()
564                    .unwrap(),
565                source: "sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="
566                    .parse()
567                    .unwrap(),
568            },
569        );
570        tree.prepare(&package.spec).unwrap();
571        let src_dir = tree.layout_for(&package.spec).src;
572        run_build(
573            &rockspec,
574            RunBuildArgs::new()
575                .package(&package.spec)
576                .no_install(false)
577                .lua(&lua)
578                .external_dependencies(&HashMap::default())
579                .deploy(rockspec.deploy().current_platform())
580                .config(&config)
581                .tree(&tree)
582                .build_dir(&build_dir)
583                .build(),
584        )
585        .await
586        .unwrap();
587        let foo_dir = src_dir.join("foo");
588        assert!(foo_dir.is_dir());
589        let foo_init = foo_dir.join("init.lua");
590        assert!(foo_init.is_file());
591        assert!(std::fs::read_to_string(&foo_init)
592            .unwrap()
593            .contains("return true"));
594        let foo_bar_dir = foo_dir.join("bar");
595        assert!(foo_bar_dir.is_dir());
596        let foo_bar_init = foo_bar_dir.join("init.lua");
597        assert!(foo_bar_init.is_file());
598        assert!(std::fs::read_to_string(&foo_bar_init)
599            .unwrap()
600            .contains("return true"));
601        let foo_bar_baz = foo_bar_dir.join("baz.lua");
602        assert!(foo_bar_baz.is_file());
603        assert!(std::fs::read_to_string(&foo_bar_baz)
604            .unwrap()
605            .contains("return true"));
606        let bin_file = tree_dir
607            .child(lua_version.to_string())
608            .child("bin")
609            .child("hello");
610        bin_file.assert(predicate::path::is_file());
611        bin_file.assert(predicate::str::contains("#!/usr/bin/env bash"));
612        bin_file.assert(predicate::str::contains("echo \"Hello\""));
613    }
614
615    #[cfg(unix)]
616    #[tokio::test]
617    async fn test_install_bin_sets_executable_permissions() {
618        let rockspec = RemoteLuaRockspec::new(
619            r#"
620rockspec_format = "3.0"
621package = "test-builtin-bin"
622version = "1.0-1"
623source = { url = "https://github.com/lumen-oss/luarocks-stub" }
624build = {
625    type = "builtin",
626    install = { bin = { mytool = "mytool" } },
627}
628deploy = { wrap_bin_scripts = false }
629"#,
630        )
631        .unwrap();
632        let lua_version = detect_installed_lua_version().or(Some(LuaVersion::Lua51));
633        let tree_dir = assert_fs::TempDir::new().unwrap();
634        let build_dir = assert_fs::TempDir::new().unwrap();
635        let config = ConfigBuilder::new()
636            .unwrap()
637            .lua_version(lua_version)
638            .user_tree(Some(tree_dir.to_path_buf()))
639            .build()
640            .unwrap();
641        let lua = LuaInstallation::new_from_config(&config).await.unwrap();
642        let tree = config
643            .user_tree(config.lua_version().cloned().unwrap())
644            .unwrap();
645        let package = LockedPackageSpec::new(
646            rockspec.package(),
647            rockspec.version(),
648            LockConstraint::Unconstrained,
649            Vec::new(),
650            Vec::new(),
651            &PinnedState::default(),
652            &OptState::default(),
653        );
654        tree.prepare(&package).unwrap();
655        crate::fs::tokio::write(build_dir.path().join("mytool"), "#!/bin/sh\necho hi\n")
656            .await
657            .unwrap();
658        install(
659            &rockspec,
660            &tree,
661            &package,
662            &lua,
663            build_dir.path(),
664            &EntryType::Entrypoint,
665            &config,
666        )
667        .await
668        .unwrap();
669        assert!(tree.bin().join("mytool").is_executable());
670    }
671
672    #[cfg(feature = "impure_tests")]
673    const LUATEST_SRC_ROCK: &str = "resources/test/luatest-0.2-1.src.rock";
674    #[cfg(feature = "impure_tests")]
675    const LUATEST_SRC_ROCK_SOURCE_HASH: &str =
676        "sha256-2jS0XOq0iIVhsZJ3BVqXSlKsx2vsqCAaaYyqcBEb7RI=";
677    #[cfg(feature = "impure_tests")]
678    const LUATEST_ROCKSPEC_HASH: &str = "sha256-NljJ20A+VadUyhhBjrRnojeQjSqRpQy7FWcgjUt2Fdc=";
679
680    #[cfg(feature = "impure_tests")]
681    fn luatest_src_rock_bytes() -> Bytes {
682        Bytes::copy_from_slice(
683            &std::fs::read(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(LUATEST_SRC_ROCK))
684                .unwrap(),
685        )
686    }
687
688    #[cfg(feature = "impure_tests")]
689    fn luatest_config(dir: &assert_fs::TempDir) -> Config {
690        ConfigBuilder::new()
691            .unwrap()
692            .user_tree(Some(dir.to_path_buf()))
693            .lua_version(Some(LuaVersion::Lua51))
694            .build()
695            .unwrap()
696    }
697
698    #[cfg(feature = "impure_tests")]
699    async fn build_luatest(
700        config: &Config,
701        tree: &Tree,
702        entry_type: EntryType,
703        behaviour: BuildBehaviour,
704    ) -> LockedPackage {
705        let bytes = luatest_src_rock_bytes();
706        let rock = DownloadedPackedRockBytes {
707            name: "luatest".into(),
708            version: "0.2-1".parse().unwrap(),
709            bytes: bytes.clone(),
710            file_name: "luatest-0.2-1.src.rock".into(),
711            url: "https://example.org/luatest-0.2-1.src.rock"
712                .parse()
713                .unwrap(),
714        };
715        let rockspec = unpack_rockspec(&rock).await.unwrap();
716        let lua = LuaInstallation::new_from_config(config).await.unwrap();
717        Build::new()
718            .rockspec(&rockspec)
719            .lua(&lua)
720            .tree(tree)
721            .entry_type(entry_type)
722            .config(config)
723            .behaviour(behaviour)
724            .source_spec(RemotePackageSourceSpec::SrcRock(SrcRockSource {
725                bytes,
726                source_url: RemotePackageSourceUrl::Url {
727                    url: "https://example.org/luatest-0.2-1.src.rock"
728                        .parse()
729                        .unwrap(),
730                },
731            }))
732            .build()
733            .await
734            .unwrap()
735    }
736
737    #[cfg(feature = "impure_tests")]
738    #[tokio::test]
739    async fn luatest_src_rock_produces_expected_hashes() {
740        let dir = assert_fs::TempDir::new().unwrap();
741        let config = luatest_config(&dir);
742        let tree = config.user_tree(LuaVersion::Lua51).unwrap();
743
744        let package =
745            build_luatest(&config, &tree, EntryType::Entrypoint, BuildBehaviour::Force).await;
746
747        assert_eq!(
748            package.hashes().source,
749            LUATEST_SRC_ROCK_SOURCE_HASH.parse().unwrap()
750        );
751        assert_eq!(
752            package.hashes().rockspec,
753            LUATEST_ROCKSPEC_HASH.parse().unwrap()
754        );
755        assert_eq!(
756            package.hashes().source,
757            luatest_src_rock_bytes().hash().await.unwrap()
758        );
759        assert!(tree.bin().join("luatest").is_file());
760    }
761
762    #[cfg(feature = "impure_tests")]
763    #[tokio::test]
764    async fn force_build_is_deterministic() {
765        let dir1 = assert_fs::TempDir::new().unwrap();
766        let config1 = luatest_config(&dir1);
767        let tree1 = config1.user_tree(LuaVersion::Lua51).unwrap();
768
769        let dir2 = assert_fs::TempDir::new().unwrap();
770        let config2 = luatest_config(&dir2);
771        let tree2 = config2.user_tree(LuaVersion::Lua51).unwrap();
772
773        let package1 = build_luatest(
774            &config1,
775            &tree1,
776            EntryType::Entrypoint,
777            BuildBehaviour::Force,
778        )
779        .await;
780        let package2 = build_luatest(
781            &config2,
782            &tree2,
783            EntryType::Entrypoint,
784            BuildBehaviour::Force,
785        )
786        .await;
787
788        assert_eq!(package1, package2);
789        assert_eq!(package1.hashes(), package2.hashes());
790    }
791
792    #[cfg(feature = "impure_tests")]
793    #[tokio::test]
794    async fn noforce_skips_rebuild_when_package_is_locked() {
795        let dir = assert_fs::TempDir::new().unwrap();
796        let config = luatest_config(&dir);
797        let tree = config.user_tree(LuaVersion::Lua51).unwrap();
798
799        let package =
800            build_luatest(&config, &tree, EntryType::Entrypoint, BuildBehaviour::Force).await;
801        let bin = tree.bin().join("luatest");
802        assert!(bin.is_file());
803
804        // Simulate a committed install
805        {
806            let mut lockfile = tree.lockfile().unwrap().write_guard();
807            lockfile.add_entrypoint(&package);
808        }
809
810        // Simulate a partially broken install.
811        std::fs::remove_file(&bin).unwrap();
812
813        let skipped = build_luatest(
814            &config,
815            &tree,
816            EntryType::Entrypoint,
817            BuildBehaviour::Ignore,
818        )
819        .await;
820        assert_eq!(skipped.id(), package.id());
821        assert!(!bin.is_file());
822
823        build_luatest(&config, &tree, EntryType::Entrypoint, BuildBehaviour::Force).await;
824        assert!(bin.is_file());
825    }
826}