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