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::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::{collections::HashMap, path::PathBuf};
24
25use crate::{
26    config::Config,
27    lua_rockspec::{
28        BuildSpec, BuildSpecInternal, BuildSpecInternalError, DisplayAsLuaKV, ExternalDependencies,
29        ExternalDependencySpec, LuaVersionError, PartialLuaRockspec, PerPlatform,
30        PlatformIdentifier, PlatformSupport, PlatformValidationError, RemoteRockSource,
31        RockDescription, RockSourceError, RockspecFormat, TestSpec, TestSpecDecodeError,
32        TestSpecInternal,
33    },
34    package::{
35        BuildDependencies, Dependencies, PackageName, PackageReq, PackageVersion, PackageVersionReq,
36    },
37    rockspec::{LuaVersionCompatibility, Rockspec},
38};
39use itertools::Itertools;
40use miette::Diagnostic;
41use nonempty::NonEmpty;
42use serde::de;
43use serde::{Deserialize, Deserializer};
44use ssri::Integrity;
45use thiserror::Error;
46
47use super::gen::GenerateSourceError;
48use super::gen::RockSourceTemplate;
49use super::r#gen::GenerateVersionError;
50use super::r#gen::PackageVersionTemplate;
51use super::ProjectRoot;
52
53pub const PROJECT_TOML: &str = "lux.toml";
54
55#[derive(Deserialize)]
56#[serde(untagged)]
57#[allow(clippy::large_enum_variant)] // This is ok because it's just a Deserialize helper
58enum DependencyEntry {
59    Simple(PackageVersionReq),
60    Detailed(DependencyTableEntry),
61}
62
63#[derive(Debug, Deserialize)]
64struct DependencyTableEntry {
65    version: PackageVersionReq,
66    #[serde(default)]
67    opt: Option<bool>,
68    #[serde(default)]
69    pin: Option<bool>,
70    #[serde(default)]
71    git: Option<RemoteGitUrlShorthand>,
72    #[serde(default)]
73    path: Option<PathBuf>,
74    #[serde(default)]
75    rev: Option<String>,
76}
77
78fn parse_map_to_dependency_vec_opt<'de, D>(
79    deserializer: D,
80) -> Result<Option<Vec<LuaDependencySpec>>, D::Error>
81where
82    D: Deserializer<'de>,
83{
84    let packages: Option<HashMap<PackageName, DependencyEntry>> =
85        Option::deserialize(deserializer)?;
86
87    match packages {
88        None => Ok(None),
89        Some(packages) => Ok(Some(
90            packages
91                .into_iter()
92                .map(|(name, spec)| match spec {
93                    DependencyEntry::Simple(version_req) => {
94                        Ok(PackageReq { name, version_req }.into())
95                    }
96                    DependencyEntry::Detailed(entry) => {
97                        let source = match (entry.git, entry.rev, entry.path) {
98                            (None, None, None) => Ok(None),
99                            (None, Some(_), None) => Err(de::Error::custom(format!(
100                                "dependency {} specifies a 'rev', but missing a 'git' field",
101                                name
102                            ))),
103                            (Some(git), Some(rev), None) => Ok(Some(RockSourceSpec::Git(GitSource {
104                                url: git.into(),
105                                checkout_ref: Some(rev),
106                            }))),
107                            (Some(git), None, None) => Ok(Some(RockSourceSpec::Git(GitSource {
108                                url: git.into(),
109                                checkout_ref: Some(
110                                    entry
111                                        .version
112                                        .clone()
113                                        .to_string()
114                                        .trim_start_matches("=")
115                                        .to_string(),
116                                ),
117                            }))),
118                            (None, None, Some(path)) => Ok(Some(RockSourceSpec::File(path))),
119                            (_, _, Some(_)) => Err(de::Error::custom(format!(
120                                "dependency '{}' specifies a 'path', which cannot be combined with 'git' or 'rev'",
121                                name
122                            ))),
123                        }?;
124                        Ok(LuaDependencySpec {
125                            package_req: PackageReq {
126                                name,
127                                version_req: entry.version,
128                            },
129                            opt: OptState::from(entry.opt.unwrap_or(false)),
130                            pin: PinnedState::from(entry.pin.unwrap_or(false)),
131                            source,
132                        })
133                    }
134                })
135                .try_collect()?,
136        )),
137    }
138}
139
140#[derive(Debug, Error, Diagnostic)]
141pub enum ProjectTomlError {
142    #[error("error generating rockspec source:\n{0}")]
143    #[diagnostic(forward(0))]
144    GenerateSource(#[from] GenerateSourceError),
145    #[error("error generating rockspec version:\n{0}")]
146    #[diagnostic(forward(0))]
147    GenerateVersion(#[from] GenerateVersionError),
148    #[error("generated rockspec exceeds computational limit of {ROCKSPEC_FUEL_LIMIT} steps")]
149    #[diagnostic(help(
150        r#"the rockspec may be too complex or contain too many dependencies.
151        Try reducing the number of dependencies or simplifying build instructions."#
152    ))]
153    FuelLimitExceeded,
154    #[error(
155        r#"generated invalid Lua from TOML:
156{0}
157"#
158    )]
159    #[diagnostic(help("this is a bug in Lux. please report it."))]
160    GeneratedInvalidLua(String),
161}
162
163#[derive(Debug, Error, Diagnostic)]
164pub enum LocalProjectTomlValidationError {
165    #[error("no lua version provided")]
166    #[diagnostic(help(
167        r#"add a `lua` field to your lux.toml, for example:
168
169  lua = ">=5.1""#
170    ))]
171    NoLuaVersion,
172    #[error("could not decode the test spec:\n:{0}")]
173    #[diagnostic(forward(0))]
174    TestSpecError(#[from] TestSpecDecodeError),
175    #[error("could not decode the build spec:\n:{0}")]
176    #[diagnostic(forward(0))]
177    BuildSpecInternal(#[from] BuildSpecInternalError),
178    #[error(transparent)]
179    #[diagnostic(transparent)]
180    PlatformValidation(#[from] PlatformValidationError),
181    #[error("{}copy_directories cannot contain a rockspec name", ._0.as_ref().map(|p| format!("{p}: ")).unwrap_or_default())]
182    CopyDirectoriesContainRockspecName(Option<String>),
183    #[error("could not decode the source spec:\n:{0}")]
184    #[diagnostic(forward(0))]
185    RockSource(#[from] RockSourceError),
186    #[error("duplicate dependencies: {0}")]
187    #[diagnostic(help("remove the duplicate entries from the [dependencies] table."))]
188    DuplicateDependencies(PackageNameList),
189    #[error("duplicate test dependencies: {0}")]
190    #[diagnostic(help("remove the duplicate entries from the [test_dependencies] table."))]
191    DuplicateTestDependencies(PackageNameList),
192    #[error("duplicate build dependencies: {0}")]
193    #[diagnostic(help("remove the duplicate entries from the [build_dependencies] table."))]
194    DuplicateBuildDependencies(PackageNameList),
195    #[error(
196        r#"dependencies field cannot contain 'lua'.
197        Please provide the version in the top-level 'lua' field
198
199        Example:
200
201        ```toml
202        package = "my-package"
203        lua = ">=5.1"
204
205        [dependencies]
206        # do not add Lua here!
207        ```
208        "#
209    )]
210    DependenciesContainLua,
211    #[error("error generating rockspec source:\n{0}")]
212    #[diagnostic(forward(0))]
213    GenerateSource(#[from] GenerateSourceError),
214    #[error("error generating rockspec version:\n{0}")]
215    #[diagnostic(forward(0))]
216    GenerateVersion(#[from] GenerateVersionError),
217}
218
219#[derive(Debug, Error, Diagnostic)]
220pub enum RemoteProjectTomlValidationError {
221    #[error("error generating rockspec source:\n{0}")]
222    #[diagnostic(forward(0))]
223    GenerateSource(#[from] GenerateSourceError),
224    #[error("error generating rockspec version:\n{0}")]
225    #[diagnostic(forward(0))]
226    GenerateVersion(#[from] GenerateVersionError),
227    #[error(transparent)]
228    #[diagnostic(transparent)]
229    LocalProjectTomlValidationError(#[from] LocalProjectTomlValidationError),
230}
231
232/// The `lux.toml` file for a project.
233/// The only required fields are `package` and `build`, which are required to build a project using `lux build`.
234/// The rest of the fields are optional, but are required to build a rockspec.
235#[derive(Clone, Debug, Deserialize)]
236pub struct PartialProjectToml {
237    pub(crate) package: PackageName,
238    #[serde(default, rename = "version")]
239    pub(crate) version_template: PackageVersionTemplate,
240    #[serde(default)]
241    pub(crate) build: BuildSpecInternal,
242    pub(crate) rockspec_format: Option<RockspecFormat>,
243    #[serde(default)]
244    pub(crate) run: Option<RunSpec>,
245    #[serde(default)]
246    pub(crate) lua: Option<PackageVersionReq>,
247    #[serde(default)]
248    pub(crate) description: Option<RockDescription>,
249    #[serde(default)]
250    pub(crate) supported_platforms: Option<HashMap<PlatformIdentifier, bool>>,
251    #[serde(default, deserialize_with = "parse_map_to_dependency_vec_opt")]
252    pub(crate) dependencies: Option<Vec<LuaDependencySpec>>,
253    #[serde(default, deserialize_with = "parse_map_to_dependency_vec_opt")]
254    pub(crate) build_dependencies: Option<Vec<LuaDependencySpec>>,
255    #[serde(default)]
256    pub(crate) external_dependencies: Option<HashMap<String, ExternalDependencySpec>>,
257    #[serde(default, deserialize_with = "parse_map_to_dependency_vec_opt")]
258    pub(crate) test_dependencies: Option<Vec<LuaDependencySpec>>,
259    #[serde(default, rename = "source")]
260    pub(crate) source_template: RockSourceTemplate,
261    #[serde(default)]
262    pub(crate) test: Option<TestSpecInternal>,
263    #[serde(default)]
264    pub(crate) deploy: Option<DeploySpec>,
265
266    /// Used to bind the project TOML to a project root
267    #[serde(skip, default = "ProjectRoot::new")]
268    pub(crate) project_root: ProjectRoot,
269}
270
271impl HasIntegrity for PartialProjectToml {
272    fn hash(&self) -> io::Result<Integrity> {
273        let toml_file = self.project_root.join(PROJECT_TOML);
274        let content = fs::sync::read_to_string(&toml_file).map_err(io::Error::other)?;
275        Ok(Integrity::from(&content))
276    }
277}
278
279impl PartialProjectToml {
280    pub(crate) fn new(
281        name: &str,
282        str: &str,
283        project_root: ProjectRoot,
284    ) -> Result<Self, TomlDeError> {
285        Ok(Self {
286            project_root,
287            ..super::parse_toml(name, str)?
288        })
289    }
290
291    /// This project's Lua version requirement, if any
292    pub fn lua(&self) -> Option<&PackageVersionReq> {
293        self.lua.as_ref()
294    }
295
296    /// Convert the `PartialProjectToml` struct into a `LocalProjectToml` struct, making
297    /// it ready to be used for building a project.
298    pub fn into_local(&self) -> Result<LocalProjectToml, LocalProjectTomlValidationError> {
299        let project_toml = self.clone();
300
301        // Disallow `lua` to be part of the `dependencies` field
302        if project_toml
303            .dependencies
304            .as_ref()
305            .is_some_and(|deps| deps.iter().any(|dep| dep.name() == &"lua".into()))
306        {
307            return Err(LocalProjectTomlValidationError::DependenciesContainLua);
308        }
309
310        let get_duplicates = |dependencies: &Option<Vec<LuaDependencySpec>>| {
311            dependencies
312                .iter()
313                .flat_map(|deps| {
314                    deps.iter()
315                        .map(|dep| dep.package_req().name())
316                        .duplicates()
317                        .cloned()
318                })
319                .collect_vec()
320        };
321        let duplicate_dependencies = get_duplicates(&self.dependencies);
322        if !duplicate_dependencies.is_empty() {
323            return Err(LocalProjectTomlValidationError::DuplicateDependencies(
324                PackageNameList::new(duplicate_dependencies),
325            ));
326        }
327        let duplicate_test_dependencies = get_duplicates(&self.test_dependencies);
328        if !duplicate_test_dependencies.is_empty() {
329            return Err(LocalProjectTomlValidationError::DuplicateTestDependencies(
330                PackageNameList::new(duplicate_test_dependencies),
331            ));
332        }
333        let duplicate_build_dependencies = get_duplicates(&self.build_dependencies);
334        if !duplicate_build_dependencies.is_empty() {
335            return Err(LocalProjectTomlValidationError::DuplicateBuildDependencies(
336                PackageNameList::new(duplicate_build_dependencies),
337            ));
338        }
339
340        let validated = LocalProjectToml {
341            internal: project_toml.clone(),
342
343            package: project_toml.package,
344            version: project_toml
345                .version_template
346                .try_generate(&self.project_root, None)
347                .unwrap_or(PackageVersion::default_dev_version()),
348            lua: project_toml
349                .lua
350                .ok_or(LocalProjectTomlValidationError::NoLuaVersion)?,
351            description: project_toml.description.unwrap_or_default(),
352            run: project_toml.run.map(PerPlatform::new),
353            supported_platforms: PlatformSupport::parse(
354                &project_toml
355                    .supported_platforms
356                    .unwrap_or_default()
357                    .into_iter()
358                    .map(|(platform, supported)| {
359                        if supported {
360                            format!("{platform}")
361                        } else {
362                            format!("!{platform}")
363                        }
364                    })
365                    .collect_vec(),
366            )?,
367            // Merge dependencies internally with lua version
368            // so the output of `dependencies()` is consistent
369            dependencies: PerPlatform::new(
370                project_toml
371                    .dependencies
372                    .unwrap_or_default()
373                    .into_iter()
374                    .map(|dep| self.resolve_lua_dependency_spec(dep))
375                    .collect_vec(),
376            ),
377            test_dependencies: PerPlatform::new(
378                project_toml
379                    .test_dependencies
380                    .unwrap_or_default()
381                    .into_iter()
382                    .map(|dep| self.resolve_lua_dependency_spec(dep))
383                    .collect_vec(),
384            ),
385            build_dependencies: PerPlatform::new(
386                project_toml
387                    .build_dependencies
388                    .unwrap_or_default()
389                    .into_iter()
390                    .map(|dep| self.resolve_lua_dependency_spec(dep))
391                    .collect_vec(),
392            ),
393            external_dependencies: PerPlatform::new(
394                project_toml.external_dependencies.unwrap_or_default(),
395            ),
396            test: PerPlatform::new(TestSpec::try_from(
397                project_toml.test.clone().unwrap_or_default(),
398            )?),
399            build: PerPlatform::new(BuildSpec::from_internal_spec(project_toml.build.clone())?),
400            deploy: PerPlatform::new(project_toml.deploy.clone().unwrap_or_default()),
401            rockspec_format: project_toml.rockspec_format.clone(),
402
403            source: PerPlatform::new(RemoteRockSource {
404                local: LocalRockSource::default(),
405                source_spec: RockSourceSpec::File(self.project_root.to_path_buf()),
406            }),
407        };
408
409        let rockspec_file_name = format!("{}-{}.rockspec", validated.package, validated.version);
410
411        if validated
412            .build
413            .default
414            .copy_directories
415            .contains(&PathBuf::from(&rockspec_file_name))
416        {
417            return Err(LocalProjectTomlValidationError::CopyDirectoriesContainRockspecName(None));
418        }
419
420        for (platform, build_override) in &validated.build.per_platform {
421            if build_override
422                .copy_directories
423                .contains(&PathBuf::from(&rockspec_file_name))
424            {
425                return Err(
426                    LocalProjectTomlValidationError::CopyDirectoriesContainRockspecName(Some(
427                        platform.to_string(),
428                    )),
429                );
430            }
431        }
432
433        Ok(validated)
434    }
435
436    /// Convert the `PartialProjectToml` struct into a `RemoteProjectToml` struct, making
437    /// it ready to be serialized into a rockspec.
438    /// A source must be provided for the rockspec to be valid.
439    pub fn into_remote(
440        &self,
441        specrev: Option<SpecRev>,
442    ) -> Result<RemoteProjectToml, RemoteProjectTomlValidationError> {
443        let version = self
444            .version_template
445            .try_generate(&self.project_root, specrev)?;
446        let source =
447            self.source_template
448                .try_generate(&self.project_root, &self.package, &version)?;
449        let source = PerPlatform::new(RemoteRockSource::try_from(source).map_err(|err| {
450            RemoteProjectTomlValidationError::LocalProjectTomlValidationError(
451                LocalProjectTomlValidationError::RockSource(err),
452            )
453        })?);
454        let mut local = self.into_local()?;
455        local.version = version;
456
457        let validated = RemoteProjectToml { source, local };
458
459        Ok(validated)
460    }
461
462    // In the not-yet-validated struct, we create getters only
463    // for the non-optional fields.
464    pub fn package(&self) -> &PackageName {
465        &self.package
466    }
467
468    /// Returns the current package version, which may be generated from a template
469    pub fn version(&self) -> Result<PackageVersion, GenerateVersionError> {
470        self.version_template.try_generate(&self.project_root, None)
471    }
472
473    /// Merge the `ProjectToml` struct with an unvalidated `LuaRockspec`.
474    /// The final merged struct can then be validated.
475    pub fn merge(self, other: PartialLuaRockspec) -> Self {
476        PartialProjectToml {
477            package: other.package.unwrap_or(self.package),
478            version_template: self.version_template,
479            lua: other
480                .dependencies
481                .as_ref()
482                .and_then(|deps| {
483                    deps.iter()
484                        .find(|dep| dep.name() == &"lua".into())
485                        .and_then(|dep| {
486                            if dep.version_req().is_any() {
487                                None
488                            } else {
489                                Some(dep.version_req().clone())
490                            }
491                        })
492                })
493                .or(self.lua),
494            build: other.build.unwrap_or(self.build),
495            run: self.run,
496            description: other.description.or(self.description),
497            supported_platforms: other
498                .supported_platforms
499                .map(|platform_support| platform_support.platforms().clone())
500                .or(self.supported_platforms),
501            dependencies: other
502                .dependencies
503                .map(|deps| {
504                    deps.into_iter()
505                        .filter(|dep| dep.name() != &"lua".into())
506                        .collect()
507                })
508                .or(self.dependencies),
509            build_dependencies: other.build_dependencies.or(self.build_dependencies),
510            test_dependencies: other.test_dependencies.or(self.test_dependencies),
511            external_dependencies: other.external_dependencies.or(self.external_dependencies),
512            source_template: self.source_template,
513            test: other.test.or(self.test),
514            deploy: other.deploy.or(self.deploy),
515            rockspec_format: other.rockspec_format.or(self.rockspec_format),
516
517            // Keep the project root the same, as it is not part of the lua rockspec
518            project_root: self.project_root,
519        }
520    }
521
522    fn resolve_lua_dependency_spec(&self, dep: LuaDependencySpec) -> LuaDependencySpec {
523        match &dep.source {
524            Some(RockSourceSpec::File(path)) if path.is_dir() => dep,
525            Some(RockSourceSpec::File(path)) => LuaDependencySpec {
526                source: Some(RockSourceSpec::File(self.project_root.join(path))),
527                ..dep
528            },
529            _ => dep,
530        }
531    }
532}
533
534// This is automatically implemented for `RemoteProjectToml`,
535// but we also add a special implementation for `ProjectToml` (as providing a lua version
536// is required even by the non-validated struct).
537impl LuaVersionCompatibility for PartialProjectToml {
538    fn validate_lua_version(&self, version: &LuaVersion) -> Result<(), LuaVersionError> {
539        if self.supports_lua_version(version) {
540            Ok(())
541        } else {
542            Err(LuaVersionError::LuaVersionUnsupported(
543                version.clone(),
544                self.package().to_owned(),
545                self.version_template
546                    .try_generate(&self.project_root, None)
547                    .unwrap_or(PackageVersion::default_dev_version()),
548            ))
549        }
550    }
551
552    fn validate_lua_version_from_config(&self, config: &Config) -> Result<(), LuaVersionError> {
553        let _ = self.lua_version_matches(config)?;
554        Ok(())
555    }
556
557    fn lua_version_matches(&self, config: &Config) -> Result<LuaVersion, LuaVersionError> {
558        let version = LuaVersion::from(config)?.clone();
559        if self.supports_lua_version(&version) {
560            Ok(version)
561        } else {
562            Err(LuaVersionError::LuaVersionUnsupported(
563                version,
564                self.package.clone(),
565                self.version_template
566                    .try_generate(&self.project_root, None)
567                    .unwrap_or(PackageVersion::default_dev_version()),
568            ))
569        }
570    }
571
572    fn supports_lua_version(&self, lua_version: &LuaVersion) -> bool {
573        self.lua
574            .as_ref()
575            .is_none_or(|lua| lua.matches(&lua_version.as_version()))
576    }
577
578    fn lua_version(&self) -> Option<LuaVersion> {
579        for (possibility, version) in [
580            ("5.5.0", LuaVersion::Lua55),
581            ("5.4.0", LuaVersion::Lua54),
582            ("5.3.0", LuaVersion::Lua53),
583            ("5.2.0", LuaVersion::Lua52),
584            ("5.1.0", LuaVersion::Lua51),
585        ] {
586            let possibility = unsafe { possibility.parse().unwrap_unchecked() };
587            if self
588                .lua
589                .as_ref()
590                .is_none_or(|lua| lua.matches(&possibility))
591            {
592                return Some(version);
593            }
594        }
595        None
596    }
597}
598
599// TODO(vhyrro): Move this struct into a different directory.
600#[derive(Debug, Clone, Deserialize)]
601pub struct RunSpec {
602    /// The command to execute when running the project
603    pub(crate) command: Option<RunCommand>,
604    /// Arguments to pass to the command
605    pub(crate) args: Option<NonEmpty<String>>,
606}
607
608/// The `lux.toml` file, after being properly deserialized.
609/// This struct may be used to build a local version of a project.
610/// To build a rockspec, use `RemoteProjectToml`.
611#[derive(Debug)]
612pub struct LocalProjectToml {
613    package: PackageName,
614    version: PackageVersion,
615    lua: PackageVersionReq,
616    rockspec_format: Option<RockspecFormat>,
617    run: Option<PerPlatform<RunSpec>>,
618    description: RockDescription,
619    supported_platforms: PlatformSupport,
620    dependencies: PerPlatform<Vec<LuaDependencySpec>>,
621    build_dependencies: PerPlatform<Vec<LuaDependencySpec>>,
622    external_dependencies: PerPlatform<HashMap<String, ExternalDependencySpec>>,
623    test_dependencies: PerPlatform<Vec<LuaDependencySpec>>,
624    test: PerPlatform<TestSpec>,
625    build: PerPlatform<BuildSpec>,
626    deploy: PerPlatform<DeploySpec>,
627
628    // Used for simpler serialization
629    internal: PartialProjectToml,
630
631    /// A source pointing to the current project's root.
632    source: PerPlatform<RemoteRockSource>,
633}
634
635impl LocalProjectToml {
636    pub fn run(&self) -> Option<&PerPlatform<RunSpec>> {
637        self.run.as_ref()
638    }
639
640    /// Convert this project TOML to a Lua rockspec.
641    /// Fails if there is no valid project root or if there are off-spec dependencies.
642    pub fn to_lua_rockspec(&self) -> Result<LocalLuaRockspec, LuaRockspecError> {
643        if let Some(dep) = self
644            .dependencies()
645            .per_platform
646            .values()
647            .filter_map(|deps| deps.iter().find(|dep| dep.source().is_some()))
648            .collect_vec()
649            .first()
650        {
651            return Err(LuaRockspecError::OffSpecDependency(dep.name().clone()));
652        }
653        if let Some(dep) = self
654            .build_dependencies()
655            .per_platform
656            .values()
657            .filter_map(|deps| deps.iter().find(|dep| dep.source().is_some()))
658            .collect_vec()
659            .first()
660        {
661            return Err(LuaRockspecError::OffSpecBuildDependency(dep.name().clone()));
662        }
663        if let Some(dep) = self
664            .test_dependencies()
665            .per_platform
666            .values()
667            .filter_map(|deps| deps.iter().find(|dep| dep.source().is_some()))
668            .collect_vec()
669            .first()
670        {
671            return Err(LuaRockspecError::OffSpecTestDependency(dep.name().clone()));
672        }
673        LocalLuaRockspec::new(
674            &self.to_lua_remote_rockspec_string()?,
675            self.internal.project_root.clone(),
676        )
677    }
678}
679
680impl Rockspec for LocalProjectToml {
681    type Error = ProjectTomlError;
682
683    fn package(&self) -> &PackageName {
684        &self.package
685    }
686
687    fn version(&self) -> &PackageVersion {
688        &self.version
689    }
690
691    fn description(&self) -> &RockDescription {
692        &self.description
693    }
694
695    fn supported_platforms(&self) -> &PlatformSupport {
696        &self.supported_platforms
697    }
698
699    fn lua(&self) -> &PackageVersionReq {
700        &self.lua
701    }
702
703    fn dependencies(&self) -> &PerPlatform<Vec<LuaDependencySpec>> {
704        &self.dependencies
705    }
706
707    fn build_dependencies(&self) -> &PerPlatform<Vec<LuaDependencySpec>> {
708        &self.build_dependencies
709    }
710
711    fn external_dependencies(&self) -> &PerPlatform<HashMap<String, ExternalDependencySpec>> {
712        &self.external_dependencies
713    }
714
715    fn test_dependencies(&self) -> &PerPlatform<Vec<LuaDependencySpec>> {
716        &self.test_dependencies
717    }
718
719    fn build(&self) -> &PerPlatform<BuildSpec> {
720        &self.build
721    }
722
723    fn test(&self) -> &PerPlatform<TestSpec> {
724        &self.test
725    }
726
727    fn build_mut(&mut self) -> &mut PerPlatform<BuildSpec> {
728        &mut self.build
729    }
730
731    fn test_mut(&mut self) -> &mut PerPlatform<TestSpec> {
732        &mut self.test
733    }
734
735    fn format(&self) -> &Option<RockspecFormat> {
736        &self.rockspec_format
737    }
738
739    fn source(&self) -> &PerPlatform<RemoteRockSource> {
740        &self.source
741    }
742
743    fn source_mut(&mut self) -> &mut PerPlatform<RemoteRockSource> {
744        &mut self.source
745    }
746
747    fn deploy(&self) -> &PerPlatform<DeploySpec> {
748        &self.deploy
749    }
750
751    fn deploy_mut(&mut self) -> &mut PerPlatform<DeploySpec> {
752        &mut self.deploy
753    }
754
755    fn to_lua_remote_rockspec_string(&self) -> Result<String, Self::Error> {
756        let project_root = &self.internal.project_root;
757        let version = self
758            .internal
759            .version_template
760            .try_generate(project_root, None)?;
761        let starter = format!(
762            r#"
763rockspec_format = "{}"
764package = "{}"
765version = "{}""#,
766            self.rockspec_format
767                .as_ref()
768                .unwrap_or(&RockspecFormat::default()),
769            self.package,
770            version
771        );
772
773        let mut template = Vec::new();
774
775        if self.description != RockDescription::default() {
776            template.push(self.description.display_lua());
777        }
778
779        if self.supported_platforms != PlatformSupport::default() {
780            template.push(self.supported_platforms.display_lua());
781        }
782
783        {
784            let mut dependencies = self.internal.dependencies.clone().unwrap_or_default();
785            dependencies.insert(
786                0,
787                PackageReq {
788                    name: "lua".into(),
789                    version_req: self.lua.clone(),
790                }
791                .into(),
792            );
793            template.push(Dependencies(&dependencies).display_lua());
794        }
795
796        let mut build_dependencies = self
797            .internal
798            .build_dependencies
799            .as_ref()
800            .cloned()
801            .unwrap_or_default();
802
803        let build_backend_dependency = self
804            .internal
805            .build
806            .build_type
807            .as_ref()
808            .and_then(|build_type| build_type.luarocks_build_backend());
809
810        if let Some(build_backend_dependency) = build_backend_dependency {
811            build_dependencies.push(build_backend_dependency);
812        }
813
814        if !build_dependencies.is_empty() {
815            template.push(BuildDependencies(&build_dependencies).display_lua());
816        }
817
818        match self.internal.external_dependencies {
819            Some(ref external_dependencies) if !external_dependencies.is_empty() => {
820                template.push(ExternalDependencies(external_dependencies).display_lua());
821            }
822            _ => {}
823        }
824
825        let source =
826            self.internal
827                .source_template
828                .try_generate(project_root, &self.package, &version)?;
829        template.push(source.display_lua());
830
831        template.push(self.internal.build.display_lua());
832
833        let unformatted_code = std::iter::once(starter)
834            .chain(template.into_iter().map(|kv| kv.to_string()))
835            .join("\n\n");
836        let result = match stylua_lib::format_code(
837            &unformatted_code,
838            stylua_lib::Config::default(),
839            None,
840            stylua_lib::OutputVerification::Full,
841        ) {
842            Ok(formatted_code) => formatted_code,
843            Err(_) => unformatted_code,
844        };
845        validate_generated_lua(&result)?;
846        Ok(result)
847    }
848}
849
850fn validate_generated_lua(lua_str: &str) -> Result<(), ProjectTomlError> {
851    let mut lua = ottavino::Lua::core();
852    lua.try_enter(|ctx| {
853        let closure = ottavino::Closure::load(ctx, None, lua_str.as_bytes())?;
854        let executor = ottavino::Executor::start(ctx, closure.into(), ());
855        if !executor.step(ctx, &mut ottavino::Fuel::with(ROCKSPEC_FUEL_LIMIT))? {
856            return Ok(Err(ProjectTomlError::FuelLimitExceeded));
857        }
858        Ok(Ok(()))
859    })
860    .map_err(|err| ProjectTomlError::GeneratedInvalidLua(err.to_string()))?
861}
862
863#[derive(Error, Debug, Diagnostic)]
864#[non_exhaustive]
865#[error(transparent)]
866pub enum ProjectTomlIntegrityError {
867    LuaRockspecError(#[from] LuaRockspecError),
868    IoError(#[from] io::Error),
869    #[error(transparent)]
870    #[diagnostic(transparent)]
871    Fs(#[from] fs::FsError),
872}
873
874impl HasIntegrity for LocalProjectToml {
875    fn hash(&self) -> io::Result<Integrity> {
876        match self.to_lua_rockspec() {
877            Ok(lua_rockspec) => lua_rockspec.hash(),
878            Err(_) => self.internal.hash(),
879        }
880    }
881}
882
883/// The `lux.toml`, after being validated and prepared for upload
884#[derive(Debug)]
885pub struct RemoteProjectToml {
886    local: LocalProjectToml,
887    source: PerPlatform<RemoteRockSource>,
888}
889
890impl RemoteProjectToml {
891    pub fn to_lua_rockspec(&self) -> Result<RemoteLuaRockspec, LuaRockspecError> {
892        RemoteLuaRockspec::new(&self.to_lua_remote_rockspec_string()?)
893    }
894}
895
896impl Rockspec for RemoteProjectToml {
897    type Error = ProjectTomlError;
898
899    fn package(&self) -> &PackageName {
900        self.local.package()
901    }
902
903    fn version(&self) -> &PackageVersion {
904        self.local.version()
905    }
906
907    fn description(&self) -> &RockDescription {
908        self.local.description()
909    }
910
911    fn supported_platforms(&self) -> &PlatformSupport {
912        self.local.supported_platforms()
913    }
914
915    fn lua(&self) -> &PackageVersionReq {
916        self.local.lua()
917    }
918
919    fn dependencies(&self) -> &PerPlatform<Vec<LuaDependencySpec>> {
920        self.local.dependencies()
921    }
922
923    fn build_dependencies(&self) -> &PerPlatform<Vec<LuaDependencySpec>> {
924        self.local.build_dependencies()
925    }
926
927    fn external_dependencies(&self) -> &PerPlatform<HashMap<String, ExternalDependencySpec>> {
928        self.local.external_dependencies()
929    }
930
931    fn test_dependencies(&self) -> &PerPlatform<Vec<LuaDependencySpec>> {
932        self.local.test_dependencies()
933    }
934
935    fn build(&self) -> &PerPlatform<BuildSpec> {
936        self.local.build()
937    }
938
939    fn test(&self) -> &PerPlatform<TestSpec> {
940        self.local.test()
941    }
942
943    fn build_mut(&mut self) -> &mut PerPlatform<BuildSpec> {
944        self.local.build_mut()
945    }
946
947    fn test_mut(&mut self) -> &mut PerPlatform<TestSpec> {
948        self.local.test_mut()
949    }
950
951    fn format(&self) -> &Option<RockspecFormat> {
952        self.local.format()
953    }
954
955    fn source(&self) -> &PerPlatform<RemoteRockSource> {
956        &self.source
957    }
958
959    fn source_mut(&mut self) -> &mut PerPlatform<RemoteRockSource> {
960        &mut self.source
961    }
962
963    fn deploy(&self) -> &PerPlatform<DeploySpec> {
964        self.local.deploy()
965    }
966
967    fn deploy_mut(&mut self) -> &mut PerPlatform<DeploySpec> {
968        self.local.deploy_mut()
969    }
970
971    fn to_lua_remote_rockspec_string(&self) -> Result<String, Self::Error> {
972        let project_root = &self.local.internal.project_root;
973        let starter = format!(
974            r#"
975rockspec_format = "{}"
976package = "{}"
977version = "{}""#,
978            self.local
979                .rockspec_format
980                .as_ref()
981                .unwrap_or(&RockspecFormat::default()),
982            self.local.package,
983            self.version()
984        );
985
986        let mut template = Vec::new();
987
988        if self.local.description != RockDescription::default() {
989            template.push(self.local.description.display_lua());
990        }
991
992        if self.local.supported_platforms != PlatformSupport::default() {
993            template.push(self.local.supported_platforms.display_lua());
994        }
995
996        {
997            let mut dependencies = self.local.internal.dependencies.clone().unwrap_or_default();
998            dependencies.insert(
999                0,
1000                PackageReq {
1001                    name: "lua".into(),
1002                    version_req: self.local.lua.clone(),
1003                }
1004                .into(),
1005            );
1006            template.push(Dependencies(&dependencies).display_lua());
1007        }
1008
1009        let mut build_dependencies = self
1010            .local
1011            .internal
1012            .build_dependencies
1013            .as_ref()
1014            .cloned()
1015            .unwrap_or_default();
1016
1017        let build_backend_dependency = self
1018            .local
1019            .internal
1020            .build
1021            .build_type
1022            .as_ref()
1023            .and_then(|build_type| build_type.luarocks_build_backend());
1024
1025        if let Some(build_backend_dependency) = build_backend_dependency {
1026            build_dependencies.push(build_backend_dependency);
1027        }
1028
1029        if !build_dependencies.is_empty() {
1030            template.push(BuildDependencies(&build_dependencies).display_lua());
1031        }
1032
1033        match self.local.internal.external_dependencies {
1034            Some(ref external_dependencies) if !external_dependencies.is_empty() => {
1035                template.push(ExternalDependencies(external_dependencies).display_lua());
1036            }
1037            _ => {}
1038        }
1039
1040        let source = self.local.internal.source_template.try_generate(
1041            project_root,
1042            &self.local.internal.package,
1043            self.version(),
1044        )?;
1045        template.push(source.display_lua());
1046
1047        if let Some(ref deploy) = self.local.internal.deploy {
1048            template.push(deploy.display_lua());
1049        }
1050
1051        template.push(self.local.internal.build.display_lua());
1052
1053        let unformatted_code = std::iter::once(starter)
1054            .chain(template.into_iter().map(|kv| kv.to_string()))
1055            .join("\n\n");
1056        let result = match stylua_lib::format_code(
1057            &unformatted_code,
1058            stylua_lib::Config::default(),
1059            None,
1060            stylua_lib::OutputVerification::Full,
1061        ) {
1062            Ok(formatted_code) => formatted_code,
1063            Err(_) => unformatted_code,
1064        };
1065        validate_generated_lua(&result)?;
1066        Ok(result)
1067    }
1068}
1069
1070impl HasIntegrity for RemoteProjectToml {
1071    fn hash(&self) -> io::Result<Integrity> {
1072        self.to_lua_rockspec()
1073            .map_err(|err| {
1074                io::Error::other(format!(
1075                    "unable to convert remote project to Lua rockspec:\n{}",
1076                    err
1077                ))
1078            })?
1079            .hash()
1080    }
1081}
1082
1083#[cfg(test)]
1084mod tests {
1085    use std::path::PathBuf;
1086
1087    use assert_fs::prelude::{PathChild, PathCopy, PathCreateDir};
1088    use git2::{Repository, RepositoryInitOptions};
1089    use url::Url;
1090
1091    use crate::{
1092        git::{url::RemoteGitUrl, GitSource},
1093        lua_rockspec::{PartialLuaRockspec, PerPlatform, RemoteLuaRockspec, RockSourceSpec},
1094        project::{Project, ProjectRoot, PROJECT_TOML},
1095        rockspec::{lua_dependency::LuaDependencySpec, Rockspec},
1096    };
1097
1098    use super::PartialProjectToml;
1099
1100    #[test]
1101    fn project_toml_parsing() {
1102        let project_toml = r#"
1103        package = "my-package"
1104        version = "1.0.0"
1105        lua = "5.3"
1106
1107        rockspec_format = "1.0"
1108
1109        [source]
1110        url = "https://example.com"
1111
1112        [dependencies]
1113        foo = "1.0"
1114        bar = ">=2.0"
1115
1116        [run]
1117        args = ["--foo", "--bar"]
1118
1119        [build]
1120        type = "builtin"
1121        "#;
1122
1123        let project =
1124            PartialProjectToml::new(PROJECT_TOML, project_toml, ProjectRoot::default()).unwrap();
1125        let _ = project.into_remote(None).unwrap();
1126
1127        let project_toml = r#"
1128        package = "my-package"
1129        version = "1.0.0"
1130        lua = "5.1"
1131
1132        [description]
1133        summary = "A summary"
1134        detailed = "A detailed description"
1135        license = "MIT"
1136        homepage = "https://example.com"
1137        issues_url = "https://example.com/issues"
1138        maintainer = "John Doe"
1139        labels = ["label1", "label2"]
1140
1141        [supported_platforms]
1142        linux = true
1143        windows = false
1144
1145        [dependencies]
1146        foo = "1.0"
1147        bar = ">=2.0"
1148
1149        [build_dependencies]
1150        baz = "1.0"
1151
1152        [external_dependencies.foo]
1153        header = "foo.h"
1154
1155        [external_dependencies.bar]
1156        library = "libbar.so"
1157
1158        [test_dependencies]
1159        busted = "69.420"
1160
1161        [source]
1162        url = "https://example.com"
1163        hash = "sha256-di00mD8txN7rjaVpvxzNbnQsAh6H16zUtJZapH7U4HU="
1164        file = "my-package-1.0.0.tar.gz"
1165        dir = "my-package-1.0.0"
1166
1167        [test]
1168        type = "command"
1169        script = "test.lua"
1170        flags = [ "foo", "bar" ]
1171
1172        [run]
1173        command = "my-command"
1174        args = ["--foo", "--bar"]
1175
1176        [build]
1177        type = "builtin"
1178        "#;
1179
1180        let project =
1181            PartialProjectToml::new(PROJECT_TOML, project_toml, ProjectRoot::default()).unwrap();
1182        let _ = project.into_remote(None).unwrap();
1183    }
1184
1185    #[test]
1186    fn compare_project_toml_with_rockspec() {
1187        let project_toml = r#"
1188        package = "my-package"
1189        version = "1.0.0"
1190        lua = "5.1"
1191
1192        # For testing, specify a custom rockspec format
1193        # (defaults to 3.0)
1194        rockspec_format = "1.0"
1195
1196        [description]
1197        summary = "A summary"
1198        detailed = "A detailed description"
1199        license = "MIT"
1200        homepage = "https://example.com"
1201        issues_url = "https://example.com/issues"
1202        maintainer = "John Doe"
1203        labels = ["label1", "label2"]
1204
1205        [supported_platforms]
1206        linux = true
1207        windows = false
1208
1209        [dependencies]
1210        foo = "1.0"
1211        bar = ">=2.0"
1212
1213        [build_dependencies]
1214        baz = "1.0"
1215
1216        [external_dependencies.foo]
1217        header = "foo.h"
1218
1219        [external_dependencies.bar]
1220        library = "libbar.so"
1221
1222        [test_dependencies]
1223        busted = "1.0"
1224
1225        [source]
1226        url = "https://example.com"
1227        file = "my-package-1.0.0.tar.gz"
1228        dir = "my-package-1.0.0"
1229
1230        [test]
1231        type = "command"
1232        script = "test.lua"
1233        flags = [ "foo", "bar" ]
1234
1235        [run]
1236        command = "my-command"
1237        args = ["--foo", "--bar"]
1238
1239        [deploy]
1240        wrap_bin_scripts = false
1241
1242        [build]
1243        type = "builtin"
1244
1245        [build.install.lua]
1246        "foo.bar" = "src/bar.lua"
1247
1248        [build.install.lib]
1249        "foo.baz" = "src/baz.c"
1250
1251        [build.install.bin]
1252        "bla" = "src/bla"
1253
1254        [build.install.conf]
1255        "cfg.conf" = "resources/config.conf"
1256        "#;
1257
1258        let expected_rockspec = r#"
1259            rockspec_format = "1.0"
1260            package = "my-package"
1261            version = "1.0.0"
1262
1263            source = {
1264                url = "https://example.com",
1265                file = "my-package-1.0.0.tar.gz",
1266                dir = "my-package-1.0.0",
1267            }
1268
1269            description = {
1270                summary = "A summary",
1271                detailed = "A detailed description",
1272                license = "MIT",
1273                homepage = "https://example.com",
1274                issues_url = "https://example.com/issues",
1275                maintainer = "John Doe",
1276                labels = {"label1", "label2"},
1277            }
1278
1279            supported_platforms = {"linux", "!windows"}
1280
1281            dependencies = {
1282                "lua ==5.1",
1283                "foo ==1.0",
1284                "bar >=2.0",
1285            }
1286
1287            build_dependencies = {
1288                "baz ==1.0",
1289            }
1290
1291            external_dependencies = {
1292                foo = { header = "foo.h" },
1293                bar = { library = "libbar.so" },
1294            }
1295
1296            source = {
1297                url = "https://example.com",
1298                hash = "sha256-di00mD8txN7rjaVpvxzNbnQsAh6H16zUtJZapH7U4HU=",
1299                file = "my-package-1.0.0.tar.gz",
1300                dir = "my-package-1.0.0",
1301            }
1302
1303            test = {
1304                type = "command",
1305                script = "test.lua",
1306                flags = {"foo", "bar"},
1307            }
1308
1309            deploy = {
1310                wrap_bin_scripts = false,
1311            }
1312
1313            build = {
1314                type = "builtin",
1315                install = {
1316                    lua = {
1317                        ["foo.bar"] = "src/bar.lua",
1318                    },
1319                    lib = {
1320                        ["foo.baz"] = "src/baz.c",
1321                    },
1322                    bin = {
1323                        bla = "src/bla",
1324                    },
1325                    conf = {
1326                        ["cfg.conf"] = "resources/config.conf",
1327                    },
1328                },
1329            }
1330        "#;
1331
1332        let expected_rockspec = RemoteLuaRockspec::new(expected_rockspec).unwrap();
1333
1334        let project_toml =
1335            PartialProjectToml::new(PROJECT_TOML, project_toml, ProjectRoot::default()).unwrap();
1336        let rockspec = project_toml
1337            .into_remote(None)
1338            .unwrap()
1339            .to_lua_rockspec()
1340            .unwrap();
1341
1342        let sorted_package_reqs = |v: &PerPlatform<Vec<LuaDependencySpec>>| {
1343            let mut v = v.current_platform().clone();
1344            v.sort_by(|a, b| a.name().cmp(b.name()));
1345            v
1346        };
1347
1348        assert_eq!(rockspec.package(), expected_rockspec.package());
1349        assert_eq!(rockspec.version(), expected_rockspec.version());
1350        assert_eq!(rockspec.description(), expected_rockspec.description());
1351        assert_eq!(
1352            rockspec.supported_platforms(),
1353            expected_rockspec.supported_platforms()
1354        );
1355        assert_eq!(
1356            sorted_package_reqs(rockspec.dependencies()),
1357            sorted_package_reqs(expected_rockspec.dependencies())
1358        );
1359        assert_eq!(
1360            sorted_package_reqs(rockspec.build_dependencies()),
1361            sorted_package_reqs(expected_rockspec.build_dependencies())
1362        );
1363        assert_eq!(
1364            rockspec.external_dependencies(),
1365            expected_rockspec.external_dependencies()
1366        );
1367        assert_eq!(rockspec.source(), expected_rockspec.source());
1368        assert_eq!(rockspec.build(), expected_rockspec.build());
1369        assert_eq!(rockspec.format(), expected_rockspec.format());
1370    }
1371
1372    #[test]
1373    fn merge_project_toml_with_partial_rockspec() {
1374        let project_toml = r#"
1375        package = "my-package"
1376        version = "1.0.0"
1377        lua = "5.1"
1378
1379        # For testing, specify a custom rockspec format
1380        # (defaults to 3.0)
1381        rockspec_format = "1.0"
1382
1383        [description]
1384        summary = "A summary"
1385        detailed = "A detailed description"
1386        license = "MIT"
1387        homepage = "https://example.com"
1388        issues_url = "https://example.com/issues"
1389        maintainer = "John Doe"
1390        labels = ["label1", "label2"]
1391
1392        [supported_platforms]
1393        linux = true
1394        windows = false
1395
1396        [dependencies]
1397        foo = "1.0"
1398        bar = ">=2.0"
1399
1400        [build_dependencies]
1401        baz = "1.0"
1402
1403        [external_dependencies.foo]
1404        header = "foo.h"
1405
1406        [external_dependencies.bar]
1407        library = "libbar.so"
1408
1409        [test_dependencies]
1410        busted = "1.0"
1411
1412        [source]
1413        url = "https://example.com"
1414        file = "my-package-1.0.0.tar.gz"
1415        dir = "my-package-1.0.0"
1416
1417        [test]
1418        type = "command"
1419        script = "test.lua"
1420        flags = [ "foo", "bar" ]
1421
1422        [run]
1423        command = "my-command"
1424        args = [ "--foo", "--bar" ]
1425
1426        [build]
1427        type = "builtin"
1428        "#;
1429
1430        let mergable_rockspec_content = r#"
1431            rockspec_format = "1.0"
1432            package = "my-package-overwritten"
1433
1434            description = {
1435                summary = "A summary overwritten",
1436                detailed = "A detailed description overwritten",
1437                license = "GPL-2.0",
1438                homepage = "https://example.com/overwritten",
1439                issues_url = "https://example.com/issues/overwritten",
1440                maintainer = "John Doe Overwritten",
1441                labels = {"over", "written"},
1442            }
1443
1444            -- Inverted supported platforms
1445            supported_platforms = {"!linux", "windows"}
1446
1447            dependencies = {
1448                "lua 5.1",
1449                "foo >1.0",
1450                "bar <=2.0",
1451            }
1452
1453            build_dependencies = {
1454                "baz >1.0",
1455            }
1456
1457            external_dependencies = {
1458                foo = { header = "overwritten.h" },
1459                bar = { library = "overwritten.so" },
1460            }
1461
1462            test = {
1463                type = "command",
1464                script = "overwritten.lua",
1465                flags = {"over", "written"},
1466            }
1467
1468            build = {
1469                type = "builtin",
1470            }
1471        "#;
1472
1473        let remote_rockspec_content = format!(
1474            r#"{}
1475            version = "1.0.0"
1476            source = {{
1477                url = "https://example.com",
1478                file = "my-package-1.0.0.tar.gz",
1479                dir = "my-package-1.0.0",
1480            }}
1481        "#,
1482            mergable_rockspec_content
1483        );
1484
1485        let project_toml =
1486            PartialProjectToml::new(PROJECT_TOML, project_toml, ProjectRoot::default()).unwrap();
1487        let partial_rockspec = PartialLuaRockspec::new(mergable_rockspec_content).unwrap();
1488        let expected_rockspec = RemoteLuaRockspec::new(&remote_rockspec_content).unwrap();
1489
1490        let merged = project_toml
1491            .merge(partial_rockspec)
1492            .into_remote(None)
1493            .unwrap();
1494
1495        let sorted_package_reqs = |v: &PerPlatform<Vec<LuaDependencySpec>>| {
1496            let mut v = v.current_platform().clone();
1497            v.sort_by(|a, b| a.name().cmp(b.name()));
1498            v
1499        };
1500
1501        assert_eq!(merged.package(), expected_rockspec.package());
1502        assert_eq!(merged.version(), expected_rockspec.version());
1503        assert_eq!(merged.description(), expected_rockspec.description());
1504        assert_eq!(
1505            merged.supported_platforms(),
1506            expected_rockspec.supported_platforms()
1507        );
1508        assert_eq!(
1509            sorted_package_reqs(merged.dependencies()),
1510            sorted_package_reqs(expected_rockspec.dependencies())
1511        );
1512        assert_eq!(
1513            sorted_package_reqs(merged.build_dependencies()),
1514            sorted_package_reqs(expected_rockspec.build_dependencies())
1515        );
1516        assert_eq!(
1517            merged.external_dependencies(),
1518            expected_rockspec.external_dependencies()
1519        );
1520        assert_eq!(merged.source(), expected_rockspec.source());
1521        assert_eq!(merged.build(), expected_rockspec.build());
1522        assert_eq!(merged.format(), expected_rockspec.format());
1523        // Ensure that the run command is retained after merge.
1524        assert!(merged.local.run().is_some());
1525    }
1526
1527    #[test]
1528    fn project_toml_with_lua_in_dependencies() {
1529        let project_toml = r#"
1530        package = "my-package"
1531        version = "1.0.0"
1532        # lua = ">5.1"
1533
1534        [dependencies]
1535        lua = "5.1" # disallowed
1536
1537        [build]
1538        type = "builtin"
1539        "#;
1540
1541        PartialProjectToml::new(PROJECT_TOML, project_toml, ProjectRoot::default())
1542            .unwrap()
1543            .into_local()
1544            .unwrap_err();
1545    }
1546
1547    #[test]
1548    fn project_toml_with_invalid_run_command() {
1549        for command in ["lua", "lua5.1", "lua5.2", "lua5.3", "lua5.4", "luajit"] {
1550            let project_toml = format!(
1551                r#"
1552                package = "my-package"
1553                version = "1.0.0"
1554                lua = "5.1"
1555
1556                [build]
1557                type = "builtin"
1558
1559                [run]
1560                command = "{command}"
1561                "#,
1562            );
1563
1564            PartialProjectToml::new(PROJECT_TOML, &project_toml, ProjectRoot::default())
1565                .unwrap_err();
1566        }
1567    }
1568
1569    #[test]
1570    fn generate_non_deterministic_git_source() {
1571        let rockspec_content = r#"
1572            package = "test-package"
1573            version = "1.0.0"
1574            lua = ">=5.1"
1575
1576            [source]
1577            url = "git+https://exaple.com/repo.git"
1578
1579            [build]
1580            type = "builtin"
1581        "#;
1582
1583        PartialProjectToml::new(PROJECT_TOML, rockspec_content, ProjectRoot::default())
1584            .unwrap()
1585            .into_remote(None)
1586            .unwrap_err();
1587    }
1588
1589    #[test]
1590    fn generate_deterministic_git_source() {
1591        let rockspec_content = r#"
1592            package = "test-package"
1593            version = "1.0.0"
1594            lua = ">=5.1"
1595
1596            [source]
1597            url = "git+https://exaple.com/owner/repo.git"
1598            tag = "v0.1.0"
1599
1600            [build]
1601            type = "builtin"
1602        "#;
1603
1604        PartialProjectToml::new(PROJECT_TOML, rockspec_content, ProjectRoot::default())
1605            .unwrap()
1606            .into_remote(None)
1607            .unwrap();
1608    }
1609
1610    fn init_sample_project_repo(temp_dir: &assert_fs::TempDir) -> Repository {
1611        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1612            .join("resources/test/sample-projects/source-template/");
1613        temp_dir.copy_from(&sample_project, &["**"]).unwrap();
1614        let repo = Repository::init(temp_dir).unwrap();
1615        let mut opts = RepositoryInitOptions::new();
1616        opts.initial_head("main");
1617        {
1618            let mut config = repo.config().unwrap();
1619            config.set_str("user.name", "name").unwrap();
1620            config.set_str("user.email", "email").unwrap();
1621            let mut index = repo.index().unwrap();
1622            let id = index.write_tree().unwrap();
1623
1624            let tree = repo.find_tree(id).unwrap();
1625            let sig = repo.signature().unwrap();
1626            repo.commit(Some("HEAD"), &sig, &sig, "initial\n\nbody", &tree, &[])
1627                .unwrap();
1628        }
1629        repo
1630    }
1631
1632    fn create_tag(repo: &Repository, name: &str) {
1633        let sig = repo.signature().unwrap();
1634        let id = repo.head().unwrap().target().unwrap();
1635        let obj = repo.find_object(id, None).unwrap();
1636        repo.tag(name, &obj, &sig, "msg", true).unwrap();
1637    }
1638
1639    #[test]
1640    fn test_git_project_generate_dev_source() {
1641        let project_root = assert_fs::TempDir::new().unwrap();
1642        init_sample_project_repo(&project_root);
1643        let project = Project::from_exact(&project_root).unwrap().unwrap();
1644        let remote_project_toml = project.toml().into_remote(None).unwrap();
1645        let source = remote_project_toml.source.current_platform();
1646        let source_spec = &source.source_spec;
1647        assert!(matches!(source_spec, &RockSourceSpec::Git { .. }));
1648        if let RockSourceSpec::Git(GitSource { url, checkout_ref }) = source_spec {
1649            let expected_url: RemoteGitUrl =
1650                "https://github.com/lumen-oss/lux.git".parse().unwrap();
1651            assert_eq!(url, &expected_url);
1652            assert!(checkout_ref.is_some());
1653        }
1654        assert_eq!(source.unpack_dir, Some("lux-dev".into()));
1655    }
1656
1657    #[test]
1658    fn test_git_project_generate_non_semver_tag_source() {
1659        let project_root = assert_fs::TempDir::new().unwrap();
1660        let repo = init_sample_project_repo(&project_root);
1661        let tag_name = "bla";
1662        create_tag(&repo, tag_name);
1663        let project = Project::from_exact(&project_root).unwrap().unwrap();
1664        let remote_project_toml = project.toml().into_remote(None).unwrap();
1665        let source = remote_project_toml.source.current_platform();
1666        let source_spec = &source.source_spec;
1667        assert!(matches!(source_spec, &RockSourceSpec::Git { .. }));
1668        if let RockSourceSpec::Git(GitSource { url, checkout_ref }) = source_spec {
1669            let expected_url: RemoteGitUrl =
1670                "https://github.com/lumen-oss/lux.git".parse().unwrap();
1671            assert_eq!(url, &expected_url);
1672            assert_eq!(checkout_ref, &Some(tag_name.to_string()));
1673        }
1674        assert_eq!(source.unpack_dir, Some("lux-dev".into()));
1675    }
1676
1677    #[test]
1678    fn test_git_project_generate_release_source_tag_with_v_prefix() {
1679        let project_root = assert_fs::TempDir::new().unwrap();
1680        let repo = init_sample_project_repo(&project_root);
1681        let tag_name = "v1.0.0";
1682        create_tag(&repo, "bla");
1683        create_tag(&repo, tag_name);
1684        let project = Project::from_exact(&project_root).unwrap().unwrap();
1685        let remote_project_toml = project.toml().into_remote(None).unwrap();
1686        let source = remote_project_toml.source.current_platform();
1687        let source_spec = &source.source_spec;
1688        assert!(matches!(source_spec, &RockSourceSpec::Url { .. }));
1689        if let RockSourceSpec::Url(url) = source_spec {
1690            let expected_url: Url = "https://github.com/lumen-oss/lux/archive/refs/tags/v1.0.0.zip"
1691                .parse()
1692                .unwrap();
1693            assert_eq!(url, &expected_url);
1694        }
1695        assert_eq!(source.unpack_dir, Some("lux-1.0.0".into()));
1696    }
1697
1698    #[test]
1699    fn test_git_project_generate_release_source_tag_without_v_prefix() {
1700        let project_root = assert_fs::TempDir::new().unwrap();
1701        let repo = init_sample_project_repo(&project_root);
1702        create_tag(&repo, "bla");
1703        let tag_name = "1.0.0";
1704        create_tag(&repo, tag_name);
1705        let project = Project::from_exact(&project_root).unwrap().unwrap();
1706        let remote_project_toml = project.toml().into_remote(None).unwrap();
1707        let source = remote_project_toml.source.current_platform();
1708        let source_spec = &source.source_spec;
1709        assert!(matches!(source_spec, &RockSourceSpec::Url { .. }));
1710        if let RockSourceSpec::Url(url) = source_spec {
1711            let expected_url: Url = "https://github.com/lumen-oss/lux/archive/refs/tags/1.0.0.zip"
1712                .parse()
1713                .unwrap();
1714            assert_eq!(url, &expected_url);
1715        }
1716        assert_eq!(source.unpack_dir, Some("lux-1.0.0".into()));
1717    }
1718
1719    #[test]
1720    fn test_git_project_in_subdirectory() {
1721        let temp_dir = assert_fs::TempDir::new().unwrap();
1722        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1723            .join("resources/test/sample-projects/source-template/");
1724        let project_dir = temp_dir.child("lux");
1725        project_dir.create_dir_all().unwrap();
1726        project_dir.copy_from(&sample_project, &["**"]).unwrap();
1727        let repo = Repository::init(&temp_dir).unwrap();
1728        let mut opts = RepositoryInitOptions::new();
1729        opts.initial_head("main");
1730        {
1731            let mut config = repo.config().unwrap();
1732            config.set_str("user.name", "name").unwrap();
1733            config.set_str("user.email", "email").unwrap();
1734            let mut index = repo.index().unwrap();
1735            let id = index.write_tree().unwrap();
1736
1737            let tree = repo.find_tree(id).unwrap();
1738            let sig = repo.signature().unwrap();
1739            repo.commit(Some("HEAD"), &sig, &sig, "initial\n\nbody", &tree, &[])
1740                .unwrap();
1741        }
1742        create_tag(&repo, "bla");
1743        let tag_name = "1.0.0";
1744        create_tag(&repo, tag_name);
1745        let project = Project::from_exact(&project_dir).unwrap().unwrap();
1746        let remote_project_toml = project.toml().into_remote(None).unwrap();
1747        let source = remote_project_toml.source.current_platform();
1748        let source_spec = &source.source_spec;
1749        assert!(matches!(source_spec, &RockSourceSpec::Url { .. }));
1750        if let RockSourceSpec::Url(url) = source_spec {
1751            let expected_url: Url = "https://github.com/lumen-oss/lux/archive/refs/tags/1.0.0.zip"
1752                .parse()
1753                .unwrap();
1754            assert_eq!(url, &expected_url);
1755        }
1756        assert_eq!(source.unpack_dir, Some("lux-1.0.0".into()));
1757    }
1758}