Skip to main content

lux_lib/project/
project_toml.rs

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