Skip to main content

lux_lib/lockfile/
mod.rs

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