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