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