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            // NOTE: Some rockspecs have redundant module definitions,
261            // assuming that the linker outputs them to the build dir root.
262            // See https://github.com/lumen-oss/lux/issues/2101
263            if !resolved_target.exists() {
264                if let Some(parent) = resolved_target.parent() {
265                    fs::tokio::create_dir_all(&parent).await?;
266                }
267                fs::tokio::copy(&absolute_source, &resolved_target)
268                    .instrument(tracing::trace_span!("copying target"))
269                    .await?;
270            }
271            utils::make_writable(&resolved_target).await?;
272        }
273    }
274    if entry_type.is_entrypoint() {
275        let span = tracing::info_span!("Installing binaries");
276        let _enter = span.enter();
277        let deploy_spec = rockspec.deploy().current_platform();
278        for (target, source) in &install_spec.bin {
279            utils::install_binary(
280                &build_dir.join(source),
281                target,
282                tree,
283                lua,
284                deploy_spec,
285                config,
286            )
287            .instrument(tracing::trace_span!("installing binary"))
288            .await
289            .map_err(|err| BuildError::InstallBinary {
290                file_name: target.clone(),
291                source: err,
292            })?;
293        }
294    }
295    if !install_spec.conf.is_empty() {
296        let span = tracing::info_span!("Copying configuration files");
297        let _enter = span.enter();
298        for (target, source) in &install_spec.conf {
299            let absolute_source = build_dir.join(source);
300            let target = {
301                let joined = layout.conf.join(target);
302                match joined.file_name() {
303                    Some(_) => joined,
304                    None => joined.join(absolute_source.file_name().unwrap_or_default()),
305                }
306            };
307            if let Some(parent_dir) = target.parent() {
308                fs::tokio::create_dir_all(parent_dir)
309                    .instrument(tracing::trace_span!("creating configuration directory"))
310                    .await?;
311            }
312            fs::tokio::copy(&absolute_source, &target)
313                .instrument(tracing::trace_span!("copying configuration file"))
314                .await?;
315            utils::make_writable(&target).await?;
316        }
317    }
318    Ok(())
319}
320
321// TODO(vhyrro): break apart deployment into a separate step (once we implement transactions)
322#[derive(Builder)]
323#[builder(start_fn = new, finish_fn(name = _build, vis = ""))]
324pub(crate) struct Deploy<'a, R: Rockspec + HasIntegrity, T: InstallTree + Sync> {
325    #[builder(start_fn)]
326    rockspec: &'a R,
327    #[builder(start_fn)]
328    tree: &'a T,
329    package: LockedPackage,
330    lua: &'a LuaInstallation,
331    source_root: &'a Path,
332    entry_type: EntryType,
333    config: &'a Config,
334    behaviour: BuildBehaviour,
335}
336
337impl<R, T, State> DeployBuilder<'_, R, T, State>
338where
339    R: Rockspec + HasIntegrity,
340    T: InstallTree + Sync,
341    State: deploy_builder::State + deploy_builder::IsComplete,
342{
343    pub(crate) async fn deploy(self) -> Result<LockedPackage, BuildError> {
344        do_deploy(self._build()).await
345    }
346}
347
348#[tracing::instrument(level = "trace", skip_all, fields(package = deploy.rockspec.package().to_string()))]
349async fn do_deploy<R, T>(deploy: Deploy<'_, R, T>) -> Result<LockedPackage, BuildError>
350where
351    R: Rockspec + HasIntegrity,
352    T: InstallTree + Sync,
353{
354    let Deploy {
355        rockspec,
356        tree,
357        package,
358        lua,
359        source_root,
360        entry_type,
361        config,
362        behaviour,
363    } = deploy;
364
365    if matches!(behaviour, BuildBehaviour::Ignore | BuildBehaviour::Conflict) {
366        if let Some(existing) = tree.lockfile()?.get(&package.id()) {
367            return Ok(existing.clone());
368        }
369    }
370
371    // TODO(vhyrro): When we implement transactionality make this a Drop guard
372    // or something else.
373    tree.prepare(&package.spec)?;
374    let layout = tree.layout_for(&package.spec);
375
376    let rock_source = rockspec.source().current_platform();
377    let build_dir = resolve_source_dir(
378        source_root,
379        rock_source.unpack_dir.as_deref(),
380        &rockspec.build().current_platform().copy_directories,
381    )?;
382
383    Patch::new(&build_dir, &rockspec.build().current_platform().patches).apply()?;
384
385    let external_dependencies = rockspec
386        .external_dependencies()
387        .current_platform()
388        .iter()
389        .map(|(name, dep)| {
390            ExternalDependencyInfo::probe(name, dep, config.external_deps())
391                .map(|info| (name.clone(), info))
392        })
393        .try_collect::<_, HashMap<_, _>, _>()?;
394
395    let output = run_build(
396        rockspec,
397        RunBuildArgs::new()
398            .package(&package.spec)
399            .no_install(false)
400            .lua(lua)
401            .external_dependencies(&external_dependencies)
402            .deploy(rockspec.deploy().current_platform())
403            .config(config)
404            .tree(tree)
405            .build_dir(&build_dir)
406            .build(),
407    )
408    .await?;
409
410    let mut binaries = rockspec.binaries();
411    binaries.extend(output.binaries);
412    tree.lockfile()?
413        .write_guard()
414        .set_binaries(&package, binaries);
415
416    install(
417        rockspec,
418        tree,
419        &package.spec,
420        lua,
421        &build_dir,
422        &entry_type,
423        config,
424    )
425    .await?;
426
427    for directory in rockspec
428        .build()
429        .current_platform()
430        .copy_directories
431        .iter()
432        .filter(|dir| {
433            dir.file_name()
434                .is_some_and(|name| name != "doc" && name != "docs")
435        })
436    {
437        recursive_copy_dir(&build_dir.join(directory), &layout.etc.join(directory)).await?;
438    }
439
440    recursive_copy_doc_dir(&layout.doc, &build_dir).await?;
441
442    if let Ok(rockspec_str) = rockspec.to_lua_remote_rockspec_string() {
443        fs::sync::write(layout.rockspec_path(), rockspec_str)?;
444    }
445
446    tree.finalize(&package, entry_type)?;
447
448    Ok(package)
449}
450
451fn is_source_or_etc_dir(dir: &DirEntry, copy_dirs: &[PathBuf]) -> bool {
452    let dir_name = dir.file_name().to_string_lossy().to_string();
453    matches!(dir_name.as_str(), "lua" | "src")
454        || copy_dirs
455            .iter()
456            .any(|copy_dir_name| copy_dir_name == &PathBuf::from(&dir_name))
457}
458
459/// Resolve the directory containing a rock's sources within an extracted source archive.
460/// When the rockspec has no `source.dir`, fall back to the archive's single top-level directory.
461pub(crate) fn resolve_source_dir(
462    source_root: &Path,
463    unpack_dir: Option<&Path>,
464    copy_dirs: &[PathBuf],
465) -> Result<PathBuf, fs::FsError> {
466    if let Some(unpack_dir) = unpack_dir {
467        return Ok(source_root.join(unpack_dir));
468    }
469    // Some older/off-spec rockspecs don't specify a `source.dir`.
470    // After unpacking the archive, if
471    //
472    //   - there exist no Lua or C sources
473    //   - there exists a single subdirectory that is not a source
474    //     or etc directory
475    //
476    // we assume it's the `source.dir`.
477    // Unlike the LuaRocks implementation - which filters when fetching sources -
478    // we only infer `source.dir` if the directory name is not 'src', 'lua'
479    // or one of the `build.copy_directories`.
480    // This allows us to build local projects with only a `src` directory.
481    //
482    // LuaRocks implementation:
483    // https://github.com/luarocks/luarocks/blob/4188fdb235aca66530d274c782374cf6afba09b8/src/luarocks/fetch.tl?plain=1#L526
484    let has_lua_or_c_sources = fs::sync::read_dir(source_root)?
485        .filter_map(Result::ok)
486        .filter(|entry| entry.path().is_file())
487        .any(|entry| {
488            entry.path().extension().is_some_and(|ext| {
489                matches!(
490                    ext.to_string_lossy().to_string().as_str(),
491                    "lua" | "luau" | "c"
492                )
493            })
494        });
495    if has_lua_or_c_sources {
496        return Ok(source_root.to_path_buf());
497    }
498    let dir_entries = fs::sync::read_dir(source_root)?
499        .filter_map(Result::ok)
500        .filter(|entry| entry.path().is_dir())
501        .collect_vec();
502    if dir_entries.len() == 1 && !is_source_or_etc_dir(&dir_entries[0], copy_dirs) {
503        Ok(dir_entries[0].path())
504    } else {
505        Ok(source_root.to_path_buf())
506    }
507}
508
509#[tracing::instrument(level = "trace")]
510async fn recursive_copy_doc_dir(target_doc_dir: &Path, build_dir: &Path) -> Result<(), BuildError> {
511    let mut doc_dir = build_dir.join("doc");
512    if !doc_dir.exists() {
513        doc_dir = build_dir.join("docs");
514    }
515    recursive_copy_dir(&doc_dir, target_doc_dir).await?;
516    Ok(())
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522    use predicates::prelude::*;
523    use std::path::PathBuf;
524
525    use assert_fs::{
526        assert::PathAssert,
527        prelude::{PathChild, PathCopy},
528    };
529
530    use is_executable::IsExecutable;
531
532    use crate::{
533        config::ConfigBuilder,
534        lockfile::{LockConstraint, LockedPackageHashes, LockedPackageSpec, OptState, PinnedState},
535        lua_installation::{detect_installed_lua_version, LuaInstallation},
536        lua_rockspec::RemoteLuaRockspec,
537        lua_version::LuaVersion,
538        package::PackageSpec,
539        project::Project,
540        remote_package_source::RemotePackageSource,
541    };
542    #[cfg(feature = "impure_tests")]
543    use crate::{
544        operations::{unpack_rockspec, DownloadedPackedRockBytes},
545        pipeline::build_local::Build,
546        tree::Tree,
547    };
548
549    #[tokio::test]
550    async fn test_builtin_build() {
551        let lua_version = detect_installed_lua_version().or(Some(LuaVersion::Lua51));
552        let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
553            .join("resources/test/sample-projects/no-build-spec/");
554        let tree_dir = assert_fs::TempDir::new().unwrap();
555        let config = ConfigBuilder::new()
556            .unwrap()
557            .lua_version(lua_version)
558            .user_tree(Some(tree_dir.to_path_buf()))
559            .build()
560            .unwrap();
561        let build_dir = assert_fs::TempDir::new().unwrap();
562        build_dir.copy_from(&project_root, &["**"]).unwrap();
563        let tree = config
564            .user_tree(config.lua_version().cloned().unwrap())
565            .unwrap();
566        let lua_version = config.lua_version().unwrap_or(&LuaVersion::Lua51);
567        let lua = LuaInstallation::new(lua_version, &config).await.unwrap();
568        let project = Project::from_exact(&project_root).unwrap().unwrap();
569        let rockspec = project.toml().into_remote(None).unwrap();
570        let package = LockedPackage::from(
571            &PackageSpec::new(rockspec.package().clone(), rockspec.version().clone()),
572            LockConstraint::Unconstrained,
573            RemotePackageSource::Test,
574            None,
575            LockedPackageHashes {
576                rockspec: "sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="
577                    .parse()
578                    .unwrap(),
579                source: "sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="
580                    .parse()
581                    .unwrap(),
582            },
583        );
584        tree.prepare(&package.spec).unwrap();
585        let src_dir = tree.layout_for(&package.spec).src;
586        run_build(
587            &rockspec,
588            RunBuildArgs::new()
589                .package(&package.spec)
590                .no_install(false)
591                .lua(&lua)
592                .external_dependencies(&HashMap::default())
593                .deploy(rockspec.deploy().current_platform())
594                .config(&config)
595                .tree(&tree)
596                .build_dir(&build_dir)
597                .build(),
598        )
599        .await
600        .unwrap();
601        let foo_dir = src_dir.join("foo");
602        assert!(foo_dir.is_dir());
603        let foo_init = foo_dir.join("init.lua");
604        assert!(foo_init.is_file());
605        assert!(std::fs::read_to_string(&foo_init)
606            .unwrap()
607            .contains("return true"));
608        let foo_bar_dir = foo_dir.join("bar");
609        assert!(foo_bar_dir.is_dir());
610        let foo_bar_init = foo_bar_dir.join("init.lua");
611        assert!(foo_bar_init.is_file());
612        assert!(std::fs::read_to_string(&foo_bar_init)
613            .unwrap()
614            .contains("return true"));
615        let foo_bar_baz = foo_bar_dir.join("baz.lua");
616        assert!(foo_bar_baz.is_file());
617        assert!(std::fs::read_to_string(&foo_bar_baz)
618            .unwrap()
619            .contains("return true"));
620        let bin_file = tree_dir
621            .child(lua_version.to_string())
622            .child("bin")
623            .child("hello");
624        bin_file.assert(predicate::path::is_file());
625        bin_file.assert(predicate::str::contains("#!/usr/bin/env bash"));
626        bin_file.assert(predicate::str::contains("echo \"Hello\""));
627    }
628
629    #[cfg(unix)]
630    #[tokio::test]
631    async fn test_install_bin_sets_executable_permissions() {
632        let rockspec = RemoteLuaRockspec::new(
633            r#"
634rockspec_format = "3.0"
635package = "test-builtin-bin"
636version = "1.0-1"
637source = { url = "https://github.com/lumen-oss/luarocks-stub" }
638build = {
639    type = "builtin",
640    install = { bin = { mytool = "mytool" } },
641}
642deploy = { wrap_bin_scripts = false }
643"#,
644        )
645        .unwrap();
646        let lua_version = detect_installed_lua_version().or(Some(LuaVersion::Lua51));
647        let tree_dir = assert_fs::TempDir::new().unwrap();
648        let build_dir = assert_fs::TempDir::new().unwrap();
649        let config = ConfigBuilder::new()
650            .unwrap()
651            .lua_version(lua_version)
652            .user_tree(Some(tree_dir.to_path_buf()))
653            .build()
654            .unwrap();
655        let lua = LuaInstallation::new_from_config(&config).await.unwrap();
656        let tree = config
657            .user_tree(config.lua_version().cloned().unwrap())
658            .unwrap();
659        let package = LockedPackageSpec::new(
660            rockspec.package(),
661            rockspec.version(),
662            LockConstraint::Unconstrained,
663            Vec::new(),
664            Vec::new(),
665            &PinnedState::default(),
666            &OptState::default(),
667        );
668        tree.prepare(&package).unwrap();
669        crate::fs::tokio::write(build_dir.path().join("mytool"), "#!/bin/sh\necho hi\n")
670            .await
671            .unwrap();
672        install(
673            &rockspec,
674            &tree,
675            &package,
676            &lua,
677            build_dir.path(),
678            &EntryType::Entrypoint,
679            &config,
680        )
681        .await
682        .unwrap();
683        assert!(tree.bin().join("mytool").is_executable());
684    }
685
686    #[tokio::test]
687    async fn test_install_conf_directory_target() {
688        let rockspec = RemoteLuaRockspec::new(
689            r#"
690rockspec_format = "3.0"
691package = "test-install-conf"
692version = "1.0-1"
693source = { url = "https://github.com/lumen-oss/luarocks-stub" }
694build = {
695    type = "builtin",
696    install = {
697        conf = {
698            [".."] = "shell.nix",
699            ["plain.conf"] = "plain.conf",
700        },
701    },
702}
703"#,
704        )
705        .unwrap();
706        let lua_version = detect_installed_lua_version().or(Some(LuaVersion::Lua51));
707        let tree_dir = assert_fs::TempDir::new().unwrap();
708        let build_dir = assert_fs::TempDir::new().unwrap();
709        let config = ConfigBuilder::new()
710            .unwrap()
711            .lua_version(lua_version)
712            .user_tree(Some(tree_dir.to_path_buf()))
713            .build()
714            .unwrap();
715        let lua = LuaInstallation::new_from_config(&config).await.unwrap();
716        let tree = config
717            .user_tree(config.lua_version().cloned().unwrap())
718            .unwrap();
719        let package = LockedPackageSpec::new(
720            rockspec.package(),
721            rockspec.version(),
722            LockConstraint::Unconstrained,
723            Vec::new(),
724            Vec::new(),
725            &PinnedState::default(),
726            &OptState::default(),
727        );
728        tree.prepare(&package).unwrap();
729        for file in ["shell.nix", "plain.conf"] {
730            crate::fs::tokio::write(build_dir.path().join(file), "return {}")
731                .await
732                .unwrap();
733        }
734        install(
735            &rockspec,
736            &tree,
737            &package,
738            &lua,
739            build_dir.path(),
740            &EntryType::Entrypoint,
741            &config,
742        )
743        .await
744        .unwrap();
745        let layout = tree.layout_for(&package);
746        assert!(layout.etc.join("shell.nix").is_file());
747        assert!(layout.conf.join("plain.conf").is_file());
748    }
749
750    #[cfg(feature = "impure_tests")]
751    const LUATEST_SRC_ROCK: &str = "resources/test/luatest-0.2-1.src.rock";
752    #[cfg(feature = "impure_tests")]
753    const LUATEST_SRC_ROCK_SOURCE_HASH: &str =
754        "sha256-2jS0XOq0iIVhsZJ3BVqXSlKsx2vsqCAaaYyqcBEb7RI=";
755    #[cfg(feature = "impure_tests")]
756    const LUATEST_ROCKSPEC_HASH: &str = "sha256-NljJ20A+VadUyhhBjrRnojeQjSqRpQy7FWcgjUt2Fdc=";
757
758    #[cfg(feature = "impure_tests")]
759    fn luatest_src_rock_bytes() -> Bytes {
760        Bytes::copy_from_slice(
761            &std::fs::read(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(LUATEST_SRC_ROCK))
762                .unwrap(),
763        )
764    }
765
766    #[cfg(feature = "impure_tests")]
767    fn luatest_config(dir: &assert_fs::TempDir) -> Config {
768        ConfigBuilder::new()
769            .unwrap()
770            .user_tree(Some(dir.to_path_buf()))
771            .lua_version(Some(LuaVersion::Lua51))
772            .build()
773            .unwrap()
774    }
775
776    #[cfg(feature = "impure_tests")]
777    async fn build_luatest(
778        config: &Config,
779        tree: &Tree,
780        entry_type: EntryType,
781        behaviour: BuildBehaviour,
782    ) -> LockedPackage {
783        let bytes = luatest_src_rock_bytes();
784        let rock = DownloadedPackedRockBytes {
785            name: "luatest".into(),
786            version: "0.2-1".parse().unwrap(),
787            bytes: bytes.clone(),
788            file_name: "luatest-0.2-1.src.rock".into(),
789            url: "https://example.org/luatest-0.2-1.src.rock"
790                .parse()
791                .unwrap(),
792        };
793        let rockspec = unpack_rockspec(&rock).await.unwrap();
794        let lua = LuaInstallation::new_from_config(config).await.unwrap();
795        Build::new()
796            .rockspec(&rockspec)
797            .lua(&lua)
798            .tree(tree)
799            .entry_type(entry_type)
800            .config(config)
801            .behaviour(behaviour)
802            .source_spec(RemotePackageSourceSpec::SrcRock(SrcRockSource {
803                bytes,
804                source_url: RemotePackageSourceUrl::Url {
805                    url: "https://example.org/luatest-0.2-1.src.rock"
806                        .parse()
807                        .unwrap(),
808                },
809            }))
810            .build()
811            .await
812            .unwrap()
813    }
814
815    #[cfg(feature = "impure_tests")]
816    #[tokio::test]
817    async fn luatest_src_rock_produces_expected_hashes() {
818        let dir = assert_fs::TempDir::new().unwrap();
819        let config = luatest_config(&dir);
820        let tree = config.user_tree(LuaVersion::Lua51).unwrap();
821
822        let package =
823            build_luatest(&config, &tree, EntryType::Entrypoint, BuildBehaviour::Force).await;
824
825        assert_eq!(
826            package.hashes().source,
827            LUATEST_SRC_ROCK_SOURCE_HASH.parse().unwrap()
828        );
829        assert_eq!(
830            package.hashes().rockspec,
831            LUATEST_ROCKSPEC_HASH.parse().unwrap()
832        );
833        assert_eq!(
834            package.hashes().source,
835            luatest_src_rock_bytes().hash().await.unwrap()
836        );
837        assert!(tree.bin().join("luatest").is_file());
838    }
839
840    #[cfg(feature = "impure_tests")]
841    #[tokio::test]
842    async fn force_build_is_deterministic() {
843        let dir1 = assert_fs::TempDir::new().unwrap();
844        let config1 = luatest_config(&dir1);
845        let tree1 = config1.user_tree(LuaVersion::Lua51).unwrap();
846
847        let dir2 = assert_fs::TempDir::new().unwrap();
848        let config2 = luatest_config(&dir2);
849        let tree2 = config2.user_tree(LuaVersion::Lua51).unwrap();
850
851        let package1 = build_luatest(
852            &config1,
853            &tree1,
854            EntryType::Entrypoint,
855            BuildBehaviour::Force,
856        )
857        .await;
858        let package2 = build_luatest(
859            &config2,
860            &tree2,
861            EntryType::Entrypoint,
862            BuildBehaviour::Force,
863        )
864        .await;
865
866        assert_eq!(package1, package2);
867        assert_eq!(package1.hashes(), package2.hashes());
868    }
869
870    #[cfg(feature = "impure_tests")]
871    #[tokio::test]
872    async fn noforce_skips_rebuild_when_package_is_locked() {
873        let dir = assert_fs::TempDir::new().unwrap();
874        let config = luatest_config(&dir);
875        let tree = config.user_tree(LuaVersion::Lua51).unwrap();
876
877        let package =
878            build_luatest(&config, &tree, EntryType::Entrypoint, BuildBehaviour::Force).await;
879        let bin = tree.bin().join("luatest");
880        assert!(bin.is_file());
881
882        // Simulate a committed install
883        {
884            let mut lockfile = tree.lockfile().unwrap().write_guard();
885            lockfile.add_entrypoint(&package);
886        }
887
888        // Simulate a partially broken install.
889        std::fs::remove_file(&bin).unwrap();
890
891        let skipped = build_luatest(
892            &config,
893            &tree,
894            EntryType::Entrypoint,
895            BuildBehaviour::Ignore,
896        )
897        .await;
898        assert_eq!(skipped.id(), package.id());
899        assert!(!bin.is_file());
900
901        build_luatest(&config, &tree, EntryType::Entrypoint, BuildBehaviour::Force).await;
902        assert!(bin.is_file());
903    }
904}