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