lux_lib/project/
mod.rs

1use itertools::Itertools;
2use lets_find_up::{find_up_with, FindUpKind, FindUpOptions};
3use mlua::{ExternalResult, UserData};
4use path_slash::PathBufExt;
5use project_toml::{
6    LocalProjectTomlValidationError, PartialProjectToml, RemoteProjectTomlValidationError,
7};
8use std::{
9    io,
10    ops::Deref,
11    path::{Path, PathBuf},
12    str::FromStr,
13};
14use thiserror::Error;
15use toml_edit::{DocumentMut, Item};
16
17use crate::{
18    build,
19    config::{Config, LuaVersion},
20    git::{self, shorthand::GitUrlShorthand, utils::GitError},
21    lockfile::{LockfileError, ProjectLockfile, ReadOnly},
22    lua::lua_runtime,
23    lua_rockspec::{
24        LocalLuaRockspec, LuaRockspecError, LuaVersionError, PartialLuaRockspec,
25        PartialRockspecError, RemoteLuaRockspec,
26    },
27    progress::Progress,
28    remote_package_db::RemotePackageDB,
29    rockspec::{
30        lua_dependency::{DependencyType, LuaDependencySpec, LuaDependencyType},
31        LuaVersionCompatibility,
32    },
33    tree::{Tree, TreeError},
34};
35use crate::{
36    lockfile::PinnedState,
37    package::{PackageName, PackageReq},
38};
39
40pub(crate) mod gen;
41pub mod project_toml;
42
43pub use project_toml::PROJECT_TOML;
44
45pub const EXTRA_ROCKSPEC: &str = "extra.rockspec";
46pub(crate) const LUX_DIR_NAME: &str = ".lux";
47const LUARC: &str = ".luarc.json";
48const EMMYRC: &str = ".emmyrc.json";
49
50#[derive(Error, Debug)]
51#[error(transparent)]
52pub enum ProjectError {
53    Io(#[from] io::Error),
54    Lockfile(#[from] LockfileError),
55    Project(#[from] LocalProjectTomlValidationError),
56    Toml(#[from] toml::de::Error),
57    #[error("error when parsing `extra.rockspec`: {0}")]
58    Rockspec(#[from] PartialRockspecError),
59    #[error("not in a lux project directory")]
60    NotAProjectDir,
61}
62
63#[derive(Error, Debug)]
64#[error(transparent)]
65pub enum IntoLocalRockspecError {
66    LocalProjectTomlValidationError(#[from] LocalProjectTomlValidationError),
67    RockspecError(#[from] LuaRockspecError),
68}
69
70#[derive(Error, Debug)]
71#[error(transparent)]
72pub enum IntoRemoteRockspecError {
73    RocksTomlValidationError(#[from] RemoteProjectTomlValidationError),
74    RockspecError(#[from] LuaRockspecError),
75}
76
77#[derive(Error, Debug)]
78pub enum ProjectEditError {
79    #[error(transparent)]
80    Io(#[from] tokio::io::Error),
81    #[error(transparent)]
82    Toml(#[from] toml_edit::TomlError),
83    #[error("error parsing lux.toml after edit. This is probably a bug.")]
84    TomlDe(#[from] toml::de::Error),
85    #[error(transparent)]
86    Git(#[from] GitError),
87    #[error("unable to query latest version for {0}")]
88    LatestVersionNotFound(PackageName),
89    #[error("expected field to be a value, but got {0}")]
90    ExpectedValue(toml_edit::Item),
91    #[error("expected string, but got {0}")]
92    ExpectedString(toml_edit::Value),
93    #[error(transparent)]
94    GitUrlShorthandParse(#[from] git::shorthand::ParseError),
95}
96
97#[derive(Error, Debug)]
98#[error(transparent)]
99pub enum ProjectTreeError {
100    Tree(#[from] TreeError),
101    LuaVersionError(#[from] LuaVersionError),
102}
103
104#[derive(Error, Debug)]
105pub enum PinError {
106    #[error("package {0} not found in dependencies")]
107    PackageNotFound(PackageName),
108    #[error("dependency {dep} is already {}pinned!", if *.pin_state == PinnedState::Unpinned { "un" } else { "" })]
109    PinStateUnchanged {
110        pin_state: PinnedState,
111        dep: PackageName,
112    },
113    #[error(transparent)]
114    Toml(#[from] toml_edit::TomlError),
115    #[error("error parsing lux.toml after edit. This is probably a bug.")]
116    TomlDe(#[from] toml::de::Error),
117    #[error(transparent)]
118    Io(#[from] tokio::io::Error),
119}
120
121/// A newtype for the project root directory.
122/// This is used to ensure that the project root is a valid project directory.
123#[derive(Clone, Debug)]
124#[cfg_attr(test, derive(Default))]
125pub struct ProjectRoot(PathBuf);
126
127impl ProjectRoot {
128    pub(crate) fn new() -> Self {
129        Self(PathBuf::new())
130    }
131}
132
133impl AsRef<Path> for ProjectRoot {
134    fn as_ref(&self) -> &Path {
135        self.0.as_ref()
136    }
137}
138
139impl Deref for ProjectRoot {
140    type Target = PathBuf;
141
142    fn deref(&self) -> &Self::Target {
143        &self.0
144    }
145}
146
147#[derive(Clone, Debug)]
148pub struct Project {
149    /// The path where the `lux.toml` resides.
150    root: ProjectRoot,
151    /// The parsed lux.toml.
152    toml: PartialProjectToml,
153}
154
155impl UserData for Project {
156    fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M) {
157        methods.add_method("toml_path", |_, this, ()| Ok(this.toml_path()));
158        methods.add_method("luarc_path", |_, this, ()| Ok(this.luarc_path()));
159        methods.add_method("extra_rockspec_path", |_, this, ()| {
160            Ok(this.extra_rockspec_path())
161        });
162        methods.add_method("lockfile_path", |_, this, ()| Ok(this.lockfile_path()));
163        methods.add_method("root", |_, this, ()| Ok(this.root().0.clone()));
164        methods.add_method("toml", |_, this, ()| Ok(this.toml().clone()));
165        methods.add_method("local_rockspec", |_, this, ()| {
166            this.local_rockspec().into_lua_err()
167        });
168        methods.add_method("remote_rockspec", |_, this, ()| {
169            this.remote_rockspec().into_lua_err()
170        });
171        methods.add_method("tree", |_, this, config: Config| {
172            this.tree(&config).into_lua_err()
173        });
174        methods.add_method("test_tree", |_, this, config: Config| {
175            this.test_tree(&config).into_lua_err()
176        });
177        methods.add_method("lua_version", |_, this, config: Config| {
178            this.lua_version(&config).into_lua_err()
179        });
180        methods.add_method("extra_rockspec", |_, this, ()| {
181            this.extra_rockspec().into_lua_err()
182        });
183
184        methods.add_async_method_mut(
185            "add",
186            |_, mut this, (deps, config): (DependencyType<PackageReq>, Config)| async move {
187                // NOTE(vhyrro): Supposedly, these guards may cause crashes since they must be
188                // dropped in reverse order of creation.
189                //
190                // However, this limitation only seems to apply to `Handle::enter()`, not
191                // `Runtime::enter()`. During testing in `lux-lua`, this seems to be working just fine.
192                let _guard = lua_runtime().enter();
193
194                let package_db = RemotePackageDB::from_config(&config, &Progress::NoProgress)
195                    .await
196                    .into_lua_err()?;
197                this.add(deps, &package_db).await.into_lua_err()
198            },
199        );
200
201        methods.add_async_method_mut(
202            "remove",
203            |_, mut this, deps: DependencyType<PackageName>| async move {
204                let _guard = lua_runtime().enter();
205
206                this.remove(deps).await.into_lua_err()
207            },
208        );
209
210        methods.add_async_method_mut(
211            "upgrade",
212            |_, mut this, (deps, package_db): (LuaDependencyType<PackageName>, RemotePackageDB)| async move {
213                let _guard = lua_runtime().enter();
214
215                this.upgrade(deps, &package_db).await.into_lua_err()
216            },
217        );
218
219        methods.add_async_method_mut(
220            "upgrade_all",
221            |_, mut this, package_db: RemotePackageDB| async move {
222                let _guard = lua_runtime().enter();
223
224                this.upgrade_all(&package_db).await.into_lua_err()
225            },
226        );
227
228        methods.add_async_method_mut(
229            "set_pinned_state",
230            |_, mut this, (deps, pin): (LuaDependencyType<PackageName>, PinnedState)| async move {
231                let _guard = lua_runtime().enter();
232
233                this.set_pinned_state(deps, pin).await.into_lua_err()
234            },
235        );
236
237        methods.add_method("project_files", |_, this, ()| Ok(this.project_files()));
238
239        // NOTE: No useful public methods for `ProjectLockfile` yet
240        // If the lockfile can be fed into other functions in the API, then we should just
241        // implement a blank UserData for it.
242        //
243        // methods.add_method("lockfile", |_, this, ()| this.lockfile().into_lua_err());
244        // methods.add_method("try_lockfile", |_, this, ()| { this.try_lockfile().into_lua_err() });
245    }
246}
247
248impl Project {
249    pub fn current() -> Result<Option<Self>, ProjectError> {
250        Self::from(&std::env::current_dir()?)
251    }
252
253    pub fn current_or_err() -> Result<Self, ProjectError> {
254        Self::current()?.ok_or(ProjectError::NotAProjectDir)
255    }
256
257    pub fn from_exact(start: impl AsRef<Path>) -> Result<Option<Self>, ProjectError> {
258        if !start.as_ref().exists() {
259            return Ok(None);
260        }
261
262        if start.as_ref().join(PROJECT_TOML).exists() {
263            let toml_content = std::fs::read_to_string(start.as_ref().join(PROJECT_TOML))?;
264            let root = start.as_ref();
265
266            let mut project = Project {
267                root: ProjectRoot(root.to_path_buf()),
268                toml: PartialProjectToml::new(&toml_content, ProjectRoot(root.to_path_buf()))?,
269            };
270
271            if let Some(extra_rockspec) = project.extra_rockspec()? {
272                project.toml = project.toml.merge(extra_rockspec);
273            }
274
275            Ok(Some(project))
276        } else {
277            Ok(None)
278        }
279    }
280
281    pub fn from(start: impl AsRef<Path>) -> Result<Option<Self>, ProjectError> {
282        if !start.as_ref().exists() {
283            return Ok(None);
284        }
285
286        match find_up_with(
287            PROJECT_TOML,
288            FindUpOptions {
289                cwd: start.as_ref(),
290                kind: FindUpKind::File,
291            },
292        ) {
293            Ok(Some(path)) => {
294                let toml_content = std::fs::read_to_string(&path)?;
295                let root = path.parent().unwrap();
296
297                let mut project = Project {
298                    root: ProjectRoot(root.to_path_buf()),
299                    toml: PartialProjectToml::new(&toml_content, ProjectRoot(root.to_path_buf()))?,
300                };
301
302                if let Some(extra_rockspec) = project.extra_rockspec()? {
303                    project.toml = project.toml.merge(extra_rockspec);
304                }
305
306                std::fs::create_dir_all(root)?;
307
308                Ok(Some(project))
309            }
310            // NOTE: If we hit a read error, it could be because we haven't found a PROJECT_TOML
311            // and have started searching too far upwards.
312            // See for example https://github.com/nvim-neorocks/lux/issues/532
313            _ => Ok(None),
314        }
315    }
316
317    /// Get the `lux.toml` path.
318    pub fn toml_path(&self) -> PathBuf {
319        self.root.join(PROJECT_TOML)
320    }
321
322    /// Get the `.luarc.json` or `.emmyrc.json` path.
323    pub(crate) fn luarc_path(&self) -> PathBuf {
324        let luarc_path = self.root.join(LUARC);
325        if luarc_path.is_file() {
326            luarc_path
327        } else {
328            let emmy_path = self.root.join(EMMYRC);
329            if emmy_path.is_file() {
330                emmy_path
331            } else {
332                luarc_path
333            }
334        }
335    }
336
337    /// Get the `extra.rockspec` path.
338    pub fn extra_rockspec_path(&self) -> PathBuf {
339        self.root.join(EXTRA_ROCKSPEC)
340    }
341
342    /// Get the `lux.lock` lockfile path.
343    pub fn lockfile_path(&self) -> PathBuf {
344        self.root.join("lux.lock")
345    }
346
347    /// Get the `lux.lock` lockfile in the project root.
348    pub fn lockfile(&self) -> Result<ProjectLockfile<ReadOnly>, ProjectError> {
349        Ok(ProjectLockfile::new(self.lockfile_path())?)
350    }
351
352    /// Get the `lux.lock` lockfile in the project root, if present.
353    pub fn try_lockfile(&self) -> Result<Option<ProjectLockfile<ReadOnly>>, ProjectError> {
354        let path = self.lockfile_path();
355        if path.is_file() {
356            Ok(Some(ProjectLockfile::load(path)?))
357        } else {
358            Ok(None)
359        }
360    }
361
362    pub fn root(&self) -> &ProjectRoot {
363        &self.root
364    }
365
366    pub fn toml(&self) -> &PartialProjectToml {
367        &self.toml
368    }
369
370    pub fn local_rockspec(&self) -> Result<LocalLuaRockspec, IntoLocalRockspecError> {
371        Ok(self.toml().into_local()?.to_lua_rockspec()?)
372    }
373
374    pub fn remote_rockspec(&self) -> Result<RemoteLuaRockspec, IntoRemoteRockspecError> {
375        Ok(self.toml().into_remote()?.to_lua_rockspec()?)
376    }
377
378    pub fn extra_rockspec(&self) -> Result<Option<PartialLuaRockspec>, PartialRockspecError> {
379        if self.extra_rockspec_path().exists() {
380            Ok(Some(PartialLuaRockspec::new(&std::fs::read_to_string(
381                self.extra_rockspec_path(),
382            )?)?))
383        } else {
384            Ok(None)
385        }
386    }
387
388    pub(crate) fn default_tree_root_dir(&self) -> PathBuf {
389        self.root.join(LUX_DIR_NAME)
390    }
391
392    pub fn tree(&self, config: &Config) -> Result<Tree, ProjectTreeError> {
393        self.lua_version_tree(self.lua_version(config)?, config)
394    }
395
396    pub(crate) fn lua_version_tree(
397        &self,
398        lua_version: LuaVersion,
399        config: &Config,
400    ) -> Result<Tree, ProjectTreeError> {
401        Ok(Tree::new(
402            self.default_tree_root_dir(),
403            lua_version,
404            config,
405        )?)
406    }
407
408    pub fn test_tree(&self, config: &Config) -> Result<Tree, ProjectTreeError> {
409        Ok(self.tree(config)?.test_tree(config)?)
410    }
411
412    pub fn build_tree(&self, config: &Config) -> Result<Tree, ProjectTreeError> {
413        Ok(self.tree(config)?.build_tree(config)?)
414    }
415
416    pub fn lua_version(&self, config: &Config) -> Result<LuaVersion, LuaVersionError> {
417        self.toml().lua_version_matches(config)
418    }
419
420    pub async fn add(
421        &mut self,
422        dependencies: DependencyType<PackageReq>,
423        package_db: &RemotePackageDB,
424    ) -> Result<(), ProjectEditError> {
425        let mut project_toml =
426            toml_edit::DocumentMut::from_str(&tokio::fs::read_to_string(self.toml_path()).await?)?;
427
428        prepare_dependency_tables(&mut project_toml);
429        let table = match dependencies {
430            DependencyType::Regular(_) => &mut project_toml["dependencies"],
431            DependencyType::Build(_) => &mut project_toml["build_dependencies"],
432            DependencyType::Test(_) => &mut project_toml["test_dependencies"],
433            DependencyType::External(_) => &mut project_toml["external_dependencies"],
434        };
435
436        match dependencies {
437            DependencyType::Regular(ref deps)
438            | DependencyType::Build(ref deps)
439            | DependencyType::Test(ref deps) => {
440                for dep in deps {
441                    let dep_version_str = if dep.version_req().is_any() {
442                        package_db
443                            .latest_version(dep.name())
444                            // This condition should never be reached, as the package should
445                            // have been found in the database or an error should have been
446                            // reported prior.
447                            // Still worth making an error message for this in the future,
448                            // though.
449                            .expect("unable to query latest version for package")
450                            .to_string()
451                    } else {
452                        dep.version_req().to_string()
453                    };
454                    table[dep.name().to_string()] = toml_edit::value(dep_version_str);
455                }
456            }
457            DependencyType::External(ref deps) => {
458                for (name, dep) in deps {
459                    if let Some(path) = &dep.header {
460                        table[name]["header"] = toml_edit::value(path.to_slash_lossy().to_string());
461                    }
462                    if let Some(path) = &dep.library {
463                        table[name]["library"] =
464                            toml_edit::value(path.to_slash_lossy().to_string());
465                    }
466                }
467            }
468        };
469
470        let toml_content = project_toml.to_string();
471        tokio::fs::write(self.toml_path(), &toml_content).await?;
472        self.toml = PartialProjectToml::new(&toml_content, self.root.clone())?;
473
474        Ok(())
475    }
476
477    pub async fn add_git(
478        &mut self,
479        dependencies: LuaDependencyType<GitUrlShorthand>,
480    ) -> Result<(), ProjectEditError> {
481        let mut project_toml =
482            toml_edit::DocumentMut::from_str(&tokio::fs::read_to_string(self.toml_path()).await?)?;
483
484        prepare_dependency_tables(&mut project_toml);
485        let table = match dependencies {
486            LuaDependencyType::Regular(_) => &mut project_toml["dependencies"],
487            LuaDependencyType::Build(_) => &mut project_toml["build_dependencies"],
488            LuaDependencyType::Test(_) => &mut project_toml["test_dependencies"],
489        };
490
491        match dependencies {
492            LuaDependencyType::Regular(ref urls)
493            | LuaDependencyType::Build(ref urls)
494            | LuaDependencyType::Test(ref urls) => {
495                for url in urls {
496                    let git_url: git_url_parse::GitUrl = url.clone().into();
497                    let rev = git::utils::latest_semver_tag_or_commit_sha(&git_url)?;
498                    table[git_url.name.clone()]["version"] = Item::Value(rev.into());
499                    table[git_url.name.clone()]["git"] = Item::Value(url.to_string().into());
500                }
501            }
502        }
503
504        let toml_content = project_toml.to_string();
505        tokio::fs::write(self.toml_path(), &toml_content).await?;
506        self.toml = PartialProjectToml::new(&toml_content, self.root.clone())?;
507
508        Ok(())
509    }
510
511    pub async fn remove(
512        &mut self,
513        dependencies: DependencyType<PackageName>,
514    ) -> Result<(), ProjectEditError> {
515        let mut project_toml =
516            toml_edit::DocumentMut::from_str(&tokio::fs::read_to_string(self.toml_path()).await?)?;
517
518        prepare_dependency_tables(&mut project_toml);
519        let table = match dependencies {
520            DependencyType::Regular(_) => &mut project_toml["dependencies"],
521            DependencyType::Build(_) => &mut project_toml["build_dependencies"],
522            DependencyType::Test(_) => &mut project_toml["test_dependencies"],
523            DependencyType::External(_) => &mut project_toml["external_dependencies"],
524        };
525
526        match dependencies {
527            DependencyType::Regular(ref deps)
528            | DependencyType::Build(ref deps)
529            | DependencyType::Test(ref deps) => {
530                for dep in deps {
531                    table[dep.to_string()] = Item::None;
532                }
533            }
534            DependencyType::External(ref deps) => {
535                for (name, dep) in deps {
536                    if dep.header.is_some() {
537                        table[name]["header"] = Item::None;
538                    }
539                    if dep.library.is_some() {
540                        table[name]["library"] = Item::None;
541                    }
542                }
543            }
544        };
545
546        let toml_content = project_toml.to_string();
547        tokio::fs::write(self.toml_path(), &toml_content).await?;
548        self.toml = PartialProjectToml::new(&toml_content, self.root.clone())?;
549
550        Ok(())
551    }
552
553    pub async fn upgrade(
554        &mut self,
555        dependencies: LuaDependencyType<PackageName>,
556        package_db: &RemotePackageDB,
557    ) -> Result<(), ProjectEditError> {
558        let mut project_toml =
559            toml_edit::DocumentMut::from_str(&tokio::fs::read_to_string(self.toml_path()).await?)?;
560
561        prepare_dependency_tables(&mut project_toml);
562        let table = match dependencies {
563            LuaDependencyType::Regular(_) => &mut project_toml["dependencies"],
564            LuaDependencyType::Build(_) => &mut project_toml["build_dependencies"],
565            LuaDependencyType::Test(_) => &mut project_toml["test_dependencies"],
566        };
567
568        match dependencies {
569            LuaDependencyType::Regular(ref deps)
570            | LuaDependencyType::Build(ref deps)
571            | LuaDependencyType::Test(ref deps) => {
572                let latest_rock_version_str =
573                    |dep: &PackageName| -> Result<String, ProjectEditError> {
574                        Ok(package_db
575                            .latest_version(dep)
576                            .ok_or(ProjectEditError::LatestVersionNotFound(dep.clone()))?
577                            .to_string())
578                    };
579                for dep in deps {
580                    let mut dep_item = table[dep.to_string()].clone();
581                    match &dep_item {
582                        Item::Value(_) => {
583                            let dep_version_str = latest_rock_version_str(dep)?;
584                            table[dep.to_string()] = toml_edit::value(dep_version_str);
585                        }
586                        Item::Table(tbl) => {
587                            if tbl.contains_key("git") {
588                                let git_value = tbl
589                                    .get("git")
590                                    .expect("expected 'git' field")
591                                    .clone()
592                                    .into_value()
593                                    .map_err(ProjectEditError::ExpectedValue)?;
594                                let git_url_str = git_value
595                                    .as_str()
596                                    .ok_or(ProjectEditError::ExpectedString(git_value.clone()))?;
597                                let shorthand: GitUrlShorthand = git_url_str.parse()?;
598                                let latest_rev =
599                                    git::utils::latest_semver_tag_or_commit_sha(&shorthand.into())?;
600                                let key = if tbl.contains_key("rev") {
601                                    "rev".to_string()
602                                } else {
603                                    "version".to_string()
604                                };
605                                dep_item[key] = toml_edit::value(latest_rev);
606                                table[dep.to_string()] = dep_item;
607                            } else {
608                                let dep_version_str = latest_rock_version_str(dep)?;
609                                dep_item["version".to_string()] = toml_edit::value(dep_version_str);
610                                table[dep.to_string()] = dep_item;
611                            }
612                        }
613                        _ => {}
614                    }
615                }
616            }
617        }
618
619        let toml_content = project_toml.to_string();
620        tokio::fs::write(self.toml_path(), &toml_content).await?;
621        self.toml = PartialProjectToml::new(&toml_content, self.root.clone())?;
622
623        Ok(())
624    }
625
626    pub async fn upgrade_all(
627        &mut self,
628        package_db: &RemotePackageDB,
629    ) -> Result<(), ProjectEditError> {
630        if let Some(dependencies) = &self.toml().dependencies {
631            let packages = dependencies
632                .iter()
633                .map(|dep| dep.name())
634                .cloned()
635                .collect_vec();
636            self.upgrade(LuaDependencyType::Regular(packages), package_db)
637                .await?;
638        }
639        if let Some(dependencies) = &self.toml().build_dependencies {
640            let packages = dependencies
641                .iter()
642                .map(|dep| dep.name())
643                .cloned()
644                .collect_vec();
645            self.upgrade(LuaDependencyType::Build(packages), package_db)
646                .await?;
647        }
648        if let Some(dependencies) = &self.toml().test_dependencies {
649            let packages = dependencies
650                .iter()
651                .map(|dep| dep.name())
652                .cloned()
653                .collect_vec();
654            self.upgrade(LuaDependencyType::Test(packages), package_db)
655                .await?;
656        }
657        Ok(())
658    }
659
660    pub async fn set_pinned_state(
661        &mut self,
662        dependencies: LuaDependencyType<PackageName>,
663        pin: PinnedState,
664    ) -> Result<(), PinError> {
665        let mut project_toml =
666            toml_edit::DocumentMut::from_str(&tokio::fs::read_to_string(self.toml_path()).await?)?;
667
668        prepare_dependency_tables(&mut project_toml);
669        let table = match dependencies {
670            LuaDependencyType::Regular(_) => &mut project_toml["dependencies"],
671            LuaDependencyType::Build(_) => &mut project_toml["build_dependencies"],
672            LuaDependencyType::Test(_) => &mut project_toml["test_dependencies"],
673        };
674
675        match dependencies {
676            LuaDependencyType::Regular(ref _deps) => {
677                self.toml.dependencies = Some(
678                    self.toml
679                        .dependencies
680                        .take()
681                        .unwrap_or_default()
682                        .into_iter()
683                        .map(|dep| LuaDependencySpec { pin, ..dep })
684                        .collect(),
685                )
686            }
687            LuaDependencyType::Build(ref _deps) => {
688                self.toml.build_dependencies = Some(
689                    self.toml
690                        .build_dependencies
691                        .take()
692                        .unwrap_or_default()
693                        .into_iter()
694                        .map(|dep| LuaDependencySpec { pin, ..dep })
695                        .collect(),
696                )
697            }
698            LuaDependencyType::Test(ref _deps) => {
699                self.toml.test_dependencies = Some(
700                    self.toml
701                        .test_dependencies
702                        .take()
703                        .unwrap_or_default()
704                        .into_iter()
705                        .map(|dep| LuaDependencySpec { pin, ..dep })
706                        .collect(),
707                )
708            }
709        }
710
711        match dependencies {
712            LuaDependencyType::Regular(ref deps)
713            | LuaDependencyType::Build(ref deps)
714            | LuaDependencyType::Test(ref deps) => {
715                for dep in deps {
716                    let mut dep_item = table[dep.to_string()].clone();
717                    match dep_item {
718                        version @ Item::Value(_) => match &pin {
719                            PinnedState::Unpinned => {}
720                            PinnedState::Pinned => {
721                                let mut dep_entry = toml_edit::table().into_table().unwrap();
722                                dep_entry.set_implicit(true);
723                                dep_entry["version"] = version;
724                                dep_entry["pin"] = toml_edit::value(true);
725                                table[dep.to_string()] = toml_edit::Item::Table(dep_entry);
726                            }
727                        },
728                        Item::Table(_) => {
729                            dep_item["pin".to_string()] = toml_edit::value(pin.as_bool());
730                            table[dep.to_string()] = dep_item;
731                        }
732                        _ => {}
733                    }
734                }
735            }
736        }
737
738        let toml_content = project_toml.to_string();
739        tokio::fs::write(self.toml_path(), &toml_content).await?;
740        self.toml = PartialProjectToml::new(&toml_content, self.root.clone())?;
741
742        Ok(())
743    }
744
745    pub fn project_files(&self) -> Vec<PathBuf> {
746        build::utils::project_files(&self.root().0)
747    }
748}
749
750fn prepare_dependency_tables(project_toml: &mut DocumentMut) {
751    if !project_toml.contains_table("dependencies") {
752        let mut table = toml_edit::table().into_table().unwrap();
753        table.set_implicit(true);
754
755        project_toml["dependencies"] = toml_edit::Item::Table(table);
756    }
757    if !project_toml.contains_table("build_dependencies") {
758        let mut table = toml_edit::table().into_table().unwrap();
759        table.set_implicit(true);
760
761        project_toml["build_dependencies"] = toml_edit::Item::Table(table);
762    }
763    if !project_toml.contains_table("test_dependencies") {
764        let mut table = toml_edit::table().into_table().unwrap();
765        table.set_implicit(true);
766
767        project_toml["test_dependencies"] = toml_edit::Item::Table(table);
768    }
769    if !project_toml.contains_table("external_dependencies") {
770        let mut table = toml_edit::table().into_table().unwrap();
771        table.set_implicit(true);
772
773        project_toml["external_dependencies"] = toml_edit::Item::Table(table);
774    }
775}
776
777// TODO: More project-based test
778#[cfg(test)]
779mod tests {
780    use std::collections::HashMap;
781
782    use assert_fs::prelude::PathCopy;
783    use url::Url;
784
785    use super::*;
786    use crate::{
787        lua_rockspec::ExternalDependencySpec,
788        manifest::{Manifest, ManifestMetadata},
789        package::PackageReq,
790        rockspec::Rockspec,
791    };
792
793    #[tokio::test]
794    async fn test_add_various_dependencies() {
795        let sample_project: PathBuf = "resources/test/sample-projects/no-build-spec/".into();
796        let project_root = assert_fs::TempDir::new().unwrap();
797        project_root.copy_from(&sample_project, &["**"]).unwrap();
798        let project_root: PathBuf = project_root.path().into();
799        let mut project = Project::from(&project_root).unwrap().unwrap();
800        let add_dependencies =
801            vec![PackageReq::new("busted".into(), Some(">= 1.0.0".into())).unwrap()];
802        let expected_dependencies = vec![PackageReq::new("busted".into(), Some(">= 1.0.0".into()))
803            .unwrap()
804            .into()];
805
806        let test_manifest_path =
807            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/manifest-5.1");
808        let content = String::from_utf8(std::fs::read(&test_manifest_path).unwrap()).unwrap();
809        let metadata = ManifestMetadata::new(&content).unwrap();
810        let package_db = Manifest::new(Url::parse("https://example.com").unwrap(), metadata).into();
811
812        project
813            .add(
814                DependencyType::Regular(add_dependencies.clone()),
815                &package_db,
816            )
817            .await
818            .unwrap();
819
820        project
821            .add(DependencyType::Build(add_dependencies.clone()), &package_db)
822            .await
823            .unwrap();
824        project
825            .add(DependencyType::Test(add_dependencies.clone()), &package_db)
826            .await
827            .unwrap();
828
829        project
830            .add(
831                DependencyType::External(HashMap::from([(
832                    "lib".into(),
833                    ExternalDependencySpec {
834                        library: Some("path.so".into()),
835                        header: None,
836                    },
837                )])),
838                &package_db,
839            )
840            .await
841            .unwrap();
842
843        // Reparse the lux.toml (not usually necessary, but we want to test that the file was
844        // written correctly)
845        let project = Project::from(&project_root).unwrap().unwrap();
846        let validated_toml = project.toml().into_remote().unwrap();
847
848        assert_eq!(
849            validated_toml.dependencies().current_platform(),
850            &expected_dependencies
851        );
852        assert_eq!(
853            validated_toml.build_dependencies().current_platform(),
854            &expected_dependencies
855        );
856        assert_eq!(
857            validated_toml.test_dependencies().current_platform(),
858            &expected_dependencies
859        );
860        assert_eq!(
861            validated_toml
862                .external_dependencies()
863                .current_platform()
864                .get("lib")
865                .unwrap(),
866            &ExternalDependencySpec {
867                library: Some("path.so".into()),
868                header: None
869            }
870        );
871    }
872
873    #[tokio::test]
874    async fn test_remove_dependencies() {
875        let sample_project: PathBuf = "resources/test/sample-projects/dependencies/".into();
876        let project_root = assert_fs::TempDir::new().unwrap();
877        project_root.copy_from(&sample_project, &["**"]).unwrap();
878        let project_root: PathBuf = project_root.path().into();
879        let mut project = Project::from(&project_root).unwrap().unwrap();
880        let remove_dependencies = vec!["lua-cjson".into(), "plenary.nvim".into()];
881        project
882            .remove(DependencyType::Regular(remove_dependencies.clone()))
883            .await
884            .unwrap();
885        let check = |project: &Project| {
886            for name in &remove_dependencies {
887                assert!(!project
888                    .toml()
889                    .dependencies
890                    .clone()
891                    .unwrap_or_default()
892                    .iter()
893                    .any(|dep| dep.name() == name));
894            }
895        };
896        check(&project);
897        // check again after reloading lux.toml
898        let reloaded_project = Project::from(&project_root).unwrap().unwrap();
899        check(&reloaded_project);
900    }
901
902    #[tokio::test]
903    async fn test_extra_rockspec_parsing() {
904        let sample_project: PathBuf = "resources/test/sample-projects/extra-rockspec/".into();
905        let project_root = assert_fs::TempDir::new().unwrap();
906        project_root.copy_from(&sample_project, &["**"]).unwrap();
907        let project_root: PathBuf = project_root.path().into();
908        let project = Project::from(project_root).unwrap().unwrap();
909
910        let extra_rockspec = project.extra_rockspec().unwrap();
911
912        assert!(extra_rockspec.is_some());
913
914        let rocks = project.toml().into_remote().unwrap();
915
916        assert_eq!(rocks.package().to_string(), "custom-package");
917    }
918
919    #[tokio::test]
920    async fn test_pin_dependencies() {
921        test_pin_unpin_dependencies(PinnedState::Pinned).await
922    }
923
924    #[tokio::test]
925    async fn test_unpin_dependencies() {
926        test_pin_unpin_dependencies(PinnedState::Unpinned).await
927    }
928
929    async fn test_pin_unpin_dependencies(pin: PinnedState) {
930        let sample_project: PathBuf = "resources/test/sample-projects/dependencies/".into();
931        let project_root = assert_fs::TempDir::new().unwrap();
932        project_root.copy_from(&sample_project, &["**"]).unwrap();
933        let project_root: PathBuf = project_root.path().into();
934        let mut project = Project::from(&project_root).unwrap().unwrap();
935        let pin_dependencies = vec!["lua-cjson".into(), "plenary.nvim".into()];
936        project
937            .set_pinned_state(LuaDependencyType::Regular(pin_dependencies.clone()), pin)
938            .await
939            .unwrap();
940        let check = |project: &Project| {
941            for name in &pin_dependencies {
942                assert!(project
943                    .toml()
944                    .dependencies
945                    .clone()
946                    .unwrap_or_default()
947                    .iter()
948                    .any(|dep| dep.name() == name && dep.pin == pin));
949            }
950        };
951        check(&project);
952        // check again after reloading lux.toml
953        let reloaded_project = Project::from(&project_root).unwrap().unwrap();
954        check(&reloaded_project);
955    }
956}