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