Skip to main content

lux_lib/lockfile/
mod.rs

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