Skip to main content

lux_lib/project/
project_toml.rs

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