Skip to main content

lux_lib/project/
mod.rs

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