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::fs;
17
18use crate::{
19    config::Config,
20    git::{
21        self,
22        shorthand::RemoteGitUrlShorthand,
23        url::RemoteGitUrl,
24        utils::{GitError, SemVerTagOrSha},
25    },
26    lua_rockspec::{
27        LocalLuaRockspec, LuaRockspecError, LuaVersionError, PartialLuaRockspec,
28        PartialRockspecError, RemoteLuaRockspec,
29    },
30    lua_version::LuaVersion,
31    package::SpecRev,
32    package_db::PackageDB,
33    rockspec::{
34        lua_dependency::{DependencyType, LuaDependencySpec, LuaDependencyType},
35        LuaVersionCompatibility,
36    },
37};
38use crate::{
39    lockfile::PinnedState,
40    package::{PackageName, PackageReq, PackageVersionReq},
41};
42
43pub(crate) mod gen;
44pub mod project_toml;
45
46pub use project_toml::PROJECT_TOML;
47
48pub const EXTRA_ROCKSPEC: &str = "extra.rockspec";
49
50/// A wrapper around [`toml::de::Error`] plus the original source code and the byte range where the
51/// error occurred so that `miette` can render the failure with line numbers and context.
52#[derive(Debug, Error, Diagnostic)]
53#[error("{message}", message = _inner.message())]
54#[diagnostic(
55    code(lux_lib::toml::deserialize),
56    help("check that the file is valid TOML and matches the expected schema"),
57    url("https://lux.lumen-labs.org/reference/lux-toml")
58)]
59pub struct TomlDeError {
60    #[source]
61    _inner: Box<toml::de::Error>,
62    #[source_code]
63    src: NamedSource<String>,
64    #[label("here")]
65    span: SourceSpan,
66}
67
68impl TomlDeError {
69    pub fn new(name: &str, src: &str, inner: toml::de::Error) -> Self {
70        let src = NamedSource::new(name, src.to_string());
71        let span = inner.span().unwrap_or(0..0).into();
72        Self {
73            _inner: Box::new(inner),
74            src,
75            span,
76        }
77    }
78
79    pub fn inner(&self) -> &toml::de::Error {
80        &self._inner
81    }
82}
83
84pub(crate) fn parse_toml<T>(name: &str, src: &str) -> Result<T, TomlDeError>
85where
86    T: serde::de::DeserializeOwned,
87{
88    toml::from_str(src).map_err(|e| TomlDeError::new(name, src, e))
89}
90
91#[derive(Error, Debug, Diagnostic)]
92#[non_exhaustive]
93#[error(transparent)]
94pub enum ProjectError {
95    #[error(transparent)]
96    #[diagnostic(transparent)]
97    Fs(#[from] fs::FsError),
98    #[diagnostic(transparent)]
99    Project(#[from] LocalProjectTomlValidationError),
100    #[diagnostic(transparent)]
101    Toml(#[from] TomlDeError),
102    #[error("error when parsing 'extra.rockspec'")]
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    #[diagnostic(transparent)]
130    Fs(#[from] fs::FsError),
131    #[error(transparent)]
132    Toml(#[from] toml_edit::TomlError),
133    #[error("error parsing {PROJECT_TOML} after edit. This is probably a bug.")]
134    #[diagnostic(forward(0))]
135    TomlDe(#[from] TomlDeError),
136    #[error(transparent)]
137    #[diagnostic(transparent)]
138    Git(#[from] GitError),
139    #[error("unable to query latest version for '{0}'")]
140    #[diagnostic(help("check your server config and ensure the package has been published"))]
141    LatestVersionNotFound(PackageName),
142    #[error("expected field to be a value, but got '{0}'")]
143    #[diagnostic(
144        help("ensure your {PROJECT_TOML} matches the specification"),
145        url("https://lux.lumen-labs.org/reference/lux-toml")
146    )]
147    ExpectedValue(Box<toml_edit::Item>),
148    #[error("expected string, but got '{0}'")]
149    #[diagnostic(
150        help("ensure your {PROJECT_TOML} matches the specification"),
151        url("https://lux.lumen-labs.org/reference/lux-toml")
152    )]
153    ExpectedString(Box<toml_edit::Value>),
154    #[error(transparent)]
155    #[diagnostic(transparent)]
156    GitUrlShorthandParse(#[from] git::shorthand::ParseError),
157}
158
159#[derive(Error, Debug, Diagnostic)]
160#[non_exhaustive]
161pub enum PinError {
162    #[error("package '{0}' not found in dependencies")]
163    #[diagnostic(help("to add it, run 'lx add {0}'"))]
164    PackageNotFound(PackageName),
165    #[error("dependency '{dep}' is already {}pinned!", if *.pin_state == PinnedState::Unpinned { "un" } else { "" })]
166    PinStateUnchanged {
167        pin_state: PinnedState,
168        dep: PackageName,
169    },
170    #[error("unable to parse {toml_path} for editing")]
171    #[diagnostic(
172        code(lux_lib::toml_edit::parse),
173        help("check that the file is valid TOML and matches the expected schema"),
174        url("https://lux.lumen-labs.org/reference/lux-toml")
175    )]
176    ParseTomlEdit {
177        toml_path: String,
178        source: toml_edit::TomlError,
179    },
180    #[error("error parsing lux.toml after edit. This is probably a bug.")]
181    #[diagnostic(forward(0))]
182    TomlDe(#[from] TomlDeError),
183    #[error(transparent)]
184    Io(#[from] tokio::io::Error),
185    #[error(transparent)]
186    #[diagnostic(transparent)]
187    Fs(#[from] fs::FsError),
188}
189
190/// A newtype for the project root directory.
191/// This is used to ensure that the project root is a valid project directory.
192#[derive(Clone, Debug)]
193#[cfg_attr(test, derive(Default))]
194pub struct ProjectRoot(PathBuf);
195
196impl ProjectRoot {
197    pub(crate) fn new() -> Self {
198        Self(PathBuf::new())
199    }
200}
201
202impl AsRef<Path> for ProjectRoot {
203    fn as_ref(&self) -> &Path {
204        self.0.as_ref()
205    }
206}
207
208impl Deref for ProjectRoot {
209    type Target = PathBuf;
210
211    fn deref(&self) -> &Self::Target {
212        &self.0
213    }
214}
215
216/// Lux project, with methods for managing dependencies, etc.
217#[derive(Clone, Debug)]
218pub struct Project {
219    /// The path where the `lux.toml` resides.
220    lux_toml_dir: ProjectRoot,
221    /// The project root. This is the same as [`Project::lux_toml_dir`],
222    /// unless overridden by the `[project] root_dir` field.
223    root: ProjectRoot,
224    /// The parsed lux.toml.
225    toml: PartialProjectToml,
226}
227
228impl Project {
229    /// Load a project at the specified path, if it exists
230    #[tracing::instrument(level = "trace", skip_all)]
231    pub fn from_exact(start: impl AsRef<Path>) -> Result<Option<Self>, ProjectError> {
232        if !start.as_ref().exists() {
233            return Ok(None);
234        }
235
236        if start.as_ref().join(PROJECT_TOML).exists() {
237            let project_toml_path = start.as_ref().join(PROJECT_TOML);
238            let toml_content = fs::sync::read_to_string(&project_toml_path)?;
239            let root = start.as_ref();
240
241            let toml = PartialProjectToml::new(
242                &project_toml_path.to_string_lossy(),
243                &toml_content,
244                ProjectRoot(root.to_path_buf()),
245            )?;
246            let mut project = Project {
247                lux_toml_dir: ProjectRoot(root.to_path_buf()),
248                root: toml.project.root.clone(),
249                toml,
250            };
251
252            if let Some(extra_rockspec) = project.extra_rockspec()? {
253                project.toml = project.toml.merge(extra_rockspec);
254            }
255
256            Ok(Some(project))
257        } else {
258            Ok(None)
259        }
260    }
261
262    /// Get the `lux.toml` path.
263    pub fn toml_path(&self) -> PathBuf {
264        self.lux_toml_dir.join(PROJECT_TOML)
265    }
266
267    /// Get the `extra.rockspec` path.
268    pub fn extra_rockspec_path(&self) -> PathBuf {
269        self.lux_toml_dir.join(EXTRA_ROCKSPEC)
270    }
271
272    pub fn root(&self) -> &ProjectRoot {
273        &self.root
274    }
275
276    pub fn toml(&self) -> &PartialProjectToml {
277        &self.toml
278    }
279
280    pub fn local_rockspec(&self) -> Result<LocalLuaRockspec, IntoLocalRockspecError> {
281        self.toml()
282            .into_local()
283            .map_err(|err| IntoLocalRockspecError::LocalProjectTomlValidationError(Box::new(err)))?
284            .to_lua_rockspec()
285            .map_err(|err| IntoLocalRockspecError::RockspecError(Box::new(err)))
286    }
287
288    /// Returns this project's rockspec as a [`RemoteLuaRockspec`] whose source points at the
289    /// project's own directory, so it can be built like any other package.
290    pub fn local_remote_rockspec(&self) -> Result<RemoteLuaRockspec, IntoLocalRockspecError> {
291        self.toml()
292            .into_local()
293            .map_err(|err| IntoLocalRockspecError::LocalProjectTomlValidationError(Box::new(err)))?
294            .to_local_rockspec()
295            .map_err(|err| IntoLocalRockspecError::RockspecError(Box::new(err)))
296    }
297
298    pub fn remote_rockspec(
299        &self,
300        specrev: Option<SpecRev>,
301    ) -> Result<RemoteLuaRockspec, IntoRemoteRockspecError> {
302        self.toml()
303            .into_remote(specrev)
304            .map_err(|err| IntoRemoteRockspecError::RocksTomlValidationError(Box::new(err)))?
305            .to_lua_rockspec()
306            .map_err(|err| IntoRemoteRockspecError::RockspecError(Box::new(err)))
307    }
308
309    pub fn extra_rockspec(&self) -> Result<Option<PartialLuaRockspec>, PartialRockspecError> {
310        if self.extra_rockspec_path().exists() {
311            Ok(Some(PartialLuaRockspec::new(
312                &fs::sync::read_to_string(self.extra_rockspec_path()).map_err(io::Error::other)?,
313            )?))
314        } else {
315            Ok(None)
316        }
317    }
318
319    pub fn lua_version(&self, config: &Config) -> Result<LuaVersion, LuaVersionError> {
320        self.toml().lua_version_matches(config)
321    }
322
323    pub async fn add(
324        &mut self,
325        dependencies: DependencyType<&PackageReq>,
326        package_db: &PackageDB,
327    ) -> Result<(), ProjectEditError> {
328        let mut project_toml =
329            toml_edit::DocumentMut::from_str(&fs::tokio::read_to_string(self.toml_path()).await?)?;
330
331        prepare_dependency_tables(&mut project_toml);
332        let table = match dependencies {
333            DependencyType::Regular(_) => &mut project_toml["dependencies"],
334            DependencyType::Build(_) => &mut project_toml["build_dependencies"],
335            DependencyType::Test(_) => &mut project_toml["test_dependencies"],
336            DependencyType::External(_) => &mut project_toml["external_dependencies"],
337        };
338
339        match dependencies {
340            DependencyType::Regular(ref deps)
341            | DependencyType::Build(ref deps)
342            | DependencyType::Test(ref deps) => {
343                for dep in deps {
344                    let dep_version_str = if dep.version_req().is_any() {
345                        let latest_version =
346                            package_db.latest_version(dep.name()).await.ok_or_else(|| {
347                                ProjectEditError::LatestVersionNotFound(dep.name().clone())
348                            })?;
349                        latest_version
350                            .to_pessimistic_version_req()
351                            .unwrap_or_else(|| latest_version.to_string())
352                    } else {
353                        dep.version_req().to_string()
354                    };
355                    table[dep.name().to_string()] = toml_edit::value(dep_version_str);
356                }
357            }
358            DependencyType::External(ref deps) => {
359                for (name, dep) in deps {
360                    if let Some(path) = &dep.header {
361                        table[name]["header"] = toml_edit::value(path.to_slash_lossy().to_string());
362                    }
363                    if let Some(path) = &dep.library {
364                        table[name]["library"] =
365                            toml_edit::value(path.to_slash_lossy().to_string());
366                    }
367                }
368            }
369        };
370
371        let toml_content = project_toml.to_string();
372        fs::tokio::write(self.toml_path(), &toml_content).await?;
373        self.toml = PartialProjectToml::new(
374            self.toml_path().to_str().unwrap_or("<lux.toml>"),
375            &toml_content,
376            self.lux_toml_dir.clone(),
377        )?;
378
379        Ok(())
380    }
381
382    pub async fn add_git(
383        &mut self,
384        dependencies: LuaDependencyType<&RemoteGitUrlShorthand>,
385        config: &Config,
386    ) -> Result<(), ProjectEditError> {
387        let mut project_toml =
388            toml_edit::DocumentMut::from_str(&fs::tokio::read_to_string(self.toml_path()).await?)?;
389
390        prepare_dependency_tables(&mut project_toml);
391        let table = match dependencies {
392            LuaDependencyType::Regular(_) => &mut project_toml["dependencies"],
393            LuaDependencyType::Build(_) => &mut project_toml["build_dependencies"],
394            LuaDependencyType::Test(_) => &mut project_toml["test_dependencies"],
395        };
396
397        match dependencies {
398            LuaDependencyType::Regular(urls)
399            | LuaDependencyType::Build(urls)
400            | LuaDependencyType::Test(urls) => {
401                for url in urls {
402                    let git_url: RemoteGitUrl = url.clone().into();
403                    let mut dep_entry = toml_edit::table();
404                    match git::utils::latest_semver_tag_or_commit_sha(&git_url, config)? {
405                        SemVerTagOrSha::SemVerTag(tag) => {
406                            dep_entry["git"] = Item::Value(url.to_string().into());
407                            dep_entry["version"] = Item::Value(tag.clone().into());
408                            if tag.contains("-") {
409                                // Tag contains a specrev.
410                                dep_entry["rev"] = Item::Value(tag.into());
411                            }
412                        }
413                        SemVerTagOrSha::CommitSha(sha) => {
414                            dep_entry["git"] = Item::Value(url.to_string().into());
415                            dep_entry["version"] = Item::Value(sha.into());
416                        }
417                    }
418                    table[git_url.repo()] = dep_entry;
419                }
420            }
421        }
422
423        let toml_content = project_toml.to_string();
424        fs::tokio::write(self.toml_path(), &toml_content).await?;
425        self.toml = PartialProjectToml::new(
426            self.toml_path().to_str().unwrap_or("<lux.toml>"),
427            &toml_content,
428            self.lux_toml_dir.clone(),
429        )?;
430
431        Ok(())
432    }
433
434    pub async fn remove(
435        &mut self,
436        dependencies: DependencyType<&PackageName>,
437    ) -> Result<(), ProjectEditError> {
438        let mut project_toml =
439            toml_edit::DocumentMut::from_str(&fs::tokio::read_to_string(self.toml_path()).await?)?;
440
441        prepare_dependency_tables(&mut project_toml);
442        let table = match dependencies {
443            DependencyType::Regular(_) => &mut project_toml["dependencies"],
444            DependencyType::Build(_) => &mut project_toml["build_dependencies"],
445            DependencyType::Test(_) => &mut project_toml["test_dependencies"],
446            DependencyType::External(_) => &mut project_toml["external_dependencies"],
447        };
448
449        match dependencies {
450            DependencyType::Regular(ref deps)
451            | DependencyType::Build(ref deps)
452            | DependencyType::Test(ref deps) => {
453                for dep in deps {
454                    table[dep.to_string()] = Item::None;
455                }
456            }
457            DependencyType::External(ref deps) => {
458                for (name, dep) in deps {
459                    if dep.header.is_some() {
460                        table[name]["header"] = Item::None;
461                    }
462                    if dep.library.is_some() {
463                        table[name]["library"] = Item::None;
464                    }
465                }
466            }
467        };
468
469        let toml_content = project_toml.to_string();
470        fs::tokio::write(self.toml_path(), &toml_content).await?;
471        self.toml = PartialProjectToml::new(
472            self.toml_path().to_str().unwrap_or("<lux.toml>"),
473            &toml_content,
474            self.lux_toml_dir.clone(),
475        )?;
476
477        Ok(())
478    }
479
480    pub async fn upgrade(
481        &mut self,
482        dependencies: LuaDependencyType<&PackageName>,
483        package_db: &PackageDB,
484        config: &Config,
485    ) -> Result<(), ProjectEditError> {
486        let mut project_toml =
487            toml_edit::DocumentMut::from_str(&fs::tokio::read_to_string(self.toml_path()).await?)?;
488
489        prepare_dependency_tables(&mut project_toml);
490        let table = match dependencies {
491            LuaDependencyType::Regular(_) => &mut project_toml["dependencies"],
492            LuaDependencyType::Build(_) => &mut project_toml["build_dependencies"],
493            LuaDependencyType::Test(_) => &mut project_toml["test_dependencies"],
494        };
495
496        match dependencies {
497            LuaDependencyType::Regular(deps)
498            | LuaDependencyType::Build(deps)
499            | LuaDependencyType::Test(deps) => {
500                for dep in deps {
501                    upgrade_dependency(table, dep, package_db, config).await?;
502                }
503            }
504        }
505
506        let toml_content = project_toml.to_string();
507        fs::tokio::write(self.toml_path(), &toml_content).await?;
508        self.toml = PartialProjectToml::new(
509            self.toml_path().to_str().unwrap_or("<lux.toml>"),
510            &toml_content,
511            self.lux_toml_dir.clone(),
512        )?;
513
514        Ok(())
515    }
516
517    pub async fn upgrade_all(
518        &mut self,
519        package_db: &PackageDB,
520        config: &Config,
521    ) -> Result<(), ProjectEditError> {
522        if let Some(dependencies) = &self.toml().dependencies {
523            let packages = dependencies
524                .iter()
525                .map(|dep| dep.name())
526                .cloned()
527                .collect_vec();
528            self.upgrade(
529                LuaDependencyType::Regular(packages.iter().collect()),
530                package_db,
531                config,
532            )
533            .await?;
534        }
535        if let Some(dependencies) = &self.toml().build_dependencies {
536            let packages = dependencies
537                .iter()
538                .map(|dep| dep.name())
539                .cloned()
540                .collect_vec();
541            self.upgrade(
542                LuaDependencyType::Build(packages.iter().collect()),
543                package_db,
544                config,
545            )
546            .await?;
547        }
548        if let Some(dependencies) = &self.toml().test_dependencies {
549            let packages = dependencies
550                .iter()
551                .map(|dep| dep.name())
552                .cloned()
553                .collect_vec();
554            self.upgrade(
555                LuaDependencyType::Test(packages.iter().collect()),
556                package_db,
557                config,
558            )
559            .await?;
560        }
561        Ok(())
562    }
563
564    pub async fn set_pinned_state(
565        &mut self,
566        dependencies: LuaDependencyType<&PackageName>,
567        pin: PinnedState,
568    ) -> Result<(), PinError> {
569        let mut project_toml =
570            toml_edit::DocumentMut::from_str(&fs::tokio::read_to_string(self.toml_path()).await?)
571                .map_err(|source| PinError::ParseTomlEdit {
572                toml_path: self.toml_path().to_slash_lossy().to_string(),
573                source,
574            })?;
575
576        prepare_dependency_tables(&mut project_toml);
577        let table = match dependencies {
578            LuaDependencyType::Regular(_) => &mut project_toml["dependencies"],
579            LuaDependencyType::Build(_) => &mut project_toml["build_dependencies"],
580            LuaDependencyType::Test(_) => &mut project_toml["test_dependencies"],
581        };
582
583        match dependencies {
584            LuaDependencyType::Regular(ref _deps) => {
585                self.toml.dependencies = Some(
586                    self.toml
587                        .dependencies
588                        .take()
589                        .unwrap_or_default()
590                        .into_iter()
591                        .map(|dep| LuaDependencySpec { pin, ..dep })
592                        .collect(),
593                )
594            }
595            LuaDependencyType::Build(ref _deps) => {
596                self.toml.build_dependencies = Some(
597                    self.toml
598                        .build_dependencies
599                        .take()
600                        .unwrap_or_default()
601                        .into_iter()
602                        .map(|dep| LuaDependencySpec { pin, ..dep })
603                        .collect(),
604                )
605            }
606            LuaDependencyType::Test(ref _deps) => {
607                self.toml.test_dependencies = Some(
608                    self.toml
609                        .test_dependencies
610                        .take()
611                        .unwrap_or_default()
612                        .into_iter()
613                        .map(|dep| LuaDependencySpec { pin, ..dep })
614                        .collect(),
615                )
616            }
617        }
618
619        match dependencies {
620            LuaDependencyType::Regular(ref deps)
621            | LuaDependencyType::Build(ref deps)
622            | LuaDependencyType::Test(ref deps) => {
623                for dep in deps {
624                    let mut dep_item = table[dep.to_string()].clone();
625                    match dep_item {
626                        version @ Item::Value(_) => match &pin {
627                            PinnedState::Unpinned => {}
628                            PinnedState::Pinned => {
629                                if let Ok(mut dep_entry) = toml_edit::table().into_table() {
630                                    dep_entry.set_implicit(true);
631                                    dep_entry["version"] = version;
632                                    dep_entry["pin"] = toml_edit::value(true);
633                                    table[dep.to_string()] = toml_edit::Item::Table(dep_entry);
634                                }
635                            }
636                        },
637                        Item::Table(_) => {
638                            dep_item["pin".to_string()] = toml_edit::value(pin.as_bool());
639                            table[dep.to_string()] = dep_item;
640                        }
641                        _ => {}
642                    }
643                }
644            }
645        }
646
647        let toml_content = project_toml.to_string();
648        fs::tokio::write(self.toml_path(), &toml_content).await?;
649        self.toml = PartialProjectToml::new(
650            self.toml_path().to_str().unwrap_or("<lux.toml>"),
651            &toml_content,
652            self.lux_toml_dir.clone(),
653        )?;
654
655        Ok(())
656    }
657
658    pub fn project_files(&self) -> Vec<PathBuf> {
659        project_files(&self.root().0)
660    }
661}
662
663/// Get the files that Lux treats as project files
664/// This respects ignore files and excludes hidden files and directories.
665pub(crate) fn project_files(src: &Path) -> Vec<PathBuf> {
666    ignore::WalkBuilder::new(src)
667        .add(src.join(".cargo"))
668        .parents(false)
669        .follow_links(false)
670        .build()
671        .filter_map(Result::ok)
672        .filter(|entry| entry.file_type().is_some_and(|ft| ft.is_file()))
673        .map(|entry| entry.into_path())
674        .collect_vec()
675}
676
677async fn upgraded_version_req(
678    existing: Option<&str>,
679    dep: &PackageName,
680    package_db: &PackageDB,
681) -> Result<Option<String>, ProjectEditError> {
682    if let Some(existing) = existing.and_then(|existing| PackageVersionReq::parse(existing).ok()) {
683        let latest = package_db
684            .latest_version(dep)
685            .await
686            .ok_or(ProjectEditError::LatestVersionNotFound(dep.clone()))?;
687        Ok(latest.upgrade_version_req(&existing))
688    } else {
689        Ok(None)
690    }
691}
692
693async fn upgrade_dependency(
694    table: &mut Item,
695    dep: &PackageName,
696    package_db: &PackageDB,
697    config: &Config,
698) -> Result<(), ProjectEditError> {
699    let mut dep_item = table[dep.to_string()].clone();
700    match &dep_item {
701        Item::Value(val) => {
702            if let Some(upgraded) = upgraded_version_req(val.as_str(), dep, package_db).await? {
703                table[dep.to_string()] = toml_edit::value(upgraded);
704            }
705        }
706        Item::Table(tbl) => {
707            if let Some(git_item) = tbl.get("git") {
708                let latest = resolve_git_latest(git_url_from_item(git_item)?, config)?;
709                apply_git_latest(table, dep, latest);
710            } else {
711                if let Some(upgraded) = upgraded_version_req(
712                    dep_item.get("version").and_then(Item::as_str),
713                    dep,
714                    package_db,
715                )
716                .await?
717                {
718                    dep_item["version"] = toml_edit::value(upgraded);
719                }
720                table[dep.to_string()] = dep_item;
721            }
722        }
723        _ => {}
724    }
725    Ok(())
726}
727
728fn git_url_from_item(git_item: &Item) -> Result<RemoteGitUrlShorthand, ProjectEditError> {
729    let git_value = git_item
730        .clone()
731        .into_value()
732        .map_err(|err| ProjectEditError::ExpectedValue(Box::new(err)))?;
733    let git_url_str = git_value
734        .as_str()
735        .ok_or(ProjectEditError::ExpectedString(Box::new(
736            git_value.clone(),
737        )))?;
738    Ok(git_url_str.parse()?)
739}
740
741fn resolve_git_latest(
742    url: RemoteGitUrlShorthand,
743    config: &Config,
744) -> Result<SemVerTagOrSha, ProjectEditError> {
745    Ok(git::utils::latest_semver_tag_or_commit_sha(
746        &url.into(),
747        config,
748    )?)
749}
750
751fn apply_git_latest(table: &mut Item, dep: &PackageName, latest: SemVerTagOrSha) {
752    match latest {
753        SemVerTagOrSha::SemVerTag(latest_tag) => {
754            table[dep.to_string()]["version"] = Item::Value(latest_tag.clone().into());
755            if latest_tag.contains("-") {
756                table[dep.to_string()]["rev"] = Item::Value(latest_tag.into());
757            }
758        }
759        SemVerTagOrSha::CommitSha(latest_sha) => {
760            table[dep.to_string()]["version"] = Item::Value(latest_sha.into());
761        }
762    }
763}
764
765fn prepare_dependency_tables(project_toml: &mut DocumentMut) {
766    if !project_toml.contains_table("dependencies") {
767        if let Ok(mut table) = toml_edit::table().into_table() {
768            table.set_implicit(true);
769            project_toml["dependencies"] = toml_edit::Item::Table(table);
770        }
771    }
772    if !project_toml.contains_table("build_dependencies") {
773        if let Ok(mut table) = toml_edit::table().into_table() {
774            table.set_implicit(true);
775            project_toml["build_dependencies"] = toml_edit::Item::Table(table);
776        }
777    }
778    if !project_toml.contains_table("test_dependencies") {
779        if let Ok(mut table) = toml_edit::table().into_table() {
780            table.set_implicit(true);
781            project_toml["test_dependencies"] = toml_edit::Item::Table(table);
782        }
783    }
784    if !project_toml.contains_table("external_dependencies") {
785        if let Ok(mut table) = toml_edit::table().into_table() {
786            table.set_implicit(true);
787            project_toml["external_dependencies"] = toml_edit::Item::Table(table);
788        }
789    }
790}
791
792// TODO: More project-based test
793#[cfg(test)]
794mod tests {
795    use std::collections::HashMap;
796
797    use assert_fs::prelude::{PathChild, PathCopy, PathCreateDir};
798    use url::Url;
799
800    use super::*;
801    use crate::{
802        config::ConfigBuilder,
803        fs,
804        lua_rockspec::ExternalDependencySpec,
805        manifest::{Manifest, ManifestMetadata},
806        package::PackageReq,
807        package_db::PackageDB,
808        rockspec::Rockspec,
809    };
810
811    #[tokio::test]
812    async fn test_add_various_dependencies() {
813        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
814            .join("resources/test/sample-projects/no-build-spec/");
815        let project_root = assert_fs::TempDir::new().unwrap();
816        project_root.copy_from(&sample_project, &["**"]).unwrap();
817        let project_root: PathBuf = project_root.path().into();
818        let mut project = Project::from_exact(&project_root).unwrap().unwrap();
819        let add_dependencies = [PackageReq::new("busted".into(), Some(">= 1.0.0".into())).unwrap()];
820        let expected_dependencies = vec![PackageReq::new("busted".into(), Some(">= 1.0.0".into()))
821            .unwrap()
822            .into()];
823
824        let test_manifest_path =
825            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/manifest-5.1");
826        let content = String::from_utf8(std::fs::read(&test_manifest_path).unwrap()).unwrap();
827        let metadata = ManifestMetadata::new(&content).unwrap();
828        let config = ConfigBuilder::new().unwrap().build().unwrap();
829        let package_db = PackageDB::from_manifest(
830            Manifest::new(
831                Url::parse("https://example.com").unwrap(),
832                metadata,
833                Default::default(),
834            ),
835            &config,
836        );
837
838        project
839            .add(
840                DependencyType::Regular(add_dependencies.iter().collect_vec()),
841                &package_db,
842            )
843            .await
844            .unwrap();
845
846        project
847            .add(
848                DependencyType::Build(add_dependencies.iter().collect_vec()),
849                &package_db,
850            )
851            .await
852            .unwrap();
853        project
854            .add(
855                DependencyType::Test(add_dependencies.iter().collect_vec()),
856                &package_db,
857            )
858            .await
859            .unwrap();
860
861        project
862            .add(
863                DependencyType::External(HashMap::from([(
864                    "lib".into(),
865                    ExternalDependencySpec {
866                        library: Some("path.so".into()),
867                        header: None,
868                    },
869                )])),
870                &package_db,
871            )
872            .await
873            .unwrap();
874
875        // Reparse the lux.toml (not usually necessary, but we want to test that the file was
876        // written correctly)
877        let project = Project::from_exact(&project_root).unwrap().unwrap();
878        let validated_toml = project.toml().into_remote(None).unwrap();
879
880        assert_eq!(
881            validated_toml.dependencies().current_platform(),
882            &expected_dependencies
883        );
884        assert_eq!(
885            validated_toml.build_dependencies().current_platform(),
886            &expected_dependencies
887        );
888        assert_eq!(
889            validated_toml.test_dependencies().current_platform(),
890            &expected_dependencies
891        );
892        assert_eq!(
893            validated_toml
894                .external_dependencies()
895                .current_platform()
896                .get("lib")
897                .unwrap(),
898            &ExternalDependencySpec {
899                library: Some("path.so".into()),
900                header: None
901            }
902        );
903    }
904
905    #[tokio::test]
906    async fn test_add_unpublished_package_does_not_edit_toml() {
907        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
908            .join("resources/test/sample-projects/no-build-spec/");
909        let project_root = assert_fs::TempDir::new().unwrap();
910        project_root.copy_from(&sample_project, &["**"]).unwrap();
911        let project_root: PathBuf = project_root.path().into();
912        let mut project = Project::from_exact(&project_root).unwrap().unwrap();
913        let toml_before = fs::sync::read_to_string(project.toml_path()).unwrap();
914
915        let test_manifest_path =
916            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/manifest-5.1");
917        let content = String::from_utf8(std::fs::read(&test_manifest_path).unwrap()).unwrap();
918        let metadata = ManifestMetadata::new(&content).unwrap();
919        let config = ConfigBuilder::new().unwrap().build().unwrap();
920        let package_db = PackageDB::from_manifest(
921            Manifest::new(
922                Url::parse("https://example.com").unwrap(),
923                metadata,
924                Default::default(),
925            ),
926            &config,
927        );
928
929        let unknown_dependencies = [PackageReq::from_str("not-a-package-in-any-manifest").unwrap()];
930        let err = project
931            .add(
932                DependencyType::Regular(unknown_dependencies.iter().collect_vec()),
933                &package_db,
934            )
935            .await
936            .unwrap_err();
937
938        assert!(matches!(err, ProjectEditError::LatestVersionNotFound(_)));
939        assert_eq!(
940            fs::sync::read_to_string(project.toml_path()).unwrap(),
941            toml_before
942        );
943    }
944
945    #[tokio::test]
946    async fn test_upgrade_preserves_version_req_style() {
947        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
948            .join("resources/test/sample-projects/no-build-spec/");
949        let project_root = assert_fs::TempDir::new().unwrap();
950        project_root.copy_from(&sample_project, &["**"]).unwrap();
951        let project_root: PathBuf = project_root.path().into();
952        let toml_path = project_root.join("lux.toml");
953
954        let test_manifest_path =
955            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/manifest-5.1");
956        let content = String::from_utf8(std::fs::read(&test_manifest_path).unwrap()).unwrap();
957        let metadata = ManifestMetadata::new(&content).unwrap();
958        let config = ConfigBuilder::default().build().unwrap();
959        let package_db = PackageDB::from_manifest(
960            Manifest::new(
961                Url::parse("https://example.com").unwrap(),
962                metadata,
963                Default::default(),
964            ),
965            &config,
966        );
967
968        let base = r#"package = "sample-project-no-build-spec"
969version = "0.1.0"
970lua = ">=5.1"
971
972[source]
973url = "https://github.com/lumen-oss/luarocks-stub"
974
975[build]
976type = "builtin"
977"#;
978        for (input, expected) in [
979            ("30log = \"~> 1.0\"", "30log = \"~> 1.3\""),
980            ("30log = \"~> 1\"", "30log = \"~> 1\""),
981            ("30log = \">= 1.0.0\"", "30log = \">= 1.0.0\""),
982            ("30log = \"1.0.0\"", "30log = \"1.3.0\""),
983        ] {
984            std::fs::write(&toml_path, format!("{base}\n[dependencies]\n{input}\n")).unwrap();
985            let mut project = Project::from_exact(&project_root).unwrap().unwrap();
986            let packages = [PackageName::from("30log")];
987            project
988                .upgrade(
989                    LuaDependencyType::Regular(packages.iter().collect()),
990                    &package_db,
991                    &config,
992                )
993                .await
994                .unwrap();
995            let rewritten = std::fs::read_to_string(&toml_path).unwrap();
996            assert!(rewritten.contains(expected), "input {input}: {rewritten}");
997        }
998    }
999
1000    #[tokio::test]
1001    async fn test_remove_dependencies() {
1002        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1003            .join("resources/test/sample-projects/dependencies/");
1004        let project_root = assert_fs::TempDir::new().unwrap();
1005        project_root.copy_from(&sample_project, &["**"]).unwrap();
1006        let project_root: PathBuf = project_root.path().into();
1007        let mut project = Project::from_exact(&project_root).unwrap().unwrap();
1008        let lua_cjson = "lua-cjson".into();
1009        let plenary_nvim = "plenary.nvim".into();
1010        let remove_dependencies = vec![&lua_cjson, &plenary_nvim];
1011        project
1012            .remove(DependencyType::Regular(remove_dependencies.clone()))
1013            .await
1014            .unwrap();
1015        let check = |project: &Project| {
1016            for name in &remove_dependencies {
1017                assert!(!project
1018                    .toml()
1019                    .dependencies
1020                    .clone()
1021                    .unwrap_or_default()
1022                    .iter()
1023                    .any(|dep| &dep.name() == name));
1024            }
1025        };
1026        check(&project);
1027        // check again after reloading lux.toml
1028        let reloaded_project = Project::from_exact(&project_root).unwrap().unwrap();
1029        check(&reloaded_project);
1030    }
1031
1032    #[tokio::test]
1033    async fn test_extra_rockspec_parsing() {
1034        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1035            .join("resources/test/sample-projects/extra-rockspec/");
1036        let project_root = assert_fs::TempDir::new().unwrap();
1037        project_root.copy_from(&sample_project, &["**"]).unwrap();
1038        let project_root: PathBuf = project_root.path().into();
1039        let project = Project::from_exact(project_root).unwrap().unwrap();
1040
1041        let extra_rockspec = project.extra_rockspec().unwrap();
1042
1043        assert!(extra_rockspec.is_some());
1044
1045        let rocks = project.toml().into_remote(None).unwrap();
1046
1047        assert_eq!(rocks.package().to_string(), "custom-package");
1048    }
1049
1050    #[tokio::test]
1051    async fn test_pin_dependencies() {
1052        test_pin_unpin_dependencies(PinnedState::Pinned).await
1053    }
1054
1055    #[tokio::test]
1056    async fn test_unpin_dependencies() {
1057        test_pin_unpin_dependencies(PinnedState::Unpinned).await
1058    }
1059
1060    async fn test_pin_unpin_dependencies(pin: PinnedState) {
1061        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1062            .join("resources/test/sample-projects/dependencies/");
1063        let project_root = assert_fs::TempDir::new().unwrap();
1064        project_root.copy_from(&sample_project, &["**"]).unwrap();
1065        let project_root: PathBuf = project_root.path().into();
1066        let mut project = Project::from_exact(&project_root).unwrap().unwrap();
1067        let lua_cjson = "lua-cjson".into();
1068        let plenary_nvim = "plenary.nvim".into();
1069        let pin_dependencies = vec![&lua_cjson, &plenary_nvim];
1070        project
1071            .set_pinned_state(LuaDependencyType::Regular(pin_dependencies.clone()), pin)
1072            .await
1073            .unwrap();
1074        let check = |project: &Project| {
1075            for name in &pin_dependencies {
1076                assert!(project
1077                    .toml()
1078                    .dependencies
1079                    .clone()
1080                    .unwrap_or_default()
1081                    .iter()
1082                    .any(|dep| &dep.name() == name && dep.pin == pin));
1083            }
1084        };
1085        check(&project);
1086        // check again after reloading lux.toml
1087        let reloaded_project = Project::from_exact(&project_root).unwrap().unwrap();
1088        check(&reloaded_project);
1089    }
1090
1091    #[tokio::test]
1092    async fn project_files_includes_cargo_directory() {
1093        let project_root = assert_fs::TempDir::new().unwrap();
1094        let cargo_dir = project_root.child(".cargo");
1095        cargo_dir.create_dir_all().unwrap();
1096        let cargo_config = cargo_dir.join("config.toml");
1097        fs::tokio::write(&cargo_config, "").await.unwrap();
1098        let project_files = project_files(&project_root);
1099        assert!(project_files.contains(&cargo_config.to_path_buf()));
1100    }
1101
1102    #[tokio::test]
1103    async fn project_files_ignores_parent_ignore_files() {
1104        let tmp = assert_fs::TempDir::new().unwrap();
1105        let parent = tmp.child("parent");
1106        parent.create_dir_all().unwrap();
1107        fs::tokio::write(parent.join(".ignore"), "*\n")
1108            .await
1109            .unwrap();
1110        let src = parent.child("etc");
1111        src.create_dir_all().unwrap();
1112        let file = src.join("payload.txt");
1113        fs::tokio::write(&file, "hello").await.unwrap();
1114
1115        let project_files = project_files(&src);
1116        assert!(project_files.contains(&file.to_path_buf()));
1117    }
1118
1119    #[tokio::test]
1120    async fn project_root_dir_override() {
1121        let root_dir = assert_fs::TempDir::new().unwrap();
1122        let lux_toml_dir = root_dir.child("proj");
1123        lux_toml_dir.create_dir_all().unwrap();
1124        fs::tokio::write(
1125            lux_toml_dir.join(PROJECT_TOML),
1126            r#"
1127            package = "my-package"
1128            version = "1.0.0"
1129            lua = "5.1"
1130
1131            [project]
1132            root_dir = ".."
1133
1134            [build]
1135            type = "builtin"
1136            "#,
1137        )
1138        .await
1139        .unwrap();
1140        let project = Project::from_exact(lux_toml_dir.path()).unwrap().unwrap();
1141        assert_eq!(
1142            std::fs::canonicalize(project.root()).unwrap(),
1143            std::fs::canonicalize(root_dir.path()).unwrap()
1144        );
1145        // The `lux.toml` path must still point to the directory containing the `lux.toml`.
1146        assert_eq!(project.toml_path(), lux_toml_dir.join(PROJECT_TOML));
1147    }
1148
1149    #[tokio::test]
1150    async fn from_exact_on_nonexistent_directory() {
1151        let empty_dir = assert_fs::TempDir::new().unwrap();
1152        assert!(Project::from_exact(&empty_dir).unwrap().is_none());
1153    }
1154}