Skip to main content

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