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::RockspecContent(_) => true,
756 RemotePackageSource::Local => true,
757 #[cfg(test)]
758 RemotePackageSource::Test => unimplemented!(),
759 },
760 None => true,
761 })
762 .rev()
763 .find(|package| req.version_req().matches(package.version()))
764 })?
765 .cloned()
766 }
767
768 fn has_rock_with_equal_constraint(&self, req: &LuaDependencySpec) -> Option<LockedPackage> {
769 self.list()
770 .get(req.name())
771 .map(|packages| {
772 packages
773 .iter()
774 .rev()
775 .find(|package| package.constraint().matches_version_req(req.version_req()))
776 })?
777 .cloned()
778 }
779
780 pub(crate) fn package_sync_spec(
787 &self,
788 packages: &[LuaDependencySpec],
789 strategy: &SyncStrategy<'_>,
790 ) -> PackageSyncSpec {
791 let pkg_dir_exists = |pkg: &LockedPackage| match strategy {
792 SyncStrategy::LockfileOnly => true,
793 SyncStrategy::EnsureInstalled(tree) => tree.layout_for(pkg.spec()).root.is_dir(),
794 };
795
796 let entrypoints_to_keep: HashSet<LockedPackage> = self
797 .entrypoints
798 .values()
799 .filter_map(|id| self.get(id))
800 .filter(|local_pkg| {
801 packages.iter().any(|req| {
802 local_pkg
803 .constraint()
804 .matches_version_req(req.version_req())
805 })
806 })
807 .cloned()
808 .collect();
809
810 let packages_to_keep: HashSet<&LockedPackage> = entrypoints_to_keep
811 .iter()
812 .flat_map(|local_pkg| self.get_all_dependencies(&local_pkg.id()))
813 .collect();
814
815 let to_add = packages
816 .iter()
817 .filter(|pkg| {
818 self.has_rock_with_equal_constraint(pkg)
819 .map(|local_pkg| !pkg_dir_exists(&local_pkg))
820 .unwrap_or(true)
821 })
822 .cloned()
823 .collect_vec();
824
825 let to_remove = self
826 .rocks()
827 .values()
828 .filter(|pkg| !packages_to_keep.contains(*pkg))
829 .cloned()
830 .collect_vec();
831
832 PackageSyncSpec { to_add, to_remove }
833 }
834
835 fn get_all_dependencies(&self, id: &LockedPackageId) -> HashSet<&LockedPackage> {
837 let mut packages = HashSet::new();
838 if let Some(local_pkg) = self.get(id) {
839 packages.insert(local_pkg);
840 packages.extend(
841 local_pkg
842 .dependencies()
843 .iter()
844 .flat_map(|id| self.get_all_dependencies(id)),
845 );
846 }
847 packages
848 }
849
850 fn reachable(&self) -> HashSet<&LockedPackage> {
853 self.entrypoints
854 .values()
855 .flat_map(|id| self.get_all_dependencies(id))
856 .collect()
857 }
858}
859
860#[derive(Clone, Debug, Serialize, Deserialize)]
862pub struct Lockfile<P: LockfilePermissions> {
863 #[serde(skip)]
864 filepath: PathBuf,
865 #[serde(skip)]
866 _marker: PhantomData<P>,
867 version: String,
869 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
870 binaries: BTreeMap<LockedPackageId, RockBinaries>,
871 #[serde(flatten)]
872 lock: PackageLock,
873}
874
875#[derive(EnumIter, Debug, Clone, Copy, PartialEq, Eq, Hash)]
876pub enum LockedPackageLockType {
877 Regular,
878 Test,
879 Build,
880}
881
882#[derive(Clone, Debug, Serialize, Deserialize)]
884pub struct WorkspaceLockfile<P: LockfilePermissions> {
885 #[serde(skip)]
886 filepath: PathBuf,
887 #[serde(skip)]
888 _marker: PhantomData<P>,
889 version: String,
890 #[serde(default, skip_serializing_if = "PackageLock::is_empty")]
891 dependencies: PackageLock,
892 #[serde(default, skip_serializing_if = "PackageLock::is_empty")]
893 test_dependencies: PackageLock,
894 #[serde(default, skip_serializing_if = "PackageLock::is_empty")]
895 build_dependencies: PackageLock,
896}
897
898#[derive(Error, Debug, Diagnostic)]
899#[non_exhaustive]
900pub enum LockfileError {
901 #[error(transparent)]
902 #[diagnostic(transparent)]
903 Fs(#[from] fs::FsError),
904 #[error("error parsing lockfile from JSON")]
905 ParseJson(#[source] serde_json::Error),
906 #[error("error writing lockfile to JSON")]
907 WriteJson(#[source] serde_json::Error),
908 #[error("invalid version string in lockfile: {version}")]
909 #[diagnostic(help("did you or a tool modify the lockfile manually?"))]
910 InvalidVersion {
911 version: String,
912 source: semver::Error,
913 },
914 #[error("lockfile '{}' has no `version` field", lockfile.display())]
915 #[diagnostic(
916 code(lux_lib::lockfile::missing_version),
917 help("the lockfile is corrupt or was hand-edited")
918 )]
919 MissingVersion { lockfile: PathBuf },
920 #[error(
921 "incompatible lockfile version in '{}': expected {expected}, found {found}",
922 lockfile.display()
923 )]
924 #[diagnostic(code(lux_lib::lockfile::incompatible_version), help("{help}"))]
925 IncompatibleVersion {
926 lockfile: PathBuf,
927 found: String,
928 expected: String,
929 help: String,
930 },
931 #[error("cannot migrate lockfile '{}'", lockfile.display())]
932 #[diagnostic(code(lux_lib::lockfile::conflicting_entrypoints), help("{help}"))]
933 ConflictingEntrypoints { lockfile: PathBuf, help: String },
934}
935
936#[derive(Error, Debug, Diagnostic)]
937#[non_exhaustive]
938pub enum LockfileIntegrityError {
939 #[error(
940 r#"rockspec integrity mismatch.
941- source: {src}
942- expected: {expected}
943- got: {got}
944"#
945 )]
946 #[diagnostic(help(
947 r#"the maintainer may have force-uploaded a different rockspec.
948check the rockspec source, then rerun the command with `--no-lock` to update the hash.
949"#
950 ))]
951 RockspecIntegrityMismatch {
952 src: String,
953 expected: Integrity,
954 got: Integrity,
955 },
956 #[error(
957 r#"source integrity mismatch.
958- source: {src}
959- expected: {expected}
960- got: {got}"#
961 )]
962 #[diagnostic(help(
963 r#"the maintainer may have force-uploaded a different source or moved a tag.
964check the source URL, then rerun the command with `lx --no-lock` to update the hash.
965"#
966 ))]
967 SourceIntegrityMismatch {
968 src: String,
969 expected: Integrity,
970 got: Integrity,
971 },
972 #[error(
973 "package {0} version {1} with pinned state {2} and constraint {3} not found in the lockfile."
974 )]
975 PackageNotFound(PackageName, Box<PackageVersion>, PinnedState, String),
976}
977
978#[derive(Error, Debug, Diagnostic)]
979#[non_exhaustive]
980#[error("error flushing the lockfile ({})", .filepath.display())]
981#[diagnostic(help("make sure the parent directory is writeable"))]
982pub struct FlushLockfileError {
983 filepath: PathBuf,
984 source: io::Error,
985}
986
987#[derive(Debug, Default)]
989pub(crate) struct PackageSyncSpec {
990 pub to_add: Vec<LuaDependencySpec>,
991 pub to_remove: Vec<LockedPackage>,
992}
993
994pub(crate) enum SyncStrategy<'a> {
996 LockfileOnly,
998 EnsureInstalled(&'a Tree),
1001}
1002
1003impl<P: LockfilePermissions> Lockfile<P> {
1004 pub fn version(&self) -> &String {
1005 &self.version
1006 }
1007
1008 pub fn rocks(&self) -> &BTreeMap<LockedPackageId, LockedPackage> {
1009 self.lock.rocks()
1010 }
1011
1012 pub fn is_dependency(&self, package: &LockedPackageId) -> bool {
1013 self.lock.is_dependency(package)
1014 }
1015
1016 pub fn is_entrypoint(&self, package: &LockedPackageId) -> bool {
1017 self.lock.is_entrypoint(package)
1018 }
1019
1020 pub fn entrypoint(&self, name: &PackageName) -> Option<&LockedPackage> {
1022 self.lock.entrypoint(name)
1023 }
1024
1025 pub fn reachable_rocks(&self) -> Vec<LockedPackage> {
1028 self.lock.reachable().into_iter().cloned().collect()
1029 }
1030
1031 pub fn entry_type(&self, package: &LockedPackageId) -> EntryType {
1032 if self.lock.is_entrypoint(package) {
1033 EntryType::Entrypoint
1034 } else {
1035 EntryType::DependencyOnly
1036 }
1037 }
1038
1039 pub(crate) fn binaries(&self, id: &LockedPackageId) -> Option<&RockBinaries> {
1041 self.binaries.get(id)
1042 }
1043
1044 pub(crate) fn local_pkg_lock(&self) -> &PackageLock {
1045 &self.lock
1046 }
1047
1048 pub fn get(&self, id: &LockedPackageId) -> Option<&LockedPackage> {
1049 self.lock.get(id)
1050 }
1051
1052 pub unsafe fn get_unchecked(&self, id: &LockedPackageId) -> &LockedPackage {
1058 self.lock.get(id).unwrap_unchecked()
1059 }
1060
1061 pub(crate) fn list(&self) -> HashMap<PackageName, Vec<LockedPackage>> {
1062 self.lock.list()
1063 }
1064
1065 pub(crate) fn has_rock(
1066 &self,
1067 req: &PackageReq,
1068 filter: Option<RemotePackageTypeFilterSpec>,
1069 ) -> Option<LockedPackage> {
1070 self.lock.has_rock(req, filter)
1071 }
1072
1073 pub(crate) fn find_rocks(&self, req: &PackageReq) -> Vec<LockedPackageId> {
1075 match self.list().get(req.name()) {
1076 Some(packages) => packages
1077 .iter()
1078 .rev()
1079 .filter(|package| req.version_req().matches(package.version()))
1080 .map(|package| package.id())
1081 .collect_vec(),
1082 None => Vec::default(),
1083 }
1084 }
1085
1086 pub(crate) fn validate_integrity(
1088 &self,
1089 expected_package: &LockedPackage,
1090 ) -> Result<(), LockfileIntegrityError> {
1091 match self.list().get(expected_package.name()) {
1094 None => Err(integrity_err_not_found(expected_package)),
1095 Some(rocks) => match rocks
1096 .iter()
1097 .find(|rock| rock.version() == expected_package.version())
1098 {
1099 None => Err(integrity_err_not_found(expected_package)),
1100 Some(installed_package) => {
1101 if expected_package
1102 .hashes
1103 .rockspec
1104 .matches(&installed_package.hashes.rockspec)
1105 .is_none()
1106 {
1107 return Err(LockfileIntegrityError::RockspecIntegrityMismatch {
1108 src: expected_package.source.to_string(),
1109 expected: expected_package.hashes.rockspec.clone(),
1110 got: installed_package.hashes.rockspec.clone(),
1111 });
1112 }
1113 if expected_package
1114 .hashes
1115 .source
1116 .matches(&installed_package.hashes.source)
1117 .is_none()
1118 {
1119 return Err(LockfileIntegrityError::SourceIntegrityMismatch {
1120 src: expected_package
1121 .source_url
1122 .as_ref()
1123 .map(|url| url.to_string())
1124 .unwrap_or(expected_package.source.to_string()),
1125 expected: expected_package.hashes.source.clone(),
1126 got: installed_package.hashes.source.clone(),
1127 });
1128 }
1129 Ok(())
1130 }
1131 },
1132 }
1133 }
1134
1135 fn flush(&self) -> Result<(), FlushLockfileError> {
1136 let content = serde_json::to_string_pretty(&self).map_err(|err| FlushLockfileError {
1137 filepath: self.filepath.to_path_buf(),
1138 source: io::Error::other(err),
1139 })?;
1140
1141 fs::sync::write_atomic(&self.filepath, content).map_err(|err| FlushLockfileError {
1142 filepath: self.filepath.to_path_buf(),
1143 source: io::Error::other(err),
1144 })
1145 }
1146}
1147
1148impl<P: LockfilePermissions> WorkspaceLockfile<P> {
1149 pub(crate) fn rocks(
1150 &self,
1151 deps: &LockedPackageLockType,
1152 ) -> &BTreeMap<LockedPackageId, LockedPackage> {
1153 match deps {
1154 LockedPackageLockType::Regular => self.dependencies.rocks(),
1155 LockedPackageLockType::Test => self.test_dependencies.rocks(),
1156 LockedPackageLockType::Build => self.build_dependencies.rocks(),
1157 }
1158 }
1159
1160 pub(crate) fn get(
1161 &self,
1162 id: &LockedPackageId,
1163 deps: &LockedPackageLockType,
1164 ) -> Option<&LockedPackage> {
1165 match deps {
1166 LockedPackageLockType::Regular => self.dependencies.get(id),
1167 LockedPackageLockType::Test => self.test_dependencies.get(id),
1168 LockedPackageLockType::Build => self.build_dependencies.get(id),
1169 }
1170 }
1171
1172 pub(crate) fn is_entrypoint(
1173 &self,
1174 package: &LockedPackageId,
1175 deps: &LockedPackageLockType,
1176 ) -> bool {
1177 match deps {
1178 LockedPackageLockType::Regular => self.dependencies.is_entrypoint(package),
1179 LockedPackageLockType::Test => self.test_dependencies.is_entrypoint(package),
1180 LockedPackageLockType::Build => self.build_dependencies.is_entrypoint(package),
1181 }
1182 }
1183
1184 pub(crate) fn package_sync_spec(
1185 &self,
1186 packages: &[LuaDependencySpec],
1187 deps: &LockedPackageLockType,
1188 strategy: &SyncStrategy<'_>,
1189 ) -> PackageSyncSpec {
1190 match deps {
1191 LockedPackageLockType::Regular => {
1192 self.dependencies.package_sync_spec(packages, strategy)
1193 }
1194 LockedPackageLockType::Test => {
1195 self.test_dependencies.package_sync_spec(packages, strategy)
1196 }
1197 LockedPackageLockType::Build => self
1198 .build_dependencies
1199 .package_sync_spec(packages, strategy),
1200 }
1201 }
1202
1203 pub(crate) fn local_pkg_lock(&self, deps: &LockedPackageLockType) -> &PackageLock {
1204 match deps {
1205 LockedPackageLockType::Regular => &self.dependencies,
1206 LockedPackageLockType::Test => &self.test_dependencies,
1207 LockedPackageLockType::Build => &self.build_dependencies,
1208 }
1209 }
1210
1211 pub(crate) fn local_pkg_locks(&self) -> Vec<PackageLock> {
1212 vec![
1213 self.dependencies.clone(),
1214 self.test_dependencies.clone(),
1215 self.build_dependencies.clone(),
1216 ]
1217 }
1218
1219 fn flush(&self) -> io::Result<()> {
1220 let content = serde_json::to_string_pretty(&self)?;
1221
1222 fs::sync::write_atomic(&self.filepath, content).map_err(io::Error::other)?;
1223
1224 Ok(())
1225 }
1226}
1227
1228impl Lockfile<ReadOnly> {
1229 #[tracing::instrument(level = "trace")]
1231 pub(crate) fn new(filepath: PathBuf) -> Result<Lockfile<ReadOnly>, LockfileError> {
1232 match File::options().create_new(true).write(true).open(&filepath) {
1234 Ok(mut file) => {
1235 let empty_lockfile: Lockfile<ReadOnly> = Lockfile {
1236 filepath: filepath.clone(),
1237 _marker: PhantomData,
1238 version: LOCKFILE_VERSION_STR.into(),
1239 binaries: BTreeMap::new(),
1240 lock: PackageLock::default(),
1241 };
1242 let json_str =
1243 serde_json::to_string(&empty_lockfile).map_err(LockfileError::WriteJson)?;
1244 write!(file, "{json_str}").map_err(|source| fs::FsError::Write {
1245 path: filepath.to_path_buf(),
1246 source,
1247 })?;
1248 }
1249 Err(err) if err.kind() == ErrorKind::AlreadyExists => {}
1250 Err(source) => {
1251 return Err(LockfileError::Fs(fs::FsError::FileOpen {
1252 path: filepath.to_path_buf(),
1253 source,
1254 }));
1255 }
1256 }
1257
1258 Self::load(filepath)
1259 }
1260
1261 #[tracing::instrument(level = "trace")]
1263 pub fn load(filepath: PathBuf) -> Result<Lockfile<ReadOnly>, LockfileError> {
1264 let content = fs::sync::read_to_string(&filepath)?;
1265 let tree_dir = filepath.parent().unwrap_or(filepath.as_path());
1266 let help = format!(
1267 "this install tree was created by an incompatible version of Lux.
1268remove the tree at '{}' and reinstall all packages it contained.",
1269 tree_dir.display()
1270 );
1271 let mut lockfile: Lockfile<ReadOnly> = load_lockfile(&content, &filepath, &help)?;
1272 lockfile.filepath = filepath;
1273 Ok(lockfile)
1274 }
1275
1276 pub(crate) fn into_temporary(self) -> Lockfile<ReadWrite> {
1278 Lockfile::<ReadWrite> {
1279 _marker: PhantomData,
1280 filepath: self.filepath,
1281 version: self.version,
1282 binaries: self.binaries,
1283 lock: self.lock,
1284 }
1285 }
1286
1287 pub fn write_guard(self) -> LockfileGuard {
1290 LockfileGuard(self.into_temporary())
1291 }
1292
1293 #[tracing::instrument(level = "trace", skip_all)]
1296 pub fn map_then_flush<T, F, E>(self, cb: F) -> Result<T, FlushLockfileError>
1297 where
1298 F: FnOnce(&mut Lockfile<ReadWrite>) -> Result<T, E>,
1299 E: Error,
1300 E: From<io::Error>,
1301 E: Into<Box<dyn Error + Send + Sync>>,
1302 {
1303 let mut writeable_lockfile = self.into_temporary();
1304
1305 let result = cb(&mut writeable_lockfile).map_err(|err| FlushLockfileError {
1306 filepath: writeable_lockfile.filepath.to_path_buf(),
1307 source: io::Error::other(err),
1308 })?;
1309
1310 writeable_lockfile.flush()?;
1311
1312 Ok(result)
1313 }
1314
1315 }
1333
1334impl WorkspaceLockfile<ReadOnly> {
1335 #[tracing::instrument(level = "trace")]
1337 pub fn new(filepath: PathBuf) -> Result<WorkspaceLockfile<ReadOnly>, LockfileError> {
1338 match File::options().create_new(true).write(true).open(&filepath) {
1340 Ok(mut file) => {
1341 let empty_lockfile: WorkspaceLockfile<ReadOnly> = WorkspaceLockfile {
1342 filepath: filepath.clone(),
1343 _marker: PhantomData,
1344 version: LOCKFILE_VERSION_STR.into(),
1345 dependencies: PackageLock::default(),
1346 test_dependencies: PackageLock::default(),
1347 build_dependencies: PackageLock::default(),
1348 };
1349 let json_str =
1350 serde_json::to_string(&empty_lockfile).map_err(LockfileError::WriteJson)?;
1351 write!(file, "{json_str}").map_err(|source| fs::FsError::Write {
1352 path: filepath.to_path_buf(),
1353 source,
1354 })?;
1355 }
1356 Err(err) if err.kind() == ErrorKind::AlreadyExists => {}
1357 Err(source) => {
1358 return Err(LockfileError::Fs(fs::FsError::FileOpen {
1359 path: filepath.to_path_buf(),
1360 source,
1361 }));
1362 }
1363 }
1364
1365 Self::load(filepath)
1366 }
1367
1368 #[tracing::instrument(level = "trace")]
1370 pub fn load(filepath: PathBuf) -> Result<WorkspaceLockfile<ReadOnly>, LockfileError> {
1371 let content = fs::sync::read_to_string(&filepath)?;
1372 let mut lockfile: WorkspaceLockfile<ReadOnly> = load_lockfile(
1373 &content,
1374 &filepath,
1375 "this lockfile was created by an incompatible version of Lux.
1376remove the `lux.lock` and run `lx sync` to regenerate it.",
1377 )?;
1378
1379 lockfile.filepath = filepath;
1380
1381 Ok(lockfile)
1382 }
1383
1384 fn into_temporary(self) -> WorkspaceLockfile<ReadWrite> {
1386 WorkspaceLockfile::<ReadWrite> {
1387 _marker: PhantomData,
1388 filepath: self.filepath,
1389 version: self.version,
1390 dependencies: self.dependencies,
1391 test_dependencies: self.test_dependencies,
1392 build_dependencies: self.build_dependencies,
1393 }
1394 }
1395
1396 pub fn write_guard(self) -> ProjectLockfileGuard {
1399 ProjectLockfileGuard(self.into_temporary())
1400 }
1401}
1402
1403impl Lockfile<ReadWrite> {
1404 pub(crate) fn add_entrypoint(&mut self, rock: &LockedPackage) {
1405 self.add(rock);
1406 self.lock.entrypoints.insert(rock.name().clone(), rock.id());
1407 }
1408
1409 pub(crate) fn add(&mut self, rock: &LockedPackage) {
1410 self.lock
1413 .rocks
1414 .entry(rock.id())
1415 .or_insert_with(|| rock.clone());
1416 }
1417
1418 pub(crate) fn add_dependency(&mut self, target: &LockedPackage, dependency: &LockedPackage) {
1420 self.lock
1421 .rocks
1422 .entry(target.id())
1423 .and_modify(|rock| {
1424 let id = dependency.id();
1425 if !rock.spec.dependencies.contains(&id) {
1426 rock.spec.dependencies.push(id);
1427 }
1428 })
1429 .or_insert_with(|| {
1430 let id = dependency.id();
1431 let mut target = target.clone();
1432 if !target.spec.dependencies.contains(&id) {
1433 target.spec.dependencies.push(id);
1434 }
1435 target
1436 });
1437 self.add(dependency);
1438 }
1439
1440 pub(crate) fn add_build_dependency(
1442 &mut self,
1443 target: &LockedPackage,
1444 dependency: &LockedPackage,
1445 ) {
1446 self.lock
1447 .rocks
1448 .entry(target.id())
1449 .and_modify(|rock| {
1450 let id = dependency.id();
1451 if !rock.spec.build_dependencies.contains(&id) {
1452 rock.spec.build_dependencies.push(id);
1453 }
1454 })
1455 .or_insert_with(|| {
1456 let id = dependency.id();
1457 let mut target = target.clone();
1458 if !target.spec.build_dependencies.contains(&id) {
1459 target.spec.build_dependencies.push(id);
1460 }
1461 target
1462 });
1463 }
1464
1465 pub(crate) fn set_binaries(&mut self, package: &LockedPackage, binaries: RockBinaries) {
1467 self.binaries.insert(package.id(), binaries);
1468 }
1469
1470 pub(crate) fn remove(&mut self, target: &LockedPackage) {
1471 self.binaries.remove(&target.id());
1472 self.lock.remove(target)
1473 }
1474
1475 pub(crate) fn remove_by_id(&mut self, target: &LockedPackageId) {
1476 self.binaries.remove(target);
1477 self.lock.remove_by_id(target)
1478 }
1479
1480 pub(crate) fn sync(&mut self, lock: &PackageLock) {
1481 self.lock = lock.clone();
1482 }
1483
1484 }
1486
1487impl WorkspaceLockfile<ReadWrite> {
1488 pub(crate) fn remove(&mut self, target: &LockedPackage, deps: &LockedPackageLockType) {
1489 match deps {
1490 LockedPackageLockType::Regular => self.dependencies.remove(target),
1491 LockedPackageLockType::Test => self.test_dependencies.remove(target),
1492 LockedPackageLockType::Build => self.build_dependencies.remove(target),
1493 }
1494 }
1495
1496 pub(crate) fn sync(&mut self, lock: &PackageLock, deps: &LockedPackageLockType) {
1497 match deps {
1498 LockedPackageLockType::Regular => {
1499 self.dependencies = lock.clone();
1500 }
1501 LockedPackageLockType::Test => {
1502 self.test_dependencies = lock.clone();
1503 }
1504 LockedPackageLockType::Build => {
1505 self.build_dependencies = lock.clone();
1506 }
1507 }
1508 }
1509
1510 pub(crate) fn merge(&mut self, lock: &PackageLock, deps: &LockedPackageLockType) {
1511 match deps {
1512 LockedPackageLockType::Regular => self.dependencies.merge(lock),
1513 LockedPackageLockType::Test => self.test_dependencies.merge(lock),
1514 LockedPackageLockType::Build => self.build_dependencies.merge(lock),
1515 }
1516 }
1517}
1518
1519pub struct LockfileGuard(Lockfile<ReadWrite>);
1521
1522pub struct ProjectLockfileGuard(WorkspaceLockfile<ReadWrite>);
1523
1524impl Serialize for LockfileGuard {
1525 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1526 where
1527 S: serde::Serializer,
1528 {
1529 self.0.serialize(serializer)
1530 }
1531}
1532
1533impl Serialize for ProjectLockfileGuard {
1534 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1535 where
1536 S: serde::Serializer,
1537 {
1538 self.0.serialize(serializer)
1539 }
1540}
1541
1542impl<'de> Deserialize<'de> for LockfileGuard {
1543 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1544 where
1545 D: serde::Deserializer<'de>,
1546 {
1547 Ok(LockfileGuard(Lockfile::<ReadWrite>::deserialize(
1548 deserializer,
1549 )?))
1550 }
1551}
1552
1553impl<'de> Deserialize<'de> for ProjectLockfileGuard {
1554 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1555 where
1556 D: serde::Deserializer<'de>,
1557 {
1558 Ok(ProjectLockfileGuard(
1559 WorkspaceLockfile::<ReadWrite>::deserialize(deserializer)?,
1560 ))
1561 }
1562}
1563
1564impl Deref for LockfileGuard {
1565 type Target = Lockfile<ReadWrite>;
1566
1567 fn deref(&self) -> &Self::Target {
1568 &self.0
1569 }
1570}
1571
1572impl Deref for ProjectLockfileGuard {
1573 type Target = WorkspaceLockfile<ReadWrite>;
1574
1575 fn deref(&self) -> &Self::Target {
1576 &self.0
1577 }
1578}
1579
1580impl DerefMut for LockfileGuard {
1581 fn deref_mut(&mut self) -> &mut Self::Target {
1582 &mut self.0
1583 }
1584}
1585
1586impl DerefMut for ProjectLockfileGuard {
1587 fn deref_mut(&mut self) -> &mut Self::Target {
1588 &mut self.0
1589 }
1590}
1591
1592impl Drop for LockfileGuard {
1593 fn drop(&mut self) {
1594 let _ = self.flush();
1595 }
1596}
1597
1598impl Drop for ProjectLockfileGuard {
1599 fn drop(&mut self) {
1600 let _ = self.flush();
1601 }
1602}
1603
1604fn load_lockfile<T>(content: &str, lockfile: &Path, help: &str) -> Result<T, LockfileError>
1605where
1606 T: serde::de::DeserializeOwned,
1607{
1608 let mut value = parse_lockfile_json(content)?;
1609 if lockfile_version(&value, lockfile)?.major == 1 {
1610 migrate_v1_lockfile(&mut value, lockfile)?;
1611 }
1612 deserialize_lockfile(value, lockfile, help)
1613}
1614
1615fn parse_lockfile_json(
1616 content: &str,
1617) -> Result<serde_json::Map<String, serde_json::Value>, LockfileError> {
1618 serde_json::from_str(content).map_err(LockfileError::ParseJson)
1619}
1620
1621fn lockfile_version(
1622 value: &serde_json::Map<String, serde_json::Value>,
1623 lockfile: &Path,
1624) -> Result<semver::Version, LockfileError> {
1625 let version = value
1626 .get("version")
1627 .and_then(|version| version.as_str())
1628 .ok_or(LockfileError::MissingVersion {
1629 lockfile: lockfile.to_path_buf(),
1630 })?;
1631 semver::Version::parse(version).map_err(|source| LockfileError::InvalidVersion {
1632 version: version.to_string(),
1633 source,
1634 })
1635}
1636
1637fn deserialize_lockfile<T>(
1638 value: serde_json::Map<String, serde_json::Value>,
1639 lockfile: &Path,
1640 help: &str,
1641) -> Result<T, LockfileError>
1642where
1643 T: serde::de::DeserializeOwned,
1644{
1645 let found = lockfile_version(&value, lockfile)?;
1646 if found.major != LOCKFILE_VERSION {
1647 return Err(LockfileError::IncompatibleVersion {
1648 lockfile: lockfile.to_path_buf(),
1649 found: found.to_string(),
1650 expected: LOCKFILE_VERSION_STR.into(),
1651 help: help.into(),
1652 });
1653 }
1654 serde_json::from_value(serde_json::Value::Object(value)).map_err(LockfileError::ParseJson)
1655}
1656
1657fn migrate_v1_lockfile(
1658 value: &mut serde_json::Map<String, serde_json::Value>,
1659 lockfile: &Path,
1660) -> Result<(), LockfileError> {
1661 let tree_dir = lockfile.parent().unwrap_or(lockfile);
1662 let found = lockfile_version(value, lockfile)?;
1663 if value.contains_key("entrypoint_layout") {
1664 return Err(LockfileError::IncompatibleVersion {
1665 lockfile: lockfile.to_path_buf(),
1666 found: found.to_string(),
1667 expected: LOCKFILE_VERSION_STR.into(),
1668 help: format!(
1669 "this install tree uses a custom entrypoint layout that cannot be migrated automatically.
1670remove the tree at '{}' and reinstall all packages it contained.",
1671 tree_dir.display()
1672 ),
1673 });
1674 }
1675
1676 let conflict_help = format!(
1677 "this lockfile contains entrypoints that cannot be represented in the current format.
1678remove the tree at '{}' and reinstall all packages it contained.",
1679 tree_dir.display()
1680 );
1681
1682 if value.contains_key("entrypoints") {
1683 migrate_entrypoints(value, lockfile, &conflict_help)?;
1684 }
1685 for section in ["dependencies", "test_dependencies", "build_dependencies"] {
1686 if let Some(section) = value
1687 .get_mut(section)
1688 .and_then(|section| section.as_object_mut())
1689 {
1690 migrate_entrypoints(section, lockfile, &conflict_help)?;
1691 }
1692 }
1693 value.insert(
1694 "version".into(),
1695 serde_json::Value::String(LOCKFILE_VERSION_STR.into()),
1696 );
1697 Ok(())
1698}
1699
1700fn migrate_entrypoints(
1701 section: &mut serde_json::Map<String, serde_json::Value>,
1702 lockfile: &Path,
1703 help: &str,
1704) -> Result<(), LockfileError> {
1705 let conflicting_entrypoints = || LockfileError::ConflictingEntrypoints {
1706 lockfile: lockfile.to_path_buf(),
1707 help: help.to_string(),
1708 };
1709
1710 let rocks = section.get("rocks").and_then(|rocks| rocks.as_object());
1711 let mut entrypoints = serde_json::Map::new();
1712 if let Some(old_entrypoints) = section.get("entrypoints") {
1713 for id in old_entrypoints
1714 .as_array()
1715 .ok_or_else(&conflicting_entrypoints)?
1716 {
1717 let id = id.as_str().ok_or_else(&conflicting_entrypoints)?;
1718 let name = rocks
1719 .and_then(|rocks| rocks.get(id))
1720 .and_then(|package| package.get("name"))
1721 .and_then(|name| name.as_str())
1722 .ok_or_else(&conflicting_entrypoints)?;
1723 if entrypoints.contains_key(name) {
1724 return Err(conflicting_entrypoints());
1725 }
1726 entrypoints.insert(name.to_string(), serde_json::Value::String(id.to_string()));
1727 }
1728 }
1729 section.insert("entrypoints".into(), serde_json::Value::Object(entrypoints));
1730 Ok(())
1731}
1732
1733fn integrity_err_not_found(package: &LockedPackage) -> LockfileIntegrityError {
1734 LockfileIntegrityError::PackageNotFound(
1735 package.name().clone(),
1736 Box::new(package.version().clone()),
1737 package.spec.pinned,
1738 package
1739 .spec
1740 .constraint
1741 .clone()
1742 .unwrap_or("UNCONSTRAINED".into()),
1743 )
1744}
1745
1746fn deserialize_url<'de, D>(deserializer: D) -> Result<Url, D::Error>
1747where
1748 D: serde::Deserializer<'de>,
1749{
1750 let s = String::deserialize(deserializer)?;
1751 Url::parse(&s).map_err(serde::de::Error::custom)
1752}
1753
1754fn serialize_url<S>(url: &Url, serializer: S) -> Result<S::Ok, S::Error>
1755where
1756 S: Serializer,
1757{
1758 url.as_str().serialize(serializer)
1759}
1760
1761#[cfg(test)]
1762mod tests {
1763 use super::*;
1764 use std::{fs::remove_file, path::PathBuf};
1765
1766 use assert_fs::fixture::PathCopy;
1767 use insta::{assert_json_snapshot, sorted_redaction};
1768
1769 use crate::{config::ConfigBuilder, lua_version::LuaVersion, package::PackageSpec};
1770
1771 #[test]
1772 fn parse_lockfile() {
1773 let temp = assert_fs::TempDir::new().unwrap();
1774 temp.copy_from(
1775 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-tree"),
1776 &["**"],
1777 )
1778 .unwrap();
1779
1780 let config = ConfigBuilder::new()
1781 .unwrap()
1782 .user_tree(Some(temp.to_path_buf()))
1783 .build()
1784 .unwrap();
1785 let tree = config.user_tree(LuaVersion::Lua51).unwrap();
1786 let lockfile = tree.lockfile().unwrap();
1787
1788 assert_json_snapshot!(lockfile, { ".**" => sorted_redaction() });
1789 }
1790
1791 #[test]
1792 fn add_rocks() {
1793 let temp = assert_fs::TempDir::new().unwrap();
1794 temp.copy_from(
1795 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-tree"),
1796 &["**"],
1797 )
1798 .unwrap();
1799
1800 let mock_hashes = LockedPackageHashes {
1801 rockspec: "sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="
1802 .parse()
1803 .unwrap(),
1804 source: "sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="
1805 .parse()
1806 .unwrap(),
1807 };
1808
1809 let config = ConfigBuilder::new()
1810 .unwrap()
1811 .user_tree(Some(temp.to_path_buf()))
1812 .build()
1813 .unwrap();
1814 let tree = config.user_tree(LuaVersion::Lua51).unwrap();
1815 let mut lockfile = tree.lockfile().unwrap().write_guard();
1816
1817 let test_package = PackageSpec::parse("test1".to_string(), "0.1.0".to_string()).unwrap();
1818 let test_local_package = LockedPackage::from(
1819 &test_package,
1820 crate::lockfile::LockConstraint::Unconstrained,
1821 RemotePackageSource::Test,
1822 None,
1823 mock_hashes.clone(),
1824 );
1825 lockfile.add_entrypoint(&test_local_package);
1826
1827 let test_dep_package =
1828 PackageSpec::parse("test2".to_string(), "0.1.0".to_string()).unwrap();
1829 let mut test_local_dep_package = LockedPackage::from(
1830 &test_dep_package,
1831 crate::lockfile::LockConstraint::Constrained(">= 1.0.0".parse().unwrap()),
1832 RemotePackageSource::Test,
1833 None,
1834 mock_hashes.clone(),
1835 );
1836 test_local_dep_package.spec.pinned = PinnedState::Pinned;
1837 lockfile.add_dependency(&test_local_package, &test_local_dep_package);
1838
1839 assert_json_snapshot!(lockfile, { ".**" => sorted_redaction() });
1840 }
1841
1842 #[test]
1843 fn parse_nonexistent_lockfile() {
1844 let tree_path =
1845 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-tree");
1846
1847 let temp = assert_fs::TempDir::new().unwrap();
1848 temp.copy_from(&tree_path, &["**"]).unwrap();
1849
1850 remove_file(temp.join("5.1/lux.lock")).unwrap();
1851
1852 let config = ConfigBuilder::new()
1853 .unwrap()
1854 .user_tree(Some(temp.to_path_buf()))
1855 .build()
1856 .unwrap();
1857 let tree = config.user_tree(LuaVersion::Lua51).unwrap();
1858
1859 let _ = tree.lockfile().unwrap().write_guard(); }
1861
1862 fn get_test_lockfile() -> Lockfile<ReadOnly> {
1863 let sample_tree = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1864 .join("resources/test/sample-tree/5.1/lux.lock");
1865 Lockfile::new(sample_tree).unwrap()
1866 }
1867
1868 #[test]
1869 fn test_sync_spec() {
1870 let lockfile = get_test_lockfile();
1871 let packages = vec![
1872 PackageReq::parse("neorg@8.8.1-1").unwrap().into(),
1873 PackageReq::parse("lua-cjson@2.1.0").unwrap().into(),
1874 PackageReq::parse("nonexistent").unwrap().into(),
1875 ];
1876
1877 let sync_spec = lockfile
1878 .lock
1879 .package_sync_spec(&packages, &SyncStrategy::LockfileOnly);
1880
1881 assert_eq!(sync_spec.to_add.len(), 1);
1882
1883 assert!(!sync_spec
1885 .to_remove
1886 .iter()
1887 .any(|pkg| pkg.name().to_string() == "nvim-nio"
1888 && pkg.constraint()
1889 == LockConstraint::Constrained(">=1.7.0, <1.8.0".parse().unwrap())));
1890 assert!(!sync_spec
1891 .to_remove
1892 .iter()
1893 .any(|pkg| pkg.name().to_string() == "lua-utils.nvim"
1894 && pkg.constraint() == LockConstraint::Constrained("=1.0.2".parse().unwrap())));
1895 assert!(!sync_spec
1896 .to_remove
1897 .iter()
1898 .any(|pkg| pkg.name().to_string() == "plenary.nvim"
1899 && pkg.constraint() == LockConstraint::Constrained("=0.1.4".parse().unwrap())));
1900 assert!(!sync_spec
1901 .to_remove
1902 .iter()
1903 .any(|pkg| pkg.name().to_string() == "nui.nvim"
1904 && pkg.constraint() == LockConstraint::Constrained("=0.3.0".parse().unwrap())));
1905 assert!(!sync_spec
1906 .to_remove
1907 .iter()
1908 .any(|pkg| pkg.name().to_string() == "pathlib.nvim"
1909 && pkg.constraint()
1910 == LockConstraint::Constrained(">=2.2.0, <2.3.0".parse().unwrap())));
1911 }
1912
1913 #[test]
1914 fn test_sync_spec_remove() {
1915 let lockfile = get_test_lockfile();
1916 let packages = vec![
1917 PackageReq::parse("lua-cjson@2.1.0").unwrap().into(),
1918 PackageReq::parse("nonexistent").unwrap().into(),
1919 ];
1920
1921 let sync_spec = lockfile
1922 .lock
1923 .package_sync_spec(&packages, &SyncStrategy::LockfileOnly);
1924
1925 assert_eq!(sync_spec.to_add.len(), 1);
1926
1927 assert!(sync_spec
1931 .to_remove
1932 .iter()
1933 .any(|pkg| pkg.name().to_string() == "neorg"
1934 && pkg.version() == &"8.8.1-1".parse().unwrap()));
1935 assert!(sync_spec
1936 .to_remove
1937 .iter()
1938 .any(|pkg| pkg.name().to_string() == "nvim-nio"
1939 && pkg.constraint()
1940 == LockConstraint::Constrained(">=1.7.0, <1.8.0".parse().unwrap())));
1941 assert!(sync_spec
1942 .to_remove
1943 .iter()
1944 .any(|pkg| pkg.name().to_string() == "lua-utils.nvim"
1945 && pkg.constraint() == LockConstraint::Constrained("=1.0.2".parse().unwrap())));
1946 assert!(sync_spec
1947 .to_remove
1948 .iter()
1949 .any(|pkg| pkg.name().to_string() == "plenary.nvim"
1950 && pkg.constraint() == LockConstraint::Constrained("=0.1.4".parse().unwrap())));
1951 assert!(sync_spec
1952 .to_remove
1953 .iter()
1954 .any(|pkg| pkg.name().to_string() == "nui.nvim"
1955 && pkg.constraint() == LockConstraint::Constrained("=0.3.0".parse().unwrap())));
1956 assert!(sync_spec
1957 .to_remove
1958 .iter()
1959 .any(|pkg| pkg.name().to_string() == "pathlib.nvim"
1960 && pkg.constraint()
1961 == LockConstraint::Constrained(">=2.2.0, <2.3.0".parse().unwrap())));
1962 }
1963
1964 #[test]
1965 fn test_sync_spec_empty() {
1966 let lockfile = get_test_lockfile();
1967 let packages = vec![];
1968 let sync_spec = lockfile
1969 .lock
1970 .package_sync_spec(&packages, &SyncStrategy::LockfileOnly);
1971
1972 assert!(sync_spec.to_add.is_empty());
1974 assert_eq!(sync_spec.to_remove.len(), lockfile.rocks().len());
1975 }
1976
1977 #[test]
1978 fn test_sync_spec_different_constraints() {
1979 let lockfile = get_test_lockfile();
1980 let packages = vec![PackageReq::parse("nvim-nio>=2.0.0").unwrap().into()];
1981 let sync_spec = lockfile
1982 .lock
1983 .package_sync_spec(&packages, &SyncStrategy::LockfileOnly);
1984
1985 let expected: PackageVersionReq = ">=2.0.0".parse().unwrap();
1986 assert!(sync_spec
1987 .to_add
1988 .iter()
1989 .any(|req| req.name().to_string() == "nvim-nio" && req.version_req() == &expected));
1990
1991 assert!(sync_spec
1992 .to_remove
1993 .iter()
1994 .any(|pkg| pkg.name().to_string() == "nvim-nio"));
1995 }
1996
1997 #[test]
1998 fn test_sync_spec_ensure_installed() {
1999 let temp = assert_fs::TempDir::new().unwrap();
2000 temp.copy_from(
2001 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-tree"),
2002 &["**"],
2003 )
2004 .unwrap();
2005
2006 let config = ConfigBuilder::new()
2007 .unwrap()
2008 .user_tree(Some(temp.to_path_buf()))
2009 .build()
2010 .unwrap();
2011 let tree = config.user_tree(LuaVersion::Lua51).unwrap();
2012 let lockfile = tree.lockfile().unwrap();
2013
2014 let packages: Vec<LuaDependencySpec> = vec![
2015 PackageReq::parse("neorg@8.8.1-1").unwrap().into(),
2016 PackageReq::parse("lua-cjson@2.1.0").unwrap().into(),
2018 PackageReq::parse("nonexistent").unwrap().into(),
2020 ];
2021
2022 let sync_spec = lockfile
2024 .lock
2025 .package_sync_spec(&packages, &SyncStrategy::EnsureInstalled(&tree));
2026
2027 assert!(!sync_spec
2028 .to_add
2029 .iter()
2030 .any(|req| req.name().to_string() == "neorg"));
2031
2032 assert!(sync_spec
2033 .to_add
2034 .iter()
2035 .any(|req| req.name().to_string() == "lua-cjson"));
2036
2037 assert!(sync_spec
2038 .to_add
2039 .iter()
2040 .any(|req| req.name().to_string() == "nonexistent"));
2041 }
2042
2043 #[test]
2044 fn verify_lockfile_version() {
2045 let deserialize = |version: &str| {
2046 let content = format!(r#"{{"version": "{version}"}}"#);
2047 parse_lockfile_json(&content).and_then(|value| {
2048 deserialize_lockfile::<Lockfile<ReadOnly>>(value, Path::new("lux.lock"), "")
2049 })
2050 };
2051
2052 assert!(matches!(
2053 deserialize("3.0.0"),
2054 Err(LockfileError::IncompatibleVersion { .. })
2055 ));
2056 assert!(matches!(
2057 deserialize("not-a-version"),
2058 Err(LockfileError::InvalidVersion { .. })
2059 ));
2060 }
2061
2062 #[test]
2063 fn migrate_v1_tree_lockfile() {
2064 let sample =
2065 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/lockfile-v1/tree.lock");
2066
2067 let temp = assert_fs::TempDir::new().unwrap();
2068 let lockfile_path = temp.path().join("lux.lock");
2069 std::fs::copy(&sample, &lockfile_path).unwrap();
2070
2071 let lockfile = Lockfile::load(lockfile_path.clone()).unwrap();
2072
2073 let on_disk: serde_json::Value =
2074 serde_json::from_str(&std::fs::read_to_string(&lockfile_path).unwrap()).unwrap();
2075 assert_eq!(on_disk["version"], "1.0.0");
2076 assert!(on_disk["entrypoints"].is_array());
2077
2078 assert_eq!(lockfile.version, "2.0.0");
2079 assert_eq!(lockfile.lock.entrypoints.len(), 3);
2080 }
2081
2082 #[test]
2083 fn refuse_migration_for_custom_layout() {
2084 let lockfile = r#"{"version":"1.0.0","entrypoint_layout":{"root":"site/pack/lux"}}"#;
2085 assert!(matches!(
2086 load_lockfile::<Lockfile<ReadOnly>>(lockfile, Path::new("lux.lock"), ""),
2087 Err(LockfileError::IncompatibleVersion { .. })
2088 ));
2089 }
2090
2091 #[test]
2092 fn refuse_migration_for_conflicting_entrypoints() {
2093 let lockfile = r#"{
2094 "version": "1.0.0",
2095 "rocks": {
2096 "a": { "name": "foo" },
2097 "b": { "name": "foo" }
2098 },
2099 "entrypoints": ["a", "b"]
2100 }"#;
2101 assert!(matches!(
2102 load_lockfile::<Lockfile<ReadOnly>>(lockfile, Path::new("lux.lock"), ""),
2103 Err(LockfileError::ConflictingEntrypoints { .. })
2104 ));
2105 }
2106
2107 #[test]
2108 fn migrate_v1_workspace_lockfile() {
2109 let sample = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2110 .join("resources/test/lockfile-v1/workspace.lock");
2111
2112 let temp = assert_fs::TempDir::new().unwrap();
2113 let lockfile_path = temp.path().join("lux.lock");
2114 std::fs::copy(&sample, &lockfile_path).unwrap();
2115
2116 let lockfile = WorkspaceLockfile::load(lockfile_path.clone()).unwrap();
2117
2118 let on_disk: serde_json::Value =
2119 serde_json::from_str(&std::fs::read_to_string(&lockfile_path).unwrap()).unwrap();
2120 assert_eq!(on_disk["version"], "1.0.0");
2121 assert!(on_disk["dependencies"]["entrypoints"].is_array());
2122
2123 assert_eq!(lockfile.version, "2.0.0");
2124 assert_eq!(lockfile.dependencies.entrypoints.len(), 3);
2125 }
2126}