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