1use std::collections::{BTreeMap, HashSet};
2use std::error::Error;
3use std::fmt::Display;
4use std::io::{self, Write};
5use std::marker::PhantomData;
6use std::ops::{Deref, DerefMut};
7use std::{collections::HashMap, fs::File, io::ErrorKind, path::PathBuf};
8
9use itertools::Itertools;
10
11use miette::Diagnostic;
12use serde::{de, Deserialize, Serialize, Serializer};
13use sha2::{Digest, Sha256};
14use ssri::Integrity;
15use strum_macros::EnumIter;
16use thiserror::Error;
17use url::Url;
18
19use crate::config::tree::RockLayoutConfig;
20use crate::fs;
21use crate::package::{
22 PackageName, PackageReq, PackageSpec, PackageVersion, PackageVersionReq,
23 PackageVersionReqError, RemotePackageTypeFilterSpec,
24};
25use crate::remote_package_source::RemotePackageSource;
26use crate::rockspec::lua_dependency::LuaDependencySpec;
27use crate::rockspec::RockBinaries;
28use crate::tree::{InstallTree, Tree};
29
30const LOCKFILE_VERSION_STR: &str = "1.0.0";
31
32#[derive(Copy, Debug, PartialEq, Eq, Hash, Clone, PartialOrd, Ord, Default)]
33pub enum PinnedState {
34 #[default]
36 Unpinned,
37 Pinned,
39}
40
41impl Display for PinnedState {
42 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43 match &self {
44 PinnedState::Unpinned => "unpinned".fmt(f),
45 PinnedState::Pinned => "pinned".fmt(f),
46 }
47 }
48}
49
50impl From<bool> for PinnedState {
51 fn from(value: bool) -> Self {
52 if value {
53 Self::Pinned
54 } else {
55 Self::Unpinned
56 }
57 }
58}
59
60impl PinnedState {
61 pub fn as_bool(&self) -> bool {
62 match self {
63 Self::Unpinned => false,
64 Self::Pinned => true,
65 }
66 }
67}
68
69impl Serialize for PinnedState {
70 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
71 where
72 S: serde::Serializer,
73 {
74 serializer.serialize_bool(self.as_bool())
75 }
76}
77
78impl<'de> Deserialize<'de> for PinnedState {
79 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
80 where
81 D: serde::Deserializer<'de>,
82 {
83 Ok(match bool::deserialize(deserializer)? {
84 false => Self::Unpinned,
85 true => Self::Pinned,
86 })
87 }
88}
89
90#[derive(Copy, Debug, PartialEq, Eq, Hash, Clone, PartialOrd, Ord, Default)]
91pub enum OptState {
92 #[default]
94 Required,
95 Optional,
97}
98
99impl OptState {
100 pub(crate) fn as_bool(&self) -> bool {
101 match self {
102 Self::Required => false,
103 Self::Optional => true,
104 }
105 }
106}
107
108impl From<bool> for OptState {
109 fn from(value: bool) -> Self {
110 if value {
111 Self::Optional
112 } else {
113 Self::Required
114 }
115 }
116}
117
118impl Display for OptState {
119 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120 match &self {
121 OptState::Required => "required".fmt(f),
122 OptState::Optional => "optional".fmt(f),
123 }
124 }
125}
126
127impl Serialize for OptState {
128 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
129 where
130 S: serde::Serializer,
131 {
132 serializer.serialize_bool(self.as_bool())
133 }
134}
135
136impl<'de> Deserialize<'de> for OptState {
137 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
138 where
139 D: serde::Deserializer<'de>,
140 {
141 Ok(match bool::deserialize(deserializer)? {
142 false => Self::Required,
143 true => Self::Optional,
144 })
145 }
146}
147
148#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
149pub(crate) struct LocalPackageSpec {
150 pub name: PackageName,
151 pub version: PackageVersion,
152 pub pinned: PinnedState,
153 pub opt: OptState,
154 pub dependencies: Vec<LocalPackageId>,
155 pub constraint: Option<String>,
157 pub binaries: RockBinaries,
158}
159
160#[derive(Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, Clone)]
167pub struct LocalPackageId(String);
168
169impl LocalPackageId {
170 pub fn new(
171 name: &PackageName,
172 version: &PackageVersion,
173 pinned: PinnedState,
174 opt: OptState,
175 constraint: LockConstraint,
176 ) -> Self {
177 let mut hasher = Sha256::new();
178
179 hasher.update(format!(
180 "{}{}{}{}{}",
181 name,
182 version,
183 pinned.as_bool(),
184 opt.as_bool(),
185 match constraint {
186 LockConstraint::Unconstrained => String::default(),
187 LockConstraint::Constrained(version_req) => version_req.to_string(),
188 },
189 ));
190
191 Self(hex::encode(hasher.finalize()))
192 }
193
194 pub unsafe fn from_unchecked(str: String) -> Self {
202 Self(str)
203 }
204
205 pub fn into_string(self) -> String {
206 self.0
207 }
208}
209
210impl Display for LocalPackageId {
211 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
212 self.0.fmt(f)
213 }
214}
215
216impl LocalPackageSpec {
217 pub fn new(
218 name: &PackageName,
219 version: &PackageVersion,
220 constraint: LockConstraint,
221 dependencies: Vec<LocalPackageId>,
222 pinned: &PinnedState,
223 opt: &OptState,
224 binaries: RockBinaries,
225 ) -> Self {
226 Self {
227 name: name.clone(),
228 version: version.clone(),
229 pinned: *pinned,
230 opt: *opt,
231 dependencies,
232 constraint: match constraint {
233 LockConstraint::Unconstrained => None,
234 LockConstraint::Constrained(version_req) => Some(version_req.to_string()),
235 },
236 binaries,
237 }
238 }
239
240 pub fn id(&self) -> LocalPackageId {
241 LocalPackageId::new(
242 self.name(),
243 self.version(),
244 self.pinned,
245 self.opt,
246 match &self.constraint {
247 None => LockConstraint::Unconstrained,
248 Some(_) => self.constraint(),
249 },
250 )
251 }
252
253 pub fn constraint(&self) -> LockConstraint {
254 unsafe { LockConstraint::try_from(&self.constraint).unwrap_unchecked() }
256 }
257
258 pub fn name(&self) -> &PackageName {
259 &self.name
260 }
261
262 pub fn version(&self) -> &PackageVersion {
263 &self.version
264 }
265
266 pub fn pinned(&self) -> PinnedState {
267 self.pinned
268 }
269
270 pub fn opt(&self) -> OptState {
271 self.opt
272 }
273
274 pub fn dependencies(&self) -> Vec<&LocalPackageId> {
275 self.dependencies.iter().collect()
276 }
277
278 pub fn binaries(&self) -> Vec<&PathBuf> {
279 self.binaries.iter().collect()
280 }
281
282 pub fn to_package(&self) -> PackageSpec {
283 PackageSpec::new(self.name.clone(), self.version.clone())
284 }
285
286 pub fn into_package_req(self) -> PackageReq {
287 PackageSpec::new(self.name, self.version).into_package_req()
288 }
289}
290
291#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
292#[serde(rename_all = "lowercase", tag = "type")]
293pub(crate) enum RemotePackageSourceUrl {
294 Git {
295 url: String,
296 #[serde(rename = "ref")]
297 checkout_ref: String,
298 }, Url {
300 #[serde(deserialize_with = "deserialize_url", serialize_with = "serialize_url")]
301 url: Url,
302 },
303 File {
304 path: PathBuf,
305 },
306}
307
308impl Display for RemotePackageSourceUrl {
309 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
310 match self {
311 RemotePackageSourceUrl::Git { url, checkout_ref } => {
312 format!("{url}@{checkout_ref}").fmt(f)
313 }
314 RemotePackageSourceUrl::Url { url } => url.fmt(f),
315 RemotePackageSourceUrl::File { path } => format!("{}", path.display()).fmt(f),
316 }
317 }
318}
319
320#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
324pub struct LocalPackage {
325 pub(crate) spec: LocalPackageSpec,
326 pub(crate) source: RemotePackageSource,
327 pub(crate) source_url: Option<RemotePackageSourceUrl>,
328 hashes: LocalPackageHashes,
329}
330
331impl LocalPackage {
332 pub fn into_package_spec(self) -> PackageSpec {
333 PackageSpec::new(self.spec.name, self.spec.version)
334 }
335
336 pub fn as_package_spec(&self) -> PackageSpec {
337 PackageSpec::new(self.spec.name.clone(), self.spec.version.clone())
338 }
339}
340
341#[derive(Debug, Serialize, Deserialize, Clone)]
342struct LocalPackageIntermediate {
343 name: PackageName,
344 version: PackageVersion,
345 pinned: PinnedState,
346 opt: OptState,
347 dependencies: Vec<LocalPackageId>,
348 constraint: Option<String>,
349 binaries: RockBinaries,
350 source: RemotePackageSource,
351 source_url: Option<RemotePackageSourceUrl>,
352 hashes: LocalPackageHashes,
353}
354
355impl TryFrom<LocalPackageIntermediate> for LocalPackage {
356 type Error = LockConstraintParseError;
357
358 fn try_from(value: LocalPackageIntermediate) -> Result<Self, Self::Error> {
359 let constraint = LockConstraint::try_from(&value.constraint)?;
360 Ok(Self {
361 spec: LocalPackageSpec::new(
362 &value.name,
363 &value.version,
364 constraint,
365 value.dependencies,
366 &value.pinned,
367 &value.opt,
368 value.binaries,
369 ),
370 source: value.source,
371 source_url: value.source_url,
372 hashes: value.hashes,
373 })
374 }
375}
376
377impl From<&LocalPackage> for LocalPackageIntermediate {
378 fn from(value: &LocalPackage) -> Self {
379 Self {
380 name: value.spec.name.clone(),
381 version: value.spec.version.clone(),
382 pinned: value.spec.pinned,
383 opt: value.spec.opt,
384 dependencies: value.spec.dependencies.clone(),
385 constraint: value.spec.constraint.clone(),
386 binaries: value.spec.binaries.clone(),
387 source: value.source.clone(),
388 source_url: value.source_url.clone(),
389 hashes: value.hashes.clone(),
390 }
391 }
392}
393
394impl<'de> Deserialize<'de> for LocalPackage {
395 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
396 where
397 D: serde::Deserializer<'de>,
398 {
399 LocalPackage::try_from(LocalPackageIntermediate::deserialize(deserializer)?)
400 .map_err(de::Error::custom)
401 }
402}
403
404impl Serialize for LocalPackage {
405 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
406 where
407 S: serde::Serializer,
408 {
409 LocalPackageIntermediate::from(self).serialize(serializer)
410 }
411}
412
413impl LocalPackage {
414 pub(crate) fn from(
415 package: &PackageSpec,
416 constraint: LockConstraint,
417 binaries: RockBinaries,
418 source: RemotePackageSource,
419 source_url: Option<RemotePackageSourceUrl>,
420 hashes: LocalPackageHashes,
421 ) -> Self {
422 Self {
423 spec: LocalPackageSpec::new(
424 package.name(),
425 package.version(),
426 constraint,
427 Vec::default(),
428 &PinnedState::Unpinned,
429 &OptState::Required,
430 binaries,
431 ),
432 source,
433 source_url,
434 hashes,
435 }
436 }
437
438 pub fn id(&self) -> LocalPackageId {
439 self.spec.id()
440 }
441
442 pub fn name(&self) -> &PackageName {
443 self.spec.name()
444 }
445
446 pub fn version(&self) -> &PackageVersion {
447 self.spec.version()
448 }
449
450 pub fn pinned(&self) -> PinnedState {
451 self.spec.pinned()
452 }
453
454 pub fn opt(&self) -> OptState {
455 self.spec.opt()
456 }
457
458 pub(crate) fn source(&self) -> &RemotePackageSource {
459 &self.source
460 }
461
462 pub fn dependencies(&self) -> Vec<&LocalPackageId> {
463 self.spec.dependencies()
464 }
465
466 pub fn constraint(&self) -> LockConstraint {
467 self.spec.constraint()
468 }
469
470 pub fn hashes(&self) -> &LocalPackageHashes {
471 &self.hashes
472 }
473
474 pub fn to_package(&self) -> PackageSpec {
475 self.spec.to_package()
476 }
477
478 pub fn into_package_req(self) -> PackageReq {
479 self.spec.into_package_req()
480 }
481}
482
483#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, Hash)]
484pub struct LocalPackageHashes {
485 pub rockspec: Integrity,
486 pub source: Integrity,
487}
488
489impl Ord for LocalPackageHashes {
490 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
491 let a = (self.rockspec.to_hex().1, self.source.to_hex().1);
492 let b = (other.rockspec.to_hex().1, other.source.to_hex().1);
493 a.cmp(&b)
494 }
495}
496
497impl PartialOrd for LocalPackageHashes {
498 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
499 Some(self.cmp(other))
500 }
501}
502
503#[derive(Clone, Debug, PartialEq, Eq, Hash, Default)]
504pub enum LockConstraint {
505 #[default]
506 Unconstrained,
507 Constrained(PackageVersionReq),
508}
509
510impl<'de> Deserialize<'de> for LockConstraint {
511 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
512 where
513 D: serde::Deserializer<'de>,
514 {
515 let str = String::deserialize(deserializer)?;
516 match str.as_str() {
517 "*" => Ok(LockConstraint::Unconstrained),
518 _ => Ok(LockConstraint::Constrained(
519 str.parse().map_err(serde::de::Error::custom)?,
520 )),
521 }
522 }
523}
524
525impl LockConstraint {
526 pub fn to_string_opt(&self) -> Option<String> {
527 match self {
528 LockConstraint::Unconstrained => None,
529 LockConstraint::Constrained(req) => Some(req.to_string()),
530 }
531 }
532
533 fn matches_version_req(&self, req: &PackageVersionReq) -> bool {
534 match self {
535 LockConstraint::Unconstrained => req.is_any(),
536 LockConstraint::Constrained(package_version_req) => package_version_req == req,
537 }
538 }
539}
540
541impl From<PackageVersionReq> for LockConstraint {
542 fn from(value: PackageVersionReq) -> Self {
543 if value.is_any() {
544 Self::Unconstrained
545 } else {
546 Self::Constrained(value)
547 }
548 }
549}
550
551#[derive(Error, Debug, Diagnostic)]
552#[non_exhaustive]
553pub enum LockConstraintParseError {
554 #[error("Invalid constraint in LuaPackage: {0}")]
555 #[diagnostic(forward(0))]
556 LockConstraintParseError(#[from] PackageVersionReqError),
557}
558
559impl TryFrom<&Option<String>> for LockConstraint {
560 type Error = LockConstraintParseError;
561
562 fn try_from(constraint: &Option<String>) -> Result<Self, Self::Error> {
563 match constraint {
564 Some(constraint) => {
565 let package_version_req = constraint.parse()?;
566 Ok(LockConstraint::Constrained(package_version_req))
567 }
568 None => Ok(LockConstraint::Unconstrained),
569 }
570 }
571}
572
573pub trait LockfilePermissions {}
574#[derive(Clone)]
575pub struct ReadOnly;
576#[derive(Clone)]
577pub struct ReadWrite;
578
579impl LockfilePermissions for ReadOnly {}
580impl LockfilePermissions for ReadWrite {}
581
582#[derive(Clone, Debug, Serialize, Deserialize, Default)]
583pub(crate) struct LocalPackageLock {
584 rocks: BTreeMap<LocalPackageId, LocalPackage>,
587
588 #[serde(serialize_with = "serialize_sorted_package_ids")]
589 entrypoints: Vec<LocalPackageId>,
590}
591
592impl LocalPackageLock {
593 fn get(&self, id: &LocalPackageId) -> Option<&LocalPackage> {
594 self.rocks.get(id)
595 }
596
597 fn is_empty(&self) -> bool {
598 self.rocks.is_empty()
599 }
600
601 pub(crate) fn rocks(&self) -> &BTreeMap<LocalPackageId, LocalPackage> {
602 &self.rocks
603 }
604
605 fn is_entrypoint(&self, package: &LocalPackageId) -> bool {
606 self.entrypoints.contains(package)
607 }
608
609 fn is_dependency(&self, package: &LocalPackageId) -> bool {
610 self.rocks
611 .values()
612 .flat_map(|rock| rock.dependencies())
613 .any(|dep_id| dep_id == package)
614 }
615
616 fn list(&self) -> HashMap<PackageName, Vec<LocalPackage>> {
617 self.rocks()
618 .values()
619 .cloned()
620 .map(|locked_rock| (locked_rock.name().clone(), locked_rock))
621 .into_group_map()
622 }
623
624 fn remove(&mut self, target: &LocalPackage) {
625 self.remove_by_id(&target.id())
626 }
627
628 fn remove_by_id(&mut self, target: &LocalPackageId) {
629 self.rocks.remove(target);
630 self.entrypoints.retain(|x| x != target);
631 }
632
633 pub(crate) fn has_rock(
634 &self,
635 req: &PackageReq,
636 filter: Option<RemotePackageTypeFilterSpec>,
637 ) -> Option<LocalPackage> {
638 self.list()
639 .get(req.name())
640 .map(|packages| {
641 packages
642 .iter()
643 .filter(|package| match &filter {
644 Some(filter_spec) => match package.source {
645 RemotePackageSource::LuarocksRockspec(_) => filter_spec.rockspec,
646 RemotePackageSource::LuarocksSrcRock(_) => filter_spec.src,
647 RemotePackageSource::LuarocksBinaryRock(_) => filter_spec.binary,
648 RemotePackageSource::RockspecContent(_) => true,
649 RemotePackageSource::Local => true,
650 #[cfg(test)]
651 RemotePackageSource::Test => unimplemented!(),
652 },
653 None => true,
654 })
655 .rev()
656 .find(|package| req.version_req().matches(package.version()))
657 })?
658 .cloned()
659 }
660
661 fn has_rock_with_equal_constraint(&self, req: &LuaDependencySpec) -> Option<LocalPackage> {
662 self.list()
663 .get(req.name())
664 .map(|packages| {
665 packages
666 .iter()
667 .rev()
668 .find(|package| package.constraint().matches_version_req(req.version_req()))
669 })?
670 .cloned()
671 }
672
673 pub(crate) fn package_sync_spec(
680 &self,
681 packages: &[LuaDependencySpec],
682 strategy: &SyncStrategy<'_>,
683 ) -> PackageSyncSpec {
684 let pkg_dir_exists = |pkg: &LocalPackage| match strategy {
685 SyncStrategy::LockfileOnly => true,
686 SyncStrategy::EnsureInstalled(tree) => tree.root_for(pkg).is_dir(),
687 };
688
689 let entrypoints_to_keep: HashSet<LocalPackage> = self
690 .entrypoints
691 .iter()
692 .filter_map(|id| self.get(id))
693 .filter(|local_pkg| {
694 packages.iter().any(|req| {
695 local_pkg
696 .constraint()
697 .matches_version_req(req.version_req())
698 })
699 })
700 .cloned()
701 .collect();
702
703 let packages_to_keep: HashSet<&LocalPackage> = entrypoints_to_keep
704 .iter()
705 .flat_map(|local_pkg| self.get_all_dependencies(&local_pkg.id()))
706 .collect();
707
708 let to_add = packages
709 .iter()
710 .filter(|pkg| {
711 self.has_rock_with_equal_constraint(pkg)
712 .map(|local_pkg| !pkg_dir_exists(&local_pkg))
713 .unwrap_or(true)
714 })
715 .cloned()
716 .collect_vec();
717
718 let to_remove = self
719 .rocks()
720 .values()
721 .filter(|pkg| !packages_to_keep.contains(*pkg))
722 .cloned()
723 .collect_vec();
724
725 PackageSyncSpec { to_add, to_remove }
726 }
727
728 fn get_all_dependencies(&self, id: &LocalPackageId) -> HashSet<&LocalPackage> {
730 let mut packages = HashSet::new();
731 if let Some(local_pkg) = self.get(id) {
732 packages.insert(local_pkg);
733 packages.extend(
734 local_pkg
735 .dependencies()
736 .iter()
737 .flat_map(|id| self.get_all_dependencies(id)),
738 );
739 }
740 packages
741 }
742}
743
744#[derive(Clone, Debug, Serialize, Deserialize)]
746pub struct Lockfile<P: LockfilePermissions> {
747 #[serde(skip)]
748 filepath: PathBuf,
749 #[serde(skip)]
750 _marker: PhantomData<P>,
751 version: String,
753 #[serde(flatten)]
754 lock: LocalPackageLock,
755 #[serde(default, skip_serializing_if = "RockLayoutConfig::is_default")]
756 pub(crate) entrypoint_layout: RockLayoutConfig,
757}
758
759#[derive(EnumIter, Debug, PartialEq, Eq)]
760pub enum LocalPackageLockType {
761 Regular,
762 Test,
763 Build,
764}
765
766#[derive(Clone, Debug, Serialize, Deserialize)]
768pub struct WorkspaceLockfile<P: LockfilePermissions> {
769 #[serde(skip)]
770 filepath: PathBuf,
771 #[serde(skip)]
772 _marker: PhantomData<P>,
773 version: String,
774 #[serde(default, skip_serializing_if = "LocalPackageLock::is_empty")]
775 dependencies: LocalPackageLock,
776 #[serde(default, skip_serializing_if = "LocalPackageLock::is_empty")]
777 test_dependencies: LocalPackageLock,
778 #[serde(default, skip_serializing_if = "LocalPackageLock::is_empty")]
779 build_dependencies: LocalPackageLock,
780}
781
782#[derive(Error, Debug, Diagnostic)]
783#[non_exhaustive]
784pub enum LockfileError {
785 #[error(transparent)]
786 #[diagnostic(transparent)]
787 Fs(#[from] fs::FsError),
788 #[error("error parsing lockfile from JSON: {0}")]
789 ParseJson(serde_json::Error),
790 #[error("error writing lockfile to JSON: {0}")]
791 WriteJson(serde_json::Error),
792 #[error("attempt load to a lockfile that does not match the expected rock layout.")]
793 MismatchedRockLayout,
794}
795
796#[derive(Error, Debug, Diagnostic)]
797#[non_exhaustive]
798pub enum LockfileIntegrityError {
799 #[error(
800 r#"rockspec integrity mismatch.
801- source: {src}
802- expected: {expected}
803- got: {got}
804"#
805 )]
806 #[diagnostic(help(
807 r#"the maintainer may have force-uploaded a different rockspec.
808check the rockspec source, then rerun the command with `lx --no-lock` to update the hash.
809"#
810 ))]
811 RockspecIntegrityMismatch {
812 src: String,
813 expected: Integrity,
814 got: Integrity,
815 },
816 #[error(
817 r#"source integrity mismatch.
818- source: {src}
819- expected: {expected}
820- got: {got}"#
821 )]
822 #[diagnostic(help(
823 r#"the maintainer may have force-uploaded a different source or moved a tag.
824check the source URL, then rerun the command with `lx --no-lock` to update the hash.
825"#
826 ))]
827 SourceIntegrityMismatch {
828 src: String,
829 expected: Integrity,
830 got: Integrity,
831 },
832 #[error("package {0} version {1} with pinned state {2} and constraint {3} not found in the lockfile.")]
833 PackageNotFound(PackageName, PackageVersion, PinnedState, String),
834}
835
836#[derive(Error, Debug, Diagnostic)]
837#[non_exhaustive]
838#[error("error flushing the lockfile ({})", .filepath.display())]
839#[diagnostic(help("make sure the parent directory is writeable"))]
840pub struct FlushLockfileError {
841 filepath: PathBuf,
842 source: io::Error,
843}
844
845#[derive(Debug, Default)]
847pub(crate) struct PackageSyncSpec {
848 pub to_add: Vec<LuaDependencySpec>,
849 pub to_remove: Vec<LocalPackage>,
850}
851
852pub(crate) enum SyncStrategy<'a> {
854 LockfileOnly,
856 EnsureInstalled(&'a Tree),
859}
860
861impl<P: LockfilePermissions> Lockfile<P> {
862 pub fn version(&self) -> &String {
863 &self.version
864 }
865
866 pub fn rocks(&self) -> &BTreeMap<LocalPackageId, LocalPackage> {
867 self.lock.rocks()
868 }
869
870 pub fn is_dependency(&self, package: &LocalPackageId) -> bool {
871 self.lock.is_dependency(package)
872 }
873
874 pub fn is_entrypoint(&self, package: &LocalPackageId) -> bool {
875 self.lock.is_entrypoint(package)
876 }
877
878 pub fn entry_type(&self, package: &LocalPackageId) -> bool {
879 self.lock.is_entrypoint(package)
880 }
881
882 pub(crate) fn local_pkg_lock(&self) -> &LocalPackageLock {
883 &self.lock
884 }
885
886 pub fn get(&self, id: &LocalPackageId) -> Option<&LocalPackage> {
887 self.lock.get(id)
888 }
889
890 pub unsafe fn get_unchecked(&self, id: &LocalPackageId) -> &LocalPackage {
896 self.lock.get(id).unwrap_unchecked()
897 }
898
899 pub(crate) fn list(&self) -> HashMap<PackageName, Vec<LocalPackage>> {
900 self.lock.list()
901 }
902
903 pub(crate) fn has_rock(
904 &self,
905 req: &PackageReq,
906 filter: Option<RemotePackageTypeFilterSpec>,
907 ) -> Option<LocalPackage> {
908 self.lock.has_rock(req, filter)
909 }
910
911 pub(crate) fn find_rocks(&self, req: &PackageReq) -> Vec<LocalPackageId> {
913 match self.list().get(req.name()) {
914 Some(packages) => packages
915 .iter()
916 .rev()
917 .filter(|package| req.version_req().matches(package.version()))
918 .map(|package| package.id())
919 .collect_vec(),
920 None => Vec::default(),
921 }
922 }
923
924 pub(crate) fn validate_integrity(
926 &self,
927 expected_package: &LocalPackage,
928 ) -> Result<(), LockfileIntegrityError> {
929 match self.list().get(expected_package.name()) {
932 None => Err(integrity_err_not_found(expected_package)),
933 Some(rocks) => match rocks
934 .iter()
935 .find(|rock| rock.version() == expected_package.version())
936 {
937 None => Err(integrity_err_not_found(expected_package)),
938 Some(installed_package) => {
939 if expected_package
940 .hashes
941 .rockspec
942 .matches(&installed_package.hashes.rockspec)
943 .is_none()
944 {
945 return Err(LockfileIntegrityError::RockspecIntegrityMismatch {
946 src: expected_package.source.to_string(),
947 expected: expected_package.hashes.rockspec.clone(),
948 got: installed_package.hashes.rockspec.clone(),
949 });
950 }
951 if expected_package
952 .hashes
953 .source
954 .matches(&installed_package.hashes.source)
955 .is_none()
956 {
957 return Err(LockfileIntegrityError::SourceIntegrityMismatch {
958 src: expected_package
959 .source_url
960 .as_ref()
961 .map(|url| url.to_string())
962 .unwrap_or(expected_package.source.to_string()),
963 expected: expected_package.hashes.source.clone(),
964 got: installed_package.hashes.source.clone(),
965 });
966 }
967 Ok(())
968 }
969 },
970 }
971 }
972
973 fn flush(&self) -> Result<(), FlushLockfileError> {
974 let content = serde_json::to_string_pretty(&self).map_err(|err| FlushLockfileError {
975 filepath: self.filepath.to_path_buf(),
976 source: io::Error::other(err),
977 })?;
978
979 fs::sync::write(&self.filepath, content).map_err(|err| FlushLockfileError {
980 filepath: self.filepath.to_path_buf(),
981 source: io::Error::other(err),
982 })
983 }
984}
985
986impl<P: LockfilePermissions> WorkspaceLockfile<P> {
987 pub(crate) fn rocks(
988 &self,
989 deps: &LocalPackageLockType,
990 ) -> &BTreeMap<LocalPackageId, LocalPackage> {
991 match deps {
992 LocalPackageLockType::Regular => self.dependencies.rocks(),
993 LocalPackageLockType::Test => self.test_dependencies.rocks(),
994 LocalPackageLockType::Build => self.build_dependencies.rocks(),
995 }
996 }
997
998 pub(crate) fn get(
999 &self,
1000 id: &LocalPackageId,
1001 deps: &LocalPackageLockType,
1002 ) -> Option<&LocalPackage> {
1003 match deps {
1004 LocalPackageLockType::Regular => self.dependencies.get(id),
1005 LocalPackageLockType::Test => self.test_dependencies.get(id),
1006 LocalPackageLockType::Build => self.build_dependencies.get(id),
1007 }
1008 }
1009
1010 pub(crate) fn is_entrypoint(
1011 &self,
1012 package: &LocalPackageId,
1013 deps: &LocalPackageLockType,
1014 ) -> bool {
1015 match deps {
1016 LocalPackageLockType::Regular => self.dependencies.is_entrypoint(package),
1017 LocalPackageLockType::Test => self.test_dependencies.is_entrypoint(package),
1018 LocalPackageLockType::Build => self.build_dependencies.is_entrypoint(package),
1019 }
1020 }
1021
1022 pub(crate) fn package_sync_spec(
1023 &self,
1024 packages: &[LuaDependencySpec],
1025 deps: &LocalPackageLockType,
1026 strategy: &SyncStrategy<'_>,
1027 ) -> PackageSyncSpec {
1028 match deps {
1029 LocalPackageLockType::Regular => {
1030 self.dependencies.package_sync_spec(packages, strategy)
1031 }
1032 LocalPackageLockType::Test => {
1033 self.test_dependencies.package_sync_spec(packages, strategy)
1034 }
1035 LocalPackageLockType::Build => self
1036 .build_dependencies
1037 .package_sync_spec(packages, strategy),
1038 }
1039 }
1040
1041 pub(crate) fn local_pkg_lock(&self, deps: &LocalPackageLockType) -> &LocalPackageLock {
1042 match deps {
1043 LocalPackageLockType::Regular => &self.dependencies,
1044 LocalPackageLockType::Test => &self.test_dependencies,
1045 LocalPackageLockType::Build => &self.build_dependencies,
1046 }
1047 }
1048
1049 fn flush(&self) -> io::Result<()> {
1050 let content = serde_json::to_string_pretty(&self)?;
1051
1052 fs::sync::write(&self.filepath, content).map_err(io::Error::other)?;
1053
1054 Ok(())
1055 }
1056}
1057
1058impl Lockfile<ReadOnly> {
1059 #[tracing::instrument(level = "trace")]
1061 pub(crate) fn new(
1062 filepath: PathBuf,
1063 rock_layout: RockLayoutConfig,
1064 ) -> Result<Lockfile<ReadOnly>, LockfileError> {
1065 match File::options().create_new(true).write(true).open(&filepath) {
1067 Ok(mut file) => {
1068 let empty_lockfile: Lockfile<ReadOnly> = Lockfile {
1069 filepath: filepath.clone(),
1070 _marker: PhantomData,
1071 version: LOCKFILE_VERSION_STR.into(),
1072 lock: LocalPackageLock::default(),
1073 entrypoint_layout: rock_layout.clone(),
1074 };
1075 let json_str =
1076 serde_json::to_string(&empty_lockfile).map_err(LockfileError::WriteJson)?;
1077 write!(file, "{json_str}").map_err(|source| fs::FsError::Write {
1078 path: filepath.to_path_buf(),
1079 source,
1080 })?;
1081 }
1082 Err(err) if err.kind() == ErrorKind::AlreadyExists => {}
1083 Err(source) => {
1084 return Err(LockfileError::Fs(fs::FsError::FileOpen {
1085 path: filepath.to_path_buf(),
1086 source,
1087 }))
1088 }
1089 }
1090
1091 Self::load(filepath, Some(&rock_layout))
1092 }
1093
1094 #[tracing::instrument(level = "trace")]
1097 pub fn load(
1098 filepath: PathBuf,
1099 expected_rock_layout: Option<&RockLayoutConfig>,
1100 ) -> Result<Lockfile<ReadOnly>, LockfileError> {
1101 let content = fs::sync::read_to_string(&filepath)?;
1102 let mut lockfile: Lockfile<ReadOnly> =
1103 serde_json::from_str(&content).map_err(LockfileError::ParseJson)?;
1104 lockfile.filepath = filepath;
1105 if let Some(expected_rock_layout) = expected_rock_layout {
1106 if &lockfile.entrypoint_layout != expected_rock_layout {
1107 return Err(LockfileError::MismatchedRockLayout);
1108 }
1109 }
1110 Ok(lockfile)
1111 }
1112
1113 pub(crate) fn into_temporary(self) -> Lockfile<ReadWrite> {
1115 Lockfile::<ReadWrite> {
1116 _marker: PhantomData,
1117 filepath: self.filepath,
1118 version: self.version,
1119 lock: self.lock,
1120 entrypoint_layout: self.entrypoint_layout,
1121 }
1122 }
1123
1124 pub fn write_guard(self) -> LockfileGuard {
1127 LockfileGuard(self.into_temporary())
1128 }
1129
1130 #[tracing::instrument(level = "trace", skip_all)]
1133 pub fn map_then_flush<T, F, E>(self, cb: F) -> Result<T, FlushLockfileError>
1134 where
1135 F: FnOnce(&mut Lockfile<ReadWrite>) -> Result<T, E>,
1136 E: Error,
1137 E: From<io::Error>,
1138 E: Into<Box<dyn Error + Send + Sync>>,
1139 {
1140 let mut writeable_lockfile = self.into_temporary();
1141
1142 let result = cb(&mut writeable_lockfile).map_err(|err| FlushLockfileError {
1143 filepath: writeable_lockfile.filepath.to_path_buf(),
1144 source: io::Error::other(err),
1145 })?;
1146
1147 writeable_lockfile.flush()?;
1148
1149 Ok(result)
1150 }
1151
1152 }
1170
1171impl WorkspaceLockfile<ReadOnly> {
1172 #[tracing::instrument(level = "trace")]
1174 pub fn new(filepath: PathBuf) -> Result<WorkspaceLockfile<ReadOnly>, LockfileError> {
1175 match File::options().create_new(true).write(true).open(&filepath) {
1177 Ok(mut file) => {
1178 let empty_lockfile: WorkspaceLockfile<ReadOnly> = WorkspaceLockfile {
1179 filepath: filepath.clone(),
1180 _marker: PhantomData,
1181 version: LOCKFILE_VERSION_STR.into(),
1182 dependencies: LocalPackageLock::default(),
1183 test_dependencies: LocalPackageLock::default(),
1184 build_dependencies: LocalPackageLock::default(),
1185 };
1186 let json_str =
1187 serde_json::to_string(&empty_lockfile).map_err(LockfileError::WriteJson)?;
1188 write!(file, "{json_str}").map_err(|source| fs::FsError::Write {
1189 path: filepath.to_path_buf(),
1190 source,
1191 })?;
1192 }
1193 Err(err) if err.kind() == ErrorKind::AlreadyExists => {}
1194 Err(source) => {
1195 return Err(LockfileError::Fs(fs::FsError::FileOpen {
1196 path: filepath.to_path_buf(),
1197 source,
1198 }))
1199 }
1200 }
1201
1202 Self::load(filepath)
1203 }
1204
1205 #[tracing::instrument(level = "trace")]
1207 pub fn load(filepath: PathBuf) -> Result<WorkspaceLockfile<ReadOnly>, LockfileError> {
1208 let content = fs::sync::read_to_string(&filepath)?;
1209 let mut lockfile: WorkspaceLockfile<ReadOnly> =
1210 serde_json::from_str(&content).map_err(LockfileError::ParseJson)?;
1211
1212 lockfile.filepath = filepath;
1213
1214 Ok(lockfile)
1215 }
1216
1217 fn into_temporary(self) -> WorkspaceLockfile<ReadWrite> {
1219 WorkspaceLockfile::<ReadWrite> {
1220 _marker: PhantomData,
1221 filepath: self.filepath,
1222 version: self.version,
1223 dependencies: self.dependencies,
1224 test_dependencies: self.test_dependencies,
1225 build_dependencies: self.build_dependencies,
1226 }
1227 }
1228
1229 pub fn write_guard(self) -> ProjectLockfileGuard {
1232 ProjectLockfileGuard(self.into_temporary())
1233 }
1234}
1235
1236impl Lockfile<ReadWrite> {
1237 pub(crate) fn add_entrypoint(&mut self, rock: &LocalPackage) {
1238 self.add(rock);
1239 let id = rock.id().clone();
1240 if !self.lock.entrypoints.contains(&id) {
1241 self.lock.entrypoints.push(id)
1242 }
1243 }
1244
1245 pub(crate) fn remove_entrypoint(&mut self, rock: &LocalPackage) {
1246 if let Some(index) = self
1247 .lock
1248 .entrypoints
1249 .iter()
1250 .position(|pkg_id| *pkg_id == rock.id())
1251 {
1252 self.lock.entrypoints.remove(index);
1253 }
1254 }
1255
1256 fn add(&mut self, rock: &LocalPackage) {
1257 self.lock
1260 .rocks
1261 .entry(rock.id())
1262 .or_insert_with(|| rock.clone());
1263 }
1264
1265 pub(crate) fn add_dependency(&mut self, target: &LocalPackage, dependency: &LocalPackage) {
1267 self.lock
1268 .rocks
1269 .entry(target.id())
1270 .and_modify(|rock| {
1271 let id = dependency.id();
1272 if !rock.spec.dependencies.contains(&id) {
1273 rock.spec.dependencies.push(id);
1274 }
1275 })
1276 .or_insert_with(|| {
1277 let id = dependency.id();
1278 let mut target = target.clone();
1279 if !target.spec.dependencies.contains(&id) {
1280 target.spec.dependencies.push(id);
1281 }
1282 target
1283 });
1284 self.add(dependency);
1285 }
1286
1287 pub(crate) fn remove(&mut self, target: &LocalPackage) {
1288 self.lock.remove(target)
1289 }
1290
1291 pub(crate) fn remove_by_id(&mut self, target: &LocalPackageId) {
1292 self.lock.remove_by_id(target)
1293 }
1294
1295 pub(crate) fn sync(&mut self, lock: &LocalPackageLock) {
1296 self.lock = lock.clone();
1297 }
1298
1299 }
1301
1302impl WorkspaceLockfile<ReadWrite> {
1303 pub(crate) fn remove(&mut self, target: &LocalPackage, deps: &LocalPackageLockType) {
1304 match deps {
1305 LocalPackageLockType::Regular => self.dependencies.remove(target),
1306 LocalPackageLockType::Test => self.test_dependencies.remove(target),
1307 LocalPackageLockType::Build => self.build_dependencies.remove(target),
1308 }
1309 }
1310
1311 pub(crate) fn sync(&mut self, lock: &LocalPackageLock, deps: &LocalPackageLockType) {
1312 match deps {
1313 LocalPackageLockType::Regular => {
1314 self.dependencies = lock.clone();
1315 }
1316 LocalPackageLockType::Test => {
1317 self.test_dependencies = lock.clone();
1318 }
1319 LocalPackageLockType::Build => {
1320 self.build_dependencies = lock.clone();
1321 }
1322 }
1323 }
1324}
1325
1326pub struct LockfileGuard(Lockfile<ReadWrite>);
1328
1329pub struct ProjectLockfileGuard(WorkspaceLockfile<ReadWrite>);
1330
1331impl Serialize for LockfileGuard {
1332 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1333 where
1334 S: serde::Serializer,
1335 {
1336 self.0.serialize(serializer)
1337 }
1338}
1339
1340impl Serialize for ProjectLockfileGuard {
1341 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1342 where
1343 S: serde::Serializer,
1344 {
1345 self.0.serialize(serializer)
1346 }
1347}
1348
1349impl<'de> Deserialize<'de> for LockfileGuard {
1350 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1351 where
1352 D: serde::Deserializer<'de>,
1353 {
1354 Ok(LockfileGuard(Lockfile::<ReadWrite>::deserialize(
1355 deserializer,
1356 )?))
1357 }
1358}
1359
1360impl<'de> Deserialize<'de> for ProjectLockfileGuard {
1361 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1362 where
1363 D: serde::Deserializer<'de>,
1364 {
1365 Ok(ProjectLockfileGuard(
1366 WorkspaceLockfile::<ReadWrite>::deserialize(deserializer)?,
1367 ))
1368 }
1369}
1370
1371impl Deref for LockfileGuard {
1372 type Target = Lockfile<ReadWrite>;
1373
1374 fn deref(&self) -> &Self::Target {
1375 &self.0
1376 }
1377}
1378
1379impl Deref for ProjectLockfileGuard {
1380 type Target = WorkspaceLockfile<ReadWrite>;
1381
1382 fn deref(&self) -> &Self::Target {
1383 &self.0
1384 }
1385}
1386
1387impl DerefMut for LockfileGuard {
1388 fn deref_mut(&mut self) -> &mut Self::Target {
1389 &mut self.0
1390 }
1391}
1392
1393impl DerefMut for ProjectLockfileGuard {
1394 fn deref_mut(&mut self) -> &mut Self::Target {
1395 &mut self.0
1396 }
1397}
1398
1399impl Drop for LockfileGuard {
1400 fn drop(&mut self) {
1401 let _ = self.flush();
1402 }
1403}
1404
1405impl Drop for ProjectLockfileGuard {
1406 fn drop(&mut self) {
1407 let _ = self.flush();
1408 }
1409}
1410
1411fn serialize_sorted_package_ids<S>(
1412 package_ids: &[LocalPackageId],
1413 serializer: S,
1414) -> Result<S::Ok, S::Error>
1415where
1416 S: Serializer,
1417{
1418 package_ids
1419 .iter()
1420 .sorted()
1421 .collect_vec()
1422 .serialize(serializer)
1423}
1424
1425fn integrity_err_not_found(package: &LocalPackage) -> LockfileIntegrityError {
1426 LockfileIntegrityError::PackageNotFound(
1427 package.name().clone(),
1428 package.version().clone(),
1429 package.spec.pinned,
1430 package
1431 .spec
1432 .constraint
1433 .clone()
1434 .unwrap_or("UNCONSTRAINED".into()),
1435 )
1436}
1437
1438fn deserialize_url<'de, D>(deserializer: D) -> Result<Url, D::Error>
1439where
1440 D: serde::Deserializer<'de>,
1441{
1442 let s = String::deserialize(deserializer)?;
1443 Url::parse(&s).map_err(serde::de::Error::custom)
1444}
1445
1446fn serialize_url<S>(url: &Url, serializer: S) -> Result<S::Ok, S::Error>
1447where
1448 S: Serializer,
1449{
1450 url.as_str().serialize(serializer)
1451}
1452
1453#[cfg(test)]
1454mod tests {
1455 use super::*;
1456 use std::{fs::remove_file, path::PathBuf};
1457
1458 use assert_fs::fixture::PathCopy;
1459 use insta::{assert_json_snapshot, sorted_redaction};
1460
1461 use crate::{config::ConfigBuilder, lua_version::LuaVersion, package::PackageSpec};
1462
1463 #[test]
1464 fn parse_lockfile() {
1465 let temp = assert_fs::TempDir::new().unwrap();
1466 temp.copy_from(
1467 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-tree"),
1468 &["**"],
1469 )
1470 .unwrap();
1471
1472 let config = ConfigBuilder::new()
1473 .unwrap()
1474 .user_tree(Some(temp.to_path_buf()))
1475 .build()
1476 .unwrap();
1477 let tree = config.user_tree(LuaVersion::Lua51).unwrap();
1478 let lockfile = tree.lockfile().unwrap();
1479
1480 assert_json_snapshot!(lockfile, { ".**" => sorted_redaction() });
1481 }
1482
1483 #[test]
1484 fn add_rocks() {
1485 let temp = assert_fs::TempDir::new().unwrap();
1486 temp.copy_from(
1487 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-tree"),
1488 &["**"],
1489 )
1490 .unwrap();
1491
1492 let mock_hashes = LocalPackageHashes {
1493 rockspec: "sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="
1494 .parse()
1495 .unwrap(),
1496 source: "sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="
1497 .parse()
1498 .unwrap(),
1499 };
1500
1501 let config = ConfigBuilder::new()
1502 .unwrap()
1503 .user_tree(Some(temp.to_path_buf()))
1504 .build()
1505 .unwrap();
1506 let tree = config.user_tree(LuaVersion::Lua51).unwrap();
1507 let mut lockfile = tree.lockfile().unwrap().write_guard();
1508
1509 let test_package = PackageSpec::parse("test1".to_string(), "0.1.0".to_string()).unwrap();
1510 let test_local_package = LocalPackage::from(
1511 &test_package,
1512 crate::lockfile::LockConstraint::Unconstrained,
1513 RockBinaries::default(),
1514 RemotePackageSource::Test,
1515 None,
1516 mock_hashes.clone(),
1517 );
1518 lockfile.add_entrypoint(&test_local_package);
1519
1520 let test_dep_package =
1521 PackageSpec::parse("test2".to_string(), "0.1.0".to_string()).unwrap();
1522 let mut test_local_dep_package = LocalPackage::from(
1523 &test_dep_package,
1524 crate::lockfile::LockConstraint::Constrained(">= 1.0.0".parse().unwrap()),
1525 RockBinaries::default(),
1526 RemotePackageSource::Test,
1527 None,
1528 mock_hashes.clone(),
1529 );
1530 test_local_dep_package.spec.pinned = PinnedState::Pinned;
1531 lockfile.add_dependency(&test_local_package, &test_local_dep_package);
1532
1533 assert_json_snapshot!(lockfile, { ".**" => sorted_redaction() });
1534 }
1535
1536 #[test]
1537 fn parse_nonexistent_lockfile() {
1538 let tree_path =
1539 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-tree");
1540
1541 let temp = assert_fs::TempDir::new().unwrap();
1542 temp.copy_from(&tree_path, &["**"]).unwrap();
1543
1544 remove_file(temp.join("5.1/lux.lock")).unwrap();
1545
1546 let config = ConfigBuilder::new()
1547 .unwrap()
1548 .user_tree(Some(temp.to_path_buf()))
1549 .build()
1550 .unwrap();
1551 let tree = config.user_tree(LuaVersion::Lua51).unwrap();
1552
1553 let _ = tree.lockfile().unwrap().write_guard(); }
1555
1556 fn get_test_lockfile() -> Lockfile<ReadOnly> {
1557 let sample_tree = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1558 .join("resources/test/sample-tree/5.1/lux.lock");
1559 Lockfile::new(sample_tree, RockLayoutConfig::default()).unwrap()
1560 }
1561
1562 #[test]
1563 fn test_sync_spec() {
1564 let lockfile = get_test_lockfile();
1565 let packages = vec![
1566 PackageReq::parse("neorg@8.8.1-1").unwrap().into(),
1567 PackageReq::parse("lua-cjson@2.1.0").unwrap().into(),
1568 PackageReq::parse("nonexistent").unwrap().into(),
1569 ];
1570
1571 let sync_spec = lockfile
1572 .lock
1573 .package_sync_spec(&packages, &SyncStrategy::LockfileOnly);
1574
1575 assert_eq!(sync_spec.to_add.len(), 1);
1576
1577 assert!(!sync_spec
1579 .to_remove
1580 .iter()
1581 .any(|pkg| pkg.name().to_string() == "nvim-nio"
1582 && pkg.constraint()
1583 == LockConstraint::Constrained(">=1.7.0, <1.8.0".parse().unwrap())));
1584 assert!(!sync_spec
1585 .to_remove
1586 .iter()
1587 .any(|pkg| pkg.name().to_string() == "lua-utils.nvim"
1588 && pkg.constraint() == LockConstraint::Constrained("=1.0.2".parse().unwrap())));
1589 assert!(!sync_spec
1590 .to_remove
1591 .iter()
1592 .any(|pkg| pkg.name().to_string() == "plenary.nvim"
1593 && pkg.constraint() == LockConstraint::Constrained("=0.1.4".parse().unwrap())));
1594 assert!(!sync_spec
1595 .to_remove
1596 .iter()
1597 .any(|pkg| pkg.name().to_string() == "nui.nvim"
1598 && pkg.constraint() == LockConstraint::Constrained("=0.3.0".parse().unwrap())));
1599 assert!(!sync_spec
1600 .to_remove
1601 .iter()
1602 .any(|pkg| pkg.name().to_string() == "pathlib.nvim"
1603 && pkg.constraint()
1604 == LockConstraint::Constrained(">=2.2.0, <2.3.0".parse().unwrap())));
1605 }
1606
1607 #[test]
1608 fn test_sync_spec_remove() {
1609 let lockfile = get_test_lockfile();
1610 let packages = vec![
1611 PackageReq::parse("lua-cjson@2.1.0").unwrap().into(),
1612 PackageReq::parse("nonexistent").unwrap().into(),
1613 ];
1614
1615 let sync_spec = lockfile
1616 .lock
1617 .package_sync_spec(&packages, &SyncStrategy::LockfileOnly);
1618
1619 assert_eq!(sync_spec.to_add.len(), 1);
1620
1621 assert!(sync_spec
1625 .to_remove
1626 .iter()
1627 .any(|pkg| pkg.name().to_string() == "neorg"
1628 && pkg.version() == &"8.8.1-1".parse().unwrap()));
1629 assert!(sync_spec
1630 .to_remove
1631 .iter()
1632 .any(|pkg| pkg.name().to_string() == "nvim-nio"
1633 && pkg.constraint()
1634 == LockConstraint::Constrained(">=1.7.0, <1.8.0".parse().unwrap())));
1635 assert!(sync_spec
1636 .to_remove
1637 .iter()
1638 .any(|pkg| pkg.name().to_string() == "lua-utils.nvim"
1639 && pkg.constraint() == LockConstraint::Constrained("=1.0.2".parse().unwrap())));
1640 assert!(sync_spec
1641 .to_remove
1642 .iter()
1643 .any(|pkg| pkg.name().to_string() == "plenary.nvim"
1644 && pkg.constraint() == LockConstraint::Constrained("=0.1.4".parse().unwrap())));
1645 assert!(sync_spec
1646 .to_remove
1647 .iter()
1648 .any(|pkg| pkg.name().to_string() == "nui.nvim"
1649 && pkg.constraint() == LockConstraint::Constrained("=0.3.0".parse().unwrap())));
1650 assert!(sync_spec
1651 .to_remove
1652 .iter()
1653 .any(|pkg| pkg.name().to_string() == "pathlib.nvim"
1654 && pkg.constraint()
1655 == LockConstraint::Constrained(">=2.2.0, <2.3.0".parse().unwrap())));
1656 }
1657
1658 #[test]
1659 fn test_sync_spec_empty() {
1660 let lockfile = get_test_lockfile();
1661 let packages = vec![];
1662 let sync_spec = lockfile
1663 .lock
1664 .package_sync_spec(&packages, &SyncStrategy::LockfileOnly);
1665
1666 assert!(sync_spec.to_add.is_empty());
1668 assert_eq!(sync_spec.to_remove.len(), lockfile.rocks().len());
1669 }
1670
1671 #[test]
1672 fn test_sync_spec_different_constraints() {
1673 let lockfile = get_test_lockfile();
1674 let packages = vec![PackageReq::parse("nvim-nio>=2.0.0").unwrap().into()];
1675 let sync_spec = lockfile
1676 .lock
1677 .package_sync_spec(&packages, &SyncStrategy::LockfileOnly);
1678
1679 let expected: PackageVersionReq = ">=2.0.0".parse().unwrap();
1680 assert!(sync_spec
1681 .to_add
1682 .iter()
1683 .any(|req| req.name().to_string() == "nvim-nio" && req.version_req() == &expected));
1684
1685 assert!(sync_spec
1686 .to_remove
1687 .iter()
1688 .any(|pkg| pkg.name().to_string() == "nvim-nio"));
1689 }
1690
1691 #[test]
1692 fn test_sync_spec_ensure_installed() {
1693 let temp = assert_fs::TempDir::new().unwrap();
1694 temp.copy_from(
1695 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-tree"),
1696 &["**"],
1697 )
1698 .unwrap();
1699
1700 let config = ConfigBuilder::new()
1701 .unwrap()
1702 .user_tree(Some(temp.to_path_buf()))
1703 .build()
1704 .unwrap();
1705 let tree = config.user_tree(LuaVersion::Lua51).unwrap();
1706 let lockfile = tree.lockfile().unwrap();
1707
1708 let packages: Vec<LuaDependencySpec> = vec![
1709 PackageReq::parse("neorg@8.8.1-1").unwrap().into(),
1710 PackageReq::parse("lua-cjson@2.1.0").unwrap().into(),
1712 PackageReq::parse("nonexistent").unwrap().into(),
1714 ];
1715
1716 let sync_spec = lockfile
1718 .lock
1719 .package_sync_spec(&packages, &SyncStrategy::EnsureInstalled(&tree));
1720
1721 assert!(!sync_spec
1722 .to_add
1723 .iter()
1724 .any(|req| req.name().to_string() == "neorg"));
1725
1726 assert!(sync_spec
1727 .to_add
1728 .iter()
1729 .any(|req| req.name().to_string() == "lua-cjson"));
1730
1731 assert!(sync_spec
1732 .to_add
1733 .iter()
1734 .any(|req| req.name().to_string() == "nonexistent"));
1735 }
1736}