Skip to main content

lux_lib/build/
mod.rs

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