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::{
8 collections::HashMap,
9 fs::File,
10 io::ErrorKind,
11 path::{Path, PathBuf},
12};
13
14use itertools::Itertools;
15
16use miette::Diagnostic;
17use serde::{de, Deserialize, Serialize, Serializer};
18use sha2::{Digest, Sha256};
19use ssri::Integrity;
20use strum_macros::EnumIter;
21use thiserror::Error;
22use url::Url;
23
24use crate::fs;
25use crate::package::{
26 PackageName, PackageReq, PackageSpec, PackageVersion, PackageVersionReq,
27 PackageVersionReqError, RemotePackageTypeFilterSpec,
28};
29use crate::remote_package_source::RemotePackageSource;
30use crate::rockspec::lua_dependency::LuaDependencySpec;
31use crate::rockspec::RockBinaries;
32use crate::tree::{EntryType, InstallTree, Tree};
33
34const LOCKFILE_VERSION: u64 = 2;
37const LOCKFILE_VERSION_STR: &str = "2.0.0";
38
39#[derive(Copy, Debug, PartialEq, Eq, Hash, Clone, PartialOrd, Ord, Default)]
40pub enum PinnedState {
41 #[default]
43 Unpinned,
44 Pinned,
46}
47
48impl Display for PinnedState {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 match &self {
51 PinnedState::Unpinned => "unpinned".fmt(f),
52 PinnedState::Pinned => "pinned".fmt(f),
53 }
54 }
55}
56
57impl From<bool> for PinnedState {
58 fn from(value: bool) -> Self {
59 if value {
60 Self::Pinned
61 } else {
62 Self::Unpinned
63 }
64 }
65}
66
67impl PinnedState {
68 pub fn as_bool(&self) -> bool {
69 match self {
70 Self::Unpinned => false,
71 Self::Pinned => true,
72 }
73 }
74
75 fn is_default(&self) -> bool {
76 &Self::default() == self
77 }
78}
79
80impl Serialize for PinnedState {
81 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
82 where
83 S: serde::Serializer,
84 {
85 serializer.serialize_bool(self.as_bool())
86 }
87}
88
89impl<'de> Deserialize<'de> for PinnedState {
90 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
91 where
92 D: serde::Deserializer<'de>,
93 {
94 Ok(match bool::deserialize(deserializer)? {
95 false => Self::Unpinned,
96 true => Self::Pinned,
97 })
98 }
99}
100
101#[derive(Copy, Debug, PartialEq, Eq, Hash, Clone, PartialOrd, Ord, Default)]
102pub enum OptState {
103 #[default]
105 Required,
106 Optional,
108}
109
110impl OptState {
111 pub(crate) fn as_bool(&self) -> bool {
112 match self {
113 Self::Required => false,
114 Self::Optional => true,
115 }
116 }
117
118 fn is_default(&self) -> bool {
119 &Self::default() == self
120 }
121}
122
123impl From<bool> for OptState {
124 fn from(value: bool) -> Self {
125 if value {
126 Self::Optional
127 } else {
128 Self::Required
129 }
130 }
131}
132
133impl Display for OptState {
134 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135 match &self {
136 OptState::Required => "required".fmt(f),
137 OptState::Optional => "optional".fmt(f),
138 }
139 }
140}
141
142impl Serialize for OptState {
143 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
144 where
145 S: serde::Serializer,
146 {
147 serializer.serialize_bool(self.as_bool())
148 }
149}
150
151impl<'de> Deserialize<'de> for OptState {
152 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
153 where
154 D: serde::Deserializer<'de>,
155 {
156 Ok(match bool::deserialize(deserializer)? {
157 false => Self::Required,
158 true => Self::Optional,
159 })
160 }
161}
162
163#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
164pub struct LockedPackageSpec {
165 name: PackageName,
166 version: PackageVersion,
167 pinned: PinnedState,
168 opt: OptState,
169 dependencies: Vec<LockedPackageId>,
170 build_dependencies: Vec<LockedPackageId>,
171 constraint: Option<String>,
173}
174
175#[derive(Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, Clone)]
182pub struct LockedPackageId(String);
183
184impl LockedPackageId {
185 pub fn new(
186 name: &PackageName,
187 version: &PackageVersion,
188 pinned: PinnedState,
189 opt: OptState,
190 constraint: LockConstraint,
191 ) -> Self {
192 let mut hasher = Sha256::new();
193
194 hasher.update(format!(
195 "{}{}{}{}{}",
196 name,
197 version,
198 pinned.as_bool(),
199 opt.as_bool(),
200 match constraint {
201 LockConstraint::Unconstrained => String::default(),
202 LockConstraint::Constrained(version_req) => version_req.to_string(),
203 },
204 ));
205
206 Self(hex::encode(hasher.finalize()))
207 }
208
209 pub unsafe fn from_unchecked(str: String) -> Self {
217 Self(str)
218 }
219
220 pub fn into_string(self) -> String {
221 self.0
222 }
223}
224
225impl Display for LockedPackageId {
226 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
227 self.0.fmt(f)
228 }
229}
230
231impl LockedPackageSpec {
232 #[allow(clippy::too_many_arguments)]
233 pub(crate) fn new(
234 name: &PackageName,
235 version: &PackageVersion,
236 constraint: LockConstraint,
237 dependencies: Vec<LockedPackageId>,
238 build_dependencies: Vec<LockedPackageId>,
239 pinned: &PinnedState,
240 opt: &OptState,
241 ) -> Self {
242 Self {
243 name: name.clone(),
244 version: version.clone(),
245 pinned: *pinned,
246 opt: *opt,
247 dependencies,
248 build_dependencies,
249 constraint: match constraint {
250 LockConstraint::Unconstrained => None,
251 LockConstraint::Constrained(version_req) => Some(version_req.to_string()),
252 },
253 }
254 }
255
256 pub fn id(&self) -> LockedPackageId {
257 LockedPackageId::new(
258 self.name(),
259 self.version(),
260 self.pinned,
261 self.opt,
262 match &self.constraint {
263 None => LockConstraint::Unconstrained,
264 Some(_) => self.constraint(),
265 },
266 )
267 }
268
269 pub fn constraint(&self) -> LockConstraint {
270 unsafe { LockConstraint::try_from(&self.constraint).unwrap_unchecked() }
272 }
273
274 pub fn name(&self) -> &PackageName {
275 &self.name
276 }
277
278 pub fn version(&self) -> &PackageVersion {
279 &self.version
280 }
281
282 pub fn pinned(&self) -> PinnedState {
283 self.pinned
284 }
285
286 pub fn opt(&self) -> OptState {
287 self.opt
288 }
289
290 pub fn dependencies(&self) -> Vec<&LockedPackageId> {
291 self.dependencies.iter().collect()
292 }
293
294 pub fn build_dependencies(&self) -> Vec<&LockedPackageId> {
295 self.build_dependencies.iter().collect()
296 }
297
298 pub fn to_package(&self) -> PackageSpec {
299 PackageSpec::new(self.name.clone(), self.version.clone())
300 }
301
302 pub fn into_package_req(self) -> PackageReq {
303 match self.constraint() {
304 LockConstraint::Unconstrained => self.name.into(),
305 LockConstraint::Constrained(version_req) => PackageReq {
306 name: self.name,
307 version_req,
308 },
309 }
310 }
311
312 pub(crate) fn as_package_req(&self) -> PackageReq {
313 match self.constraint() {
314 LockConstraint::Unconstrained => self.name.clone().into(),
315 LockConstraint::Constrained(version_req) => PackageReq {
316 name: self.name.clone(),
317 version_req: version_req.clone(),
318 },
319 }
320 }
321}
322
323#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
324#[serde(rename_all = "lowercase", tag = "type")]
325pub(crate) enum RemotePackageSourceUrl {
326 Git {
327 url: String,
328 #[serde(rename = "ref")]
329 checkout_ref: String,
330 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
333 submodules: bool,
334 }, Url {
336 #[serde(deserialize_with = "deserialize_url", serialize_with = "serialize_url")]
337 url: Url,
338 },
339 File {
340 path: PathBuf,
341 },
342}
343
344impl Display for RemotePackageSourceUrl {
345 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
346 match self {
347 RemotePackageSourceUrl::Git {
348 url, checkout_ref, ..
349 } => format!("{url}@{checkout_ref}").fmt(f),
350 RemotePackageSourceUrl::Url { url } => url.fmt(f),
351 RemotePackageSourceUrl::File { path } => format!("{}", path.display()).fmt(f),
352 }
353 }
354}
355
356#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
360pub struct LockedPackage {
361 pub(crate) spec: LockedPackageSpec,
362 pub(crate) source: RemotePackageSource,
363 pub(crate) source_url: Option<RemotePackageSourceUrl>,
364 pub(crate) hashes: LockedPackageHashes,
365}
366
367impl LockedPackage {
368 pub(crate) fn repin(mut self, pinned: PinnedState) -> Self {
369 self.spec.pinned = pinned;
370 self
371 }
372
373 #[cfg(test)]
374 pub(crate) fn from(
375 package: &PackageSpec,
376 constraint: LockConstraint,
377 source: RemotePackageSource,
378 source_url: Option<RemotePackageSourceUrl>,
379 hashes: LockedPackageHashes,
380 ) -> Self {
381 Self::new(
382 LockedPackageSpec::new(
383 package.name(),
384 package.version(),
385 constraint,
386 Vec::default(),
387 Vec::default(),
388 &PinnedState::Unpinned,
389 &OptState::Required,
390 ),
391 source,
392 source_url,
393 hashes,
394 )
395 }
396
397 pub(crate) fn new(
398 spec: LockedPackageSpec,
399 source: RemotePackageSource,
400 source_url: Option<RemotePackageSourceUrl>,
401 hashes: LockedPackageHashes,
402 ) -> Self {
403 Self {
404 spec,
405 source,
406 source_url,
407 hashes,
408 }
409 }
410
411 pub fn spec(&self) -> &LockedPackageSpec {
412 &self.spec
413 }
414
415 pub fn into_package_spec(self) -> PackageSpec {
416 self.spec.to_package()
417 }
418
419 pub fn as_package_spec(&self) -> PackageSpec {
420 self.spec.to_package()
421 }
422
423 pub fn id(&self) -> LockedPackageId {
424 self.spec.id()
425 }
426
427 pub fn name(&self) -> &PackageName {
428 self.spec.name()
429 }
430
431 pub fn version(&self) -> &PackageVersion {
432 self.spec.version()
433 }
434
435 pub fn pinned(&self) -> PinnedState {
436 self.spec.pinned()
437 }
438
439 pub fn opt(&self) -> OptState {
440 self.spec.opt()
441 }
442
443 pub fn dependencies(&self) -> Vec<&LockedPackageId> {
444 self.spec.dependencies()
445 }
446
447 pub fn build_dependencies(&self) -> Vec<&LockedPackageId> {
448 self.spec.build_dependencies()
449 }
450
451 pub fn constraint(&self) -> LockConstraint {
452 self.spec.constraint()
453 }
454
455 pub(crate) fn source(&self) -> &RemotePackageSource {
456 &self.source
457 }
458
459 pub(crate) fn source_url(&self) -> Option<&RemotePackageSourceUrl> {
460 self.source_url.as_ref()
461 }
462
463 pub fn hashes(&self) -> &LockedPackageHashes {
464 &self.hashes
465 }
466
467 pub fn to_package(&self) -> PackageSpec {
468 self.spec.to_package()
469 }
470
471 pub fn into_package_req(self) -> PackageReq {
472 self.spec.into_package_req()
473 }
474}
475
476#[derive(Debug, Serialize, Deserialize, Clone)]
477struct LockedPackageIntermediate {
478 name: PackageName,
479 version: PackageVersion,
480 #[serde(default, skip_serializing_if = "PinnedState::is_default")]
481 pinned: PinnedState,
482 #[serde(default, skip_serializing_if = "OptState::is_default")]
483 opt: OptState,
484 #[serde(default, skip_serializing_if = "Vec::is_empty")]
485 dependencies: Vec<LockedPackageId>,
486 #[serde(default, skip_serializing_if = "Vec::is_empty")]
487 build_dependencies: Vec<LockedPackageId>,
488 #[serde(default, skip_serializing_if = "Option::is_none")]
489 constraint: Option<String>,
490 source: RemotePackageSource,
491 #[serde(default, skip_serializing_if = "Option::is_none")]
492 source_url: Option<RemotePackageSourceUrl>,
493 hashes: LockedPackageHashes,
494}
495
496impl TryFrom<LockedPackageIntermediate> for LockedPackage {
497 type Error = LockConstraintParseError;
498
499 fn try_from(value: LockedPackageIntermediate) -> Result<Self, Self::Error> {
500 let constraint = LockConstraint::try_from(&value.constraint)?;
501 Ok(Self {
502 spec: LockedPackageSpec::new(
503 &value.name,
504 &value.version,
505 constraint,
506 value.dependencies,
507 value.build_dependencies,
508 &value.pinned,
509 &value.opt,
510 ),
511 source: value.source,
512 source_url: value.source_url,
513 hashes: value.hashes,
514 })
515 }
516}
517
518impl From<&LockedPackage> for LockedPackageIntermediate {
519 fn from(value: &LockedPackage) -> Self {
520 Self {
521 name: value.spec.name.clone(),
522 version: value.spec.version.clone(),
523 pinned: value.spec.pinned,
524 opt: value.spec.opt,
525 dependencies: value.spec.dependencies.clone(),
526 build_dependencies: value.spec.build_dependencies.clone(),
527 constraint: value.spec.constraint.clone(),
528 source: value.source.clone(),
529 source_url: value.source_url.clone(),
530 hashes: value.hashes.clone(),
531 }
532 }
533}
534
535impl<'de> Deserialize<'de> for LockedPackage {
536 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
537 where
538 D: serde::Deserializer<'de>,
539 {
540 LockedPackage::try_from(LockedPackageIntermediate::deserialize(deserializer)?)
541 .map_err(de::Error::custom)
542 }
543}
544
545impl Serialize for LockedPackage {
546 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
547 where
548 S: serde::Serializer,
549 {
550 LockedPackageIntermediate::from(self).serialize(serializer)
551 }
552}
553
554#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, Hash)]
555pub struct LockedPackageHashes {
556 pub rockspec: Integrity,
557 pub source: Integrity,
558}
559
560impl Ord for LockedPackageHashes {
561 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
562 let a = (self.rockspec.to_hex().1, self.source.to_hex().1);
563 let b = (other.rockspec.to_hex().1, other.source.to_hex().1);
564 a.cmp(&b)
565 }
566}
567
568impl PartialOrd for LockedPackageHashes {
569 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
570 Some(self.cmp(other))
571 }
572}
573
574#[derive(Clone, Debug, PartialEq, Eq, Hash, Default)]
575pub enum LockConstraint {
576 #[default]
577 Unconstrained,
578 Constrained(PackageVersionReq),
579}
580
581impl<'de> Deserialize<'de> for LockConstraint {
582 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
583 where
584 D: serde::Deserializer<'de>,
585 {
586 let str = String::deserialize(deserializer)?;
587 match str.as_str() {
588 "*" => Ok(LockConstraint::Unconstrained),
589 _ => Ok(LockConstraint::Constrained(
590 str.parse().map_err(serde::de::Error::custom)?,
591 )),
592 }
593 }
594}
595
596impl LockConstraint {
597 pub fn to_string_opt(&self) -> Option<String> {
598 match self {
599 LockConstraint::Unconstrained => None,
600 LockConstraint::Constrained(req) => Some(req.to_string()),
601 }
602 }
603
604 fn matches_version_req(&self, req: &PackageVersionReq) -> bool {
605 match self {
606 LockConstraint::Unconstrained => req.is_any(),
607 LockConstraint::Constrained(package_version_req) => package_version_req == req,
608 }
609 }
610}
611
612impl From<PackageVersionReq> for LockConstraint {
613 fn from(value: PackageVersionReq) -> Self {
614 if value.is_any() {
615 Self::Unconstrained
616 } else {
617 Self::Constrained(value)
618 }
619 }
620}
621
622#[derive(Error, Debug, Diagnostic)]
623#[non_exhaustive]
624pub enum LockConstraintParseError {
625 #[error("Invalid constraint in LuaPackage")]
626 #[diagnostic(forward(0))]
627 LockConstraintParseError(#[from] PackageVersionReqError),
628}
629
630impl TryFrom<&Option<String>> for LockConstraint {
631 type Error = LockConstraintParseError;
632
633 fn try_from(constraint: &Option<String>) -> Result<Self, Self::Error> {
634 match constraint {
635 Some(constraint) => {
636 let package_version_req = constraint.parse()?;
637 Ok(LockConstraint::Constrained(package_version_req))
638 }
639 None => Ok(LockConstraint::Unconstrained),
640 }
641 }
642}
643
644pub trait LockfilePermissions {}
645#[derive(Clone)]
646pub struct ReadOnly;
647#[derive(Clone)]
648pub struct ReadWrite;
649
650impl LockfilePermissions for ReadOnly {}
651impl LockfilePermissions for ReadWrite {}
652
653#[derive(Clone, Debug, Serialize, Deserialize, Default)]
654pub(crate) struct PackageLock {
655 rocks: BTreeMap<LockedPackageId, LockedPackage>,
658
659 entrypoints: BTreeMap<PackageName, LockedPackageId>,
661}
662
663impl PackageLock {
664 pub(crate) fn from_packages(
665 packages: impl Iterator<Item = (LockedPackage, EntryType)>,
666 ) -> Self {
667 let mut lock = Self::default();
668
669 for (package, entry_type) in packages {
670 if matches!(entry_type, EntryType::Entrypoint) {
671 lock.entrypoints
672 .insert(package.name().clone(), package.id());
673 }
674 lock.rocks.insert(package.id(), package);
675 }
676
677 lock
678 }
679
680 pub(crate) fn merge(&mut self, other: &PackageLock) {
681 self.entrypoints.extend(
682 other
683 .entrypoints
684 .iter()
685 .map(|(name, id)| (name.clone(), id.clone())),
686 );
687 self.rocks.extend(
688 other
689 .rocks
690 .iter()
691 .map(|(id, package)| (id.clone(), package.clone())),
692 );
693 }
694
695 fn get(&self, id: &LockedPackageId) -> Option<&LockedPackage> {
696 self.rocks.get(id)
697 }
698
699 fn is_empty(&self) -> bool {
700 self.rocks.is_empty()
701 }
702
703 pub(crate) fn rocks(&self) -> &BTreeMap<LockedPackageId, LockedPackage> {
704 &self.rocks
705 }
706
707 fn is_entrypoint(&self, package: &LockedPackageId) -> bool {
708 self.entrypoints.values().any(|id| id == package)
709 }
710
711 pub(crate) fn entrypoint(&self, name: &PackageName) -> Option<&LockedPackage> {
713 self.entrypoints.get(name).and_then(|id| self.get(id))
714 }
715
716 fn is_dependency(&self, package: &LockedPackageId) -> bool {
717 self.rocks
718 .values()
719 .flat_map(|rock| rock.dependencies())
720 .any(|dep_id| dep_id == package)
721 }
722
723 fn list(&self) -> HashMap<PackageName, Vec<LockedPackage>> {
724 self.rocks()
725 .values()
726 .cloned()
727 .map(|locked_rock| (locked_rock.name().clone(), locked_rock))
728 .into_group_map()
729 }
730
731 fn remove(&mut self, target: &LockedPackage) {
732 self.remove_by_id(&target.id())
733 }
734
735 fn remove_by_id(&mut self, target: &LockedPackageId) {
736 self.rocks.remove(target);
737 self.entrypoints.retain(|_, id| id != target);
738 }
739
740 pub(crate) fn has_rock(
741 &self,
742 req: &PackageReq,
743 filter: Option<RemotePackageTypeFilterSpec>,
744 ) -> Option<LockedPackage> {
745 self.list()
746 .get(req.name())
747 .map(|packages| {
748 packages
749 .iter()
750 .filter(|package| match &filter {
751 Some(filter_spec) => match &package.source {
752 RemotePackageSource::LuarocksRockspec(_) => filter_spec.rockspec,
753 RemotePackageSource::LuarocksSrcRock(_) => filter_spec.src,
754 RemotePackageSource::LuarocksBinaryRock(_) => filter_spec.binary,
755 RemotePackageSource::Wally(_) => true,
756 RemotePackageSource::RockspecContent(_) => true,
757 RemotePackageSource::Local => true,
758 #[cfg(test)]
759 RemotePackageSource::Test => unimplemented!(),
760 },
761 None => true,
762 })
763 .rev()
764 .find(|package| req.version_req().matches(package.version()))
765 })?
766 .cloned()
767 }
768
769 fn has_rock_with_equal_constraint(&self, req: &LuaDependencySpec) -> Option<LockedPackage> {
770 self.list()
771 .get(req.name())
772 .map(|packages| {
773 packages
774 .iter()
775 .rev()
776 .find(|package| package.constraint().matches_version_req(req.version_req()))
777 })?
778 .cloned()
779 }
780
781 pub(crate) fn package_sync_spec(
788 &self,
789 packages: &[LuaDependencySpec],
790 strategy: &SyncStrategy<'_>,
791 ) -> PackageSyncSpec {
792 let pkg_dir_exists = |pkg: &LockedPackage| match strategy {
793 SyncStrategy::LockfileOnly => true,
794 SyncStrategy::EnsureInstalled(tree) => tree.layout_for(pkg.spec()).root.is_dir(),
795 };
796
797 let entrypoints_to_keep: HashSet<LockedPackage> = self
798 .entrypoints
799 .values()
800 .filter_map(|id| self.get(id))
801 .filter(|local_pkg| {
802 packages.iter().any(|req| {
803 local_pkg
804 .constraint()
805 .matches_version_req(req.version_req())
806 })
807 })
808 .cloned()
809 .collect();
810
811 let packages_to_keep: HashSet<&LockedPackage> = entrypoints_to_keep
812 .iter()
813 .flat_map(|local_pkg| self.get_all_dependencies(&local_pkg.id()))
814 .collect();
815
816 let to_add = packages
817 .iter()
818 .filter(|pkg| {
819 self.has_rock_with_equal_constraint(pkg)
820 .map(|local_pkg| !pkg_dir_exists(&local_pkg))
821 .unwrap_or(true)
822 })
823 .cloned()
824 .collect_vec();
825
826 let to_remove = self
827 .rocks()
828 .values()
829 .filter(|pkg| !packages_to_keep.contains(*pkg))
830 .cloned()
831 .collect_vec();
832
833 PackageSyncSpec { to_add, to_remove }
834 }
835
836 fn get_all_dependencies(&self, id: &LockedPackageId) -> HashSet<&LockedPackage> {
838 let mut packages = HashSet::new();
839 if let Some(local_pkg) = self.get(id) {
840 packages.insert(local_pkg);
841 packages.extend(
842 local_pkg
843 .dependencies()
844 .iter()
845 .flat_map(|id| self.get_all_dependencies(id)),
846 );
847 }
848 packages
849 }
850
851 fn reachable(&self) -> HashSet<&LockedPackage> {
854 self.entrypoints
855 .values()
856 .flat_map(|id| self.get_all_dependencies(id))
857 .collect()
858 }
859}
860
861#[derive(Clone, Debug, Serialize, Deserialize)]
863pub struct Lockfile<P: LockfilePermissions> {
864 #[serde(skip)]
865 filepath: PathBuf,
866 #[serde(skip)]
867 _marker: PhantomData<P>,
868 version: String,
870 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
871 binaries: BTreeMap<LockedPackageId, RockBinaries>,
872 #[serde(flatten)]
873 lock: PackageLock,
874}
875
876#[derive(EnumIter, Debug, Clone, Copy, PartialEq, Eq, Hash)]
877pub enum LockedPackageLockType {
878 Regular,
879 Test,
880 Build,
881}
882
883#[derive(Clone, Debug, Serialize, Deserialize)]
885pub struct WorkspaceLockfile<P: LockfilePermissions> {
886 #[serde(skip)]
887 filepath: PathBuf,
888 #[serde(skip)]
889 _marker: PhantomData<P>,
890 version: String,
891 #[serde(default, skip_serializing_if = "PackageLock::is_empty")]
892 dependencies: PackageLock,
893 #[serde(default, skip_serializing_if = "PackageLock::is_empty")]
894 test_dependencies: PackageLock,
895 #[serde(default, skip_serializing_if = "PackageLock::is_empty")]
896 build_dependencies: PackageLock,
897}
898
899#[derive(Error, Debug, Diagnostic)]
900#[non_exhaustive]
901pub enum LockfileError {
902 #[error(transparent)]
903 #[diagnostic(transparent)]
904 Fs(#[from] fs::FsError),
905 #[error("error parsing lockfile from JSON")]
906 ParseJson(#[source] serde_json::Error),
907 #[error("error writing lockfile to JSON")]
908 WriteJson(#[source] serde_json::Error),
909 #[error("invalid version string in lockfile: {version}")]
910 #[diagnostic(help("did you or a tool modify the lockfile manually?"))]
911 InvalidVersion {
912 version: String,
913 source: semver::Error,
914 },
915 #[error("lockfile '{}' has no `version` field", lockfile.display())]
916 #[diagnostic(
917 code(lux_lib::lockfile::missing_version),
918 help("the lockfile is corrupt or was hand-edited")
919 )]
920 MissingVersion { lockfile: PathBuf },
921 #[error(
922 "incompatible lockfile version in '{}': expected {expected}, found {found}",
923 lockfile.display()
924 )]
925 #[diagnostic(code(lux_lib::lockfile::incompatible_version), help("{help}"))]
926 IncompatibleVersion {
927 lockfile: PathBuf,
928 found: String,
929 expected: String,
930 help: String,
931 },
932 #[error("cannot migrate lockfile '{}'", lockfile.display())]
933 #[diagnostic(code(lux_lib::lockfile::conflicting_entrypoints), help("{help}"))]
934 ConflictingEntrypoints { lockfile: PathBuf, help: String },
935}
936
937#[derive(Error, Debug, Diagnostic)]
938#[non_exhaustive]
939pub enum LockfileIntegrityError {
940 #[error(
941 r#"rockspec integrity mismatch.
942- source: {src}
943- expected: {expected}
944- got: {got}
945"#
946 )]
947 #[diagnostic(help(
948 r#"the maintainer may have force-uploaded a different rockspec.
949check the rockspec source, then rerun the command with `--no-lock` to update the hash.
950"#
951 ))]
952 RockspecIntegrityMismatch {
953 src: String,
954 expected: Integrity,
955 got: Integrity,
956 },
957 #[error(
958 r#"source integrity mismatch.
959- source: {src}
960- expected: {expected}
961- got: {got}"#
962 )]
963 #[diagnostic(help(
964 r#"the maintainer may have force-uploaded a different source or moved a tag.
965check the source URL, then rerun the command with `lx --no-lock` to update the hash.
966"#
967 ))]
968 SourceIntegrityMismatch {
969 src: String,
970 expected: Integrity,
971 got: Integrity,
972 },
973 #[error(
974 "package {0} version {1} with pinned state {2} and constraint {3} not found in the lockfile."
975 )]
976 PackageNotFound(PackageName, Box<PackageVersion>, PinnedState, String),
977}
978
979#[derive(Error, Debug, Diagnostic)]
980#[non_exhaustive]
981#[error("error flushing the lockfile ({})", .filepath.display())]
982#[diagnostic(help("make sure the parent directory is writeable"))]
983pub struct FlushLockfileError {
984 filepath: PathBuf,
985 source: io::Error,
986}
987
988#[derive(Debug, Default)]
990pub(crate) struct PackageSyncSpec {
991 pub to_add: Vec<LuaDependencySpec>,
992 pub to_remove: Vec<LockedPackage>,
993}
994
995pub(crate) enum SyncStrategy<'a> {
997 LockfileOnly,
999 EnsureInstalled(&'a Tree),
1002}
1003
1004impl<P: LockfilePermissions> Lockfile<P> {
1005 pub fn version(&self) -> &String {
1006 &self.version
1007 }
1008
1009 pub fn rocks(&self) -> &BTreeMap<LockedPackageId, LockedPackage> {
1010 self.lock.rocks()
1011 }
1012
1013 pub fn is_dependency(&self, package: &LockedPackageId) -> bool {
1014 self.lock.is_dependency(package)
1015 }
1016
1017 pub fn is_entrypoint(&self, package: &LockedPackageId) -> bool {
1018 self.lock.is_entrypoint(package)
1019 }
1020
1021 pub fn entrypoint(&self, name: &PackageName) -> Option<&LockedPackage> {
1023 self.lock.entrypoint(name)
1024 }
1025
1026 pub fn reachable_rocks(&self) -> Vec<LockedPackage> {
1029 self.lock.reachable().into_iter().cloned().collect()
1030 }
1031
1032 pub fn entry_type(&self, package: &LockedPackageId) -> EntryType {
1033 if self.lock.is_entrypoint(package) {
1034 EntryType::Entrypoint
1035 } else {
1036 EntryType::DependencyOnly
1037 }
1038 }
1039
1040 pub(crate) fn binaries(&self, id: &LockedPackageId) -> Option<&RockBinaries> {
1042 self.binaries.get(id)
1043 }
1044
1045 pub(crate) fn local_pkg_lock(&self) -> &PackageLock {
1046 &self.lock
1047 }
1048
1049 pub fn get(&self, id: &LockedPackageId) -> Option<&LockedPackage> {
1050 self.lock.get(id)
1051 }
1052
1053 pub unsafe fn get_unchecked(&self, id: &LockedPackageId) -> &LockedPackage {
1059 self.lock.get(id).unwrap_unchecked()
1060 }
1061
1062 pub(crate) fn list(&self) -> HashMap<PackageName, Vec<LockedPackage>> {
1063 self.lock.list()
1064 }
1065
1066 pub(crate) fn has_rock(
1067 &self,
1068 req: &PackageReq,
1069 filter: Option<RemotePackageTypeFilterSpec>,
1070 ) -> Option<LockedPackage> {
1071 self.lock.has_rock(req, filter)
1072 }
1073
1074 pub(crate) fn find_rocks(&self, req: &PackageReq) -> Vec<LockedPackageId> {
1076 match self.list().get(req.name()) {
1077 Some(packages) => packages
1078 .iter()
1079 .rev()
1080 .filter(|package| req.version_req().matches(package.version()))
1081 .map(|package| package.id())
1082 .collect_vec(),
1083 None => Vec::default(),
1084 }
1085 }
1086
1087 pub(crate) fn validate_integrity(
1089 &self,
1090 expected_package: &LockedPackage,
1091 ) -> Result<(), LockfileIntegrityError> {
1092 match self.list().get(expected_package.name()) {
1095 None => Err(integrity_err_not_found(expected_package)),
1096 Some(rocks) => match rocks
1097 .iter()
1098 .find(|rock| rock.version() == expected_package.version())
1099 {
1100 None => Err(integrity_err_not_found(expected_package)),
1101 Some(installed_package) => {
1102 if expected_package
1103 .hashes
1104 .rockspec
1105 .matches(&installed_package.hashes.rockspec)
1106 .is_none()
1107 {
1108 return Err(LockfileIntegrityError::RockspecIntegrityMismatch {
1109 src: expected_package.source.to_string(),
1110 expected: expected_package.hashes.rockspec.clone(),
1111 got: installed_package.hashes.rockspec.clone(),
1112 });
1113 }
1114 if expected_package
1115 .hashes
1116 .source
1117 .matches(&installed_package.hashes.source)
1118 .is_none()
1119 {
1120 return Err(LockfileIntegrityError::SourceIntegrityMismatch {
1121 src: expected_package
1122 .source_url
1123 .as_ref()
1124 .map(|url| url.to_string())
1125 .unwrap_or(expected_package.source.to_string()),
1126 expected: expected_package.hashes.source.clone(),
1127 got: installed_package.hashes.source.clone(),
1128 });
1129 }
1130 Ok(())
1131 }
1132 },
1133 }
1134 }
1135
1136 fn flush(&self) -> Result<(), FlushLockfileError> {
1137 let content = serde_json::to_string_pretty(&self).map_err(|err| FlushLockfileError {
1138 filepath: self.filepath.to_path_buf(),
1139 source: io::Error::other(err),
1140 })?;
1141
1142 fs::sync::write_atomic(&self.filepath, content).map_err(|err| FlushLockfileError {
1143 filepath: self.filepath.to_path_buf(),
1144 source: io::Error::other(err),
1145 })
1146 }
1147}
1148
1149impl<P: LockfilePermissions> WorkspaceLockfile<P> {
1150 pub(crate) fn rocks(
1151 &self,
1152 deps: &LockedPackageLockType,
1153 ) -> &BTreeMap<LockedPackageId, LockedPackage> {
1154 match deps {
1155 LockedPackageLockType::Regular => self.dependencies.rocks(),
1156 LockedPackageLockType::Test => self.test_dependencies.rocks(),
1157 LockedPackageLockType::Build => self.build_dependencies.rocks(),
1158 }
1159 }
1160
1161 pub(crate) fn get(
1162 &self,
1163 id: &LockedPackageId,
1164 deps: &LockedPackageLockType,
1165 ) -> Option<&LockedPackage> {
1166 match deps {
1167 LockedPackageLockType::Regular => self.dependencies.get(id),
1168 LockedPackageLockType::Test => self.test_dependencies.get(id),
1169 LockedPackageLockType::Build => self.build_dependencies.get(id),
1170 }
1171 }
1172
1173 pub(crate) fn is_entrypoint(
1174 &self,
1175 package: &LockedPackageId,
1176 deps: &LockedPackageLockType,
1177 ) -> bool {
1178 match deps {
1179 LockedPackageLockType::Regular => self.dependencies.is_entrypoint(package),
1180 LockedPackageLockType::Test => self.test_dependencies.is_entrypoint(package),
1181 LockedPackageLockType::Build => self.build_dependencies.is_entrypoint(package),
1182 }
1183 }
1184
1185 pub(crate) fn package_sync_spec(
1186 &self,
1187 packages: &[LuaDependencySpec],
1188 deps: &LockedPackageLockType,
1189 strategy: &SyncStrategy<'_>,
1190 ) -> PackageSyncSpec {
1191 match deps {
1192 LockedPackageLockType::Regular => {
1193 self.dependencies.package_sync_spec(packages, strategy)
1194 }
1195 LockedPackageLockType::Test => {
1196 self.test_dependencies.package_sync_spec(packages, strategy)
1197 }
1198 LockedPackageLockType::Build => self
1199 .build_dependencies
1200 .package_sync_spec(packages, strategy),
1201 }
1202 }
1203
1204 pub(crate) fn local_pkg_lock(&self, deps: &LockedPackageLockType) -> &PackageLock {
1205 match deps {
1206 LockedPackageLockType::Regular => &self.dependencies,
1207 LockedPackageLockType::Test => &self.test_dependencies,
1208 LockedPackageLockType::Build => &self.build_dependencies,
1209 }
1210 }
1211
1212 pub(crate) fn local_pkg_locks(&self) -> Vec<PackageLock> {
1213 vec![
1214 self.dependencies.clone(),
1215 self.test_dependencies.clone(),
1216 self.build_dependencies.clone(),
1217 ]
1218 }
1219
1220 fn flush(&self) -> io::Result<()> {
1221 let content = serde_json::to_string_pretty(&self)?;
1222
1223 fs::sync::write_atomic(&self.filepath, content).map_err(io::Error::other)?;
1224
1225 Ok(())
1226 }
1227}
1228
1229impl Lockfile<ReadOnly> {
1230 #[tracing::instrument(level = "trace")]
1232 pub(crate) fn new(filepath: PathBuf) -> Result<Lockfile<ReadOnly>, LockfileError> {
1233 match File::options().create_new(true).write(true).open(&filepath) {
1235 Ok(mut file) => {
1236 let empty_lockfile: Lockfile<ReadOnly> = Lockfile {
1237 filepath: filepath.clone(),
1238 _marker: PhantomData,
1239 version: LOCKFILE_VERSION_STR.into(),
1240 binaries: BTreeMap::new(),
1241 lock: PackageLock::default(),
1242 };
1243 let json_str =
1244 serde_json::to_string(&empty_lockfile).map_err(LockfileError::WriteJson)?;
1245 write!(file, "{json_str}").map_err(|source| fs::FsError::Write {
1246 path: filepath.to_path_buf(),
1247 source,
1248 })?;
1249 }
1250 Err(err) if err.kind() == ErrorKind::AlreadyExists => {}
1251 Err(source) => {
1252 return Err(LockfileError::Fs(fs::FsError::FileOpen {
1253 path: filepath.to_path_buf(),
1254 source,
1255 }));
1256 }
1257 }
1258
1259 Self::load(filepath)
1260 }
1261
1262 #[tracing::instrument(level = "trace")]
1264 pub fn load(filepath: PathBuf) -> Result<Lockfile<ReadOnly>, LockfileError> {
1265 let content = fs::sync::read_to_string(&filepath)?;
1266 let tree_dir = filepath.parent().unwrap_or(filepath.as_path());
1267 let help = format!(
1268 "this install tree was created by an incompatible version of Lux.
1269remove the tree at '{}' and reinstall all packages it contained.",
1270 tree_dir.display()
1271 );
1272 let mut lockfile: Lockfile<ReadOnly> = load_lockfile(&content, &filepath, &help)?;
1273 lockfile.filepath = filepath;
1274 Ok(lockfile)
1275 }
1276
1277 pub(crate) fn into_temporary(self) -> Lockfile<ReadWrite> {
1279 Lockfile::<ReadWrite> {
1280 _marker: PhantomData,
1281 filepath: self.filepath,
1282 version: self.version,
1283 binaries: self.binaries,
1284 lock: self.lock,
1285 }
1286 }
1287
1288 pub fn write_guard(self) -> LockfileGuard {
1291 LockfileGuard(self.into_temporary())
1292 }
1293
1294 #[tracing::instrument(level = "trace", skip_all)]
1297 pub fn map_then_flush<T, F, E>(self, cb: F) -> Result<T, FlushLockfileError>
1298 where
1299 F: FnOnce(&mut Lockfile<ReadWrite>) -> Result<T, E>,
1300 E: Error,
1301 E: From<io::Error>,
1302 E: Into<Box<dyn Error + Send + Sync>>,
1303 {
1304 let mut writeable_lockfile = self.into_temporary();
1305
1306 let result = cb(&mut writeable_lockfile).map_err(|err| FlushLockfileError {
1307 filepath: writeable_lockfile.filepath.to_path_buf(),
1308 source: io::Error::other(err),
1309 })?;
1310
1311 writeable_lockfile.flush()?;
1312
1313 Ok(result)
1314 }
1315
1316 }
1334
1335impl WorkspaceLockfile<ReadOnly> {
1336 #[tracing::instrument(level = "trace")]
1338 pub fn new(filepath: PathBuf) -> Result<WorkspaceLockfile<ReadOnly>, LockfileError> {
1339 match File::options().create_new(true).write(true).open(&filepath) {
1341 Ok(mut file) => {
1342 let empty_lockfile: WorkspaceLockfile<ReadOnly> = WorkspaceLockfile {
1343 filepath: filepath.clone(),
1344 _marker: PhantomData,
1345 version: LOCKFILE_VERSION_STR.into(),
1346 dependencies: PackageLock::default(),
1347 test_dependencies: PackageLock::default(),
1348 build_dependencies: PackageLock::default(),
1349 };
1350 let json_str =
1351 serde_json::to_string(&empty_lockfile).map_err(LockfileError::WriteJson)?;
1352 write!(file, "{json_str}").map_err(|source| fs::FsError::Write {
1353 path: filepath.to_path_buf(),
1354 source,
1355 })?;
1356 }
1357 Err(err) if err.kind() == ErrorKind::AlreadyExists => {}
1358 Err(source) => {
1359 return Err(LockfileError::Fs(fs::FsError::FileOpen {
1360 path: filepath.to_path_buf(),
1361 source,
1362 }));
1363 }
1364 }
1365
1366 Self::load(filepath)
1367 }
1368
1369 #[tracing::instrument(level = "trace")]
1371 pub fn load(filepath: PathBuf) -> Result<WorkspaceLockfile<ReadOnly>, LockfileError> {
1372 let content = fs::sync::read_to_string(&filepath)?;
1373 let mut lockfile: WorkspaceLockfile<ReadOnly> = load_lockfile(
1374 &content,
1375 &filepath,
1376 "this lockfile was created by an incompatible version of Lux.
1377remove the `lux.lock` and run `lx sync` to regenerate it.",
1378 )?;
1379
1380 lockfile.filepath = filepath;
1381
1382 Ok(lockfile)
1383 }
1384
1385 fn into_temporary(self) -> WorkspaceLockfile<ReadWrite> {
1387 WorkspaceLockfile::<ReadWrite> {
1388 _marker: PhantomData,
1389 filepath: self.filepath,
1390 version: self.version,
1391 dependencies: self.dependencies,
1392 test_dependencies: self.test_dependencies,
1393 build_dependencies: self.build_dependencies,
1394 }
1395 }
1396
1397 pub fn write_guard(self) -> ProjectLockfileGuard {
1400 ProjectLockfileGuard(self.into_temporary())
1401 }
1402}
1403
1404impl Lockfile<ReadWrite> {
1405 pub(crate) fn add_entrypoint(&mut self, rock: &LockedPackage) {
1406 self.add(rock);
1407 self.lock.entrypoints.insert(rock.name().clone(), rock.id());
1408 }
1409
1410 pub(crate) fn add(&mut self, rock: &LockedPackage) {
1411 self.lock
1414 .rocks
1415 .entry(rock.id())
1416 .or_insert_with(|| rock.clone());
1417 }
1418
1419 pub(crate) fn add_dependency(&mut self, target: &LockedPackage, dependency: &LockedPackage) {
1421 self.lock
1422 .rocks
1423 .entry(target.id())
1424 .and_modify(|rock| {
1425 let id = dependency.id();
1426 if !rock.spec.dependencies.contains(&id) {
1427 rock.spec.dependencies.push(id);
1428 }
1429 })
1430 .or_insert_with(|| {
1431 let id = dependency.id();
1432 let mut target = target.clone();
1433 if !target.spec.dependencies.contains(&id) {
1434 target.spec.dependencies.push(id);
1435 }
1436 target
1437 });
1438 self.add(dependency);
1439 }
1440
1441 pub(crate) fn add_build_dependency(
1443 &mut self,
1444 target: &LockedPackage,
1445 dependency: &LockedPackage,
1446 ) {
1447 self.lock
1448 .rocks
1449 .entry(target.id())
1450 .and_modify(|rock| {
1451 let id = dependency.id();
1452 if !rock.spec.build_dependencies.contains(&id) {
1453 rock.spec.build_dependencies.push(id);
1454 }
1455 })
1456 .or_insert_with(|| {
1457 let id = dependency.id();
1458 let mut target = target.clone();
1459 if !target.spec.build_dependencies.contains(&id) {
1460 target.spec.build_dependencies.push(id);
1461 }
1462 target
1463 });
1464 }
1465
1466 pub(crate) fn set_binaries(&mut self, package: &LockedPackage, binaries: RockBinaries) {
1468 self.binaries.insert(package.id(), binaries);
1469 }
1470
1471 pub(crate) fn remove(&mut self, target: &LockedPackage) {
1472 self.binaries.remove(&target.id());
1473 self.lock.remove(target)
1474 }
1475
1476 pub(crate) fn remove_by_id(&mut self, target: &LockedPackageId) {
1477 self.binaries.remove(target);
1478 self.lock.remove_by_id(target)
1479 }
1480
1481 pub(crate) fn sync(&mut self, lock: &PackageLock) {
1482 self.lock = lock.clone();
1483 }
1484
1485 }
1487
1488impl WorkspaceLockfile<ReadWrite> {
1489 pub(crate) fn remove(&mut self, target: &LockedPackage, deps: &LockedPackageLockType) {
1490 match deps {
1491 LockedPackageLockType::Regular => self.dependencies.remove(target),
1492 LockedPackageLockType::Test => self.test_dependencies.remove(target),
1493 LockedPackageLockType::Build => self.build_dependencies.remove(target),
1494 }
1495 }
1496
1497 pub(crate) fn sync(&mut self, lock: &PackageLock, deps: &LockedPackageLockType) {
1498 match deps {
1499 LockedPackageLockType::Regular => {
1500 self.dependencies = lock.clone();
1501 }
1502 LockedPackageLockType::Test => {
1503 self.test_dependencies = lock.clone();
1504 }
1505 LockedPackageLockType::Build => {
1506 self.build_dependencies = lock.clone();
1507 }
1508 }
1509 }
1510
1511 pub(crate) fn merge(&mut self, lock: &PackageLock, deps: &LockedPackageLockType) {
1512 match deps {
1513 LockedPackageLockType::Regular => self.dependencies.merge(lock),
1514 LockedPackageLockType::Test => self.test_dependencies.merge(lock),
1515 LockedPackageLockType::Build => self.build_dependencies.merge(lock),
1516 }
1517 }
1518}
1519
1520pub struct LockfileGuard(Lockfile<ReadWrite>);
1522
1523pub struct ProjectLockfileGuard(WorkspaceLockfile<ReadWrite>);
1524
1525impl Serialize for LockfileGuard {
1526 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1527 where
1528 S: serde::Serializer,
1529 {
1530 self.0.serialize(serializer)
1531 }
1532}
1533
1534impl Serialize for ProjectLockfileGuard {
1535 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1536 where
1537 S: serde::Serializer,
1538 {
1539 self.0.serialize(serializer)
1540 }
1541}
1542
1543impl<'de> Deserialize<'de> for LockfileGuard {
1544 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1545 where
1546 D: serde::Deserializer<'de>,
1547 {
1548 Ok(LockfileGuard(Lockfile::<ReadWrite>::deserialize(
1549 deserializer,
1550 )?))
1551 }
1552}
1553
1554impl<'de> Deserialize<'de> for ProjectLockfileGuard {
1555 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1556 where
1557 D: serde::Deserializer<'de>,
1558 {
1559 Ok(ProjectLockfileGuard(
1560 WorkspaceLockfile::<ReadWrite>::deserialize(deserializer)?,
1561 ))
1562 }
1563}
1564
1565impl Deref for LockfileGuard {
1566 type Target = Lockfile<ReadWrite>;
1567
1568 fn deref(&self) -> &Self::Target {
1569 &self.0
1570 }
1571}
1572
1573impl Deref for ProjectLockfileGuard {
1574 type Target = WorkspaceLockfile<ReadWrite>;
1575
1576 fn deref(&self) -> &Self::Target {
1577 &self.0
1578 }
1579}
1580
1581impl DerefMut for LockfileGuard {
1582 fn deref_mut(&mut self) -> &mut Self::Target {
1583 &mut self.0
1584 }
1585}
1586
1587impl DerefMut for ProjectLockfileGuard {
1588 fn deref_mut(&mut self) -> &mut Self::Target {
1589 &mut self.0
1590 }
1591}
1592
1593impl Drop for LockfileGuard {
1594 fn drop(&mut self) {
1595 let _ = self.flush();
1596 }
1597}
1598
1599impl Drop for ProjectLockfileGuard {
1600 fn drop(&mut self) {
1601 let _ = self.flush();
1602 }
1603}
1604
1605fn load_lockfile<T>(content: &str, lockfile: &Path, help: &str) -> Result<T, LockfileError>
1606where
1607 T: serde::de::DeserializeOwned,
1608{
1609 let mut value = parse_lockfile_json(content)?;
1610 if lockfile_version(&value, lockfile)?.major == 1 {
1611 migrate_v1_lockfile(&mut value, lockfile)?;
1612 }
1613 deserialize_lockfile(value, lockfile, help)
1614}
1615
1616fn parse_lockfile_json(
1617 content: &str,
1618) -> Result<serde_json::Map<String, serde_json::Value>, LockfileError> {
1619 serde_json::from_str(content).map_err(LockfileError::ParseJson)
1620}
1621
1622fn lockfile_version(
1623 value: &serde_json::Map<String, serde_json::Value>,
1624 lockfile: &Path,
1625) -> Result<semver::Version, LockfileError> {
1626 let version = value
1627 .get("version")
1628 .and_then(|version| version.as_str())
1629 .ok_or(LockfileError::MissingVersion {
1630 lockfile: lockfile.to_path_buf(),
1631 })?;
1632 semver::Version::parse(version).map_err(|source| LockfileError::InvalidVersion {
1633 version: version.to_string(),
1634 source,
1635 })
1636}
1637
1638fn deserialize_lockfile<T>(
1639 value: serde_json::Map<String, serde_json::Value>,
1640 lockfile: &Path,
1641 help: &str,
1642) -> Result<T, LockfileError>
1643where
1644 T: serde::de::DeserializeOwned,
1645{
1646 let found = lockfile_version(&value, lockfile)?;
1647 if found.major != LOCKFILE_VERSION {
1648 return Err(LockfileError::IncompatibleVersion {
1649 lockfile: lockfile.to_path_buf(),
1650 found: found.to_string(),
1651 expected: LOCKFILE_VERSION_STR.into(),
1652 help: help.into(),
1653 });
1654 }
1655 serde_json::from_value(serde_json::Value::Object(value)).map_err(LockfileError::ParseJson)
1656}
1657
1658fn migrate_v1_lockfile(
1659 value: &mut serde_json::Map<String, serde_json::Value>,
1660 lockfile: &Path,
1661) -> Result<(), LockfileError> {
1662 let tree_dir = lockfile.parent().unwrap_or(lockfile);
1663 let found = lockfile_version(value, lockfile)?;
1664 if value.contains_key("entrypoint_layout") {
1665 return Err(LockfileError::IncompatibleVersion {
1666 lockfile: lockfile.to_path_buf(),
1667 found: found.to_string(),
1668 expected: LOCKFILE_VERSION_STR.into(),
1669 help: format!(
1670 "this install tree uses a custom entrypoint layout that cannot be migrated automatically.
1671remove the tree at '{}' and reinstall all packages it contained.",
1672 tree_dir.display()
1673 ),
1674 });
1675 }
1676
1677 let conflict_help = format!(
1678 "this lockfile contains entrypoints that cannot be represented in the current format.
1679remove the tree at '{}' and reinstall all packages it contained.",
1680 tree_dir.display()
1681 );
1682
1683 if value.contains_key("entrypoints") {
1684 migrate_entrypoints(value, lockfile, &conflict_help)?;
1685 }
1686 for section in ["dependencies", "test_dependencies", "build_dependencies"] {
1687 if let Some(section) = value
1688 .get_mut(section)
1689 .and_then(|section| section.as_object_mut())
1690 {
1691 migrate_entrypoints(section, lockfile, &conflict_help)?;
1692 }
1693 }
1694 value.insert(
1695 "version".into(),
1696 serde_json::Value::String(LOCKFILE_VERSION_STR.into()),
1697 );
1698 Ok(())
1699}
1700
1701fn migrate_entrypoints(
1702 section: &mut serde_json::Map<String, serde_json::Value>,
1703 lockfile: &Path,
1704 help: &str,
1705) -> Result<(), LockfileError> {
1706 let conflicting_entrypoints = || LockfileError::ConflictingEntrypoints {
1707 lockfile: lockfile.to_path_buf(),
1708 help: help.to_string(),
1709 };
1710
1711 let rocks = section.get("rocks").and_then(|rocks| rocks.as_object());
1712 let mut entrypoints = serde_json::Map::new();
1713 if let Some(old_entrypoints) = section.get("entrypoints") {
1714 for id in old_entrypoints
1715 .as_array()
1716 .ok_or_else(&conflicting_entrypoints)?
1717 {
1718 let id = id.as_str().ok_or_else(&conflicting_entrypoints)?;
1719 let name = rocks
1720 .and_then(|rocks| rocks.get(id))
1721 .and_then(|package| package.get("name"))
1722 .and_then(|name| name.as_str())
1723 .ok_or_else(&conflicting_entrypoints)?;
1724 if entrypoints.contains_key(name) {
1725 return Err(conflicting_entrypoints());
1726 }
1727 entrypoints.insert(name.to_string(), serde_json::Value::String(id.to_string()));
1728 }
1729 }
1730 section.insert("entrypoints".into(), serde_json::Value::Object(entrypoints));
1731 Ok(())
1732}
1733
1734fn integrity_err_not_found(package: &LockedPackage) -> LockfileIntegrityError {
1735 LockfileIntegrityError::PackageNotFound(
1736 package.name().clone(),
1737 Box::new(package.version().clone()),
1738 package.spec.pinned,
1739 package
1740 .spec
1741 .constraint
1742 .clone()
1743 .unwrap_or("UNCONSTRAINED".into()),
1744 )
1745}
1746
1747fn deserialize_url<'de, D>(deserializer: D) -> Result<Url, D::Error>
1748where
1749 D: serde::Deserializer<'de>,
1750{
1751 let s = String::deserialize(deserializer)?;
1752 Url::parse(&s).map_err(serde::de::Error::custom)
1753}
1754
1755fn serialize_url<S>(url: &Url, serializer: S) -> Result<S::Ok, S::Error>
1756where
1757 S: Serializer,
1758{
1759 url.as_str().serialize(serializer)
1760}
1761
1762#[cfg(test)]
1763mod tests {
1764 use super::*;
1765 use std::{fs::remove_file, path::PathBuf};
1766
1767 use assert_fs::fixture::PathCopy;
1768 use insta::{assert_json_snapshot, sorted_redaction};
1769
1770 use crate::{config::ConfigBuilder, lua_version::LuaVersion, package::PackageSpec};
1771
1772 #[test]
1773 fn parse_lockfile() {
1774 let temp = assert_fs::TempDir::new().unwrap();
1775 temp.copy_from(
1776 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-tree"),
1777 &["**"],
1778 )
1779 .unwrap();
1780
1781 let config = ConfigBuilder::new()
1782 .unwrap()
1783 .user_tree(Some(temp.to_path_buf()))
1784 .build()
1785 .unwrap();
1786 let tree = config.user_tree(LuaVersion::Lua51).unwrap();
1787 let lockfile = tree.lockfile().unwrap();
1788
1789 assert_json_snapshot!(lockfile, { ".**" => sorted_redaction() });
1790 }
1791
1792 #[test]
1793 fn add_rocks() {
1794 let temp = assert_fs::TempDir::new().unwrap();
1795 temp.copy_from(
1796 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-tree"),
1797 &["**"],
1798 )
1799 .unwrap();
1800
1801 let mock_hashes = LockedPackageHashes {
1802 rockspec: "sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="
1803 .parse()
1804 .unwrap(),
1805 source: "sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="
1806 .parse()
1807 .unwrap(),
1808 };
1809
1810 let config = ConfigBuilder::new()
1811 .unwrap()
1812 .user_tree(Some(temp.to_path_buf()))
1813 .build()
1814 .unwrap();
1815 let tree = config.user_tree(LuaVersion::Lua51).unwrap();
1816 let mut lockfile = tree.lockfile().unwrap().write_guard();
1817
1818 let test_package = PackageSpec::parse("test1".to_string(), "0.1.0".to_string()).unwrap();
1819 let test_local_package = LockedPackage::from(
1820 &test_package,
1821 crate::lockfile::LockConstraint::Unconstrained,
1822 RemotePackageSource::Test,
1823 None,
1824 mock_hashes.clone(),
1825 );
1826 lockfile.add_entrypoint(&test_local_package);
1827
1828 let test_dep_package =
1829 PackageSpec::parse("test2".to_string(), "0.1.0".to_string()).unwrap();
1830 let mut test_local_dep_package = LockedPackage::from(
1831 &test_dep_package,
1832 crate::lockfile::LockConstraint::Constrained(">= 1.0.0".parse().unwrap()),
1833 RemotePackageSource::Test,
1834 None,
1835 mock_hashes.clone(),
1836 );
1837 test_local_dep_package.spec.pinned = PinnedState::Pinned;
1838 lockfile.add_dependency(&test_local_package, &test_local_dep_package);
1839
1840 assert_json_snapshot!(lockfile, { ".**" => sorted_redaction() });
1841 }
1842
1843 #[test]
1844 fn parse_nonexistent_lockfile() {
1845 let tree_path =
1846 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-tree");
1847
1848 let temp = assert_fs::TempDir::new().unwrap();
1849 temp.copy_from(&tree_path, &["**"]).unwrap();
1850
1851 remove_file(temp.join("5.1/lux.lock")).unwrap();
1852
1853 let config = ConfigBuilder::new()
1854 .unwrap()
1855 .user_tree(Some(temp.to_path_buf()))
1856 .build()
1857 .unwrap();
1858 let tree = config.user_tree(LuaVersion::Lua51).unwrap();
1859
1860 let _ = tree.lockfile().unwrap().write_guard(); }
1862
1863 fn get_test_lockfile() -> Lockfile<ReadOnly> {
1864 let sample_tree = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1865 .join("resources/test/sample-tree/5.1/lux.lock");
1866 Lockfile::new(sample_tree).unwrap()
1867 }
1868
1869 #[test]
1870 fn test_sync_spec() {
1871 let lockfile = get_test_lockfile();
1872 let packages = vec![
1873 PackageReq::parse("neorg@8.8.1-1").unwrap().into(),
1874 PackageReq::parse("lua-cjson@2.1.0").unwrap().into(),
1875 PackageReq::parse("nonexistent").unwrap().into(),
1876 ];
1877
1878 let sync_spec = lockfile
1879 .lock
1880 .package_sync_spec(&packages, &SyncStrategy::LockfileOnly);
1881
1882 assert_eq!(sync_spec.to_add.len(), 1);
1883
1884 assert!(!sync_spec
1886 .to_remove
1887 .iter()
1888 .any(|pkg| pkg.name().to_string() == "nvim-nio"
1889 && pkg.constraint()
1890 == LockConstraint::Constrained(">=1.7.0, <1.8.0".parse().unwrap())));
1891 assert!(!sync_spec
1892 .to_remove
1893 .iter()
1894 .any(|pkg| pkg.name().to_string() == "lua-utils.nvim"
1895 && pkg.constraint() == LockConstraint::Constrained("=1.0.2".parse().unwrap())));
1896 assert!(!sync_spec
1897 .to_remove
1898 .iter()
1899 .any(|pkg| pkg.name().to_string() == "plenary.nvim"
1900 && pkg.constraint() == LockConstraint::Constrained("=0.1.4".parse().unwrap())));
1901 assert!(!sync_spec
1902 .to_remove
1903 .iter()
1904 .any(|pkg| pkg.name().to_string() == "nui.nvim"
1905 && pkg.constraint() == LockConstraint::Constrained("=0.3.0".parse().unwrap())));
1906 assert!(!sync_spec
1907 .to_remove
1908 .iter()
1909 .any(|pkg| pkg.name().to_string() == "pathlib.nvim"
1910 && pkg.constraint()
1911 == LockConstraint::Constrained(">=2.2.0, <2.3.0".parse().unwrap())));
1912 }
1913
1914 #[test]
1915 fn test_sync_spec_remove() {
1916 let lockfile = get_test_lockfile();
1917 let packages = vec![
1918 PackageReq::parse("lua-cjson@2.1.0").unwrap().into(),
1919 PackageReq::parse("nonexistent").unwrap().into(),
1920 ];
1921
1922 let sync_spec = lockfile
1923 .lock
1924 .package_sync_spec(&packages, &SyncStrategy::LockfileOnly);
1925
1926 assert_eq!(sync_spec.to_add.len(), 1);
1927
1928 assert!(sync_spec
1932 .to_remove
1933 .iter()
1934 .any(|pkg| pkg.name().to_string() == "neorg"
1935 && pkg.version() == &"8.8.1-1".parse().unwrap()));
1936 assert!(sync_spec
1937 .to_remove
1938 .iter()
1939 .any(|pkg| pkg.name().to_string() == "nvim-nio"
1940 && pkg.constraint()
1941 == LockConstraint::Constrained(">=1.7.0, <1.8.0".parse().unwrap())));
1942 assert!(sync_spec
1943 .to_remove
1944 .iter()
1945 .any(|pkg| pkg.name().to_string() == "lua-utils.nvim"
1946 && pkg.constraint() == LockConstraint::Constrained("=1.0.2".parse().unwrap())));
1947 assert!(sync_spec
1948 .to_remove
1949 .iter()
1950 .any(|pkg| pkg.name().to_string() == "plenary.nvim"
1951 && pkg.constraint() == LockConstraint::Constrained("=0.1.4".parse().unwrap())));
1952 assert!(sync_spec
1953 .to_remove
1954 .iter()
1955 .any(|pkg| pkg.name().to_string() == "nui.nvim"
1956 && pkg.constraint() == LockConstraint::Constrained("=0.3.0".parse().unwrap())));
1957 assert!(sync_spec
1958 .to_remove
1959 .iter()
1960 .any(|pkg| pkg.name().to_string() == "pathlib.nvim"
1961 && pkg.constraint()
1962 == LockConstraint::Constrained(">=2.2.0, <2.3.0".parse().unwrap())));
1963 }
1964
1965 #[test]
1966 fn test_sync_spec_empty() {
1967 let lockfile = get_test_lockfile();
1968 let packages = vec![];
1969 let sync_spec = lockfile
1970 .lock
1971 .package_sync_spec(&packages, &SyncStrategy::LockfileOnly);
1972
1973 assert!(sync_spec.to_add.is_empty());
1975 assert_eq!(sync_spec.to_remove.len(), lockfile.rocks().len());
1976 }
1977
1978 #[test]
1979 fn test_sync_spec_different_constraints() {
1980 let lockfile = get_test_lockfile();
1981 let packages = vec![PackageReq::parse("nvim-nio>=2.0.0").unwrap().into()];
1982 let sync_spec = lockfile
1983 .lock
1984 .package_sync_spec(&packages, &SyncStrategy::LockfileOnly);
1985
1986 let expected: PackageVersionReq = ">=2.0.0".parse().unwrap();
1987 assert!(sync_spec
1988 .to_add
1989 .iter()
1990 .any(|req| req.name().to_string() == "nvim-nio" && req.version_req() == &expected));
1991
1992 assert!(sync_spec
1993 .to_remove
1994 .iter()
1995 .any(|pkg| pkg.name().to_string() == "nvim-nio"));
1996 }
1997
1998 #[test]
1999 fn test_sync_spec_ensure_installed() {
2000 let temp = assert_fs::TempDir::new().unwrap();
2001 temp.copy_from(
2002 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-tree"),
2003 &["**"],
2004 )
2005 .unwrap();
2006
2007 let config = ConfigBuilder::new()
2008 .unwrap()
2009 .user_tree(Some(temp.to_path_buf()))
2010 .build()
2011 .unwrap();
2012 let tree = config.user_tree(LuaVersion::Lua51).unwrap();
2013 let lockfile = tree.lockfile().unwrap();
2014
2015 let packages: Vec<LuaDependencySpec> = vec![
2016 PackageReq::parse("neorg@8.8.1-1").unwrap().into(),
2017 PackageReq::parse("lua-cjson@2.1.0").unwrap().into(),
2019 PackageReq::parse("nonexistent").unwrap().into(),
2021 ];
2022
2023 let sync_spec = lockfile
2025 .lock
2026 .package_sync_spec(&packages, &SyncStrategy::EnsureInstalled(&tree));
2027
2028 assert!(!sync_spec
2029 .to_add
2030 .iter()
2031 .any(|req| req.name().to_string() == "neorg"));
2032
2033 assert!(sync_spec
2034 .to_add
2035 .iter()
2036 .any(|req| req.name().to_string() == "lua-cjson"));
2037
2038 assert!(sync_spec
2039 .to_add
2040 .iter()
2041 .any(|req| req.name().to_string() == "nonexistent"));
2042 }
2043
2044 #[test]
2045 fn verify_lockfile_version() {
2046 let deserialize = |version: &str| {
2047 let content = format!(r#"{{"version": "{version}"}}"#);
2048 parse_lockfile_json(&content).and_then(|value| {
2049 deserialize_lockfile::<Lockfile<ReadOnly>>(value, Path::new("lux.lock"), "")
2050 })
2051 };
2052
2053 assert!(matches!(
2054 deserialize("3.0.0"),
2055 Err(LockfileError::IncompatibleVersion { .. })
2056 ));
2057 assert!(matches!(
2058 deserialize("not-a-version"),
2059 Err(LockfileError::InvalidVersion { .. })
2060 ));
2061 }
2062
2063 #[test]
2064 fn migrate_v1_tree_lockfile() {
2065 let sample =
2066 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/lockfile-v1/tree.lock");
2067
2068 let temp = assert_fs::TempDir::new().unwrap();
2069 let lockfile_path = temp.path().join("lux.lock");
2070 std::fs::copy(&sample, &lockfile_path).unwrap();
2071
2072 let lockfile = Lockfile::load(lockfile_path.clone()).unwrap();
2073
2074 let on_disk: serde_json::Value =
2075 serde_json::from_str(&std::fs::read_to_string(&lockfile_path).unwrap()).unwrap();
2076 assert_eq!(on_disk["version"], "1.0.0");
2077 assert!(on_disk["entrypoints"].is_array());
2078
2079 assert_eq!(lockfile.version, "2.0.0");
2080 assert_eq!(lockfile.lock.entrypoints.len(), 3);
2081 }
2082
2083 #[test]
2084 fn refuse_migration_for_custom_layout() {
2085 let lockfile = r#"{"version":"1.0.0","entrypoint_layout":{"root":"site/pack/lux"}}"#;
2086 assert!(matches!(
2087 load_lockfile::<Lockfile<ReadOnly>>(lockfile, Path::new("lux.lock"), ""),
2088 Err(LockfileError::IncompatibleVersion { .. })
2089 ));
2090 }
2091
2092 #[test]
2093 fn refuse_migration_for_conflicting_entrypoints() {
2094 let lockfile = r#"{
2095 "version": "1.0.0",
2096 "rocks": {
2097 "a": { "name": "foo" },
2098 "b": { "name": "foo" }
2099 },
2100 "entrypoints": ["a", "b"]
2101 }"#;
2102 assert!(matches!(
2103 load_lockfile::<Lockfile<ReadOnly>>(lockfile, Path::new("lux.lock"), ""),
2104 Err(LockfileError::ConflictingEntrypoints { .. })
2105 ));
2106 }
2107
2108 #[test]
2109 fn migrate_v1_workspace_lockfile() {
2110 let sample = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2111 .join("resources/test/lockfile-v1/workspace.lock");
2112
2113 let temp = assert_fs::TempDir::new().unwrap();
2114 let lockfile_path = temp.path().join("lux.lock");
2115 std::fs::copy(&sample, &lockfile_path).unwrap();
2116
2117 let lockfile = WorkspaceLockfile::load(lockfile_path.clone()).unwrap();
2118
2119 let on_disk: serde_json::Value =
2120 serde_json::from_str(&std::fs::read_to_string(&lockfile_path).unwrap()).unwrap();
2121 assert_eq!(on_disk["version"], "1.0.0");
2122 assert!(on_disk["dependencies"]["entrypoints"].is_array());
2123
2124 assert_eq!(lockfile.version, "2.0.0");
2125 assert_eq!(lockfile.dependencies.entrypoints.len(), 3);
2126 }
2127}