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