Skip to main content

lux_lib/lockfile/
mod.rs

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