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