Skip to main content

lux_lib/project/
project_toml.rs

1//! Structs and utilities for `lux.toml`
2
3use crate::fs;
4use crate::git::shorthand::RemoteGitUrlShorthand;
5use crate::git::{GitRef, GitSource};
6use crate::hash::HasIntegrity;
7use crate::lockfile::OptState;
8use crate::lockfile::PinnedState;
9use crate::lua_rockspec;
10use crate::lua_rockspec::DeploySpec;
11use crate::lua_rockspec::LocalLuaRockspec;
12use crate::lua_rockspec::LocalRockSource;
13use crate::lua_rockspec::RemoteLuaRockspec;
14use crate::lua_rockspec::RockSourceSpec;
15use crate::lua_version::LuaVersion;
16use crate::operations::RunCommand;
17use crate::package;
18use crate::project::TomlDeError;
19use crate::rockspec::lua_dependency::LuaDependencySpec;
20use crate::ROCKSPEC_FUEL_LIMIT;
21use std::io;
22use std::path::Path;
23use std::{collections::HashMap, path::PathBuf};
24
25use crate::{
26    build::autodetect_modules,
27    config::Config,
28    lua_rockspec::{
29        BuildSpec, BuildSpecInternal, BuildSpecInternalError, BuildType, DisplayAsLuaKV,
30        DisplayLuaKV, ExternalDependencies, ExternalDependencySpec, LuaTableKey, LuaVersionError,
31        ModuleSpec, ModuleSpecInternal, PartialLuaRockspec, PerPlatform, PlatformIdentifier,
32        PlatformSupport, PlatformValidationError, RemoteRockSource, RockDescription,
33        RockSourceError, RockspecFormat, TestSpec, TestSpecDecodeError, TestSpecInternal,
34    },
35    package::{BuildDependencies, Dependencies},
36    rockspec::{LuaVersionCompatibility, Rockspec},
37};
38use itertools::Itertools;
39use miette::Diagnostic;
40use nonempty::NonEmpty;
41
42use crate::wally;
43use serde::de;
44use serde::{Deserialize, Deserializer};
45use ssri::Integrity;
46use thiserror::Error;
47
48use super::gen::GenerateSourceError;
49use super::gen::RockSourceTemplate;
50use super::r#gen::GenerateVersionError;
51use super::r#gen::PackageVersionTemplate;
52use super::ProjectRoot;
53
54pub const PROJECT_TOML: &str = "lux.toml";
55
56#[derive(Deserialize)]
57#[serde(untagged)]
58#[allow(clippy::large_enum_variant)] // This is ok because it's just a Deserialize helper
59enum DependencyEntry {
60    Simple(package::VersionReq),
61    Detailed(DependencyTableEntry),
62}
63
64#[derive(Debug, Deserialize)]
65struct DependencyTableEntry {
66    #[serde(default)]
67    version: Option<package::VersionReq>,
68    #[serde(default)]
69    opt: Option<bool>,
70    #[serde(default)]
71    pin: Option<bool>,
72    #[serde(default)]
73    git: Option<RemoteGitUrlShorthand>,
74    #[serde(default)]
75    path: Option<PathBuf>,
76    #[serde(default)]
77    rev: Option<String>,
78    #[serde(default)]
79    wally: Option<wally::package::Req>,
80}
81
82fn parse_map_to_dependency_vec_opt<'de, D>(
83    deserializer: D,
84) -> Result<Option<Vec<LuaDependencySpec>>, D::Error>
85where
86    D: Deserializer<'de>,
87{
88    let packages: Option<HashMap<package::Name, DependencyEntry>> =
89        Option::deserialize(deserializer)?;
90
91    match packages {
92        None => Ok(None),
93        Some(packages) => Ok(Some(
94            packages
95                .into_iter()
96                .map(|(name, spec)| match spec {
97                    DependencyEntry::Simple(version_req) => {
98                        Ok(package::Req { name, version_req }.into())
99                    }
100                    DependencyEntry::Detailed(entry) => {
101                        let DependencyTableEntry {
102                            version,
103                            opt,
104                            pin,
105                            git,
106                            path,
107                            rev,
108                            wally,
109                        } = entry;
110
111                        let (version_req, source) = if let Some(wally) = wally {
112                            if version.is_some() || git.is_some() || rev.is_some() || path.is_some() {
113                                return Err(de::Error::custom(format!(
114                                    "dependency '{}' specifies 'wally', which cannot be combined with 'version', 'git', 'rev', or 'path'",
115                                    name
116                                )));
117                            }
118                            (
119                                package::VersionReq::SemVer(wally.version_req().clone()),
120                                Some(RockSourceSpec::Wally(wally)),
121                            )
122                        } else {
123                            let version = version.ok_or_else(|| {
124                                de::Error::custom(format!(
125                                    "dependency '{name}' is missing a 'version' or 'wally' field"
126                                ))
127                            })?;
128                            let source = match (git, rev, path) {
129                                (None, None, None) => Ok(None),
130                                (None, Some(_), None) => Err(de::Error::custom(format!(
131                                    "dependency {} specifies a 'rev', but missing a 'git' field",
132                                    name
133                                ))),
134                                (Some(git), Some(rev), None) => {
135                                    Ok(Some(RockSourceSpec::Git(GitSource {
136                                        url: git.into(),
137                                        git_ref: Some(GitRef::Tag(rev)),
138                                    })))
139                                }
140                                (Some(git), None, None) => Ok(Some(RockSourceSpec::Git(GitSource {
141                                    url: git.into(),
142                                    git_ref: Some(GitRef::Tag(
143                                        version
144                                            .clone()
145                                            .to_string()
146                                            .trim_start_matches("=")
147                                            .to_string(),
148                                    )),
149                                }))),
150                                (None, None, Some(path)) => Ok(Some(RockSourceSpec::File(path))),
151                                (_, _, Some(_)) => Err(de::Error::custom(format!(
152                                    "dependency '{}' specifies a 'path', which cannot be combined with 'git' or 'rev'",
153                                    name
154                                ))),
155                            }?;
156                            (version, source)
157                        };
158
159                        Ok(LuaDependencySpec {
160                            package_req: package::Req { name, version_req },
161                            opt: OptState::from(opt.unwrap_or(false)),
162                            pin: PinnedState::from(pin.unwrap_or(false)),
163                            source,
164                        })
165                    }
166                })
167                .try_collect()?,
168        )),
169    }
170}
171
172#[derive(Debug, Error, Diagnostic)]
173pub enum Error {
174    #[error("error generating rockspec source")]
175    #[diagnostic(forward(0))]
176    GenerateSource(#[from] GenerateSourceError),
177    #[error("error generating rockspec version")]
178    #[diagnostic(forward(0))]
179    GenerateVersion(#[from] GenerateVersionError),
180    #[error("generated rockspec exceeds computational limit of {ROCKSPEC_FUEL_LIMIT} steps")]
181    #[diagnostic(help(
182        r#"the rockspec may be too complex or contain too many dependencies.
183        Try reducing the number of dependencies or simplifying build instructions."#
184    ))]
185    FuelLimitExceeded,
186    #[error(
187        r#"generated invalid Lua from TOML:
188{0}
189"#
190    )]
191    #[diagnostic(help("this is a bug in Lux. please report it."))]
192    GeneratedInvalidLua(String),
193}
194
195#[derive(Debug, Error, Diagnostic)]
196pub enum LocalProjectTomlValidationError {
197    #[error("no lua version provided")]
198    #[diagnostic(help(
199        r#"add a `lua` field to your lux.toml, for example:
200
201  lua = ">=5.1""#
202    ))]
203    NoLuaVersion,
204    #[error("could not decode the test spec")]
205    #[diagnostic(forward(0))]
206    TestSpecError(#[from] TestSpecDecodeError),
207    #[error("could not decode the build spec")]
208    #[diagnostic(forward(0))]
209    BuildSpecInternal(#[from] BuildSpecInternalError),
210    #[error(transparent)]
211    #[diagnostic(transparent)]
212    PlatformValidation(#[from] PlatformValidationError),
213    #[error("{}copy_directories cannot contain a rockspec name", ._0.as_ref().map(|p| format!("{p}: ")).unwrap_or_default())]
214    CopyDirectoriesContainRockspecName(Option<String>),
215    #[error("could not decode the source spec")]
216    #[diagnostic(forward(0))]
217    RockSource(#[from] RockSourceError),
218    #[error("duplicate dependencies: {0}")]
219    #[diagnostic(help("remove the duplicate entries from the [dependencies] table."))]
220    DuplicateDependencies(package::List),
221    #[error("duplicate test dependencies: {0}")]
222    #[diagnostic(help("remove the duplicate entries from the [test_dependencies] table."))]
223    DuplicateTestDependencies(package::List),
224    #[error("duplicate build dependencies: {0}")]
225    #[diagnostic(help("remove the duplicate entries from the [build_dependencies] table."))]
226    DuplicateBuildDependencies(package::List),
227    #[error(
228        r#"dependencies field cannot contain 'lua'.
229        Please provide the version in the top-level 'lua' field
230
231        Example:
232
233        ```toml
234        package = "my-package"
235        lua = ">=5.1"
236
237        [dependencies]
238        # do not add Lua here!
239        ```
240        "#
241    )]
242    DependenciesContainLua,
243    #[error("error generating rockspec source")]
244    #[diagnostic(forward(0))]
245    GenerateSource(#[from] GenerateSourceError),
246    #[error("error generating rockspec version")]
247    #[diagnostic(forward(0))]
248    GenerateVersion(#[from] GenerateVersionError),
249}
250
251#[derive(Debug, Error, Diagnostic)]
252pub enum RemoteProjectTomlValidationError {
253    #[error("error generating rockspec source")]
254    #[diagnostic(forward(0))]
255    GenerateSource(#[from] GenerateSourceError),
256    #[error("error generating rockspec version")]
257    #[diagnostic(forward(0))]
258    GenerateVersion(#[from] GenerateVersionError),
259    #[error(transparent)]
260    #[diagnostic(transparent)]
261    LocalProjectTomlValidationError(#[from] LocalProjectTomlValidationError),
262}
263
264/// The `lux.toml` file for a project.
265/// The only required fields are `package` and `build`, which are required to build a project using `lux build`.
266/// The rest of the fields are optional, but are required to build a rockspec.
267#[derive(Clone, Debug, Deserialize)]
268#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
269pub struct PartialProjectToml {
270    /// The name of the rock. Lux converts it to lower case when publishing.
271    #[cfg_attr(
272        feature = "schema",
273        schemars(extend("x-lux-toml-example" = r#"package = "my-lua-project""#))
274    )]
275    pub(crate) package: package::Name,
276    /// The package version. If unset, Lux derives it from the current SemVer
277    /// git tag (optional `v` prefix), falling back to "dev".
278    ///
279    /// Versions like `1.0` are corrected to `1.0.0`.
280    /// Non-SemVer versions (e.g. `1.0-rc1` instead of `1.0.0-rc1`) are treated
281    /// as Dev versions and cannot be compared with SemVer versions.
282    #[serde(default, rename = "version")]
283    #[cfg_attr(feature = "schema", schemars(with = "String"))]
284    #[cfg_attr(
285        feature = "schema",
286        schemars(extend("x-lux-toml-example" = r#"version = "1.0.0""#))
287    )]
288    pub(crate) version_template: PackageVersionTemplate,
289    /// How to build the project. See the build backend fields below.
290    ///
291    /// Rocks with C sources require a C compiler. Lux does not bundle one.
292    #[serde(default)]
293    pub(crate) build: BuildSpecInternal,
294    /// The LuaRocks rockspec format version to write to the generated rockspec ("1.0", "2.0", or "3.0").
295    /// Default: "3.0".
296    #[cfg_attr(
297        feature = "schema",
298        schemars(extend("x-lux-toml-example" = r#"rockspec_format = "3.0""#))
299    )]
300    pub(crate) rockspec_format: Option<RockspecFormat>,
301    /// How to run the project with `lx run`.
302    #[serde(default)]
303    #[cfg_attr(
304        feature = "schema",
305        schemars(extend("x-lux-toml-example" = r#"
306[run]
307args = ["src/main.lua"]
308"#))
309    )]
310    pub(crate) run: Option<RunSpec>,
311    /// The Lua version requirement, e.g. ">= 5.1". Required to build a rockspec.
312    /// Set to "luau" to build and run the project with Luau.
313    #[serde(default)]
314    #[cfg_attr(feature = "schema", schemars(with = "Option<String>"))]
315    #[cfg_attr(
316        feature = "schema",
317        schemars(extend("x-lux-toml-example" = r#"lua = ">=5.1""#))
318    )]
319    pub(crate) lua: Option<package::VersionReq>,
320    /// Metadata published to LuaRocks.
321    #[serde(default)]
322    #[cfg_attr(
323        feature = "schema",
324        schemars(extend("x-lux-toml-example" = r#"
325[description]
326summary = "A one-line description of the package"
327detailed = """
328A longer description of the package
329that often spans multiple lines.
330"""
331license = "GPLv2"
332homepage = "https://lumen-oss.github.io"
333issues_url = "https://github.com/lumen-oss/lux/issues"
334maintainer = "neorocks org"
335labels = ["neovim", "tls", "https"]
336"#))
337    )]
338    pub(crate) description: Option<RockDescription>,
339    /// Platforms this rock supports, mapping platform identifiers to `true` or
340    /// `false` (e.g. `linux = true`).
341    #[serde(default)]
342    #[cfg_attr(
343        feature = "schema",
344        schemars(with = "Option<std::collections::BTreeMap<String, bool>>")
345    )]
346    #[cfg_attr(
347        feature = "schema",
348        schemars(extend("x-lux-toml-example" = r#"
349[supported_platforms]
350linux = true
351windows = false
352"#))
353    )]
354    pub(crate) supported_platforms: Option<HashMap<PlatformIdentifier, bool>>,
355    /// Runtime Lua dependencies, mapping package names to a version requirement
356    /// (e.g. `foo = "1.0"`) or to a detailed table with `version`, `opt`, `pin`,
357    /// `git`, `rev`, and `path` keys.
358    ///
359    /// Version requirements support the operators `==`, `~=`, `<`, `>`, `<=`,
360    /// `>=`, and `~>`. `~> 2` means `>= 2, < 3`. An operator is optional;
361    /// without one, the requirement is treated as `==`. Version requirements
362    /// are mandatory.
363    ///
364    /// Dependencies are declared under `lua`, `[dependencies]`,
365    /// `[build_dependencies]`, `[test_dependencies]`, and
366    /// `[external_dependencies]`.
367    ///
368    /// Git dependencies support the `github:` (default), `gitlab:`,
369    /// `codeberg:`, and `sourcehut:` shorthands, as well as full HTTPS and SSH
370    /// URLs. If `rev` is unset, `version` is used as the commit SHA or tag.
371    /// Lux searches the repository root for a `lux.toml` or `.rockspec` to
372    /// determine the build specification, defaulting to `builtin` if none is
373    /// found. Rocks with git dependencies cannot be published to LuaRocks. Git
374    /// dependencies that require a custom LuaRocks build backend are not
375    /// supported.
376    #[serde(default, deserialize_with = "parse_map_to_dependency_vec_opt")]
377    #[cfg_attr(
378        feature = "schema",
379        schemars(
380            with = "Option<std::collections::BTreeMap<String, crate::schema::DependencyEntry>>"
381        )
382    )]
383    #[cfg_attr(
384        feature = "schema",
385        schemars(extend("x-lux-toml-example" = r#"
386[dependencies]
387luafilesystem = "1.0.0"
388
389[dependencies.argparse]
390version = "0.7"
391opt = true
392
393[dependencies.foo]
394version = "09511782201302ade916d4b250d01a6c61b56844"
395git = "github:lunarmodules/foo"
396
397[dependencies.bar]
398path = "../bar"
399version = "1.0.0"
400"#))
401    )]
402    pub(crate) dependencies: Option<Vec<LuaDependencySpec>>,
403    /// Dependencies needed at build time. Same syntax as `dependencies`.
404    #[serde(default, deserialize_with = "parse_map_to_dependency_vec_opt")]
405    #[cfg_attr(
406        feature = "schema",
407        schemars(
408            with = "Option<std::collections::BTreeMap<String, crate::schema::DependencyEntry>>"
409        )
410    )]
411    pub(crate) build_dependencies: Option<Vec<LuaDependencySpec>>,
412    /// System (C/FFI) dependencies, discovered via pkg-config. Each entry may
413    /// set `header` and/or `library`.
414    #[serde(default)]
415    #[cfg_attr(
416        feature = "schema",
417        schemars(extend("x-lux-toml-example" = r#"
418[external_dependencies.OPENSSL]
419header = "openssl/ssl.h"
420library = "ssl"
421"#))
422    )]
423    pub(crate) external_dependencies: Option<HashMap<String, ExternalDependencySpec>>,
424    /// Dependencies needed when running `lx test`. Same syntax as `dependencies`.
425    #[serde(default, deserialize_with = "parse_map_to_dependency_vec_opt")]
426    #[cfg_attr(
427        feature = "schema",
428        schemars(
429            with = "Option<std::collections::BTreeMap<String, crate::schema::DependencyEntry>>"
430        )
431    )]
432    #[cfg_attr(
433        feature = "schema",
434        schemars(extend("x-lux-toml-example" = r#"
435[test_dependencies]
436busted = ">=2.0.0"
437nlua = "~>0.3"
438"#))
439    )]
440    pub(crate) test_dependencies: Option<Vec<LuaDependencySpec>>,
441    /// Templates for generating the source URL of a release.
442    ///
443    /// Variable substitutions:
444    /// - `$(PACKAGE)`: package name
445    /// - `$(VERSION)`: package version (from the git tag if not declared)
446    /// - `$(REF)`: git tag or revision (priority: SemVer tag, tag, commit SHA)
447    /// - `$(<VAR_NAME>)`: any environment variable
448    #[serde(default, rename = "source")]
449    #[cfg_attr(
450        feature = "schema",
451        schemars(extend("x-lux-toml-example" = r#"
452[source]
453url = "https://host.com/owner/$(PACKAGE)/archive/refs/tags/$(REF).zip"
454dev = "git+https://host.com/owner/$(PACKAGE).git"
455"#))
456    )]
457    pub(crate) source_template: RockSourceTemplate,
458    /// How to run the test suite with `lx test`.
459    ///
460    /// The `busted` backend is used when a `.busted` file exists and no backend
461    /// is specified. `busted-nlua` is used when a `.busted` file exists or
462    /// `busted` and `nlua` are `test_dependencies`. The `busted-nlua` backend is
463    /// currently broken on macOS and Windows. Luau projects default to `tiniest`.
464    #[serde(default)]
465    #[cfg_attr(
466        feature = "schema",
467        schemars(extend("x-lux-toml-example" = r#"
468[test]
469type = "busted"
470flags = []
471"#))
472    )]
473    pub(crate) test: Option<TestSpecInternal>,
474    /// Additional install and deployment options.
475    #[serde(default)]
476    #[cfg_attr(
477        feature = "schema",
478        schemars(extend("x-lux-toml-example" = r#"
479[deploy]
480wrap_bin_scripts = false
481"#))
482    )]
483    pub(crate) deploy: Option<DeploySpec>,
484    /// Lux-specific project settings, ignored when generating a rockspec.
485    #[serde(default)]
486    #[cfg_attr(
487        feature = "schema",
488        schemars(extend("x-lux-toml-example" = r#"
489[project]
490root_dir = ".."
491"#))
492    )]
493    pub(crate) project: ProjectSpec,
494
495    /// Used to bind the TOML to the directory containing the `lux.toml`.
496    #[serde(skip, default = "PathBuf::new")]
497    lux_toml_dir: PathBuf,
498}
499
500impl HasIntegrity for PartialProjectToml {
501    async fn hash(&self) -> io::Result<Integrity> {
502        let toml_file = self.lux_toml_dir.join(PROJECT_TOML);
503        let content = fs::sync::read_to_string(&toml_file).map_err(io::Error::other)?;
504        Ok(Integrity::from(&content))
505    }
506}
507
508impl PartialProjectToml {
509    pub(crate) fn new(
510        name: &str,
511        str: &str,
512        lux_toml_dir: ProjectRoot,
513    ) -> Result<Self, TomlDeError> {
514        let mut this: Self = super::parse_toml(name, str)?;
515        this.lux_toml_dir = lux_toml_dir.to_path_buf();
516        this.project.root = match this.project.root_dir {
517            Some(ref root_dir) => {
518                let relative_project_root = lux_toml_dir.join(root_dir);
519                let project_root =
520                    std::fs::canonicalize(&relative_project_root).unwrap_or(relative_project_root);
521                ProjectRoot(project_root)
522            }
523            None => lux_toml_dir,
524        };
525        Ok(this)
526    }
527
528    /// This project's Lua version requirement, if any
529    pub fn lua(&self) -> Option<&package::VersionReq> {
530        self.lua.as_ref()
531    }
532
533    /// Convert the `PartialProjectToml` struct into a `LocalProjectToml` struct, making
534    /// it ready to be used for building a project.
535    pub fn into_local(&self) -> Result<LocalProjectToml, LocalProjectTomlValidationError> {
536        let project_toml = self.clone();
537
538        // Disallow `lua` to be part of the `dependencies` field
539        if project_toml
540            .dependencies
541            .as_ref()
542            .is_some_and(|deps| deps.iter().any(|dep| dep.name() == &"lua".into()))
543        {
544            return Err(LocalProjectTomlValidationError::DependenciesContainLua);
545        }
546
547        let get_duplicates = |dependencies: &Option<Vec<LuaDependencySpec>>| {
548            dependencies
549                .iter()
550                .flat_map(|deps| {
551                    deps.iter()
552                        .map(|dep| dep.package_req().name())
553                        .duplicates()
554                        .cloned()
555                })
556                .collect_vec()
557        };
558        let duplicate_dependencies = get_duplicates(&self.dependencies);
559        if !duplicate_dependencies.is_empty() {
560            return Err(LocalProjectTomlValidationError::DuplicateDependencies(
561                package::List::new(duplicate_dependencies),
562            ));
563        }
564        let duplicate_test_dependencies = get_duplicates(&self.test_dependencies);
565        if !duplicate_test_dependencies.is_empty() {
566            return Err(LocalProjectTomlValidationError::DuplicateTestDependencies(
567                package::List::new(duplicate_test_dependencies),
568            ));
569        }
570        let duplicate_build_dependencies = get_duplicates(&self.build_dependencies);
571        if !duplicate_build_dependencies.is_empty() {
572            return Err(LocalProjectTomlValidationError::DuplicateBuildDependencies(
573                package::List::new(duplicate_build_dependencies),
574            ));
575        }
576
577        let validated = LocalProjectToml {
578            internal: project_toml.clone(),
579
580            package: project_toml.package,
581            version: project_toml
582                .version_template
583                .try_generate(&self.lux_toml_dir, None)
584                .unwrap_or(package::Version::default_dev_version()),
585            lua: project_toml
586                .lua
587                .ok_or(LocalProjectTomlValidationError::NoLuaVersion)?,
588            description: project_toml.description.unwrap_or_default(),
589            run: project_toml.run.map(PerPlatform::new),
590            supported_platforms: PlatformSupport::parse(
591                &project_toml
592                    .supported_platforms
593                    .unwrap_or_default()
594                    .into_iter()
595                    .map(|(platform, supported)| {
596                        if supported {
597                            format!("{platform}")
598                        } else {
599                            format!("!{platform}")
600                        }
601                    })
602                    .collect_vec(),
603            )?,
604            // Merge dependencies internally with lua version
605            // so the output of `dependencies()` is consistent
606            dependencies: PerPlatform::new(
607                project_toml
608                    .dependencies
609                    .unwrap_or_default()
610                    .into_iter()
611                    .map(|dep| self.resolve_lua_dependency_spec(dep))
612                    .collect_vec(),
613            ),
614            test_dependencies: PerPlatform::new(
615                project_toml
616                    .test_dependencies
617                    .unwrap_or_default()
618                    .into_iter()
619                    .map(|dep| self.resolve_lua_dependency_spec(dep))
620                    .collect_vec(),
621            ),
622            build_dependencies: PerPlatform::new(
623                project_toml
624                    .build_dependencies
625                    .unwrap_or_default()
626                    .into_iter()
627                    .map(|dep| self.resolve_lua_dependency_spec(dep))
628                    .collect_vec(),
629            ),
630            external_dependencies: PerPlatform::new(
631                project_toml.external_dependencies.unwrap_or_default(),
632            ),
633            test: PerPlatform::new(TestSpec::try_from(
634                project_toml.test.clone().unwrap_or_default(),
635            )?),
636            build: PerPlatform::new(BuildSpec::from_internal_spec(project_toml.build.clone())?),
637            deploy: PerPlatform::new(project_toml.deploy.clone().unwrap_or_default()),
638            rockspec_format: project_toml.rockspec_format.clone(),
639
640            source: PerPlatform::new(RemoteRockSource::new(
641                LocalRockSource::default(),
642                RockSourceSpec::File(self.project.root.to_path_buf()),
643            )),
644        };
645
646        let rockspec_file_name = format!("{}-{}.rockspec", validated.package, validated.version);
647
648        if validated
649            .build
650            .default
651            .copy_directories
652            .contains(&PathBuf::from(&rockspec_file_name))
653        {
654            return Err(LocalProjectTomlValidationError::CopyDirectoriesContainRockspecName(None));
655        }
656
657        for (platform, build_override) in &validated.build.per_platform {
658            if build_override
659                .copy_directories
660                .contains(&PathBuf::from(&rockspec_file_name))
661            {
662                return Err(
663                    LocalProjectTomlValidationError::CopyDirectoriesContainRockspecName(Some(
664                        platform.to_string(),
665                    )),
666                );
667            }
668        }
669
670        Ok(validated)
671    }
672
673    /// Convert the `PartialProjectToml` struct into a `RemoteProjectToml` struct, making
674    /// it ready to be serialized into a rockspec.
675    /// A source must be provided for the rockspec to be valid.
676    pub fn into_remote(
677        &self,
678        specrev: Option<package::version::SpecRev>,
679    ) -> Result<RemoteProjectToml, RemoteProjectTomlValidationError> {
680        self.into_remote_with_version(None, specrev)
681    }
682
683    /// Like [`Self::into_remote`] but generates the rockspec for a specified
684    /// target version.
685    pub fn into_remote_with_version(
686        &self,
687        version: Option<package::Version>,
688        specrev: Option<package::version::SpecRev>,
689    ) -> Result<RemoteProjectToml, RemoteProjectTomlValidationError> {
690        let version = match version {
691            Some(version) => version,
692            None => self
693                .version_template
694                .try_generate(&self.lux_toml_dir, specrev)?,
695        };
696        let source =
697            self.source_template
698                .try_generate(&self.lux_toml_dir, &self.package, &version)?;
699        let source = PerPlatform::new(RemoteRockSource::try_from(source).map_err(|err| {
700            RemoteProjectTomlValidationError::LocalProjectTomlValidationError(
701                LocalProjectTomlValidationError::RockSource(err),
702            )
703        })?);
704        let mut local = self.into_local()?;
705        local.version = version;
706
707        let validated = RemoteProjectToml { source, local };
708
709        Ok(validated)
710    }
711
712    // In the not-yet-validated struct, we create getters only
713    // for the non-optional fields.
714    pub fn package(&self) -> &package::Name {
715        &self.package
716    }
717
718    /// Returns the current package version, which may be generated from a template
719    pub fn version(&self) -> Result<package::Version, GenerateVersionError> {
720        self.version_template.try_generate(&self.lux_toml_dir, None)
721    }
722
723    /// Merge the `ProjectToml` struct with an unvalidated `LuaRockspec`.
724    /// The final merged struct can then be validated.
725    pub fn merge(self, other: PartialLuaRockspec) -> Self {
726        PartialProjectToml {
727            package: other.package.unwrap_or(self.package),
728            version_template: self.version_template,
729            lua: other
730                .dependencies
731                .as_ref()
732                .and_then(|deps| {
733                    deps.iter()
734                        .find(|dep| dep.name() == &"lua".into())
735                        .and_then(|dep| {
736                            if dep.version_req().is_any() {
737                                None
738                            } else {
739                                Some(dep.version_req().clone())
740                            }
741                        })
742                })
743                .or(self.lua),
744            build: other.build.unwrap_or(self.build),
745            run: self.run,
746            description: other.description.or(self.description),
747            supported_platforms: other
748                .supported_platforms
749                .map(|platform_support| platform_support.platforms().clone())
750                .or(self.supported_platforms),
751            dependencies: other
752                .dependencies
753                .map(|deps| {
754                    deps.into_iter()
755                        .filter(|dep| dep.name() != &"lua".into())
756                        .collect()
757                })
758                .or(self.dependencies),
759            build_dependencies: other.build_dependencies.or(self.build_dependencies),
760            test_dependencies: other.test_dependencies.or(self.test_dependencies),
761            external_dependencies: other.external_dependencies.or(self.external_dependencies),
762            source_template: self.source_template,
763            test: other.test.or(self.test),
764            deploy: other.deploy.or(self.deploy),
765            rockspec_format: other.rockspec_format.or(self.rockspec_format),
766
767            // Keep the project root the same, as it is not part of the lua rockspec
768            project: self.project,
769            lux_toml_dir: self.lux_toml_dir.clone(),
770        }
771    }
772
773    fn resolve_lua_dependency_spec(&self, dep: LuaDependencySpec) -> LuaDependencySpec {
774        match &dep.source {
775            Some(RockSourceSpec::File(path)) if path.is_dir() => dep,
776            Some(RockSourceSpec::File(path)) => LuaDependencySpec {
777                source: Some(RockSourceSpec::File(self.lux_toml_dir.join(path))),
778                ..dep
779            },
780            _ => dep,
781        }
782    }
783}
784
785// This is automatically implemented for `RemoteProjectToml`,
786// but we also add a special implementation for `ProjectToml` (as providing a lua version
787// is required even by the non-validated struct).
788impl LuaVersionCompatibility for PartialProjectToml {
789    fn validate_lua_version(&self, version: &LuaVersion) -> Result<(), LuaVersionError> {
790        if self.supports_lua_version(version) {
791            Ok(())
792        } else {
793            Err(LuaVersionError::LuaVersionUnsupported(
794                version.clone(),
795                self.package().to_owned(),
796                self.version_template
797                    .try_generate(&self.lux_toml_dir, None)
798                    .unwrap_or(package::Version::default_dev_version()),
799            ))
800        }
801    }
802
803    fn validate_lua_version_from_config(&self, config: &Config) -> Result<(), LuaVersionError> {
804        let _ = self.lua_version_matches(config)?;
805        Ok(())
806    }
807
808    fn lua_version_matches(&self, config: &Config) -> Result<LuaVersion, LuaVersionError> {
809        let version = LuaVersion::from(config)?.clone();
810        if self.supports_lua_version(&version) {
811            Ok(version)
812        } else {
813            Err(LuaVersionError::LuaVersionUnsupported(
814                version,
815                self.package.clone(),
816                self.version_template
817                    .try_generate(&self.lux_toml_dir, None)
818                    .unwrap_or(package::Version::default_dev_version()),
819            ))
820        }
821    }
822
823    fn supports_lua_version(&self, lua_version: &LuaVersion) -> bool {
824        self.lua.as_ref().is_none_or(|lua| {
825            if lua.is_luau() {
826                return matches!(lua_version, LuaVersion::Luau);
827            }
828            lua.matches(&lua_version.as_version())
829        })
830    }
831
832    fn lua_version(&self) -> Option<LuaVersion> {
833        if self.lua.as_ref().is_some_and(|lua| lua.is_luau()) {
834            return Some(LuaVersion::Luau);
835        }
836        for (possibility, version) in [
837            ("5.5.0", LuaVersion::Lua55),
838            ("5.4.0", LuaVersion::Lua54),
839            ("5.3.0", LuaVersion::Lua53),
840            ("5.2.0", LuaVersion::Lua52),
841            ("5.1.0", LuaVersion::Lua51),
842        ] {
843            let possibility = unsafe { possibility.parse().unwrap_unchecked() };
844            if self
845                .lua
846                .as_ref()
847                .is_none_or(|lua| lua.matches(&possibility))
848            {
849                return Some(version);
850            }
851        }
852        None
853    }
854}
855
856/// Lux-specific project configuration.
857///
858/// This is not part of the rockspec format and is ignored when generating a rockspec.
859#[derive(Debug, Clone, Deserialize)]
860#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
861pub(crate) struct ProjectSpec {
862    /// The project root directory, relative to the directory containing the `lux.toml`.
863    /// Useful for multi-project workspaces, where the build files
864    /// (e.g. the cargo workspace `Cargo.toml`) do not live next to the `lux.toml`.
865    root_dir: Option<PathBuf>,
866    /// The project root directory, resolved from `root_dir` when deserializing.
867    #[serde(skip, default = "ProjectRoot::new")]
868    pub(crate) root: ProjectRoot,
869}
870
871impl Default for ProjectSpec {
872    fn default() -> Self {
873        Self {
874            root_dir: None,
875            root: ProjectRoot::new(),
876        }
877    }
878}
879
880// TODO(vhyrro): Move this struct into a different directory.
881#[derive(Debug, Clone, Deserialize)]
882#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
883pub struct RunSpec {
884    /// The command to execute when running the project
885    pub(crate) command: Option<RunCommand>,
886    /// Arguments to pass to the command
887    #[cfg_attr(feature = "schema", schemars(with = "Option<Vec<String>>"))]
888    pub(crate) args: Option<NonEmpty<String>>,
889}
890
891/// The `lux.toml` file, after being properly deserialized.
892/// This struct may be used to build a local version of a project.
893/// To build a rockspec, use `RemoteProjectToml`.
894#[derive(Debug)]
895pub struct LocalProjectToml {
896    package: package::Name,
897    version: package::Version,
898    lua: package::VersionReq,
899    rockspec_format: Option<RockspecFormat>,
900    run: Option<PerPlatform<RunSpec>>,
901    description: RockDescription,
902    supported_platforms: PlatformSupport,
903    dependencies: PerPlatform<Vec<LuaDependencySpec>>,
904    build_dependencies: PerPlatform<Vec<LuaDependencySpec>>,
905    external_dependencies: PerPlatform<HashMap<String, ExternalDependencySpec>>,
906    test_dependencies: PerPlatform<Vec<LuaDependencySpec>>,
907    test: PerPlatform<TestSpec>,
908    build: PerPlatform<BuildSpec>,
909    deploy: PerPlatform<DeploySpec>,
910
911    // Used for simpler serialization
912    internal: PartialProjectToml,
913
914    /// A source pointing to the current project's root.
915    source: PerPlatform<RemoteRockSource>,
916}
917
918impl LocalProjectToml {
919    pub fn run(&self) -> Option<&PerPlatform<RunSpec>> {
920        self.run.as_ref()
921    }
922
923    /// Convert this project TOML to a Lua rockspec.
924    /// Fails if there is no valid project root or if there are off-spec dependencies.
925    pub fn to_lua_rockspec(&self) -> Result<LocalLuaRockspec, lua_rockspec::Error> {
926        if let Some(dep) = self
927            .dependencies()
928            .per_platform
929            .values()
930            .filter_map(|deps| deps.iter().find(|dep| dep.source().is_some()))
931            .collect_vec()
932            .first()
933        {
934            return Err(lua_rockspec::Error::OffSpecDependency(dep.name().clone()));
935        }
936        if let Some(dep) = self
937            .build_dependencies()
938            .per_platform
939            .values()
940            .filter_map(|deps| deps.iter().find(|dep| dep.source().is_some()))
941            .collect_vec()
942            .first()
943        {
944            return Err(lua_rockspec::Error::OffSpecBuildDependency(
945                dep.name().clone(),
946            ));
947        }
948        if let Some(dep) = self
949            .test_dependencies()
950            .per_platform
951            .values()
952            .filter_map(|deps| deps.iter().find(|dep| dep.source().is_some()))
953            .collect_vec()
954            .first()
955        {
956            return Err(lua_rockspec::Error::OffSpecTestDependency(
957                dep.name().clone(),
958            ));
959        }
960        LocalLuaRockspec::new(
961            &self.to_lua_remote_rockspec_string()?,
962            ProjectRoot(self.internal.lux_toml_dir.clone()),
963        )
964    }
965
966    /// Renders the rockspec for local builds, using the rockspec's own version
967    /// and its source pointing at the project root.
968    pub fn to_local_rockspec(&self) -> Result<RemoteLuaRockspec, lua_rockspec::Error> {
969        let content = self.to_lua_local_rockspec_string()?;
970        Ok(LocalLuaRockspec::from_project_toml(self, content)
971            .into_remote_with_source(self.source.clone()))
972    }
973
974    /// Like [`Rockspec::to_lua_remote_rockspec_string`], but pointing at the project root,
975    /// so projects without a release URL can still render a rockspec.
976    fn to_lua_local_rockspec_string(&self) -> Result<String, Error> {
977        self.render_rockspec(
978            &self.version,
979            Some(self.source.current_platform().source_spec.display_lua()),
980        )
981    }
982
983    fn render_rockspec(
984        &self,
985        version: &package::Version,
986        source: Option<DisplayLuaKV>,
987    ) -> Result<String, Error> {
988        let starter = format!(
989            r#"
990rockspec_format = "{}"
991package = "{}"
992version = "{}""#,
993            self.rockspec_format
994                .as_ref()
995                .unwrap_or(&RockspecFormat::default()),
996            self.package,
997            version
998        );
999
1000        let mut template = Vec::new();
1001
1002        if self.description != RockDescription::default() {
1003            template.push(self.description.display_lua());
1004        }
1005
1006        if self.supported_platforms != PlatformSupport::default() {
1007            template.push(self.supported_platforms.display_lua());
1008        }
1009
1010        {
1011            let mut dependencies = self.internal.dependencies.clone().unwrap_or_default();
1012            if !self.lua.is_luau() {
1013                dependencies.insert(
1014                    0,
1015                    package::Req {
1016                        name: "lua".into(),
1017                        version_req: self.lua.clone(),
1018                    }
1019                    .into(),
1020                );
1021            }
1022            template.push(Dependencies(&dependencies).display_lua());
1023        }
1024
1025        let mut build_dependencies = self
1026            .internal
1027            .build_dependencies
1028            .as_ref()
1029            .cloned()
1030            .unwrap_or_default();
1031
1032        let build_backend_dependency = self
1033            .internal
1034            .build
1035            .build_type
1036            .as_ref()
1037            .and_then(|build_type| build_type.luarocks_build_backend());
1038
1039        if let Some(build_backend_dependency) = build_backend_dependency {
1040            build_dependencies.push(build_backend_dependency);
1041        }
1042
1043        if !build_dependencies.is_empty() {
1044            template.push(BuildDependencies(&build_dependencies).display_lua());
1045        }
1046
1047        match self.internal.external_dependencies {
1048            Some(ref external_dependencies) if !external_dependencies.is_empty() => {
1049                template.push(ExternalDependencies(external_dependencies).display_lua());
1050            }
1051            _ => {}
1052        }
1053
1054        if let Some(source) = source {
1055            template.push(source);
1056        }
1057
1058        template.push(
1059            build_spec_with_autodetected_modules(&self.internal.build, &self.internal.lux_toml_dir)
1060                .display_lua(),
1061        );
1062
1063        let unformatted_code = std::iter::once(starter)
1064            .chain(template.into_iter().map(|kv| kv.to_string()))
1065            .join("\n\n");
1066        let result = match stylua_lib::format_code(
1067            &unformatted_code,
1068            stylua_lib::Config::default(),
1069            None,
1070            stylua_lib::OutputVerification::Full,
1071        ) {
1072            Ok(formatted_code) => formatted_code,
1073            Err(_) => unformatted_code,
1074        };
1075        validate_generated_lua(&result)?;
1076        Ok(result)
1077    }
1078}
1079
1080impl Rockspec for LocalProjectToml {
1081    type Error = Error;
1082
1083    fn package(&self) -> &package::Name {
1084        &self.package
1085    }
1086
1087    fn version(&self) -> &package::Version {
1088        &self.version
1089    }
1090
1091    fn description(&self) -> &RockDescription {
1092        &self.description
1093    }
1094
1095    fn supported_platforms(&self) -> &PlatformSupport {
1096        &self.supported_platforms
1097    }
1098
1099    fn lua(&self) -> &package::VersionReq {
1100        &self.lua
1101    }
1102
1103    fn dependencies(&self) -> &PerPlatform<Vec<LuaDependencySpec>> {
1104        &self.dependencies
1105    }
1106
1107    fn build_dependencies(&self) -> &PerPlatform<Vec<LuaDependencySpec>> {
1108        &self.build_dependencies
1109    }
1110
1111    fn external_dependencies(&self) -> &PerPlatform<HashMap<String, ExternalDependencySpec>> {
1112        &self.external_dependencies
1113    }
1114
1115    fn test_dependencies(&self) -> &PerPlatform<Vec<LuaDependencySpec>> {
1116        &self.test_dependencies
1117    }
1118
1119    fn build(&self) -> &PerPlatform<BuildSpec> {
1120        &self.build
1121    }
1122
1123    fn test(&self) -> &PerPlatform<TestSpec> {
1124        &self.test
1125    }
1126
1127    fn build_mut(&mut self) -> &mut PerPlatform<BuildSpec> {
1128        &mut self.build
1129    }
1130
1131    fn test_mut(&mut self) -> &mut PerPlatform<TestSpec> {
1132        &mut self.test
1133    }
1134
1135    fn format(&self) -> &Option<RockspecFormat> {
1136        &self.rockspec_format
1137    }
1138
1139    fn source(&self) -> &PerPlatform<RemoteRockSource> {
1140        &self.source
1141    }
1142
1143    fn source_mut(&mut self) -> &mut PerPlatform<RemoteRockSource> {
1144        &mut self.source
1145    }
1146
1147    fn deploy(&self) -> &PerPlatform<DeploySpec> {
1148        &self.deploy
1149    }
1150
1151    fn deploy_mut(&mut self) -> &mut PerPlatform<DeploySpec> {
1152        &mut self.deploy
1153    }
1154
1155    fn to_lua_remote_rockspec_string(&self) -> Result<String, Self::Error> {
1156        let lux_toml_dir = &self.internal.lux_toml_dir;
1157        let version = self
1158            .internal
1159            .version_template
1160            .try_generate(lux_toml_dir, None)?;
1161        let source =
1162            self.internal
1163                .source_template
1164                .try_generate(lux_toml_dir, &self.package, &version)?;
1165        self.render_rockspec(&version, Some(source.display_lua()))
1166    }
1167}
1168
1169/// Creates a [`BuildSpecInternal`] with auto-detected modules (e.g. *.luau sources)
1170/// that LuaRocks does not auto-detect, so that LuaRocks can install them.
1171fn build_spec_with_autodetected_modules(
1172    spec: &BuildSpecInternal,
1173    project_root: &Path,
1174) -> BuildSpecInternal {
1175    let builtin_capable = spec
1176        .build_type
1177        .as_ref()
1178        .is_none_or(|build_type| matches!(build_type, BuildType::Builtin | BuildType::None));
1179    if !builtin_capable {
1180        return spec.clone();
1181    }
1182    let detected = match autodetect_modules(project_root, Default::default()) {
1183        Ok(modules) => modules
1184            .into_iter()
1185            .filter_map(|(module, spec)| match spec {
1186                ModuleSpec::SourcePath(path)
1187                    if path.extension().is_some_and(|ext| ext == "luau") =>
1188                {
1189                    Some((module, path))
1190                }
1191                _ => None,
1192            })
1193            .collect_vec(),
1194        Err(_) => return spec.clone(),
1195    };
1196    if detected.is_empty() {
1197        spec.clone()
1198    } else {
1199        let mut new_spec = spec.clone();
1200        let modules = new_spec.builtin_spec.get_or_insert_with(Default::default);
1201        for (module, path) in detected {
1202            modules
1203                .entry(LuaTableKey::StringKey(module.to_string()))
1204                .or_insert(ModuleSpecInternal::SourcePath(path));
1205        }
1206        new_spec
1207    }
1208}
1209
1210fn validate_generated_lua(lua_str: &str) -> Result<(), Error> {
1211    let mut lua = ottavino::Lua::core();
1212    lua.try_enter(|ctx| {
1213        let closure = ottavino::Closure::load(ctx, None, lua_str.as_bytes())?;
1214        let executor = ottavino::Executor::start(ctx, closure.into(), ());
1215        if !executor.step(ctx, &mut ottavino::Fuel::with(ROCKSPEC_FUEL_LIMIT))? {
1216            return Ok(Err(Error::FuelLimitExceeded));
1217        }
1218        Ok(Ok(()))
1219    })
1220    .map_err(|err| Error::GeneratedInvalidLua(err.to_string()))?
1221}
1222
1223#[derive(Error, Debug, Diagnostic)]
1224#[non_exhaustive]
1225#[error(transparent)]
1226pub enum IntegrityError {
1227    LuaRockspec(#[from] lua_rockspec::Error),
1228    IoError(#[from] io::Error),
1229    #[error(transparent)]
1230    #[diagnostic(transparent)]
1231    Fs(#[from] fs::Error),
1232}
1233
1234impl HasIntegrity for LocalProjectToml {
1235    async fn hash(&self) -> io::Result<Integrity> {
1236        match self.to_lua_rockspec() {
1237            Ok(lua_rockspec) => lua_rockspec.hash().await,
1238            Err(_) => self.internal.hash().await,
1239        }
1240    }
1241}
1242
1243/// The `lux.toml`, after being validated and prepared for upload
1244#[derive(Debug)]
1245pub struct RemoteProjectToml {
1246    local: LocalProjectToml,
1247    source: PerPlatform<RemoteRockSource>,
1248}
1249
1250impl RemoteProjectToml {
1251    pub fn to_lua_rockspec(&self) -> Result<RemoteLuaRockspec, lua_rockspec::Error> {
1252        RemoteLuaRockspec::new(&self.to_lua_remote_rockspec_string()?)
1253    }
1254}
1255
1256impl Rockspec for RemoteProjectToml {
1257    type Error = Error;
1258
1259    fn package(&self) -> &package::Name {
1260        self.local.package()
1261    }
1262
1263    fn version(&self) -> &package::Version {
1264        self.local.version()
1265    }
1266
1267    fn description(&self) -> &RockDescription {
1268        self.local.description()
1269    }
1270
1271    fn supported_platforms(&self) -> &PlatformSupport {
1272        self.local.supported_platforms()
1273    }
1274
1275    fn lua(&self) -> &package::VersionReq {
1276        self.local.lua()
1277    }
1278
1279    fn dependencies(&self) -> &PerPlatform<Vec<LuaDependencySpec>> {
1280        self.local.dependencies()
1281    }
1282
1283    fn build_dependencies(&self) -> &PerPlatform<Vec<LuaDependencySpec>> {
1284        self.local.build_dependencies()
1285    }
1286
1287    fn external_dependencies(&self) -> &PerPlatform<HashMap<String, ExternalDependencySpec>> {
1288        self.local.external_dependencies()
1289    }
1290
1291    fn test_dependencies(&self) -> &PerPlatform<Vec<LuaDependencySpec>> {
1292        self.local.test_dependencies()
1293    }
1294
1295    fn build(&self) -> &PerPlatform<BuildSpec> {
1296        self.local.build()
1297    }
1298
1299    fn test(&self) -> &PerPlatform<TestSpec> {
1300        self.local.test()
1301    }
1302
1303    fn build_mut(&mut self) -> &mut PerPlatform<BuildSpec> {
1304        self.local.build_mut()
1305    }
1306
1307    fn test_mut(&mut self) -> &mut PerPlatform<TestSpec> {
1308        self.local.test_mut()
1309    }
1310
1311    fn format(&self) -> &Option<RockspecFormat> {
1312        self.local.format()
1313    }
1314
1315    fn source(&self) -> &PerPlatform<RemoteRockSource> {
1316        &self.source
1317    }
1318
1319    fn source_mut(&mut self) -> &mut PerPlatform<RemoteRockSource> {
1320        &mut self.source
1321    }
1322
1323    fn deploy(&self) -> &PerPlatform<DeploySpec> {
1324        self.local.deploy()
1325    }
1326
1327    fn deploy_mut(&mut self) -> &mut PerPlatform<DeploySpec> {
1328        self.local.deploy_mut()
1329    }
1330
1331    fn to_lua_remote_rockspec_string(&self) -> Result<String, Self::Error> {
1332        let lux_toml_dir = &self.local.internal.lux_toml_dir;
1333        let starter = format!(
1334            r#"
1335rockspec_format = "{}"
1336package = "{}"
1337version = "{}""#,
1338            self.local
1339                .rockspec_format
1340                .as_ref()
1341                .unwrap_or(&RockspecFormat::default()),
1342            self.local.package,
1343            self.version()
1344        );
1345
1346        let mut template = Vec::new();
1347
1348        if self.local.description != RockDescription::default() {
1349            template.push(self.local.description.display_lua());
1350        }
1351
1352        if self.local.supported_platforms != PlatformSupport::default() {
1353            template.push(self.local.supported_platforms.display_lua());
1354        }
1355
1356        {
1357            let mut dependencies = self.local.internal.dependencies.clone().unwrap_or_default();
1358            if !self.local.lua.is_luau() {
1359                dependencies.insert(
1360                    0,
1361                    package::Req {
1362                        name: "lua".into(),
1363                        version_req: self.local.lua.clone(),
1364                    }
1365                    .into(),
1366                );
1367            }
1368            template.push(Dependencies(&dependencies).display_lua());
1369        }
1370
1371        let mut build_dependencies = self
1372            .local
1373            .internal
1374            .build_dependencies
1375            .as_ref()
1376            .cloned()
1377            .unwrap_or_default();
1378
1379        let build_backend_dependency = self
1380            .local
1381            .internal
1382            .build
1383            .build_type
1384            .as_ref()
1385            .and_then(|build_type| build_type.luarocks_build_backend());
1386
1387        if let Some(build_backend_dependency) = build_backend_dependency {
1388            build_dependencies.push(build_backend_dependency);
1389        }
1390
1391        if !build_dependencies.is_empty() {
1392            template.push(BuildDependencies(&build_dependencies).display_lua());
1393        }
1394
1395        match self.local.internal.external_dependencies {
1396            Some(ref external_dependencies) if !external_dependencies.is_empty() => {
1397                template.push(ExternalDependencies(external_dependencies).display_lua());
1398            }
1399            _ => {}
1400        }
1401
1402        let source = self.local.internal.source_template.try_generate(
1403            lux_toml_dir,
1404            &self.local.internal.package,
1405            self.version(),
1406        )?;
1407        template.push(source.display_lua());
1408
1409        if let Some(ref deploy) = self.local.internal.deploy {
1410            template.push(deploy.display_lua());
1411        }
1412
1413        template.push(
1414            build_spec_with_autodetected_modules(&self.local.internal.build, lux_toml_dir)
1415                .display_lua(),
1416        );
1417
1418        let unformatted_code = std::iter::once(starter)
1419            .chain(template.into_iter().map(|kv| kv.to_string()))
1420            .join("\n\n");
1421        let result = match stylua_lib::format_code(
1422            &unformatted_code,
1423            stylua_lib::Config::default(),
1424            None,
1425            stylua_lib::OutputVerification::Full,
1426        ) {
1427            Ok(formatted_code) => formatted_code,
1428            Err(_) => unformatted_code,
1429        };
1430        validate_generated_lua(&result)?;
1431        Ok(result)
1432    }
1433}
1434
1435impl HasIntegrity for RemoteProjectToml {
1436    async fn hash(&self) -> io::Result<Integrity> {
1437        self.to_lua_rockspec()
1438            .map_err(|err| {
1439                io::Error::other(format!(
1440                    "unable to convert remote project to Lua rockspec:\n{}",
1441                    err
1442                ))
1443            })?
1444            .hash()
1445            .await
1446    }
1447}
1448
1449#[cfg(test)]
1450mod tests {
1451    use std::path::PathBuf;
1452
1453    use assert_fs::prelude::{PathChild, PathCopy, PathCreateDir};
1454    use git2::Repository;
1455    use url::Url;
1456
1457    use crate::{
1458        git::{url::RemoteGitUrl, GitRef, GitSource},
1459        lua_rockspec::{PartialLuaRockspec, PerPlatform, RemoteLuaRockspec, RockSourceSpec},
1460        project::{Project, ProjectRoot, PROJECT_TOML},
1461        rockspec::{lua_dependency::LuaDependencySpec, Rockspec},
1462    };
1463
1464    use super::PartialProjectToml;
1465
1466    #[test]
1467    fn project_toml_parsing() {
1468        let project_toml = r#"
1469        package = "my-package"
1470        version = "1.0.0"
1471        lua = "5.3"
1472
1473        rockspec_format = "1.0"
1474
1475        [source]
1476        url = "https://example.com"
1477
1478        [dependencies]
1479        foo = "1.0"
1480        bar = ">=2.0"
1481
1482        [run]
1483        args = ["--foo", "--bar"]
1484
1485        [build]
1486        type = "builtin"
1487        "#;
1488
1489        let project =
1490            PartialProjectToml::new(PROJECT_TOML, project_toml, ProjectRoot::default()).unwrap();
1491        let _ = project.into_remote(None).unwrap();
1492
1493        let project_toml = r#"
1494        package = "my-package"
1495        version = "1.0.0"
1496        lua = "5.1"
1497
1498        [description]
1499        summary = "A summary"
1500        detailed = "A detailed description"
1501        license = "MIT"
1502        homepage = "https://example.com"
1503        issues_url = "https://example.com/issues"
1504        maintainer = "John Doe"
1505        labels = ["label1", "label2"]
1506
1507        [supported_platforms]
1508        linux = true
1509        windows = false
1510
1511        [dependencies]
1512        foo = "1.0"
1513        bar = ">=2.0"
1514
1515        [build_dependencies]
1516        baz = "1.0"
1517
1518        [external_dependencies.foo]
1519        header = "foo.h"
1520
1521        [external_dependencies.bar]
1522        library = "libbar.so"
1523
1524        [test_dependencies]
1525        busted = "69.420"
1526
1527        [source]
1528        url = "https://example.com"
1529        hash = "sha256-di00mD8txN7rjaVpvxzNbnQsAh6H16zUtJZapH7U4HU="
1530        file = "my-package-1.0.0.tar.gz"
1531        dir = "my-package-1.0.0"
1532
1533        [test]
1534        type = "command"
1535        script = "test.lua"
1536        flags = [ "foo", "bar" ]
1537
1538        [run]
1539        command = "my-command"
1540        args = ["--foo", "--bar"]
1541
1542        [build]
1543        type = "builtin"
1544        "#;
1545
1546        let project =
1547            PartialProjectToml::new(PROJECT_TOML, project_toml, ProjectRoot::default()).unwrap();
1548        let _ = project.into_remote(None).unwrap();
1549    }
1550
1551    #[test]
1552    fn compare_project_toml_with_rockspec() {
1553        let project_toml = r#"
1554        package = "my-package"
1555        version = "1.0.0"
1556        lua = "5.1"
1557
1558        # For testing, specify a custom rockspec format
1559        # (defaults to 3.0)
1560        rockspec_format = "1.0"
1561
1562        [description]
1563        summary = "A summary"
1564        detailed = "A detailed description"
1565        license = "MIT"
1566        homepage = "https://example.com"
1567        issues_url = "https://example.com/issues"
1568        maintainer = "John Doe"
1569        labels = ["label1", "label2"]
1570
1571        [supported_platforms]
1572        linux = true
1573        windows = false
1574
1575        [dependencies]
1576        foo = "1.0"
1577        bar = ">=2.0"
1578
1579        [build_dependencies]
1580        baz = "1.0"
1581
1582        [external_dependencies.foo]
1583        header = "foo.h"
1584
1585        [external_dependencies.bar]
1586        library = "libbar.so"
1587
1588        [test_dependencies]
1589        busted = "1.0"
1590
1591        [source]
1592        url = "https://example.com"
1593        file = "my-package-1.0.0.tar.gz"
1594        dir = "my-package-1.0.0"
1595
1596        [test]
1597        type = "command"
1598        script = "test.lua"
1599        flags = [ "foo", "bar" ]
1600
1601        [run]
1602        command = "my-command"
1603        args = ["--foo", "--bar"]
1604
1605        [deploy]
1606        wrap_bin_scripts = false
1607
1608        [build]
1609        type = "builtin"
1610
1611        [build.install.lua]
1612        "foo.bar" = "src/bar.lua"
1613
1614        [build.install.lib]
1615        "foo.baz" = "src/baz.c"
1616
1617        [build.install.bin]
1618        "bla" = "src/bla"
1619
1620        [build.install.conf]
1621        "cfg.conf" = "resources/config.conf"
1622        "#;
1623
1624        let expected_rockspec = r#"
1625            rockspec_format = "1.0"
1626            package = "my-package"
1627            version = "1.0.0"
1628
1629            source = {
1630                url = "https://example.com",
1631                file = "my-package-1.0.0.tar.gz",
1632                dir = "my-package-1.0.0",
1633            }
1634
1635            description = {
1636                summary = "A summary",
1637                detailed = "A detailed description",
1638                license = "MIT",
1639                homepage = "https://example.com",
1640                issues_url = "https://example.com/issues",
1641                maintainer = "John Doe",
1642                labels = {"label1", "label2"},
1643            }
1644
1645            supported_platforms = {"linux", "!windows"}
1646
1647            dependencies = {
1648                "lua ==5.1",
1649                "foo ==1.0",
1650                "bar >=2.0",
1651            }
1652
1653            build_dependencies = {
1654                "baz ==1.0",
1655            }
1656
1657            external_dependencies = {
1658                foo = { header = "foo.h" },
1659                bar = { library = "libbar.so" },
1660            }
1661
1662            source = {
1663                url = "https://example.com",
1664                hash = "sha256-di00mD8txN7rjaVpvxzNbnQsAh6H16zUtJZapH7U4HU=",
1665                file = "my-package-1.0.0.tar.gz",
1666                dir = "my-package-1.0.0",
1667            }
1668
1669            test = {
1670                type = "command",
1671                script = "test.lua",
1672                flags = {"foo", "bar"},
1673            }
1674
1675            deploy = {
1676                wrap_bin_scripts = false,
1677            }
1678
1679            build = {
1680                type = "builtin",
1681                install = {
1682                    lua = {
1683                        ["foo.bar"] = "src/bar.lua",
1684                    },
1685                    lib = {
1686                        ["foo.baz"] = "src/baz.c",
1687                    },
1688                    bin = {
1689                        bla = "src/bla",
1690                    },
1691                    conf = {
1692                        ["cfg.conf"] = "resources/config.conf",
1693                    },
1694                },
1695            }
1696        "#;
1697
1698        let expected_rockspec = RemoteLuaRockspec::new(expected_rockspec).unwrap();
1699
1700        let project_toml =
1701            PartialProjectToml::new(PROJECT_TOML, project_toml, ProjectRoot::default()).unwrap();
1702        let rockspec = project_toml
1703            .into_remote(None)
1704            .unwrap()
1705            .to_lua_rockspec()
1706            .unwrap();
1707
1708        let sorted_package_reqs = |v: &PerPlatform<Vec<LuaDependencySpec>>| {
1709            let mut v = v.current_platform().clone();
1710            v.sort_by(|a, b| a.name().cmp(b.name()));
1711            v
1712        };
1713
1714        assert_eq!(rockspec.package(), expected_rockspec.package());
1715        assert_eq!(rockspec.version(), expected_rockspec.version());
1716        assert_eq!(rockspec.description(), expected_rockspec.description());
1717        assert_eq!(
1718            rockspec.supported_platforms(),
1719            expected_rockspec.supported_platforms()
1720        );
1721        assert_eq!(
1722            sorted_package_reqs(rockspec.dependencies()),
1723            sorted_package_reqs(expected_rockspec.dependencies())
1724        );
1725        assert_eq!(
1726            sorted_package_reqs(rockspec.build_dependencies()),
1727            sorted_package_reqs(expected_rockspec.build_dependencies())
1728        );
1729        assert_eq!(
1730            rockspec.external_dependencies(),
1731            expected_rockspec.external_dependencies()
1732        );
1733        assert_eq!(rockspec.source(), expected_rockspec.source());
1734        assert_eq!(rockspec.build(), expected_rockspec.build());
1735        assert_eq!(rockspec.format(), expected_rockspec.format());
1736    }
1737
1738    #[test]
1739    fn merge_project_toml_with_partial_rockspec() {
1740        let project_toml = r#"
1741        package = "my-package"
1742        version = "1.0.0"
1743        lua = "5.1"
1744
1745        # For testing, specify a custom rockspec format
1746        # (defaults to 3.0)
1747        rockspec_format = "1.0"
1748
1749        [description]
1750        summary = "A summary"
1751        detailed = "A detailed description"
1752        license = "MIT"
1753        homepage = "https://example.com"
1754        issues_url = "https://example.com/issues"
1755        maintainer = "John Doe"
1756        labels = ["label1", "label2"]
1757
1758        [supported_platforms]
1759        linux = true
1760        windows = false
1761
1762        [dependencies]
1763        foo = "1.0"
1764        bar = ">=2.0"
1765
1766        [build_dependencies]
1767        baz = "1.0"
1768
1769        [external_dependencies.foo]
1770        header = "foo.h"
1771
1772        [external_dependencies.bar]
1773        library = "libbar.so"
1774
1775        [test_dependencies]
1776        busted = "1.0"
1777
1778        [source]
1779        url = "https://example.com"
1780        file = "my-package-1.0.0.tar.gz"
1781        dir = "my-package-1.0.0"
1782
1783        [test]
1784        type = "command"
1785        script = "test.lua"
1786        flags = [ "foo", "bar" ]
1787
1788        [run]
1789        command = "my-command"
1790        args = [ "--foo", "--bar" ]
1791
1792        [build]
1793        type = "builtin"
1794        "#;
1795
1796        let mergable_rockspec_content = r#"
1797            rockspec_format = "1.0"
1798            package = "my-package-overwritten"
1799
1800            description = {
1801                summary = "A summary overwritten",
1802                detailed = "A detailed description overwritten",
1803                license = "GPL-2.0",
1804                homepage = "https://example.com/overwritten",
1805                issues_url = "https://example.com/issues/overwritten",
1806                maintainer = "John Doe Overwritten",
1807                labels = {"over", "written"},
1808            }
1809
1810            -- Inverted supported platforms
1811            supported_platforms = {"!linux", "windows"}
1812
1813            dependencies = {
1814                "lua 5.1",
1815                "foo >1.0",
1816                "bar <=2.0",
1817            }
1818
1819            build_dependencies = {
1820                "baz >1.0",
1821            }
1822
1823            external_dependencies = {
1824                foo = { header = "overwritten.h" },
1825                bar = { library = "overwritten.so" },
1826            }
1827
1828            test = {
1829                type = "command",
1830                script = "overwritten.lua",
1831                flags = {"over", "written"},
1832            }
1833
1834            build = {
1835                type = "builtin",
1836            }
1837        "#;
1838
1839        let remote_rockspec_content = format!(
1840            r#"{}
1841            version = "1.0.0"
1842            source = {{
1843                url = "https://example.com",
1844                file = "my-package-1.0.0.tar.gz",
1845                dir = "my-package-1.0.0",
1846            }}
1847        "#,
1848            mergable_rockspec_content
1849        );
1850
1851        let project_toml =
1852            PartialProjectToml::new(PROJECT_TOML, project_toml, ProjectRoot::default()).unwrap();
1853        let partial_rockspec = PartialLuaRockspec::new(mergable_rockspec_content).unwrap();
1854        let expected_rockspec = RemoteLuaRockspec::new(&remote_rockspec_content).unwrap();
1855
1856        let merged = project_toml
1857            .merge(partial_rockspec)
1858            .into_remote(None)
1859            .unwrap();
1860
1861        let sorted_package_reqs = |v: &PerPlatform<Vec<LuaDependencySpec>>| {
1862            let mut v = v.current_platform().clone();
1863            v.sort_by(|a, b| a.name().cmp(b.name()));
1864            v
1865        };
1866
1867        assert_eq!(merged.package(), expected_rockspec.package());
1868        assert_eq!(merged.version(), expected_rockspec.version());
1869        assert_eq!(merged.description(), expected_rockspec.description());
1870        assert_eq!(
1871            merged.supported_platforms(),
1872            expected_rockspec.supported_platforms()
1873        );
1874        assert_eq!(
1875            sorted_package_reqs(merged.dependencies()),
1876            sorted_package_reqs(expected_rockspec.dependencies())
1877        );
1878        assert_eq!(
1879            sorted_package_reqs(merged.build_dependencies()),
1880            sorted_package_reqs(expected_rockspec.build_dependencies())
1881        );
1882        assert_eq!(
1883            merged.external_dependencies(),
1884            expected_rockspec.external_dependencies()
1885        );
1886        assert_eq!(merged.source(), expected_rockspec.source());
1887        assert_eq!(merged.build(), expected_rockspec.build());
1888        assert_eq!(merged.format(), expected_rockspec.format());
1889        // Ensure that the run command is retained after merge.
1890        assert!(merged.local.run().is_some());
1891    }
1892
1893    #[test]
1894    fn wally_dependency_parsing() {
1895        let project_toml = r#"
1896        package = "my-package"
1897        version = "1.0.0"
1898        lua = "5.1"
1899
1900        [dependencies.roact]
1901        wally = "jsdotperf/roact@^1.4.0"
1902
1903        [build]
1904        type = "builtin"
1905        "#;
1906
1907        let project =
1908            PartialProjectToml::new(PROJECT_TOML, project_toml, ProjectRoot::default()).unwrap();
1909        let deps = project.dependencies.unwrap();
1910        assert_eq!(deps.len(), 1);
1911        assert_eq!(deps[0].name().to_string(), "roact");
1912        assert!(matches!(
1913            deps[0].source(),
1914            Some(RockSourceSpec::Wally(req)) if req.name().to_string() == "jsdotperf/roact"
1915        ));
1916    }
1917
1918    #[test]
1919    fn wally_dependency_conflicts_with_version() {
1920        let project_toml = r#"
1921        package = "my-package"
1922        version = "1.0.0"
1923        lua = "5.1"
1924
1925        [dependencies.roact]
1926        wally = "jsdotperf/roact@^1.4.0"
1927        version = "1.4.0"
1928
1929        [build]
1930        type = "builtin"
1931        "#;
1932
1933        PartialProjectToml::new(PROJECT_TOML, project_toml, ProjectRoot::default()).unwrap_err();
1934    }
1935
1936    #[test]
1937    fn project_toml_with_lua_in_dependencies() {
1938        let project_toml = r#"
1939        package = "my-package"
1940        version = "1.0.0"
1941        # lua = ">5.1"
1942
1943        [dependencies]
1944        lua = "5.1" # disallowed
1945
1946        [build]
1947        type = "builtin"
1948        "#;
1949
1950        PartialProjectToml::new(PROJECT_TOML, project_toml, ProjectRoot::default())
1951            .unwrap()
1952            .into_local()
1953            .unwrap_err();
1954    }
1955
1956    #[test]
1957    fn project_toml_with_invalid_run_command() {
1958        for command in ["lua", "lua5.1", "lua5.2", "lua5.3", "lua5.4", "luajit"] {
1959            let project_toml = format!(
1960                r#"
1961                package = "my-package"
1962                version = "1.0.0"
1963                lua = "5.1"
1964
1965                [build]
1966                type = "builtin"
1967
1968                [run]
1969                command = "{command}"
1970                "#,
1971            );
1972
1973            PartialProjectToml::new(PROJECT_TOML, &project_toml, ProjectRoot::default())
1974                .unwrap_err();
1975        }
1976    }
1977
1978    #[test]
1979    fn project_root_dir_override_and_rockspec_generation() {
1980        let project_toml = r#"
1981        package = "my-package"
1982        version = "1.0.0"
1983        lua = "5.1"
1984
1985        [project]
1986        root_dir = ".."
1987
1988        [source]
1989        url = "https://example.com"
1990
1991        [build]
1992        type = "builtin"
1993        "#;
1994
1995        let workspace_root = assert_fs::TempDir::new().unwrap();
1996        let lux_toml_dir = workspace_root.child("proj");
1997        lux_toml_dir.create_dir_all().unwrap();
1998        std::fs::write(lux_toml_dir.join(PROJECT_TOML), project_toml).unwrap();
1999
2000        let project = PartialProjectToml::new(
2001            PROJECT_TOML,
2002            project_toml,
2003            ProjectRoot(lux_toml_dir.to_path_buf()),
2004        )
2005        .unwrap();
2006        let local = project.into_local().unwrap();
2007        let RockSourceSpec::File(root_dir) = &local.source().current_platform().source_spec else {
2008            panic!("expected a file source");
2009        };
2010        assert_eq!(
2011            root_dir,
2012            &std::fs::canonicalize(workspace_root.path()).unwrap()
2013        );
2014
2015        // The `[project]` table must be ignored when generating a rockspec.
2016        let rockspec = local.to_lua_rockspec().unwrap();
2017        assert!(!rockspec
2018            .to_lua_remote_rockspec_string()
2019            .unwrap()
2020            .contains("root_dir"));
2021    }
2022
2023    #[test]
2024    fn generate_non_deterministic_git_source() {
2025        let rockspec_content = r#"
2026            package = "test-package"
2027            version = "1.0.0"
2028            lua = ">=5.1"
2029
2030            [source]
2031            url = "git+https://exaple.com/repo.git"
2032
2033            [build]
2034            type = "builtin"
2035        "#;
2036
2037        PartialProjectToml::new(PROJECT_TOML, rockspec_content, ProjectRoot::default())
2038            .unwrap()
2039            .into_remote(None)
2040            .unwrap_err();
2041    }
2042
2043    #[test]
2044    fn generate_deterministic_git_source() {
2045        let rockspec_content = r#"
2046            package = "test-package"
2047            version = "1.0.0"
2048            lua = ">=5.1"
2049
2050            [source]
2051            url = "git+https://exaple.com/owner/repo.git"
2052            tag = "v0.1.0"
2053
2054            [build]
2055            type = "builtin"
2056        "#;
2057
2058        PartialProjectToml::new(PROJECT_TOML, rockspec_content, ProjectRoot::default())
2059            .unwrap()
2060            .into_remote(None)
2061            .unwrap();
2062    }
2063
2064    #[test]
2065    fn generate_git_source_with_branch() {
2066        let rockspec_content = r#"
2067            package = "test-package"
2068            version = "1.0.0"
2069            lua = ">=5.1"
2070
2071            [source]
2072            url = "git+https://example.com/owner/repo.git"
2073            branch = "main"
2074
2075            [build]
2076            type = "builtin"
2077        "#;
2078
2079        let toml = PartialProjectToml::new(PROJECT_TOML, rockspec_content, ProjectRoot::default())
2080            .unwrap()
2081            .into_remote(None)
2082            .unwrap();
2083
2084        let lua = toml.to_lua_remote_rockspec_string().unwrap();
2085        assert!(lua.contains("branch = \"main\""));
2086        assert!(!lua.contains("tag = "));
2087    }
2088
2089    fn init_git_repo(path: &std::path::Path) -> Repository {
2090        let repo = Repository::init(path).unwrap();
2091        {
2092            let tree_id = repo.index().unwrap().write_tree().unwrap();
2093            let tree = repo.find_tree(tree_id).unwrap();
2094            let sig = git2::Signature::now("name", "email").unwrap();
2095            repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
2096                .unwrap();
2097        }
2098        repo
2099    }
2100
2101    fn init_sample_project_repo(temp_dir: &assert_fs::TempDir) -> Repository {
2102        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2103            .join("resources/test/sample-projects/source-template/");
2104        temp_dir.copy_from(&sample_project, &["**"]).unwrap();
2105        init_git_repo(temp_dir.path())
2106    }
2107
2108    fn create_tag(repo: &Repository, name: &str) {
2109        let sig = git2::Signature::now("name", "email").unwrap();
2110        let id = repo.head().unwrap().target().unwrap();
2111        let obj = repo.find_object(id, None).unwrap();
2112        repo.tag(name, &obj, &sig, "msg", true).unwrap();
2113    }
2114
2115    #[test]
2116    fn test_git_project_generate_dev_source() {
2117        let project_root = assert_fs::TempDir::new().unwrap();
2118        init_sample_project_repo(&project_root);
2119        let project = Project::from_exact(&project_root).unwrap().unwrap();
2120        let remote_project_toml = project.toml().into_remote(None).unwrap();
2121        let source = remote_project_toml.source.current_platform();
2122        let source_spec = &source.source_spec;
2123        assert!(matches!(source_spec, &RockSourceSpec::Git { .. }));
2124        if let RockSourceSpec::Git(GitSource { url, git_ref }) = source_spec {
2125            let expected_url: RemoteGitUrl =
2126                "https://github.com/lumen-oss/lux.git".parse().unwrap();
2127            assert_eq!(url, &expected_url);
2128            assert!(git_ref.is_some());
2129        }
2130        assert_eq!(source.unpack_dir, Some("lux-dev".into()));
2131    }
2132
2133    #[test]
2134    fn test_git_project_generate_non_semver_tag_source() {
2135        let project_root = assert_fs::TempDir::new().unwrap();
2136        let repo = init_sample_project_repo(&project_root);
2137        let tag_name = "bla";
2138        create_tag(&repo, tag_name);
2139        let project = Project::from_exact(&project_root).unwrap().unwrap();
2140        let remote_project_toml = project.toml().into_remote(None).unwrap();
2141        let source = remote_project_toml.source.current_platform();
2142        let source_spec = &source.source_spec;
2143        assert!(matches!(source_spec, &RockSourceSpec::Git { .. }));
2144        if let RockSourceSpec::Git(GitSource { url, git_ref }) = source_spec {
2145            let expected_url: RemoteGitUrl =
2146                "https://github.com/lumen-oss/lux.git".parse().unwrap();
2147            assert_eq!(url, &expected_url);
2148            assert_eq!(git_ref, &Some(GitRef::Tag(tag_name.to_string())));
2149        }
2150        assert_eq!(source.unpack_dir, Some("lux-dev".into()));
2151    }
2152
2153    #[test]
2154    fn test_git_project_generate_release_source_tag_with_v_prefix() {
2155        let project_root = assert_fs::TempDir::new().unwrap();
2156        let repo = init_sample_project_repo(&project_root);
2157        let tag_name = "v1.0.0";
2158        create_tag(&repo, "bla");
2159        create_tag(&repo, tag_name);
2160        let project = Project::from_exact(&project_root).unwrap().unwrap();
2161        let remote_project_toml = project.toml().into_remote(None).unwrap();
2162        let source = remote_project_toml.source.current_platform();
2163        let source_spec = &source.source_spec;
2164        assert!(matches!(source_spec, &RockSourceSpec::Url { .. }));
2165        if let RockSourceSpec::Url(url) = source_spec {
2166            let expected_url: Url = "https://github.com/lumen-oss/lux/archive/refs/tags/v1.0.0.zip"
2167                .parse()
2168                .unwrap();
2169            assert_eq!(url, &expected_url);
2170        }
2171        assert_eq!(source.unpack_dir, Some("lux-1.0.0".into()));
2172    }
2173
2174    #[test]
2175    fn test_git_project_generate_release_source_tag_without_v_prefix() {
2176        let project_root = assert_fs::TempDir::new().unwrap();
2177        let repo = init_sample_project_repo(&project_root);
2178        create_tag(&repo, "bla");
2179        let tag_name = "1.0.0";
2180        create_tag(&repo, tag_name);
2181        let project = Project::from_exact(&project_root).unwrap().unwrap();
2182        let remote_project_toml = project.toml().into_remote(None).unwrap();
2183        let source = remote_project_toml.source.current_platform();
2184        let source_spec = &source.source_spec;
2185        assert!(matches!(source_spec, &RockSourceSpec::Url { .. }));
2186        if let RockSourceSpec::Url(url) = source_spec {
2187            let expected_url: Url = "https://github.com/lumen-oss/lux/archive/refs/tags/1.0.0.zip"
2188                .parse()
2189                .unwrap();
2190            assert_eq!(url, &expected_url);
2191        }
2192        assert_eq!(source.unpack_dir, Some("lux-1.0.0".into()));
2193    }
2194
2195    #[test]
2196    fn test_git_project_in_subdirectory() {
2197        let temp_dir = assert_fs::TempDir::new().unwrap();
2198        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2199            .join("resources/test/sample-projects/source-template/");
2200        let project_dir = temp_dir.child("lux");
2201        project_dir.create_dir_all().unwrap();
2202        project_dir.copy_from(&sample_project, &["**"]).unwrap();
2203        let repo = init_git_repo(temp_dir.path());
2204        create_tag(&repo, "bla");
2205        let tag_name = "1.0.0";
2206        create_tag(&repo, tag_name);
2207        let project = Project::from_exact(&project_dir).unwrap().unwrap();
2208        let remote_project_toml = project.toml().into_remote(None).unwrap();
2209        let source = remote_project_toml.source.current_platform();
2210        let source_spec = &source.source_spec;
2211        assert!(matches!(source_spec, &RockSourceSpec::Url { .. }));
2212        if let RockSourceSpec::Url(url) = source_spec {
2213            let expected_url: Url = "https://github.com/lumen-oss/lux/archive/refs/tags/1.0.0.zip"
2214                .parse()
2215                .unwrap();
2216            assert_eq!(url, &expected_url);
2217        }
2218        assert_eq!(source.unpack_dir, Some("lux-1.0.0".into()));
2219    }
2220
2221    #[test]
2222    fn test_git_project_in_subdirectory_generate_dev_source() {
2223        let temp_dir = assert_fs::TempDir::new().unwrap();
2224        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2225            .join("resources/test/sample-projects/source-template/");
2226        let project_dir = temp_dir.child("lux");
2227        project_dir.create_dir_all().unwrap();
2228        project_dir.copy_from(&sample_project, &["**"]).unwrap();
2229        init_git_repo(temp_dir.path());
2230
2231        let project = Project::from_exact(&project_dir).unwrap().unwrap();
2232        let remote_project_toml = project.toml().into_remote(None).unwrap();
2233        let RockSourceSpec::Git(GitSource { git_ref, .. }) =
2234            &remote_project_toml.source.current_platform().source_spec
2235        else {
2236            panic!("expected a git source");
2237        };
2238        assert!(git_ref.is_some());
2239    }
2240}