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::Wally(_) => true,
756                            RemotePackageSource::RockspecContent(_) => true,
757                            RemotePackageSource::Local => true,
758                            #[cfg(test)]
759                            RemotePackageSource::Test => unimplemented!(),
760                        },
761                        None => true,
762                    })
763                    .rev()
764                    .find(|package| req.version_req().matches(package.version()))
765            })?
766            .cloned()
767    }
768
769    fn has_rock_with_equal_constraint(&self, req: &LuaDependencySpec) -> Option<LockedPackage> {
770        self.list()
771            .get(req.name())
772            .map(|packages| {
773                packages
774                    .iter()
775                    .rev()
776                    .find(|package| package.constraint().matches_version_req(req.version_req()))
777            })?
778            .cloned()
779    }
780
781    /// Synchronise a list of packages with this lock,
782    /// producing a report of packages to add and packages to remove,
783    /// based on the version constraint and the given [`SyncStrategy`].
784    ///
785    /// NOTE: The reason we produce a report and don't add/remove packages
786    /// here is because packages need to be installed in order to be added.
787    pub(crate) fn package_sync_spec(
788        &self,
789        packages: &[LuaDependencySpec],
790        strategy: &SyncStrategy<'_>,
791    ) -> PackageSyncSpec {
792        let pkg_dir_exists = |pkg: &LockedPackage| match strategy {
793            SyncStrategy::LockfileOnly => true,
794            SyncStrategy::EnsureInstalled(tree) => tree.layout_for(pkg.spec()).root.is_dir(),
795        };
796
797        let entrypoints_to_keep: HashSet<LockedPackage> = self
798            .entrypoints
799            .values()
800            .filter_map(|id| self.get(id))
801            .filter(|local_pkg| {
802                packages.iter().any(|req| {
803                    local_pkg
804                        .constraint()
805                        .matches_version_req(req.version_req())
806                })
807            })
808            .cloned()
809            .collect();
810
811        let packages_to_keep: HashSet<&LockedPackage> = entrypoints_to_keep
812            .iter()
813            .flat_map(|local_pkg| self.get_all_dependencies(&local_pkg.id()))
814            .collect();
815
816        let to_add = packages
817            .iter()
818            .filter(|pkg| {
819                self.has_rock_with_equal_constraint(pkg)
820                    .map(|local_pkg| !pkg_dir_exists(&local_pkg))
821                    .unwrap_or(true)
822            })
823            .cloned()
824            .collect_vec();
825
826        let to_remove = self
827            .rocks()
828            .values()
829            .filter(|pkg| !packages_to_keep.contains(*pkg))
830            .cloned()
831            .collect_vec();
832
833        PackageSyncSpec { to_add, to_remove }
834    }
835
836    /// Return all dependencies of a package, including itself
837    fn get_all_dependencies(&self, id: &LockedPackageId) -> HashSet<&LockedPackage> {
838        let mut packages = HashSet::new();
839        if let Some(local_pkg) = self.get(id) {
840            packages.insert(local_pkg);
841            packages.extend(
842                local_pkg
843                    .dependencies()
844                    .iter()
845                    .flat_map(|id| self.get_all_dependencies(id)),
846            );
847        }
848        packages
849    }
850
851    /// The set of all packages reachable from the entrypoints via the
852    /// dependency graph.
853    fn reachable(&self) -> HashSet<&LockedPackage> {
854        self.entrypoints
855            .values()
856            .flat_map(|id| self.get_all_dependencies(id))
857            .collect()
858    }
859}
860
861/// A lockfile for an install tree
862#[derive(Clone, Debug, Serialize, Deserialize)]
863pub struct Lockfile<P: LockfilePermissions> {
864    #[serde(skip)]
865    filepath: PathBuf,
866    #[serde(skip)]
867    _marker: PhantomData<P>,
868    // TODO: Serialize this directly into a `Version`
869    version: String,
870    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
871    binaries: BTreeMap<LockedPackageId, RockBinaries>,
872    #[serde(flatten)]
873    lock: PackageLock,
874}
875
876#[derive(EnumIter, Debug, Clone, Copy, PartialEq, Eq, Hash)]
877pub enum LockedPackageLockType {
878    Regular,
879    Test,
880    Build,
881}
882
883/// A lockfile for a Lua project
884#[derive(Clone, Debug, Serialize, Deserialize)]
885pub struct WorkspaceLockfile<P: LockfilePermissions> {
886    #[serde(skip)]
887    filepath: PathBuf,
888    #[serde(skip)]
889    _marker: PhantomData<P>,
890    version: String,
891    #[serde(default, skip_serializing_if = "PackageLock::is_empty")]
892    dependencies: PackageLock,
893    #[serde(default, skip_serializing_if = "PackageLock::is_empty")]
894    test_dependencies: PackageLock,
895    #[serde(default, skip_serializing_if = "PackageLock::is_empty")]
896    build_dependencies: PackageLock,
897}
898
899#[derive(Error, Debug, Diagnostic)]
900#[non_exhaustive]
901pub enum LockfileError {
902    #[error(transparent)]
903    #[diagnostic(transparent)]
904    Fs(#[from] fs::FsError),
905    #[error("error parsing lockfile from JSON")]
906    ParseJson(#[source] serde_json::Error),
907    #[error("error writing lockfile to JSON")]
908    WriteJson(#[source] serde_json::Error),
909    #[error("invalid version string in lockfile: {version}")]
910    #[diagnostic(help("did you or a tool modify the lockfile manually?"))]
911    InvalidVersion {
912        version: String,
913        source: semver::Error,
914    },
915    #[error("lockfile '{}' has no `version` field", lockfile.display())]
916    #[diagnostic(
917        code(lux_lib::lockfile::missing_version),
918        help("the lockfile is corrupt or was hand-edited")
919    )]
920    MissingVersion { lockfile: PathBuf },
921    #[error(
922        "incompatible lockfile version in '{}': expected {expected}, found {found}",
923        lockfile.display()
924    )]
925    #[diagnostic(code(lux_lib::lockfile::incompatible_version), help("{help}"))]
926    IncompatibleVersion {
927        lockfile: PathBuf,
928        found: String,
929        expected: String,
930        help: String,
931    },
932    #[error("cannot migrate lockfile '{}'", lockfile.display())]
933    #[diagnostic(code(lux_lib::lockfile::conflicting_entrypoints), help("{help}"))]
934    ConflictingEntrypoints { lockfile: PathBuf, help: String },
935}
936
937#[derive(Error, Debug, Diagnostic)]
938#[non_exhaustive]
939pub enum LockfileIntegrityError {
940    #[error(
941        r#"rockspec integrity mismatch.
942- source: {src}
943- expected: {expected}
944- got: {got}
945"#
946    )]
947    #[diagnostic(help(
948        r#"the maintainer may have force-uploaded a different rockspec.
949check the rockspec source, then rerun the command with `--no-lock` to update the hash.
950"#
951    ))]
952    RockspecIntegrityMismatch {
953        src: String,
954        expected: Integrity,
955        got: Integrity,
956    },
957    #[error(
958        r#"source integrity mismatch.
959- source: {src}
960- expected: {expected}
961- got: {got}"#
962    )]
963    #[diagnostic(help(
964        r#"the maintainer may have force-uploaded a different source or moved a tag.
965check the source URL, then rerun the command with `lx --no-lock` to update the hash.
966"#
967    ))]
968    SourceIntegrityMismatch {
969        src: String,
970        expected: Integrity,
971        got: Integrity,
972    },
973    #[error(
974        "package {0} version {1} with pinned state {2} and constraint {3} not found in the lockfile."
975    )]
976    PackageNotFound(PackageName, Box<PackageVersion>, PinnedState, String),
977}
978
979#[derive(Error, Debug, Diagnostic)]
980#[non_exhaustive]
981#[error("error flushing the lockfile ({})", .filepath.display())]
982#[diagnostic(help("make sure the parent directory is writeable"))]
983pub struct FlushLockfileError {
984    filepath: PathBuf,
985    source: io::Error,
986}
987
988/// A specification for syncing a list of packages with a lockfile
989#[derive(Debug, Default)]
990pub(crate) struct PackageSyncSpec {
991    pub to_add: Vec<LuaDependencySpec>,
992    pub to_remove: Vec<LockedPackage>,
993}
994
995/// Controls how `package_sync_spec` determines whether a package exists.
996pub(crate) enum SyncStrategy<'a> {
997    /// Only check the lockfile for constraint matches.
998    LockfileOnly,
999    /// In addition to checking lockfile constraints, verify that each
1000    /// package's installation directory exists in the given tree.
1001    EnsureInstalled(&'a Tree),
1002}
1003
1004impl<P: LockfilePermissions> Lockfile<P> {
1005    pub fn version(&self) -> &String {
1006        &self.version
1007    }
1008
1009    pub fn rocks(&self) -> &BTreeMap<LockedPackageId, LockedPackage> {
1010        self.lock.rocks()
1011    }
1012
1013    pub fn is_dependency(&self, package: &LockedPackageId) -> bool {
1014        self.lock.is_dependency(package)
1015    }
1016
1017    pub fn is_entrypoint(&self, package: &LockedPackageId) -> bool {
1018        self.lock.is_entrypoint(package)
1019    }
1020
1021    /// Returns the entrypoint with the given package name, if any.
1022    pub fn entrypoint(&self, name: &PackageName) -> Option<&LockedPackage> {
1023        self.lock.entrypoint(name)
1024    }
1025
1026    /// Returns all rocks in the lockfile that are reachable from an entrypoint
1027    /// via the dependency graph.
1028    pub fn reachable_rocks(&self) -> Vec<LockedPackage> {
1029        self.lock.reachable().into_iter().cloned().collect()
1030    }
1031
1032    pub fn entry_type(&self, package: &LockedPackageId) -> EntryType {
1033        if self.lock.is_entrypoint(package) {
1034            EntryType::Entrypoint
1035        } else {
1036            EntryType::DependencyOnly
1037        }
1038    }
1039
1040    /// The binaries installed in the tree for a given package.
1041    pub(crate) fn binaries(&self, id: &LockedPackageId) -> Option<&RockBinaries> {
1042        self.binaries.get(id)
1043    }
1044
1045    pub(crate) fn local_pkg_lock(&self) -> &PackageLock {
1046        &self.lock
1047    }
1048
1049    pub fn get(&self, id: &LockedPackageId) -> Option<&LockedPackage> {
1050        self.lock.get(id)
1051    }
1052
1053    /// Unsafe because this assumes a prior check if the package is present
1054    ///
1055    /// # Safety
1056    ///
1057    /// Ensure that the package is present in the lockfile before calling this function.
1058    pub unsafe fn get_unchecked(&self, id: &LockedPackageId) -> &LockedPackage {
1059        self.lock.get(id).unwrap_unchecked()
1060    }
1061
1062    pub(crate) fn list(&self) -> HashMap<PackageName, Vec<LockedPackage>> {
1063        self.lock.list()
1064    }
1065
1066    pub(crate) fn has_rock(
1067        &self,
1068        req: &PackageReq,
1069        filter: Option<RemotePackageTypeFilterSpec>,
1070    ) -> Option<LockedPackage> {
1071        self.lock.has_rock(req, filter)
1072    }
1073
1074    /// Find all rocks that match the requirement
1075    pub(crate) fn find_rocks(&self, req: &PackageReq) -> Vec<LockedPackageId> {
1076        match self.list().get(req.name()) {
1077            Some(packages) => packages
1078                .iter()
1079                .rev()
1080                .filter(|package| req.version_req().matches(package.version()))
1081                .map(|package| package.id())
1082                .collect_vec(),
1083            None => Vec::default(),
1084        }
1085    }
1086
1087    /// Validate the integrity of an installed package with the entry in this lockfile.
1088    pub(crate) fn validate_integrity(
1089        &self,
1090        expected_package: &LockedPackage,
1091    ) -> Result<(), LockfileIntegrityError> {
1092        // NOTE: We can't query by ID, because when installing from a lockfile (e.g. during sync),
1093        // the constraint is always `==`.
1094        match self.list().get(expected_package.name()) {
1095            None => Err(integrity_err_not_found(expected_package)),
1096            Some(rocks) => match rocks
1097                .iter()
1098                .find(|rock| rock.version() == expected_package.version())
1099            {
1100                None => Err(integrity_err_not_found(expected_package)),
1101                Some(installed_package) => {
1102                    if expected_package
1103                        .hashes
1104                        .rockspec
1105                        .matches(&installed_package.hashes.rockspec)
1106                        .is_none()
1107                    {
1108                        return Err(LockfileIntegrityError::RockspecIntegrityMismatch {
1109                            src: expected_package.source.to_string(),
1110                            expected: expected_package.hashes.rockspec.clone(),
1111                            got: installed_package.hashes.rockspec.clone(),
1112                        });
1113                    }
1114                    if expected_package
1115                        .hashes
1116                        .source
1117                        .matches(&installed_package.hashes.source)
1118                        .is_none()
1119                    {
1120                        return Err(LockfileIntegrityError::SourceIntegrityMismatch {
1121                            src: expected_package
1122                                .source_url
1123                                .as_ref()
1124                                .map(|url| url.to_string())
1125                                .unwrap_or(expected_package.source.to_string()),
1126                            expected: expected_package.hashes.source.clone(),
1127                            got: installed_package.hashes.source.clone(),
1128                        });
1129                    }
1130                    Ok(())
1131                }
1132            },
1133        }
1134    }
1135
1136    fn flush(&self) -> Result<(), FlushLockfileError> {
1137        let content = serde_json::to_string_pretty(&self).map_err(|err| FlushLockfileError {
1138            filepath: self.filepath.to_path_buf(),
1139            source: io::Error::other(err),
1140        })?;
1141
1142        fs::sync::write_atomic(&self.filepath, content).map_err(|err| FlushLockfileError {
1143            filepath: self.filepath.to_path_buf(),
1144            source: io::Error::other(err),
1145        })
1146    }
1147}
1148
1149impl<P: LockfilePermissions> WorkspaceLockfile<P> {
1150    pub(crate) fn rocks(
1151        &self,
1152        deps: &LockedPackageLockType,
1153    ) -> &BTreeMap<LockedPackageId, LockedPackage> {
1154        match deps {
1155            LockedPackageLockType::Regular => self.dependencies.rocks(),
1156            LockedPackageLockType::Test => self.test_dependencies.rocks(),
1157            LockedPackageLockType::Build => self.build_dependencies.rocks(),
1158        }
1159    }
1160
1161    pub(crate) fn get(
1162        &self,
1163        id: &LockedPackageId,
1164        deps: &LockedPackageLockType,
1165    ) -> Option<&LockedPackage> {
1166        match deps {
1167            LockedPackageLockType::Regular => self.dependencies.get(id),
1168            LockedPackageLockType::Test => self.test_dependencies.get(id),
1169            LockedPackageLockType::Build => self.build_dependencies.get(id),
1170        }
1171    }
1172
1173    pub(crate) fn is_entrypoint(
1174        &self,
1175        package: &LockedPackageId,
1176        deps: &LockedPackageLockType,
1177    ) -> bool {
1178        match deps {
1179            LockedPackageLockType::Regular => self.dependencies.is_entrypoint(package),
1180            LockedPackageLockType::Test => self.test_dependencies.is_entrypoint(package),
1181            LockedPackageLockType::Build => self.build_dependencies.is_entrypoint(package),
1182        }
1183    }
1184
1185    pub(crate) fn package_sync_spec(
1186        &self,
1187        packages: &[LuaDependencySpec],
1188        deps: &LockedPackageLockType,
1189        strategy: &SyncStrategy<'_>,
1190    ) -> PackageSyncSpec {
1191        match deps {
1192            LockedPackageLockType::Regular => {
1193                self.dependencies.package_sync_spec(packages, strategy)
1194            }
1195            LockedPackageLockType::Test => {
1196                self.test_dependencies.package_sync_spec(packages, strategy)
1197            }
1198            LockedPackageLockType::Build => self
1199                .build_dependencies
1200                .package_sync_spec(packages, strategy),
1201        }
1202    }
1203
1204    pub(crate) fn local_pkg_lock(&self, deps: &LockedPackageLockType) -> &PackageLock {
1205        match deps {
1206            LockedPackageLockType::Regular => &self.dependencies,
1207            LockedPackageLockType::Test => &self.test_dependencies,
1208            LockedPackageLockType::Build => &self.build_dependencies,
1209        }
1210    }
1211
1212    pub(crate) fn local_pkg_locks(&self) -> Vec<PackageLock> {
1213        vec![
1214            self.dependencies.clone(),
1215            self.test_dependencies.clone(),
1216            self.build_dependencies.clone(),
1217        ]
1218    }
1219
1220    fn flush(&self) -> io::Result<()> {
1221        let content = serde_json::to_string_pretty(&self)?;
1222
1223        fs::sync::write_atomic(&self.filepath, content).map_err(io::Error::other)?;
1224
1225        Ok(())
1226    }
1227}
1228
1229impl Lockfile<ReadOnly> {
1230    /// Create a new `Lockfile`, writing an empty file if none exists.
1231    #[tracing::instrument(level = "trace")]
1232    pub(crate) fn new(filepath: PathBuf) -> Result<Lockfile<ReadOnly>, LockfileError> {
1233        // Ensure that the lockfile exists
1234        match File::options().create_new(true).write(true).open(&filepath) {
1235            Ok(mut file) => {
1236                let empty_lockfile: Lockfile<ReadOnly> = Lockfile {
1237                    filepath: filepath.clone(),
1238                    _marker: PhantomData,
1239                    version: LOCKFILE_VERSION_STR.into(),
1240                    binaries: BTreeMap::new(),
1241                    lock: PackageLock::default(),
1242                };
1243                let json_str =
1244                    serde_json::to_string(&empty_lockfile).map_err(LockfileError::WriteJson)?;
1245                write!(file, "{json_str}").map_err(|source| fs::FsError::Write {
1246                    path: filepath.to_path_buf(),
1247                    source,
1248                })?;
1249            }
1250            Err(err) if err.kind() == ErrorKind::AlreadyExists => {}
1251            Err(source) => {
1252                return Err(LockfileError::Fs(fs::FsError::FileOpen {
1253                    path: filepath.to_path_buf(),
1254                    source,
1255                }));
1256            }
1257        }
1258
1259        Self::load(filepath)
1260    }
1261
1262    /// Load a `Lockfile`, failing if none exists.
1263    #[tracing::instrument(level = "trace")]
1264    pub fn load(filepath: PathBuf) -> Result<Lockfile<ReadOnly>, LockfileError> {
1265        let content = fs::sync::read_to_string(&filepath)?;
1266        let tree_dir = filepath.parent().unwrap_or(filepath.as_path());
1267        let help = format!(
1268            "this install tree was created by an incompatible version of Lux.
1269remove the tree at '{}' and reinstall all packages it contained.",
1270            tree_dir.display()
1271        );
1272        let mut lockfile: Lockfile<ReadOnly> = load_lockfile(&content, &filepath, &help)?;
1273        lockfile.filepath = filepath;
1274        Ok(lockfile)
1275    }
1276
1277    /// Creates a temporary, writeable lockfile which can never flush.
1278    pub(crate) fn into_temporary(self) -> Lockfile<ReadWrite> {
1279        Lockfile::<ReadWrite> {
1280            _marker: PhantomData,
1281            filepath: self.filepath,
1282            version: self.version,
1283            binaries: self.binaries,
1284            lock: self.lock,
1285        }
1286    }
1287
1288    /// Creates a lockfile guard, flushing the lockfile automatically
1289    /// once the guard goes out of scope.
1290    pub fn write_guard(self) -> LockfileGuard {
1291        LockfileGuard(self.into_temporary())
1292    }
1293
1294    /// Converts the current lockfile into a writeable one, executes `cb` and flushes
1295    /// the lockfile.
1296    #[tracing::instrument(level = "trace", skip_all)]
1297    pub fn map_then_flush<T, F, E>(self, cb: F) -> Result<T, FlushLockfileError>
1298    where
1299        F: FnOnce(&mut Lockfile<ReadWrite>) -> Result<T, E>,
1300        E: Error,
1301        E: From<io::Error>,
1302        E: Into<Box<dyn Error + Send + Sync>>,
1303    {
1304        let mut writeable_lockfile = self.into_temporary();
1305
1306        let result = cb(&mut writeable_lockfile).map_err(|err| FlushLockfileError {
1307            filepath: writeable_lockfile.filepath.to_path_buf(),
1308            source: io::Error::other(err),
1309        })?;
1310
1311        writeable_lockfile.flush()?;
1312
1313        Ok(result)
1314    }
1315
1316    // TODO: Add this once async closures are stabilized
1317    // Converts the current lockfile into a writeable one, executes `cb` asynchronously and flushes
1318    // the lockfile.
1319    //pub async fn map_then_flush_async<T, F, E, Fut>(self, cb: F) -> Result<T, E>
1320    //where
1321    //    F: AsyncFnOnce(&mut Lockfile<ReadWrite>) -> Result<T, E>,
1322    //    E: Error,
1323    //    E: From<io::Error>,
1324    //{
1325    //    let mut writeable_lockfile = self.into_temporary();
1326    //
1327    //    let result = cb(&mut writeable_lockfile).await?;
1328    //
1329    //    writeable_lockfile.flush()?;
1330    //
1331    //    Ok(result)
1332    //}
1333}
1334
1335impl WorkspaceLockfile<ReadOnly> {
1336    /// Create a new `ProjectLockfile`, writing an empty file if none exists.
1337    #[tracing::instrument(level = "trace")]
1338    pub fn new(filepath: PathBuf) -> Result<WorkspaceLockfile<ReadOnly>, LockfileError> {
1339        // Ensure that the lockfile exists
1340        match File::options().create_new(true).write(true).open(&filepath) {
1341            Ok(mut file) => {
1342                let empty_lockfile: WorkspaceLockfile<ReadOnly> = WorkspaceLockfile {
1343                    filepath: filepath.clone(),
1344                    _marker: PhantomData,
1345                    version: LOCKFILE_VERSION_STR.into(),
1346                    dependencies: PackageLock::default(),
1347                    test_dependencies: PackageLock::default(),
1348                    build_dependencies: PackageLock::default(),
1349                };
1350                let json_str =
1351                    serde_json::to_string(&empty_lockfile).map_err(LockfileError::WriteJson)?;
1352                write!(file, "{json_str}").map_err(|source| fs::FsError::Write {
1353                    path: filepath.to_path_buf(),
1354                    source,
1355                })?;
1356            }
1357            Err(err) if err.kind() == ErrorKind::AlreadyExists => {}
1358            Err(source) => {
1359                return Err(LockfileError::Fs(fs::FsError::FileOpen {
1360                    path: filepath.to_path_buf(),
1361                    source,
1362                }));
1363            }
1364        }
1365
1366        Self::load(filepath)
1367    }
1368
1369    /// Load a `ProjectLockfile`, failing if none exists.
1370    #[tracing::instrument(level = "trace")]
1371    pub fn load(filepath: PathBuf) -> Result<WorkspaceLockfile<ReadOnly>, LockfileError> {
1372        let content = fs::sync::read_to_string(&filepath)?;
1373        let mut lockfile: WorkspaceLockfile<ReadOnly> = load_lockfile(
1374            &content,
1375            &filepath,
1376            "this lockfile was created by an incompatible version of Lux.
1377remove the `lux.lock` and run `lx sync` to regenerate it.",
1378        )?;
1379
1380        lockfile.filepath = filepath;
1381
1382        Ok(lockfile)
1383    }
1384
1385    /// Creates a temporary, writeable project lockfile which can never flush.
1386    fn into_temporary(self) -> WorkspaceLockfile<ReadWrite> {
1387        WorkspaceLockfile::<ReadWrite> {
1388            _marker: PhantomData,
1389            filepath: self.filepath,
1390            version: self.version,
1391            dependencies: self.dependencies,
1392            test_dependencies: self.test_dependencies,
1393            build_dependencies: self.build_dependencies,
1394        }
1395    }
1396
1397    /// Creates a project lockfile guard, flushing the lockfile automatically
1398    /// once the guard goes out of scope.
1399    pub fn write_guard(self) -> ProjectLockfileGuard {
1400        ProjectLockfileGuard(self.into_temporary())
1401    }
1402}
1403
1404impl Lockfile<ReadWrite> {
1405    pub(crate) fn add_entrypoint(&mut self, rock: &LockedPackage) {
1406        self.add(rock);
1407        self.lock.entrypoints.insert(rock.name().clone(), rock.id());
1408    }
1409
1410    pub(crate) fn add(&mut self, rock: &LockedPackage) {
1411        // Since rocks entries are mutable, we only add the dependency if it
1412        // has not already been added.
1413        self.lock
1414            .rocks
1415            .entry(rock.id())
1416            .or_insert_with(|| rock.clone());
1417    }
1418
1419    /// Add a dependency for a package.
1420    pub(crate) fn add_dependency(&mut self, target: &LockedPackage, dependency: &LockedPackage) {
1421        self.lock
1422            .rocks
1423            .entry(target.id())
1424            .and_modify(|rock| {
1425                let id = dependency.id();
1426                if !rock.spec.dependencies.contains(&id) {
1427                    rock.spec.dependencies.push(id);
1428                }
1429            })
1430            .or_insert_with(|| {
1431                let id = dependency.id();
1432                let mut target = target.clone();
1433                if !target.spec.dependencies.contains(&id) {
1434                    target.spec.dependencies.push(id);
1435                }
1436                target
1437            });
1438        self.add(dependency);
1439    }
1440
1441    /// Add a build dependency for a package.
1442    pub(crate) fn add_build_dependency(
1443        &mut self,
1444        target: &LockedPackage,
1445        dependency: &LockedPackage,
1446    ) {
1447        self.lock
1448            .rocks
1449            .entry(target.id())
1450            .and_modify(|rock| {
1451                let id = dependency.id();
1452                if !rock.spec.build_dependencies.contains(&id) {
1453                    rock.spec.build_dependencies.push(id);
1454                }
1455            })
1456            .or_insert_with(|| {
1457                let id = dependency.id();
1458                let mut target = target.clone();
1459                if !target.spec.build_dependencies.contains(&id) {
1460                    target.spec.build_dependencies.push(id);
1461                }
1462                target
1463            });
1464    }
1465
1466    /// Record the binaries installed for `package` into the tree lockfile.
1467    pub(crate) fn set_binaries(&mut self, package: &LockedPackage, binaries: RockBinaries) {
1468        self.binaries.insert(package.id(), binaries);
1469    }
1470
1471    pub(crate) fn remove(&mut self, target: &LockedPackage) {
1472        self.binaries.remove(&target.id());
1473        self.lock.remove(target)
1474    }
1475
1476    pub(crate) fn remove_by_id(&mut self, target: &LockedPackageId) {
1477        self.binaries.remove(target);
1478        self.lock.remove_by_id(target)
1479    }
1480
1481    pub(crate) fn sync(&mut self, lock: &PackageLock) {
1482        self.lock = lock.clone();
1483    }
1484
1485    // TODO: `fn entrypoints() -> Vec<LockedRock>`
1486}
1487
1488impl WorkspaceLockfile<ReadWrite> {
1489    pub(crate) fn remove(&mut self, target: &LockedPackage, deps: &LockedPackageLockType) {
1490        match deps {
1491            LockedPackageLockType::Regular => self.dependencies.remove(target),
1492            LockedPackageLockType::Test => self.test_dependencies.remove(target),
1493            LockedPackageLockType::Build => self.build_dependencies.remove(target),
1494        }
1495    }
1496
1497    pub(crate) fn sync(&mut self, lock: &PackageLock, deps: &LockedPackageLockType) {
1498        match deps {
1499            LockedPackageLockType::Regular => {
1500                self.dependencies = lock.clone();
1501            }
1502            LockedPackageLockType::Test => {
1503                self.test_dependencies = lock.clone();
1504            }
1505            LockedPackageLockType::Build => {
1506                self.build_dependencies = lock.clone();
1507            }
1508        }
1509    }
1510
1511    pub(crate) fn merge(&mut self, lock: &PackageLock, deps: &LockedPackageLockType) {
1512        match deps {
1513            LockedPackageLockType::Regular => self.dependencies.merge(lock),
1514            LockedPackageLockType::Test => self.test_dependencies.merge(lock),
1515            LockedPackageLockType::Build => self.build_dependencies.merge(lock),
1516        }
1517    }
1518}
1519
1520/// Flushes a lockfile automatically when it goes out of scope
1521pub struct LockfileGuard(Lockfile<ReadWrite>);
1522
1523pub struct ProjectLockfileGuard(WorkspaceLockfile<ReadWrite>);
1524
1525impl Serialize for LockfileGuard {
1526    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1527    where
1528        S: serde::Serializer,
1529    {
1530        self.0.serialize(serializer)
1531    }
1532}
1533
1534impl Serialize for ProjectLockfileGuard {
1535    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1536    where
1537        S: serde::Serializer,
1538    {
1539        self.0.serialize(serializer)
1540    }
1541}
1542
1543impl<'de> Deserialize<'de> for LockfileGuard {
1544    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1545    where
1546        D: serde::Deserializer<'de>,
1547    {
1548        Ok(LockfileGuard(Lockfile::<ReadWrite>::deserialize(
1549            deserializer,
1550        )?))
1551    }
1552}
1553
1554impl<'de> Deserialize<'de> for ProjectLockfileGuard {
1555    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1556    where
1557        D: serde::Deserializer<'de>,
1558    {
1559        Ok(ProjectLockfileGuard(
1560            WorkspaceLockfile::<ReadWrite>::deserialize(deserializer)?,
1561        ))
1562    }
1563}
1564
1565impl Deref for LockfileGuard {
1566    type Target = Lockfile<ReadWrite>;
1567
1568    fn deref(&self) -> &Self::Target {
1569        &self.0
1570    }
1571}
1572
1573impl Deref for ProjectLockfileGuard {
1574    type Target = WorkspaceLockfile<ReadWrite>;
1575
1576    fn deref(&self) -> &Self::Target {
1577        &self.0
1578    }
1579}
1580
1581impl DerefMut for LockfileGuard {
1582    fn deref_mut(&mut self) -> &mut Self::Target {
1583        &mut self.0
1584    }
1585}
1586
1587impl DerefMut for ProjectLockfileGuard {
1588    fn deref_mut(&mut self) -> &mut Self::Target {
1589        &mut self.0
1590    }
1591}
1592
1593impl Drop for LockfileGuard {
1594    fn drop(&mut self) {
1595        let _ = self.flush();
1596    }
1597}
1598
1599impl Drop for ProjectLockfileGuard {
1600    fn drop(&mut self) {
1601        let _ = self.flush();
1602    }
1603}
1604
1605fn load_lockfile<T>(content: &str, lockfile: &Path, help: &str) -> Result<T, LockfileError>
1606where
1607    T: serde::de::DeserializeOwned,
1608{
1609    let mut value = parse_lockfile_json(content)?;
1610    if lockfile_version(&value, lockfile)?.major == 1 {
1611        migrate_v1_lockfile(&mut value, lockfile)?;
1612    }
1613    deserialize_lockfile(value, lockfile, help)
1614}
1615
1616fn parse_lockfile_json(
1617    content: &str,
1618) -> Result<serde_json::Map<String, serde_json::Value>, LockfileError> {
1619    serde_json::from_str(content).map_err(LockfileError::ParseJson)
1620}
1621
1622fn lockfile_version(
1623    value: &serde_json::Map<String, serde_json::Value>,
1624    lockfile: &Path,
1625) -> Result<semver::Version, LockfileError> {
1626    let version = value
1627        .get("version")
1628        .and_then(|version| version.as_str())
1629        .ok_or(LockfileError::MissingVersion {
1630            lockfile: lockfile.to_path_buf(),
1631        })?;
1632    semver::Version::parse(version).map_err(|source| LockfileError::InvalidVersion {
1633        version: version.to_string(),
1634        source,
1635    })
1636}
1637
1638fn deserialize_lockfile<T>(
1639    value: serde_json::Map<String, serde_json::Value>,
1640    lockfile: &Path,
1641    help: &str,
1642) -> Result<T, LockfileError>
1643where
1644    T: serde::de::DeserializeOwned,
1645{
1646    let found = lockfile_version(&value, lockfile)?;
1647    if found.major != LOCKFILE_VERSION {
1648        return Err(LockfileError::IncompatibleVersion {
1649            lockfile: lockfile.to_path_buf(),
1650            found: found.to_string(),
1651            expected: LOCKFILE_VERSION_STR.into(),
1652            help: help.into(),
1653        });
1654    }
1655    serde_json::from_value(serde_json::Value::Object(value)).map_err(LockfileError::ParseJson)
1656}
1657
1658fn migrate_v1_lockfile(
1659    value: &mut serde_json::Map<String, serde_json::Value>,
1660    lockfile: &Path,
1661) -> Result<(), LockfileError> {
1662    let tree_dir = lockfile.parent().unwrap_or(lockfile);
1663    let found = lockfile_version(value, lockfile)?;
1664    if value.contains_key("entrypoint_layout") {
1665        return Err(LockfileError::IncompatibleVersion {
1666            lockfile: lockfile.to_path_buf(),
1667            found: found.to_string(),
1668            expected: LOCKFILE_VERSION_STR.into(),
1669            help: format!(
1670                "this install tree uses a custom entrypoint layout that cannot be migrated automatically.
1671remove the tree at '{}' and reinstall all packages it contained.",
1672                tree_dir.display()
1673            ),
1674        });
1675    }
1676
1677    let conflict_help = format!(
1678        "this lockfile contains entrypoints that cannot be represented in the current format.
1679remove the tree at '{}' and reinstall all packages it contained.",
1680        tree_dir.display()
1681    );
1682
1683    if value.contains_key("entrypoints") {
1684        migrate_entrypoints(value, lockfile, &conflict_help)?;
1685    }
1686    for section in ["dependencies", "test_dependencies", "build_dependencies"] {
1687        if let Some(section) = value
1688            .get_mut(section)
1689            .and_then(|section| section.as_object_mut())
1690        {
1691            migrate_entrypoints(section, lockfile, &conflict_help)?;
1692        }
1693    }
1694    value.insert(
1695        "version".into(),
1696        serde_json::Value::String(LOCKFILE_VERSION_STR.into()),
1697    );
1698    Ok(())
1699}
1700
1701fn migrate_entrypoints(
1702    section: &mut serde_json::Map<String, serde_json::Value>,
1703    lockfile: &Path,
1704    help: &str,
1705) -> Result<(), LockfileError> {
1706    let conflicting_entrypoints = || LockfileError::ConflictingEntrypoints {
1707        lockfile: lockfile.to_path_buf(),
1708        help: help.to_string(),
1709    };
1710
1711    let rocks = section.get("rocks").and_then(|rocks| rocks.as_object());
1712    let mut entrypoints = serde_json::Map::new();
1713    if let Some(old_entrypoints) = section.get("entrypoints") {
1714        for id in old_entrypoints
1715            .as_array()
1716            .ok_or_else(conflicting_entrypoints)?
1717        {
1718            let id = id.as_str().ok_or_else(conflicting_entrypoints)?;
1719            let name = rocks
1720                .and_then(|rocks| rocks.get(id))
1721                .and_then(|package| package.get("name"))
1722                .and_then(|name| name.as_str())
1723                .ok_or_else(conflicting_entrypoints)?;
1724            if entrypoints.contains_key(name) {
1725                return Err(conflicting_entrypoints());
1726            }
1727            entrypoints.insert(name.to_string(), serde_json::Value::String(id.to_string()));
1728        }
1729    }
1730    section.insert("entrypoints".into(), serde_json::Value::Object(entrypoints));
1731    Ok(())
1732}
1733
1734fn integrity_err_not_found(package: &LockedPackage) -> LockfileIntegrityError {
1735    LockfileIntegrityError::PackageNotFound(
1736        package.name().clone(),
1737        Box::new(package.version().clone()),
1738        package.spec.pinned,
1739        package
1740            .spec
1741            .constraint
1742            .clone()
1743            .unwrap_or("UNCONSTRAINED".into()),
1744    )
1745}
1746
1747fn deserialize_url<'de, D>(deserializer: D) -> Result<Url, D::Error>
1748where
1749    D: serde::Deserializer<'de>,
1750{
1751    let s = String::deserialize(deserializer)?;
1752    Url::parse(&s).map_err(serde::de::Error::custom)
1753}
1754
1755fn serialize_url<S>(url: &Url, serializer: S) -> Result<S::Ok, S::Error>
1756where
1757    S: Serializer,
1758{
1759    url.as_str().serialize(serializer)
1760}
1761
1762#[cfg(test)]
1763mod tests {
1764    use super::*;
1765    use std::{fs::remove_file, path::PathBuf};
1766
1767    use assert_fs::fixture::PathCopy;
1768    use insta::{assert_json_snapshot, sorted_redaction};
1769
1770    use crate::{config::ConfigBuilder, lua_version::LuaVersion, package::PackageSpec};
1771
1772    #[test]
1773    fn parse_lockfile() {
1774        let temp = assert_fs::TempDir::new().unwrap();
1775        temp.copy_from(
1776            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-tree"),
1777            &["**"],
1778        )
1779        .unwrap();
1780
1781        let config = ConfigBuilder::new()
1782            .unwrap()
1783            .user_tree(Some(temp.to_path_buf()))
1784            .build()
1785            .unwrap();
1786        let tree = config.user_tree(LuaVersion::Lua51).unwrap();
1787        let lockfile = tree.lockfile().unwrap();
1788
1789        assert_json_snapshot!(lockfile, { ".**" => sorted_redaction() });
1790    }
1791
1792    #[test]
1793    fn add_rocks() {
1794        let temp = assert_fs::TempDir::new().unwrap();
1795        temp.copy_from(
1796            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-tree"),
1797            &["**"],
1798        )
1799        .unwrap();
1800
1801        let mock_hashes = LockedPackageHashes {
1802            rockspec: "sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="
1803                .parse()
1804                .unwrap(),
1805            source: "sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="
1806                .parse()
1807                .unwrap(),
1808        };
1809
1810        let config = ConfigBuilder::new()
1811            .unwrap()
1812            .user_tree(Some(temp.to_path_buf()))
1813            .build()
1814            .unwrap();
1815        let tree = config.user_tree(LuaVersion::Lua51).unwrap();
1816        let mut lockfile = tree.lockfile().unwrap().write_guard();
1817
1818        let test_package = PackageSpec::parse("test1".to_string(), "0.1.0".to_string()).unwrap();
1819        let test_local_package = LockedPackage::from(
1820            &test_package,
1821            crate::lockfile::LockConstraint::Unconstrained,
1822            RemotePackageSource::Test,
1823            None,
1824            mock_hashes.clone(),
1825        );
1826        lockfile.add_entrypoint(&test_local_package);
1827
1828        let test_dep_package =
1829            PackageSpec::parse("test2".to_string(), "0.1.0".to_string()).unwrap();
1830        let mut test_local_dep_package = LockedPackage::from(
1831            &test_dep_package,
1832            crate::lockfile::LockConstraint::Constrained(">= 1.0.0".parse().unwrap()),
1833            RemotePackageSource::Test,
1834            None,
1835            mock_hashes.clone(),
1836        );
1837        test_local_dep_package.spec.pinned = PinnedState::Pinned;
1838        lockfile.add_dependency(&test_local_package, &test_local_dep_package);
1839
1840        assert_json_snapshot!(lockfile, { ".**" => sorted_redaction() });
1841    }
1842
1843    #[test]
1844    fn parse_nonexistent_lockfile() {
1845        let tree_path =
1846            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-tree");
1847
1848        let temp = assert_fs::TempDir::new().unwrap();
1849        temp.copy_from(&tree_path, &["**"]).unwrap();
1850
1851        remove_file(temp.join("5.1/lux.lock")).unwrap();
1852
1853        let config = ConfigBuilder::new()
1854            .unwrap()
1855            .user_tree(Some(temp.to_path_buf()))
1856            .build()
1857            .unwrap();
1858        let tree = config.user_tree(LuaVersion::Lua51).unwrap();
1859
1860        let _ = tree.lockfile().unwrap().write_guard(); // Try to create the lockfile but don't actually do anything with it.
1861    }
1862
1863    fn get_test_lockfile() -> Lockfile<ReadOnly> {
1864        let sample_tree = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1865            .join("resources/test/sample-tree/5.1/lux.lock");
1866        Lockfile::new(sample_tree).unwrap()
1867    }
1868
1869    #[test]
1870    fn test_sync_spec() {
1871        let lockfile = get_test_lockfile();
1872        let packages = vec![
1873            PackageReq::parse("neorg@8.8.1-1").unwrap().into(),
1874            PackageReq::parse("lua-cjson@2.1.0").unwrap().into(),
1875            PackageReq::parse("nonexistent").unwrap().into(),
1876        ];
1877
1878        let sync_spec = lockfile
1879            .lock
1880            .package_sync_spec(&packages, &SyncStrategy::LockfileOnly);
1881
1882        assert_eq!(sync_spec.to_add.len(), 1);
1883
1884        // Should keep dependencies of neorg 8.8.1-1
1885        assert!(!sync_spec
1886            .to_remove
1887            .iter()
1888            .any(|pkg| pkg.name().to_string() == "nvim-nio"
1889                && pkg.constraint()
1890                    == LockConstraint::Constrained(">=1.7.0, <1.8.0".parse().unwrap())));
1891        assert!(!sync_spec
1892            .to_remove
1893            .iter()
1894            .any(|pkg| pkg.name().to_string() == "lua-utils.nvim"
1895                && pkg.constraint() == LockConstraint::Constrained("=1.0.2".parse().unwrap())));
1896        assert!(!sync_spec
1897            .to_remove
1898            .iter()
1899            .any(|pkg| pkg.name().to_string() == "plenary.nvim"
1900                && pkg.constraint() == LockConstraint::Constrained("=0.1.4".parse().unwrap())));
1901        assert!(!sync_spec
1902            .to_remove
1903            .iter()
1904            .any(|pkg| pkg.name().to_string() == "nui.nvim"
1905                && pkg.constraint() == LockConstraint::Constrained("=0.3.0".parse().unwrap())));
1906        assert!(!sync_spec
1907            .to_remove
1908            .iter()
1909            .any(|pkg| pkg.name().to_string() == "pathlib.nvim"
1910                && pkg.constraint()
1911                    == LockConstraint::Constrained(">=2.2.0, <2.3.0".parse().unwrap())));
1912    }
1913
1914    #[test]
1915    fn test_sync_spec_remove() {
1916        let lockfile = get_test_lockfile();
1917        let packages = vec![
1918            PackageReq::parse("lua-cjson@2.1.0").unwrap().into(),
1919            PackageReq::parse("nonexistent").unwrap().into(),
1920        ];
1921
1922        let sync_spec = lockfile
1923            .lock
1924            .package_sync_spec(&packages, &SyncStrategy::LockfileOnly);
1925
1926        assert_eq!(sync_spec.to_add.len(), 1);
1927
1928        // Should remove:
1929        // - neorg
1930        // - dependencies unique to neorg
1931        assert!(sync_spec
1932            .to_remove
1933            .iter()
1934            .any(|pkg| pkg.name().to_string() == "neorg"
1935                && pkg.version() == &"8.8.1-1".parse().unwrap()));
1936        assert!(sync_spec
1937            .to_remove
1938            .iter()
1939            .any(|pkg| pkg.name().to_string() == "nvim-nio"
1940                && pkg.constraint()
1941                    == LockConstraint::Constrained(">=1.7.0, <1.8.0".parse().unwrap())));
1942        assert!(sync_spec
1943            .to_remove
1944            .iter()
1945            .any(|pkg| pkg.name().to_string() == "lua-utils.nvim"
1946                && pkg.constraint() == LockConstraint::Constrained("=1.0.2".parse().unwrap())));
1947        assert!(sync_spec
1948            .to_remove
1949            .iter()
1950            .any(|pkg| pkg.name().to_string() == "plenary.nvim"
1951                && pkg.constraint() == LockConstraint::Constrained("=0.1.4".parse().unwrap())));
1952        assert!(sync_spec
1953            .to_remove
1954            .iter()
1955            .any(|pkg| pkg.name().to_string() == "nui.nvim"
1956                && pkg.constraint() == LockConstraint::Constrained("=0.3.0".parse().unwrap())));
1957        assert!(sync_spec
1958            .to_remove
1959            .iter()
1960            .any(|pkg| pkg.name().to_string() == "pathlib.nvim"
1961                && pkg.constraint()
1962                    == LockConstraint::Constrained(">=2.2.0, <2.3.0".parse().unwrap())));
1963    }
1964
1965    #[test]
1966    fn test_sync_spec_empty() {
1967        let lockfile = get_test_lockfile();
1968        let packages = vec![];
1969        let sync_spec = lockfile
1970            .lock
1971            .package_sync_spec(&packages, &SyncStrategy::LockfileOnly);
1972
1973        // Should remove all packages
1974        assert!(sync_spec.to_add.is_empty());
1975        assert_eq!(sync_spec.to_remove.len(), lockfile.rocks().len());
1976    }
1977
1978    #[test]
1979    fn test_sync_spec_different_constraints() {
1980        let lockfile = get_test_lockfile();
1981        let packages = vec![PackageReq::parse("nvim-nio>=2.0.0").unwrap().into()];
1982        let sync_spec = lockfile
1983            .lock
1984            .package_sync_spec(&packages, &SyncStrategy::LockfileOnly);
1985
1986        let expected: PackageVersionReq = ">=2.0.0".parse().unwrap();
1987        assert!(sync_spec
1988            .to_add
1989            .iter()
1990            .any(|req| req.name().to_string() == "nvim-nio" && req.version_req() == &expected));
1991
1992        assert!(sync_spec
1993            .to_remove
1994            .iter()
1995            .any(|pkg| pkg.name().to_string() == "nvim-nio"));
1996    }
1997
1998    #[test]
1999    fn test_sync_spec_ensure_installed() {
2000        let temp = assert_fs::TempDir::new().unwrap();
2001        temp.copy_from(
2002            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-tree"),
2003            &["**"],
2004        )
2005        .unwrap();
2006
2007        let config = ConfigBuilder::new()
2008            .unwrap()
2009            .user_tree(Some(temp.to_path_buf()))
2010            .build()
2011            .unwrap();
2012        let tree = config.user_tree(LuaVersion::Lua51).unwrap();
2013        let lockfile = tree.lockfile().unwrap();
2014
2015        let packages: Vec<LuaDependencySpec> = vec![
2016            PackageReq::parse("neorg@8.8.1-1").unwrap().into(),
2017            // This package isn't installed in the tree
2018            PackageReq::parse("lua-cjson@2.1.0").unwrap().into(),
2019            // And neither is this
2020            PackageReq::parse("nonexistent").unwrap().into(),
2021        ];
2022
2023        // Since lua-cjson is not present in the tree, it should get put into `to_add`
2024        let sync_spec = lockfile
2025            .lock
2026            .package_sync_spec(&packages, &SyncStrategy::EnsureInstalled(&tree));
2027
2028        assert!(!sync_spec
2029            .to_add
2030            .iter()
2031            .any(|req| req.name().to_string() == "neorg"));
2032
2033        assert!(sync_spec
2034            .to_add
2035            .iter()
2036            .any(|req| req.name().to_string() == "lua-cjson"));
2037
2038        assert!(sync_spec
2039            .to_add
2040            .iter()
2041            .any(|req| req.name().to_string() == "nonexistent"));
2042    }
2043
2044    #[test]
2045    fn verify_lockfile_version() {
2046        let deserialize = |version: &str| {
2047            let content = format!(r#"{{"version": "{version}"}}"#);
2048            parse_lockfile_json(&content).and_then(|value| {
2049                deserialize_lockfile::<Lockfile<ReadOnly>>(value, Path::new("lux.lock"), "")
2050            })
2051        };
2052
2053        assert!(matches!(
2054            deserialize("3.0.0"),
2055            Err(LockfileError::IncompatibleVersion { .. })
2056        ));
2057        assert!(matches!(
2058            deserialize("not-a-version"),
2059            Err(LockfileError::InvalidVersion { .. })
2060        ));
2061    }
2062
2063    #[test]
2064    fn migrate_v1_tree_lockfile() {
2065        let sample =
2066            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/lockfile-v1/tree.lock");
2067
2068        let temp = assert_fs::TempDir::new().unwrap();
2069        let lockfile_path = temp.path().join("lux.lock");
2070        std::fs::copy(&sample, &lockfile_path).unwrap();
2071
2072        let lockfile = Lockfile::load(lockfile_path.clone()).unwrap();
2073
2074        let on_disk: serde_json::Value =
2075            serde_json::from_str(&std::fs::read_to_string(&lockfile_path).unwrap()).unwrap();
2076        assert_eq!(on_disk["version"], "1.0.0");
2077        assert!(on_disk["entrypoints"].is_array());
2078
2079        assert_eq!(lockfile.version, "2.0.0");
2080        assert_eq!(lockfile.lock.entrypoints.len(), 3);
2081    }
2082
2083    #[test]
2084    fn refuse_migration_for_custom_layout() {
2085        let lockfile = r#"{"version":"1.0.0","entrypoint_layout":{"root":"site/pack/lux"}}"#;
2086        assert!(matches!(
2087            load_lockfile::<Lockfile<ReadOnly>>(lockfile, Path::new("lux.lock"), ""),
2088            Err(LockfileError::IncompatibleVersion { .. })
2089        ));
2090    }
2091
2092    #[test]
2093    fn refuse_migration_for_conflicting_entrypoints() {
2094        let lockfile = r#"{
2095            "version": "1.0.0",
2096            "rocks": {
2097                "a": { "name": "foo" },
2098                "b": { "name": "foo" }
2099            },
2100            "entrypoints": ["a", "b"]
2101        }"#;
2102        assert!(matches!(
2103            load_lockfile::<Lockfile<ReadOnly>>(lockfile, Path::new("lux.lock"), ""),
2104            Err(LockfileError::ConflictingEntrypoints { .. })
2105        ));
2106    }
2107
2108    #[test]
2109    fn migrate_v1_workspace_lockfile() {
2110        let sample = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2111            .join("resources/test/lockfile-v1/workspace.lock");
2112
2113        let temp = assert_fs::TempDir::new().unwrap();
2114        let lockfile_path = temp.path().join("lux.lock");
2115        std::fs::copy(&sample, &lockfile_path).unwrap();
2116
2117        let lockfile = WorkspaceLockfile::load(lockfile_path.clone()).unwrap();
2118
2119        let on_disk: serde_json::Value =
2120            serde_json::from_str(&std::fs::read_to_string(&lockfile_path).unwrap()).unwrap();
2121        assert_eq!(on_disk["version"], "1.0.0");
2122        assert!(on_disk["dependencies"]["entrypoints"].is_array());
2123
2124        assert_eq!(lockfile.version, "2.0.0");
2125        assert_eq!(lockfile.dependencies.entrypoints.len(), 3);
2126    }
2127}