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, lockfile::OptState};
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 self, LocalLuaRockspec, LuaVersionError, PartialLuaRockspec, PartialRockspecError,
28 RemoteLuaRockspec,
29 },
30 lua_version::LuaVersion,
31 package::SpecRev,
32 package_db::PackageDB,
33 rockspec::{
34 lua_dependency::{DependencyType, 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#[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 Error {
95 #[error(transparent)]
96 #[diagnostic(transparent)]
97 Fs(#[from] fs::Error),
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 Rockspec(Box<lua_rockspec::Error>),
113}
114
115#[derive(Error, Debug, Diagnostic)]
116#[non_exhaustive]
117#[error(transparent)]
118pub enum IntoRemoteRockspecError {
119 RocksTomlValidationError(Box<RemoteProjectTomlValidationError>),
120 Rockspec(Box<lua_rockspec::Error>),
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::Error),
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::Error),
188}
189
190#[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#[derive(Clone, Debug)]
218pub struct Project {
219 lux_toml_dir: ProjectRoot,
221 root: ProjectRoot,
224 toml: PartialProjectToml,
226}
227
228impl Project {
229 #[tracing::instrument(level = "trace", skip_all)]
231 pub fn from_exact(start: impl AsRef<Path>) -> Result<Option<Self>, Error> {
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 pub fn toml_path(&self) -> PathBuf {
264 self.lux_toml_dir.join(PROJECT_TOML)
265 }
266
267 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::Rockspec(Box::new(err)))
286 }
287
288 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::Rockspec(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::Rockspec(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 async fn write_toml(&mut self, project_toml: DocumentMut) -> Result<(), ProjectEditError> {
324 let toml_content = project_toml.to_string();
325 fs::tokio::write(self.toml_path(), &toml_content).await?;
326 self.toml = PartialProjectToml::new(
327 self.toml_path().to_str().unwrap_or("<lux.toml>"),
328 &toml_content,
329 self.lux_toml_dir.clone(),
330 )?;
331 Ok(())
332 }
333
334 pub async fn add(
335 &mut self,
336 dependencies: DependencyType<&PackageReq>,
337 package_db: &PackageDB,
338 ) -> Result<(), ProjectEditError> {
339 let mut project_toml =
340 toml_edit::DocumentMut::from_str(&fs::tokio::read_to_string(self.toml_path()).await?)?;
341
342 prepare_dependency_tables(&mut project_toml);
343 let table = match dependencies {
344 DependencyType::Regular(_) => &mut project_toml["dependencies"],
345 DependencyType::Build(_) => &mut project_toml["build_dependencies"],
346 DependencyType::Test(_) => &mut project_toml["test_dependencies"],
347 DependencyType::External(_) => &mut project_toml["external_dependencies"],
348 };
349
350 match dependencies {
351 DependencyType::Regular(ref deps)
352 | DependencyType::Build(ref deps)
353 | DependencyType::Test(ref deps) => {
354 for dep in deps {
355 let dep_version_str = if dep.version_req().is_any() {
356 let latest_version =
357 package_db.latest_version(dep.name()).await.ok_or_else(|| {
358 ProjectEditError::LatestVersionNotFound(dep.name().clone())
359 })?;
360 latest_version
361 .to_pessimistic_version_req()
362 .unwrap_or_else(|| latest_version.to_string())
363 } else {
364 dep.version_req().to_string()
365 };
366 table[dep.name().to_string()] = toml_edit::value(dep_version_str);
367 }
368 }
369 DependencyType::External(ref deps) => {
370 for (name, dep) in deps {
371 if let Some(path) = &dep.header {
372 table[name]["header"] = toml_edit::value(path.to_slash_lossy().to_string());
373 }
374 if let Some(path) = &dep.library {
375 table[name]["library"] =
376 toml_edit::value(path.to_slash_lossy().to_string());
377 }
378 }
379 }
380 };
381
382 self.write_toml(project_toml).await?;
383 Ok(())
384 }
385
386 pub async fn add_git(
387 &mut self,
388 dependencies: LuaDependencyType<&RemoteGitUrlShorthand>,
389 config: &Config,
390 ) -> Result<(), ProjectEditError> {
391 let mut project_toml =
392 toml_edit::DocumentMut::from_str(&fs::tokio::read_to_string(self.toml_path()).await?)?;
393
394 prepare_dependency_tables(&mut project_toml);
395 let table = match dependencies {
396 LuaDependencyType::Regular(_) => &mut project_toml["dependencies"],
397 LuaDependencyType::Build(_) => &mut project_toml["build_dependencies"],
398 LuaDependencyType::Test(_) => &mut project_toml["test_dependencies"],
399 };
400
401 match dependencies {
402 LuaDependencyType::Regular(urls)
403 | LuaDependencyType::Build(urls)
404 | LuaDependencyType::Test(urls) => {
405 for url in urls {
406 let git_url: RemoteGitUrl = url.clone().into();
407 let mut dep_entry = toml_edit::table();
408 match git::utils::latest_semver_tag_or_commit_sha(&git_url, config)? {
409 SemVerTagOrSha::SemVerTag(tag) => {
410 dep_entry["git"] = Item::Value(url.to_string().into());
411 dep_entry["version"] = Item::Value(tag.clone().into());
412 if tag.contains("-") {
413 dep_entry["rev"] = Item::Value(tag.into());
415 }
416 }
417 SemVerTagOrSha::CommitSha(sha) => {
418 dep_entry["git"] = Item::Value(url.to_string().into());
419 dep_entry["version"] = Item::Value(sha.into());
420 }
421 }
422 table[git_url.repo()] = dep_entry;
423 }
424 }
425 }
426
427 self.write_toml(project_toml).await?;
428
429 Ok(())
430 }
431
432 pub async fn remove(
433 &mut self,
434 dependencies: DependencyType<&PackageName>,
435 ) -> Result<(), ProjectEditError> {
436 let mut project_toml =
437 toml_edit::DocumentMut::from_str(&fs::tokio::read_to_string(self.toml_path()).await?)?;
438
439 prepare_dependency_tables(&mut project_toml);
440 let table = match dependencies {
441 DependencyType::Regular(_) => &mut project_toml["dependencies"],
442 DependencyType::Build(_) => &mut project_toml["build_dependencies"],
443 DependencyType::Test(_) => &mut project_toml["test_dependencies"],
444 DependencyType::External(_) => &mut project_toml["external_dependencies"],
445 };
446
447 match dependencies {
448 DependencyType::Regular(ref deps)
449 | DependencyType::Build(ref deps)
450 | DependencyType::Test(ref deps) => {
451 for dep in deps {
452 table[dep.to_string()] = Item::None;
453 }
454 }
455 DependencyType::External(ref deps) => {
456 for (name, dep) in deps {
457 if dep.header.is_some() {
458 table[name]["header"] = Item::None;
459 }
460 if dep.library.is_some() {
461 table[name]["library"] = Item::None;
462 }
463 }
464 }
465 };
466
467 self.write_toml(project_toml).await?;
468
469 Ok(())
470 }
471
472 pub async fn upgrade(
473 &mut self,
474 dependencies: LuaDependencyType<&PackageName>,
475 package_db: &PackageDB,
476 config: &Config,
477 ) -> Result<(), ProjectEditError> {
478 let mut project_toml =
479 toml_edit::DocumentMut::from_str(&fs::tokio::read_to_string(self.toml_path()).await?)?;
480
481 prepare_dependency_tables(&mut project_toml);
482 let table = match dependencies {
483 LuaDependencyType::Regular(_) => &mut project_toml["dependencies"],
484 LuaDependencyType::Build(_) => &mut project_toml["build_dependencies"],
485 LuaDependencyType::Test(_) => &mut project_toml["test_dependencies"],
486 };
487
488 match dependencies {
489 LuaDependencyType::Regular(deps)
490 | LuaDependencyType::Build(deps)
491 | LuaDependencyType::Test(deps) => {
492 for dep in deps {
493 upgrade_dependency(table, dep, package_db, config).await?;
494 }
495 }
496 }
497
498 self.write_toml(project_toml).await?;
499
500 Ok(())
501 }
502
503 pub async fn upgrade_all(
504 &mut self,
505 package_db: &PackageDB,
506 config: &Config,
507 ) -> Result<(), ProjectEditError> {
508 if let Some(dependencies) = &self.toml().dependencies {
509 let packages = dependencies
510 .iter()
511 .map(|dep| dep.name())
512 .cloned()
513 .collect_vec();
514 self.upgrade(
515 LuaDependencyType::Regular(packages.iter().collect()),
516 package_db,
517 config,
518 )
519 .await?;
520 }
521 if let Some(dependencies) = &self.toml().build_dependencies {
522 let packages = dependencies
523 .iter()
524 .map(|dep| dep.name())
525 .cloned()
526 .collect_vec();
527 self.upgrade(
528 LuaDependencyType::Build(packages.iter().collect()),
529 package_db,
530 config,
531 )
532 .await?;
533 }
534 if let Some(dependencies) = &self.toml().test_dependencies {
535 let packages = dependencies
536 .iter()
537 .map(|dep| dep.name())
538 .cloned()
539 .collect_vec();
540 self.upgrade(
541 LuaDependencyType::Test(packages.iter().collect()),
542 package_db,
543 config,
544 )
545 .await?;
546 }
547 Ok(())
548 }
549
550 async fn set_dependency_flag(
551 &mut self,
552 dependencies: LuaDependencyType<&PackageName>,
553 key: &str,
554 enabled: bool,
555 ) -> Result<(), PinError> {
556 let mut project_toml =
557 toml_edit::DocumentMut::from_str(&fs::tokio::read_to_string(self.toml_path()).await?)
558 .map_err(|source| PinError::ParseTomlEdit {
559 toml_path: self.toml_path().to_slash_lossy().to_string(),
560 source,
561 })?;
562
563 prepare_dependency_tables(&mut project_toml);
564 let table = match dependencies {
565 LuaDependencyType::Regular(_) => &mut project_toml["dependencies"],
566 LuaDependencyType::Build(_) => &mut project_toml["build_dependencies"],
567 LuaDependencyType::Test(_) => &mut project_toml["test_dependencies"],
568 };
569
570 match dependencies {
571 LuaDependencyType::Regular(ref deps)
572 | LuaDependencyType::Build(ref deps)
573 | LuaDependencyType::Test(ref deps) => {
574 for dep in deps {
575 let mut dep_item = table[dep.to_string()].clone();
576 match dep_item {
577 version @ Item::Value(_) => {
578 if enabled {
579 if let Ok(mut dep_entry) = toml_edit::table().into_table() {
580 dep_entry.set_implicit(true);
581 dep_entry["version"] = version;
582 dep_entry[key] = toml_edit::value(true);
583 table[dep.to_string()] = toml_edit::Item::Table(dep_entry);
584 }
585 }
586 }
587 Item::Table(_) => {
588 dep_item[key] = toml_edit::value(enabled);
589 table[dep.to_string()] = dep_item;
590 }
591 _ => {}
592 }
593 }
594 }
595 }
596
597 let toml_content = project_toml.to_string();
598 fs::tokio::write(self.toml_path(), &toml_content).await?;
599 self.toml = PartialProjectToml::new(
600 self.toml_path().to_str().unwrap_or("<lux.toml>"),
601 &toml_content,
602 self.lux_toml_dir.clone(),
603 )?;
604
605 Ok(())
606 }
607
608 pub async fn set_pinned_state(
609 &mut self,
610 dependencies: LuaDependencyType<&PackageName>,
611 pin: PinnedState,
612 ) -> Result<(), PinError> {
613 self.set_dependency_flag(dependencies, "pin", pin.as_bool())
614 .await
615 }
616
617 pub async fn set_opt_state(
618 &mut self,
619 dependencies: LuaDependencyType<&PackageName>,
620 opt: OptState,
621 ) -> Result<(), PinError> {
622 self.set_dependency_flag(dependencies, "opt", opt.as_bool())
623 .await
624 }
625
626 pub fn project_files(&self) -> Vec<PathBuf> {
627 project_files(&self.root().0)
628 }
629}
630
631pub(crate) fn project_files(src: &Path) -> Vec<PathBuf> {
634 ignore::WalkBuilder::new(src)
635 .add(src.join(".cargo"))
636 .parents(false)
637 .follow_links(false)
638 .build()
639 .filter_map(Result::ok)
640 .filter(|entry| entry.file_type().is_some_and(|ft| ft.is_file()))
641 .map(|entry| entry.into_path())
642 .collect_vec()
643}
644
645#[tracing::instrument(level = "trace")]
647pub(crate) async fn copy_dir(src: &Path, dest: &Path) -> Result<(), fs::Error> {
648 if src.exists() {
649 for file in project_files(src) {
650 let relative_src_path: PathBuf = pathdiff::diff_paths(src.join(&file), src)
651 .unwrap_or_else(|| unreachable!("diff_path with self"));
652 let target = dest.join(relative_src_path);
653 if let Some(parent) = target.parent() {
654 fs::tokio::create_dir_all(parent).await?;
655 }
656 fs::tokio::copy(&file, &target).await?;
657 fs::make_writable(&target).await?;
658 }
659 }
660 Ok(())
661}
662
663async fn upgraded_version_req(
664 existing: Option<&str>,
665 dep: &PackageName,
666 package_db: &PackageDB,
667) -> Result<Option<String>, ProjectEditError> {
668 if let Some(existing) = existing.and_then(|existing| PackageVersionReq::parse(existing).ok()) {
669 let latest = package_db
670 .latest_version(dep)
671 .await
672 .ok_or(ProjectEditError::LatestVersionNotFound(dep.clone()))?;
673 Ok(latest.upgrade_version_req(&existing))
674 } else {
675 Ok(None)
676 }
677}
678
679async fn upgrade_dependency(
680 table: &mut Item,
681 dep: &PackageName,
682 package_db: &PackageDB,
683 config: &Config,
684) -> Result<(), ProjectEditError> {
685 let mut dep_item = table[dep.to_string()].clone();
686 match &dep_item {
687 Item::Value(val) => {
688 if let Some(upgraded) = upgraded_version_req(val.as_str(), dep, package_db).await? {
689 table[dep.to_string()] = toml_edit::value(upgraded);
690 }
691 }
692 Item::Table(tbl) => {
693 if let Some(git_item) = tbl.get("git") {
694 let latest = resolve_git_latest(git_url_from_item(git_item)?, config)?;
695 apply_git_latest(table, dep, latest);
696 } else {
697 if let Some(upgraded) = upgraded_version_req(
698 dep_item.get("version").and_then(Item::as_str),
699 dep,
700 package_db,
701 )
702 .await?
703 {
704 dep_item["version"] = toml_edit::value(upgraded);
705 }
706 table[dep.to_string()] = dep_item;
707 }
708 }
709 _ => {}
710 }
711 Ok(())
712}
713
714fn git_url_from_item(git_item: &Item) -> Result<RemoteGitUrlShorthand, ProjectEditError> {
715 let git_value = git_item
716 .clone()
717 .into_value()
718 .map_err(|err| ProjectEditError::ExpectedValue(Box::new(err)))?;
719 let git_url_str = git_value
720 .as_str()
721 .ok_or(ProjectEditError::ExpectedString(Box::new(
722 git_value.clone(),
723 )))?;
724 Ok(git_url_str.parse()?)
725}
726
727fn resolve_git_latest(
728 url: RemoteGitUrlShorthand,
729 config: &Config,
730) -> Result<SemVerTagOrSha, ProjectEditError> {
731 Ok(git::utils::latest_semver_tag_or_commit_sha(
732 &url.into(),
733 config,
734 )?)
735}
736
737fn apply_git_latest(table: &mut Item, dep: &PackageName, latest: SemVerTagOrSha) {
738 match latest {
739 SemVerTagOrSha::SemVerTag(latest_tag) => {
740 table[dep.to_string()]["version"] = Item::Value(latest_tag.clone().into());
741 if latest_tag.contains("-") {
742 table[dep.to_string()]["rev"] = Item::Value(latest_tag.into());
743 }
744 }
745 SemVerTagOrSha::CommitSha(latest_sha) => {
746 table[dep.to_string()]["version"] = Item::Value(latest_sha.into());
747 }
748 }
749}
750
751fn prepare_dependency_tables(project_toml: &mut DocumentMut) {
752 if !project_toml.contains_table("dependencies") {
753 if let Ok(mut table) = toml_edit::table().into_table() {
754 table.set_implicit(true);
755 project_toml["dependencies"] = toml_edit::Item::Table(table);
756 }
757 }
758 if !project_toml.contains_table("build_dependencies") {
759 if let Ok(mut table) = toml_edit::table().into_table() {
760 table.set_implicit(true);
761 project_toml["build_dependencies"] = toml_edit::Item::Table(table);
762 }
763 }
764 if !project_toml.contains_table("test_dependencies") {
765 if let Ok(mut table) = toml_edit::table().into_table() {
766 table.set_implicit(true);
767 project_toml["test_dependencies"] = toml_edit::Item::Table(table);
768 }
769 }
770 if !project_toml.contains_table("external_dependencies") {
771 if let Ok(mut table) = toml_edit::table().into_table() {
772 table.set_implicit(true);
773 project_toml["external_dependencies"] = toml_edit::Item::Table(table);
774 }
775 }
776}
777
778#[cfg(test)]
780mod tests {
781 use std::collections::HashMap;
782
783 use assert_fs::prelude::{PathChild, PathCopy, PathCreateDir};
784 use url::Url;
785
786 use super::*;
787 use crate::{
788 config::ConfigBuilder,
789 fs,
790 lua_rockspec::ExternalDependencySpec,
791 manifest::{Manifest, ManifestMetadata},
792 package::PackageReq,
793 package_db::PackageDB,
794 rockspec::Rockspec,
795 };
796
797 #[tokio::test]
798 async fn test_add_various_dependencies() {
799 let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
800 .join("resources/test/sample-projects/no-build-spec/");
801 let project_root = assert_fs::TempDir::new().unwrap();
802 project_root.copy_from(&sample_project, &["**"]).unwrap();
803 let project_root: PathBuf = project_root.path().into();
804 let mut project = Project::from_exact(&project_root).unwrap().unwrap();
805 let add_dependencies = [PackageReq::new("busted".into(), Some(">= 1.0.0".into())).unwrap()];
806 let expected_dependencies = vec![PackageReq::new("busted".into(), Some(">= 1.0.0".into()))
807 .unwrap()
808 .into()];
809
810 let test_manifest_path =
811 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/manifest-5.1");
812 let content = String::from_utf8(std::fs::read(&test_manifest_path).unwrap()).unwrap();
813 let metadata = ManifestMetadata::new(&content).unwrap();
814 let config = ConfigBuilder::new().unwrap().build().unwrap();
815 let package_db = PackageDB::from_manifest(
816 Manifest::new(
817 Url::parse("https://example.com").unwrap(),
818 metadata,
819 Default::default(),
820 ),
821 &config,
822 );
823
824 project
825 .add(
826 DependencyType::Regular(add_dependencies.iter().collect_vec()),
827 &package_db,
828 )
829 .await
830 .unwrap();
831
832 project
833 .add(
834 DependencyType::Build(add_dependencies.iter().collect_vec()),
835 &package_db,
836 )
837 .await
838 .unwrap();
839 project
840 .add(
841 DependencyType::Test(add_dependencies.iter().collect_vec()),
842 &package_db,
843 )
844 .await
845 .unwrap();
846
847 project
848 .add(
849 DependencyType::External(HashMap::from([(
850 "lib".into(),
851 ExternalDependencySpec {
852 library: Some("path.so".into()),
853 header: None,
854 },
855 )])),
856 &package_db,
857 )
858 .await
859 .unwrap();
860
861 let project = Project::from_exact(&project_root).unwrap().unwrap();
864 let validated_toml = project.toml().into_remote(None).unwrap();
865
866 assert_eq!(
867 validated_toml.dependencies().current_platform(),
868 &expected_dependencies
869 );
870 assert_eq!(
871 validated_toml.build_dependencies().current_platform(),
872 &expected_dependencies
873 );
874 assert_eq!(
875 validated_toml.test_dependencies().current_platform(),
876 &expected_dependencies
877 );
878 assert_eq!(
879 validated_toml
880 .external_dependencies()
881 .current_platform()
882 .get("lib")
883 .unwrap(),
884 &ExternalDependencySpec {
885 library: Some("path.so".into()),
886 header: None
887 }
888 );
889 }
890
891 #[tokio::test]
892 async fn test_add_unpublished_package_does_not_edit_toml() {
893 let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
894 .join("resources/test/sample-projects/no-build-spec/");
895 let project_root = assert_fs::TempDir::new().unwrap();
896 project_root.copy_from(&sample_project, &["**"]).unwrap();
897 let project_root: PathBuf = project_root.path().into();
898 let mut project = Project::from_exact(&project_root).unwrap().unwrap();
899 let toml_before = fs::sync::read_to_string(project.toml_path()).unwrap();
900
901 let test_manifest_path =
902 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/manifest-5.1");
903 let content = String::from_utf8(std::fs::read(&test_manifest_path).unwrap()).unwrap();
904 let metadata = ManifestMetadata::new(&content).unwrap();
905 let config = ConfigBuilder::new().unwrap().build().unwrap();
906 let package_db = PackageDB::from_manifest(
907 Manifest::new(
908 Url::parse("https://example.com").unwrap(),
909 metadata,
910 Default::default(),
911 ),
912 &config,
913 );
914
915 let unknown_dependencies = [PackageReq::from_str("not-a-package-in-any-manifest").unwrap()];
916 let err = project
917 .add(
918 DependencyType::Regular(unknown_dependencies.iter().collect_vec()),
919 &package_db,
920 )
921 .await
922 .unwrap_err();
923
924 assert!(matches!(err, ProjectEditError::LatestVersionNotFound(_)));
925 assert_eq!(
926 fs::sync::read_to_string(project.toml_path()).unwrap(),
927 toml_before
928 );
929 }
930
931 #[tokio::test]
932 async fn test_upgrade_preserves_version_req_style() {
933 let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
934 .join("resources/test/sample-projects/no-build-spec/");
935 let project_root = assert_fs::TempDir::new().unwrap();
936 project_root.copy_from(&sample_project, &["**"]).unwrap();
937 let project_root: PathBuf = project_root.path().into();
938 let toml_path = project_root.join("lux.toml");
939
940 let test_manifest_path =
941 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/manifest-5.1");
942 let content = String::from_utf8(std::fs::read(&test_manifest_path).unwrap()).unwrap();
943 let metadata = ManifestMetadata::new(&content).unwrap();
944 let config = ConfigBuilder::default().build().unwrap();
945 let package_db = PackageDB::from_manifest(
946 Manifest::new(
947 Url::parse("https://example.com").unwrap(),
948 metadata,
949 Default::default(),
950 ),
951 &config,
952 );
953
954 let base = r#"package = "sample-project-no-build-spec"
955version = "0.1.0"
956lua = ">=5.1"
957
958[source]
959url = "https://github.com/lumen-oss/luarocks-stub"
960
961[build]
962type = "builtin"
963"#;
964 for (input, expected) in [
965 ("30log = \"~> 1.0\"", "30log = \"~> 1.3\""),
966 ("30log = \"~> 1\"", "30log = \"~> 1\""),
967 ("30log = \">= 1.0.0\"", "30log = \">= 1.0.0\""),
968 ("30log = \"1.0.0\"", "30log = \"1.3.0\""),
969 ] {
970 std::fs::write(&toml_path, format!("{base}\n[dependencies]\n{input}\n")).unwrap();
971 let mut project = Project::from_exact(&project_root).unwrap().unwrap();
972 let packages = [PackageName::from("30log")];
973 project
974 .upgrade(
975 LuaDependencyType::Regular(packages.iter().collect()),
976 &package_db,
977 &config,
978 )
979 .await
980 .unwrap();
981 let rewritten = std::fs::read_to_string(&toml_path).unwrap();
982 assert!(rewritten.contains(expected), "input {input}: {rewritten}");
983 }
984 }
985
986 #[tokio::test]
987 async fn test_remove_dependencies() {
988 let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
989 .join("resources/test/sample-projects/dependencies/");
990 let project_root = assert_fs::TempDir::new().unwrap();
991 project_root.copy_from(&sample_project, &["**"]).unwrap();
992 let project_root: PathBuf = project_root.path().into();
993 let mut project = Project::from_exact(&project_root).unwrap().unwrap();
994 let lua_cjson = "lua-cjson".into();
995 let plenary_nvim = "plenary.nvim".into();
996 let remove_dependencies = vec![&lua_cjson, &plenary_nvim];
997 project
998 .remove(DependencyType::Regular(remove_dependencies.clone()))
999 .await
1000 .unwrap();
1001 let check = |project: &Project| {
1002 for name in &remove_dependencies {
1003 assert!(!project
1004 .toml()
1005 .dependencies
1006 .clone()
1007 .unwrap_or_default()
1008 .iter()
1009 .any(|dep| &dep.name() == name));
1010 }
1011 };
1012 check(&project);
1013 let reloaded_project = Project::from_exact(&project_root).unwrap().unwrap();
1015 check(&reloaded_project);
1016 }
1017
1018 #[tokio::test]
1019 async fn test_extra_rockspec_parsing() {
1020 let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1021 .join("resources/test/sample-projects/extra-rockspec/");
1022 let project_root = assert_fs::TempDir::new().unwrap();
1023 project_root.copy_from(&sample_project, &["**"]).unwrap();
1024 let project_root: PathBuf = project_root.path().into();
1025 let project = Project::from_exact(project_root).unwrap().unwrap();
1026
1027 let extra_rockspec = project.extra_rockspec().unwrap();
1028
1029 assert!(extra_rockspec.is_some());
1030
1031 let rocks = project.toml().into_remote(None).unwrap();
1032
1033 assert_eq!(rocks.package().to_string(), "custom-package");
1034 }
1035
1036 #[tokio::test]
1037 async fn test_pin_dependencies() {
1038 test_pin_unpin_dependencies(PinnedState::Pinned).await
1039 }
1040
1041 #[tokio::test]
1042 async fn test_unpin_dependencies() {
1043 test_pin_unpin_dependencies(PinnedState::Unpinned).await
1044 }
1045
1046 async fn test_pin_unpin_dependencies(pin: PinnedState) {
1047 let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1048 .join("resources/test/sample-projects/dependencies/");
1049 let project_root = assert_fs::TempDir::new().unwrap();
1050 project_root.copy_from(&sample_project, &["**"]).unwrap();
1051 let project_root: PathBuf = project_root.path().into();
1052 let mut project = Project::from_exact(&project_root).unwrap().unwrap();
1053 let lua_cjson = "lua-cjson".into();
1054 let plenary_nvim = "plenary.nvim".into();
1055 let pin_dependencies = vec![&lua_cjson, &plenary_nvim];
1056 project
1057 .set_pinned_state(LuaDependencyType::Regular(pin_dependencies.clone()), pin)
1058 .await
1059 .unwrap();
1060 let check = |project: &Project| {
1061 for name in &pin_dependencies {
1062 assert!(project
1063 .toml()
1064 .dependencies
1065 .clone()
1066 .unwrap_or_default()
1067 .iter()
1068 .any(|dep| &dep.name() == name && dep.pin == pin));
1069 }
1070 };
1071 check(&project);
1072 let reloaded_project = Project::from_exact(&project_root).unwrap().unwrap();
1074 check(&reloaded_project);
1075 }
1076
1077 #[tokio::test]
1078 async fn test_set_opt_state() {
1079 let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1080 .join("resources/test/sample-projects/dependencies/");
1081 let project_root = assert_fs::TempDir::new().unwrap();
1082 project_root.copy_from(&sample_project, &["**"]).unwrap();
1083 let project_root: PathBuf = project_root.path().into();
1084 let mut project = Project::from_exact(&project_root).unwrap().unwrap();
1085 let lua_cjson = "lua-cjson".into();
1086 let plenary_nvim = "plenary.nvim".into();
1087
1088 project
1089 .set_opt_state(
1090 LuaDependencyType::Regular(vec![&lua_cjson]),
1091 OptState::Optional,
1092 )
1093 .await
1094 .unwrap();
1095
1096 let check = |project: &Project, opt: OptState| {
1097 let dependencies = project.toml().dependencies.clone().unwrap_or_default();
1098 assert!(dependencies
1099 .iter()
1100 .any(|dep| dep.name() == &lua_cjson && *dep.opt() == opt));
1101 assert!(dependencies
1102 .iter()
1103 .any(|dep| dep.name() == &plenary_nvim && *dep.opt() == OptState::Required));
1104 };
1105 check(&project, OptState::Optional);
1106 let reloaded_project = Project::from_exact(&project_root).unwrap().unwrap();
1107 check(&reloaded_project, OptState::Optional);
1108
1109 project
1110 .set_opt_state(
1111 LuaDependencyType::Regular(vec![&lua_cjson]),
1112 OptState::Required,
1113 )
1114 .await
1115 .unwrap();
1116 check(&project, OptState::Required);
1117 let reloaded_project = Project::from_exact(&project_root).unwrap().unwrap();
1118 check(&reloaded_project, OptState::Required);
1119 }
1120
1121 #[tokio::test]
1122 async fn project_files_includes_cargo_directory() {
1123 let project_root = assert_fs::TempDir::new().unwrap();
1124 let cargo_dir = project_root.child(".cargo");
1125 cargo_dir.create_dir_all().unwrap();
1126 let cargo_config = cargo_dir.join("config.toml");
1127 fs::tokio::write(&cargo_config, "").await.unwrap();
1128 let project_files = project_files(&project_root);
1129 assert!(project_files.contains(&cargo_config.to_path_buf()));
1130 }
1131
1132 #[tokio::test]
1133 async fn project_files_ignores_parent_ignore_files() {
1134 let tmp = assert_fs::TempDir::new().unwrap();
1135 let parent = tmp.child("parent");
1136 parent.create_dir_all().unwrap();
1137 fs::tokio::write(parent.join(".ignore"), "*\n")
1138 .await
1139 .unwrap();
1140 let src = parent.child("etc");
1141 src.create_dir_all().unwrap();
1142 let file = src.join("payload.txt");
1143 fs::tokio::write(&file, "hello").await.unwrap();
1144
1145 let project_files = project_files(&src);
1146 assert!(project_files.contains(&file.to_path_buf()));
1147 }
1148
1149 #[tokio::test]
1150 async fn project_root_dir_override() {
1151 let root_dir = assert_fs::TempDir::new().unwrap();
1152 let lux_toml_dir = root_dir.child("proj");
1153 lux_toml_dir.create_dir_all().unwrap();
1154 fs::tokio::write(
1155 lux_toml_dir.join(PROJECT_TOML),
1156 r#"
1157 package = "my-package"
1158 version = "1.0.0"
1159 lua = "5.1"
1160
1161 [project]
1162 root_dir = ".."
1163
1164 [build]
1165 type = "builtin"
1166 "#,
1167 )
1168 .await
1169 .unwrap();
1170 let project = Project::from_exact(lux_toml_dir.path()).unwrap().unwrap();
1171 assert_eq!(
1172 std::fs::canonicalize(project.root()).unwrap(),
1173 std::fs::canonicalize(root_dir.path()).unwrap()
1174 );
1175 assert_eq!(project.toml_path(), lux_toml_dir.join(PROJECT_TOML));
1177 }
1178
1179 #[tokio::test]
1180 async fn from_exact_on_nonexistent_directory() {
1181 let empty_dir = assert_fs::TempDir::new().unwrap();
1182 assert!(Project::from_exact(&empty_dir).unwrap().is_none());
1183 }
1184}