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