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