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
661    #[serde(serialize_with = "serialize_sorted_package_ids")]
662    entrypoints: Vec<LocalPackageId>,
663}
664
665impl LocalPackageLock {
666    fn get(&self, id: &LocalPackageId) -> Option<&LocalPackage> {
667        self.rocks.get(id)
668    }
669
670    fn is_empty(&self) -> bool {
671        self.rocks.is_empty()
672    }
673
674    pub(crate) fn rocks(&self) -> &BTreeMap<LocalPackageId, LocalPackage> {
675        &self.rocks
676    }
677
678    fn is_entrypoint(&self, package: &LocalPackageId) -> bool {
679        self.entrypoints.contains(package)
680    }
681
682    fn is_dependency(&self, package: &LocalPackageId) -> bool {
683        self.rocks
684            .values()
685            .flat_map(|rock| rock.dependencies())
686            .any(|dep_id| dep_id == package)
687    }
688
689    fn list(&self) -> HashMap<PackageName, Vec<LocalPackage>> {
690        self.rocks()
691            .values()
692            .cloned()
693            .map(|locked_rock| (locked_rock.name().clone(), locked_rock))
694            .into_group_map()
695    }
696
697    fn remove(&mut self, target: &LocalPackage) {
698        self.remove_by_id(&target.id())
699    }
700
701    fn remove_by_id(&mut self, target: &LocalPackageId) {
702        self.rocks.remove(target);
703        self.entrypoints.retain(|x| x != target);
704    }
705
706    pub(crate) fn has_rock(
707        &self,
708        req: &PackageReq,
709        filter: Option<RemotePackageTypeFilterSpec>,
710    ) -> Option<LocalPackage> {
711        self.list()
712            .get(req.name())
713            .map(|packages| {
714                packages
715                    .iter()
716                    .filter(|package| match &filter {
717                        Some(filter_spec) => match package.source {
718                            RemotePackageSource::LuarocksRockspec(_) => filter_spec.rockspec,
719                            RemotePackageSource::LuarocksSrcRock(_) => filter_spec.src,
720                            RemotePackageSource::LuarocksBinaryRock(_) => filter_spec.binary,
721                            RemotePackageSource::RockspecContent(_) => true,
722                            RemotePackageSource::Local => true,
723                            #[cfg(test)]
724                            RemotePackageSource::Test => unimplemented!(),
725                        },
726                        None => true,
727                    })
728                    .rev()
729                    .find(|package| req.version_req().matches(package.version()))
730            })?
731            .cloned()
732    }
733
734    fn has_rock_with_equal_constraint(&self, req: &LuaDependencySpec) -> Option<LocalPackage> {
735        self.list()
736            .get(req.name())
737            .map(|packages| {
738                packages
739                    .iter()
740                    .rev()
741                    .find(|package| package.constraint().matches_version_req(req.version_req()))
742            })?
743            .cloned()
744    }
745
746    /// Synchronise a list of packages with this lock,
747    /// producing a report of packages to add and packages to remove,
748    /// based on the version constraint.
749    ///
750    /// NOTE: The reason we produce a report and don't add/remove packages
751    /// here is because packages need to be installed in order to be added.
752    pub(crate) fn package_sync_spec(&self, packages: &[LuaDependencySpec]) -> PackageSyncSpec {
753        let entrypoints_to_keep: HashSet<LocalPackage> = self
754            .entrypoints
755            .iter()
756            .map(|id| {
757                self.get(id)
758                    .expect("entrypoint not found in malformed lockfile.")
759            })
760            .filter(|local_pkg| {
761                packages.iter().any(|req| {
762                    local_pkg
763                        .constraint()
764                        .matches_version_req(req.version_req())
765                })
766            })
767            .cloned()
768            .collect();
769
770        let packages_to_keep: HashSet<&LocalPackage> = entrypoints_to_keep
771            .iter()
772            .flat_map(|local_pkg| self.get_all_dependencies(&local_pkg.id()))
773            .collect();
774
775        let to_add = packages
776            .iter()
777            .filter(|pkg| self.has_rock_with_equal_constraint(pkg).is_none())
778            .cloned()
779            .collect_vec();
780
781        let to_remove = self
782            .rocks()
783            .values()
784            .filter(|pkg| !packages_to_keep.contains(*pkg))
785            .cloned()
786            .collect_vec();
787
788        PackageSyncSpec { to_add, to_remove }
789    }
790
791    /// Return all dependencies of a package, including itself
792    fn get_all_dependencies(&self, id: &LocalPackageId) -> HashSet<&LocalPackage> {
793        let mut packages = HashSet::new();
794        if let Some(local_pkg) = self.get(id) {
795            packages.insert(local_pkg);
796            packages.extend(
797                local_pkg
798                    .dependencies()
799                    .iter()
800                    .flat_map(|id| self.get_all_dependencies(id)),
801            );
802        }
803        packages
804    }
805}
806
807/// A lockfile for an install tree
808#[derive(Clone, Debug, Serialize, Deserialize)]
809pub struct Lockfile<P: LockfilePermissions> {
810    #[serde(skip)]
811    filepath: PathBuf,
812    #[serde(skip)]
813    _marker: PhantomData<P>,
814    // TODO: Serialize this directly into a `Version`
815    version: String,
816    #[serde(flatten)]
817    lock: LocalPackageLock,
818    #[serde(default, skip_serializing_if = "RockLayoutConfig::is_default")]
819    pub(crate) entrypoint_layout: RockLayoutConfig,
820}
821
822pub enum LocalPackageLockType {
823    Regular,
824    Test,
825    Build,
826}
827
828/// A lockfile for a Lua project
829#[derive(Clone, Debug, Serialize, Deserialize)]
830pub struct ProjectLockfile<P: LockfilePermissions> {
831    #[serde(skip)]
832    filepath: PathBuf,
833    #[serde(skip)]
834    _marker: PhantomData<P>,
835    version: String,
836    #[serde(default, skip_serializing_if = "LocalPackageLock::is_empty")]
837    dependencies: LocalPackageLock,
838    #[serde(default, skip_serializing_if = "LocalPackageLock::is_empty")]
839    test_dependencies: LocalPackageLock,
840    #[serde(default, skip_serializing_if = "LocalPackageLock::is_empty")]
841    build_dependencies: LocalPackageLock,
842}
843
844#[derive(Error, Debug)]
845pub enum LockfileError {
846    #[error("error loading lockfile: {0}")]
847    Load(io::Error),
848    #[error("error creating lockfile: {0}")]
849    Create(io::Error),
850    #[error("error parsing lockfile from JSON: {0}")]
851    ParseJson(serde_json::Error),
852    #[error("error writing lockfile to JSON: {0}")]
853    WriteJson(serde_json::Error),
854    #[error("attempt load to a lockfile that does not match the expected rock layout.")]
855    MismatchedRockLayout,
856}
857
858#[derive(Error, Debug)]
859pub enum LockfileIntegrityError {
860    #[error("rockspec integirty mismatch.\nExpected: {expected}\nBut got: {got}")]
861    RockspecIntegrityMismatch { expected: Integrity, got: Integrity },
862    #[error("source integrity mismatch.\nExpected: {expected}\nBut got: {got}")]
863    SourceIntegrityMismatch { expected: Integrity, got: Integrity },
864    #[error("package {0} version {1} with pinned state {2} and constraint {3} not found in the lockfile.")]
865    PackageNotFound(PackageName, PackageVersion, PinnedState, String),
866}
867
868/// A specification for syncing a list of packages with a lockfile
869#[derive(Debug, Default)]
870pub(crate) struct PackageSyncSpec {
871    pub to_add: Vec<LuaDependencySpec>,
872    pub to_remove: Vec<LocalPackage>,
873}
874
875impl UserData for Lockfile<ReadOnly> {
876    fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M) {
877        methods.add_method("version", |_, this, _: ()| Ok(this.version().clone()));
878        methods.add_method("rocks", |_, this, _: ()| Ok(this.rocks().clone()));
879        methods.add_method("get", |_, this, id: LocalPackageId| {
880            Ok(this.get(&id).cloned())
881        });
882        methods.add_method("map_then_flush", |_, this, f: mlua::Function| {
883            let lockfile = this.clone().write_guard();
884            f.call::<()>(lockfile)?;
885            Ok(())
886        });
887    }
888}
889
890impl<P: LockfilePermissions> Lockfile<P> {
891    pub fn version(&self) -> &String {
892        &self.version
893    }
894
895    pub fn rocks(&self) -> &BTreeMap<LocalPackageId, LocalPackage> {
896        self.lock.rocks()
897    }
898
899    pub fn is_dependency(&self, package: &LocalPackageId) -> bool {
900        self.lock.is_dependency(package)
901    }
902
903    pub fn is_entrypoint(&self, package: &LocalPackageId) -> bool {
904        self.lock.is_entrypoint(package)
905    }
906
907    pub fn entry_type(&self, package: &LocalPackageId) -> bool {
908        self.lock.is_entrypoint(package)
909    }
910
911    pub(crate) fn local_pkg_lock(&self) -> &LocalPackageLock {
912        &self.lock
913    }
914
915    pub fn get(&self, id: &LocalPackageId) -> Option<&LocalPackage> {
916        self.lock.get(id)
917    }
918
919    /// Unsafe because this assumes a prior check if the package is present
920    ///
921    /// # Safety
922    ///
923    /// Ensure that the package is present in the lockfile before calling this function.
924    pub unsafe fn get_unchecked(&self, id: &LocalPackageId) -> &LocalPackage {
925        self.lock
926            .get(id)
927            .expect("error getting package from lockfile")
928    }
929
930    pub(crate) fn list(&self) -> HashMap<PackageName, Vec<LocalPackage>> {
931        self.lock.list()
932    }
933
934    pub(crate) fn has_rock(
935        &self,
936        req: &PackageReq,
937        filter: Option<RemotePackageTypeFilterSpec>,
938    ) -> Option<LocalPackage> {
939        self.lock.has_rock(req, filter)
940    }
941
942    /// Find all rocks that match the requirement
943    pub(crate) fn find_rocks(&self, req: &PackageReq) -> Vec<LocalPackageId> {
944        match self.list().get(req.name()) {
945            Some(packages) => packages
946                .iter()
947                .rev()
948                .filter(|package| req.version_req().matches(package.version()))
949                .map(|package| package.id())
950                .collect_vec(),
951            None => Vec::default(),
952        }
953    }
954
955    /// Validate the integrity of an installed package with the entry in this lockfile.
956    pub(crate) fn validate_integrity(
957        &self,
958        package: &LocalPackage,
959    ) -> Result<(), LockfileIntegrityError> {
960        // NOTE: We can't query by ID, because when installing from a lockfile (e.g. during sync),
961        // the constraint is always `==`.
962        match self.list().get(package.name()) {
963            None => Err(integrity_err_not_found(package)),
964            Some(rocks) => match rocks
965                .iter()
966                .find(|rock| rock.version() == package.version())
967            {
968                None => Err(integrity_err_not_found(package)),
969                Some(expected_package) => {
970                    if package
971                        .hashes
972                        .rockspec
973                        .matches(&expected_package.hashes.rockspec)
974                        .is_none()
975                    {
976                        return Err(LockfileIntegrityError::RockspecIntegrityMismatch {
977                            expected: expected_package.hashes.rockspec.clone(),
978                            got: package.hashes.rockspec.clone(),
979                        });
980                    }
981                    if package
982                        .hashes
983                        .source
984                        .matches(&expected_package.hashes.source)
985                        .is_none()
986                    {
987                        return Err(LockfileIntegrityError::SourceIntegrityMismatch {
988                            expected: expected_package.hashes.source.clone(),
989                            got: package.hashes.source.clone(),
990                        });
991                    }
992                    Ok(())
993                }
994            },
995        }
996    }
997
998    fn flush(&self) -> io::Result<()> {
999        let content = serde_json::to_string_pretty(&self)?;
1000
1001        std::fs::write(&self.filepath, content)?;
1002
1003        Ok(())
1004    }
1005}
1006
1007impl<P: LockfilePermissions> ProjectLockfile<P> {
1008    pub(crate) fn rocks(
1009        &self,
1010        deps: &LocalPackageLockType,
1011    ) -> &BTreeMap<LocalPackageId, LocalPackage> {
1012        match deps {
1013            LocalPackageLockType::Regular => self.dependencies.rocks(),
1014            LocalPackageLockType::Test => self.test_dependencies.rocks(),
1015            LocalPackageLockType::Build => self.build_dependencies.rocks(),
1016        }
1017    }
1018
1019    pub(crate) fn get(
1020        &self,
1021        id: &LocalPackageId,
1022        deps: &LocalPackageLockType,
1023    ) -> Option<&LocalPackage> {
1024        match deps {
1025            LocalPackageLockType::Regular => self.dependencies.get(id),
1026            LocalPackageLockType::Test => self.test_dependencies.get(id),
1027            LocalPackageLockType::Build => self.build_dependencies.get(id),
1028        }
1029    }
1030
1031    pub(crate) fn is_entrypoint(
1032        &self,
1033        package: &LocalPackageId,
1034        deps: &LocalPackageLockType,
1035    ) -> bool {
1036        match deps {
1037            LocalPackageLockType::Regular => self.dependencies.is_entrypoint(package),
1038            LocalPackageLockType::Test => self.test_dependencies.is_entrypoint(package),
1039            LocalPackageLockType::Build => self.build_dependencies.is_entrypoint(package),
1040        }
1041    }
1042
1043    pub(crate) fn package_sync_spec(
1044        &self,
1045        packages: &[LuaDependencySpec],
1046        deps: &LocalPackageLockType,
1047    ) -> PackageSyncSpec {
1048        match deps {
1049            LocalPackageLockType::Regular => self.dependencies.package_sync_spec(packages),
1050            LocalPackageLockType::Test => self.test_dependencies.package_sync_spec(packages),
1051            LocalPackageLockType::Build => self.build_dependencies.package_sync_spec(packages),
1052        }
1053    }
1054
1055    pub(crate) fn local_pkg_lock(&self, deps: &LocalPackageLockType) -> &LocalPackageLock {
1056        match deps {
1057            LocalPackageLockType::Regular => &self.dependencies,
1058            LocalPackageLockType::Test => &self.test_dependencies,
1059            LocalPackageLockType::Build => &self.build_dependencies,
1060        }
1061    }
1062
1063    fn flush(&self) -> io::Result<()> {
1064        let content = serde_json::to_string_pretty(&self)?;
1065
1066        std::fs::write(&self.filepath, content)?;
1067
1068        Ok(())
1069    }
1070}
1071
1072impl Lockfile<ReadOnly> {
1073    /// Create a new `Lockfile`, writing an empty file if none exists.
1074    pub(crate) fn new(
1075        filepath: PathBuf,
1076        rock_layout: RockLayoutConfig,
1077    ) -> Result<Lockfile<ReadOnly>, LockfileError> {
1078        // Ensure that the lockfile exists
1079        match File::options().create_new(true).write(true).open(&filepath) {
1080            Ok(mut file) => {
1081                let empty_lockfile: Lockfile<ReadOnly> = Lockfile {
1082                    filepath: filepath.clone(),
1083                    _marker: PhantomData,
1084                    version: LOCKFILE_VERSION_STR.into(),
1085                    lock: LocalPackageLock::default(),
1086                    entrypoint_layout: rock_layout.clone(),
1087                };
1088                let json_str =
1089                    serde_json::to_string(&empty_lockfile).map_err(LockfileError::WriteJson)?;
1090                write!(file, "{json_str}").map_err(LockfileError::Create)?;
1091            }
1092            Err(err) if err.kind() == ErrorKind::AlreadyExists => {}
1093            Err(err) => return Err(LockfileError::Create(err)),
1094        }
1095
1096        Self::load(filepath, Some(&rock_layout))
1097    }
1098
1099    /// Load a `Lockfile`, failing if none exists.
1100    /// If `expected_rock_layout` is `Some`, this fails if the rock layouts don't match
1101    pub fn load(
1102        filepath: PathBuf,
1103        expected_rock_layout: Option<&RockLayoutConfig>,
1104    ) -> Result<Lockfile<ReadOnly>, LockfileError> {
1105        let content = std::fs::read_to_string(&filepath).map_err(LockfileError::Load)?;
1106        let mut lockfile: Lockfile<ReadOnly> =
1107            serde_json::from_str(&content).map_err(LockfileError::ParseJson)?;
1108        lockfile.filepath = filepath;
1109        if let Some(expected_rock_layout) = expected_rock_layout {
1110            if &lockfile.entrypoint_layout != expected_rock_layout {
1111                return Err(LockfileError::MismatchedRockLayout);
1112            }
1113        }
1114        Ok(lockfile)
1115    }
1116
1117    /// Creates a temporary, writeable lockfile which can never flush.
1118    pub(crate) fn into_temporary(self) -> Lockfile<ReadWrite> {
1119        Lockfile::<ReadWrite> {
1120            _marker: PhantomData,
1121            filepath: self.filepath,
1122            version: self.version,
1123            lock: self.lock,
1124            entrypoint_layout: self.entrypoint_layout,
1125        }
1126    }
1127
1128    /// Creates a lockfile guard, flushing the lockfile automatically
1129    /// once the guard goes out of scope.
1130    pub fn write_guard(self) -> LockfileGuard {
1131        LockfileGuard(self.into_temporary())
1132    }
1133
1134    /// Converts the current lockfile into a writeable one, executes `cb` and flushes
1135    /// the lockfile.
1136    pub fn map_then_flush<T, F, E>(self, cb: F) -> Result<T, E>
1137    where
1138        F: FnOnce(&mut Lockfile<ReadWrite>) -> Result<T, E>,
1139        E: Error,
1140        E: From<io::Error>,
1141    {
1142        let mut writeable_lockfile = self.into_temporary();
1143
1144        let result = cb(&mut writeable_lockfile)?;
1145
1146        writeable_lockfile.flush()?;
1147
1148        Ok(result)
1149    }
1150
1151    // TODO: Add this once async closures are stabilized
1152    // Converts the current lockfile into a writeable one, executes `cb` asynchronously and flushes
1153    // the lockfile.
1154    //pub async fn map_then_flush_async<T, F, E, Fut>(self, cb: F) -> Result<T, E>
1155    //where
1156    //    F: AsyncFnOnce(&mut Lockfile<ReadWrite>) -> Result<T, E>,
1157    //    E: Error,
1158    //    E: From<io::Error>,
1159    //{
1160    //    let mut writeable_lockfile = self.into_temporary();
1161    //
1162    //    let result = cb(&mut writeable_lockfile).await?;
1163    //
1164    //    writeable_lockfile.flush()?;
1165    //
1166    //    Ok(result)
1167    //}
1168}
1169
1170impl ProjectLockfile<ReadOnly> {
1171    /// Create a new `ProjectLockfile`, writing an empty file if none exists.
1172    pub fn new(filepath: PathBuf) -> Result<ProjectLockfile<ReadOnly>, LockfileError> {
1173        // Ensure that the lockfile exists
1174        match File::options().create_new(true).write(true).open(&filepath) {
1175            Ok(mut file) => {
1176                let empty_lockfile: ProjectLockfile<ReadOnly> = ProjectLockfile {
1177                    filepath: filepath.clone(),
1178                    _marker: PhantomData,
1179                    version: LOCKFILE_VERSION_STR.into(),
1180                    dependencies: LocalPackageLock::default(),
1181                    test_dependencies: LocalPackageLock::default(),
1182                    build_dependencies: LocalPackageLock::default(),
1183                };
1184                let json_str =
1185                    serde_json::to_string(&empty_lockfile).map_err(LockfileError::WriteJson)?;
1186                write!(file, "{json_str}").map_err(LockfileError::Create)?;
1187            }
1188            Err(err) if err.kind() == ErrorKind::AlreadyExists => {}
1189            Err(err) => return Err(LockfileError::Create(err)),
1190        }
1191
1192        Self::load(filepath)
1193    }
1194
1195    /// Load a `ProjectLockfile`, failing if none exists.
1196    pub fn load(filepath: PathBuf) -> Result<ProjectLockfile<ReadOnly>, LockfileError> {
1197        let content = std::fs::read_to_string(&filepath).map_err(LockfileError::Load)?;
1198        let mut lockfile: ProjectLockfile<ReadOnly> =
1199            serde_json::from_str(&content).map_err(LockfileError::ParseJson)?;
1200
1201        lockfile.filepath = filepath;
1202
1203        Ok(lockfile)
1204    }
1205
1206    /// Creates a temporary, writeable project lockfile which can never flush.
1207    fn into_temporary(self) -> ProjectLockfile<ReadWrite> {
1208        ProjectLockfile::<ReadWrite> {
1209            _marker: PhantomData,
1210            filepath: self.filepath,
1211            version: self.version,
1212            dependencies: self.dependencies,
1213            test_dependencies: self.test_dependencies,
1214            build_dependencies: self.build_dependencies,
1215        }
1216    }
1217
1218    /// Creates a project lockfile guard, flushing the lockfile automatically
1219    /// once the guard goes out of scope.
1220    pub fn write_guard(self) -> ProjectLockfileGuard {
1221        ProjectLockfileGuard(self.into_temporary())
1222    }
1223}
1224
1225impl Lockfile<ReadWrite> {
1226    pub(crate) fn add_entrypoint(&mut self, rock: &LocalPackage) {
1227        self.add(rock);
1228        self.lock.entrypoints.push(rock.id().clone())
1229    }
1230
1231    pub(crate) fn remove_entrypoint(&mut self, rock: &LocalPackage) {
1232        if let Some(index) = self
1233            .lock
1234            .entrypoints
1235            .iter()
1236            .position(|pkg_id| *pkg_id == rock.id())
1237        {
1238            self.lock.entrypoints.remove(index);
1239        }
1240    }
1241
1242    fn add(&mut self, rock: &LocalPackage) {
1243        // Since rocks entries are mutable, we only add the dependency if it
1244        // has not already been added.
1245        self.lock
1246            .rocks
1247            .entry(rock.id())
1248            .or_insert_with(|| rock.clone());
1249    }
1250
1251    /// Add a dependency for a package.
1252    pub(crate) fn add_dependency(&mut self, target: &LocalPackage, dependency: &LocalPackage) {
1253        self.lock
1254            .rocks
1255            .entry(target.id())
1256            .and_modify(|rock| rock.spec.dependencies.push(dependency.id()))
1257            .or_insert_with(|| {
1258                let mut target = target.clone();
1259                target.spec.dependencies.push(dependency.id());
1260                target
1261            });
1262        self.add(dependency);
1263    }
1264
1265    pub(crate) fn remove(&mut self, target: &LocalPackage) {
1266        self.lock.remove(target)
1267    }
1268
1269    pub(crate) fn remove_by_id(&mut self, target: &LocalPackageId) {
1270        self.lock.remove_by_id(target)
1271    }
1272
1273    pub(crate) fn sync(&mut self, lock: &LocalPackageLock) {
1274        self.lock = lock.clone();
1275    }
1276
1277    // TODO: `fn entrypoints() -> Vec<LockedRock>`
1278}
1279
1280impl ProjectLockfile<ReadWrite> {
1281    pub(crate) fn remove(&mut self, target: &LocalPackage, deps: &LocalPackageLockType) {
1282        match deps {
1283            LocalPackageLockType::Regular => self.dependencies.remove(target),
1284            LocalPackageLockType::Test => self.test_dependencies.remove(target),
1285            LocalPackageLockType::Build => self.build_dependencies.remove(target),
1286        }
1287    }
1288
1289    pub(crate) fn sync(&mut self, lock: &LocalPackageLock, deps: &LocalPackageLockType) {
1290        match deps {
1291            LocalPackageLockType::Regular => {
1292                self.dependencies = lock.clone();
1293            }
1294            LocalPackageLockType::Test => {
1295                self.test_dependencies = lock.clone();
1296            }
1297            LocalPackageLockType::Build => {
1298                self.build_dependencies = lock.clone();
1299            }
1300        }
1301    }
1302}
1303
1304pub struct LockfileGuard(Lockfile<ReadWrite>);
1305
1306pub struct ProjectLockfileGuard(ProjectLockfile<ReadWrite>);
1307
1308impl UserData for LockfileGuard {
1309    fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M) {
1310        methods.add_method("version", |_, this, _: ()| Ok(this.version().clone()));
1311        methods.add_method("rocks", |_, this, _: ()| Ok(this.rocks().clone()));
1312        methods.add_method("get", |_, this, id: LocalPackageId| {
1313            Ok(this.get(&id).cloned())
1314        });
1315    }
1316}
1317
1318impl Serialize for LockfileGuard {
1319    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1320    where
1321        S: serde::Serializer,
1322    {
1323        self.0.serialize(serializer)
1324    }
1325}
1326
1327impl Serialize for ProjectLockfileGuard {
1328    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1329    where
1330        S: serde::Serializer,
1331    {
1332        self.0.serialize(serializer)
1333    }
1334}
1335
1336impl<'de> Deserialize<'de> for LockfileGuard {
1337    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1338    where
1339        D: serde::Deserializer<'de>,
1340    {
1341        Ok(LockfileGuard(Lockfile::<ReadWrite>::deserialize(
1342            deserializer,
1343        )?))
1344    }
1345}
1346
1347impl<'de> Deserialize<'de> for ProjectLockfileGuard {
1348    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1349    where
1350        D: serde::Deserializer<'de>,
1351    {
1352        Ok(ProjectLockfileGuard(
1353            ProjectLockfile::<ReadWrite>::deserialize(deserializer)?,
1354        ))
1355    }
1356}
1357
1358impl Deref for LockfileGuard {
1359    type Target = Lockfile<ReadWrite>;
1360
1361    fn deref(&self) -> &Self::Target {
1362        &self.0
1363    }
1364}
1365
1366impl Deref for ProjectLockfileGuard {
1367    type Target = ProjectLockfile<ReadWrite>;
1368
1369    fn deref(&self) -> &Self::Target {
1370        &self.0
1371    }
1372}
1373
1374impl DerefMut for LockfileGuard {
1375    fn deref_mut(&mut self) -> &mut Self::Target {
1376        &mut self.0
1377    }
1378}
1379
1380impl DerefMut for ProjectLockfileGuard {
1381    fn deref_mut(&mut self) -> &mut Self::Target {
1382        &mut self.0
1383    }
1384}
1385
1386impl Drop for LockfileGuard {
1387    fn drop(&mut self) {
1388        let _ = self.flush();
1389    }
1390}
1391
1392impl Drop for ProjectLockfileGuard {
1393    fn drop(&mut self) {
1394        let _ = self.flush();
1395    }
1396}
1397
1398impl UserData for Lockfile<ReadWrite> {
1399    fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M) {
1400        methods.add_method("version", |_, this, ()| Ok(this.version().to_owned()));
1401        methods.add_method("rocks", |_, this, ()| {
1402            Ok(this
1403                .rocks()
1404                .iter()
1405                .map(|(id, rock)| (id.0.clone(), rock.clone()))
1406                .collect::<HashMap<String, LocalPackage>>())
1407        });
1408
1409        methods.add_method("get", |_, this, id: String| {
1410            Ok(this.get(&LocalPackageId(id)).cloned())
1411        });
1412        methods.add_method_mut("flush", |_, this, ()| this.flush().into_lua_err());
1413    }
1414}
1415
1416fn serialize_sorted_package_ids<S>(
1417    package_ids: &[LocalPackageId],
1418    serializer: S,
1419) -> Result<S::Ok, S::Error>
1420where
1421    S: Serializer,
1422{
1423    package_ids
1424        .iter()
1425        .sorted()
1426        .collect_vec()
1427        .serialize(serializer)
1428}
1429
1430fn integrity_err_not_found(package: &LocalPackage) -> LockfileIntegrityError {
1431    LockfileIntegrityError::PackageNotFound(
1432        package.name().clone(),
1433        package.version().clone(),
1434        package.spec.pinned,
1435        package
1436            .spec
1437            .constraint
1438            .clone()
1439            .unwrap_or("UNCONSTRAINED".into()),
1440    )
1441}
1442
1443fn deserialize_url<'de, D>(deserializer: D) -> Result<Url, D::Error>
1444where
1445    D: serde::Deserializer<'de>,
1446{
1447    let s = String::deserialize(deserializer)?;
1448    Url::parse(&s).map_err(serde::de::Error::custom)
1449}
1450
1451fn serialize_url<S>(url: &Url, serializer: S) -> Result<S::Ok, S::Error>
1452where
1453    S: Serializer,
1454{
1455    url.as_str().serialize(serializer)
1456}
1457
1458#[cfg(test)]
1459mod tests {
1460    use super::*;
1461    use std::{fs::remove_file, path::PathBuf};
1462
1463    use assert_fs::fixture::PathCopy;
1464    use insta::{assert_json_snapshot, sorted_redaction};
1465
1466    use crate::{
1467        config::{ConfigBuilder, LuaVersion::Lua51},
1468        package::PackageSpec,
1469    };
1470
1471    #[test]
1472    fn parse_lockfile() {
1473        let temp = assert_fs::TempDir::new().unwrap();
1474        temp.copy_from(
1475            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-tree"),
1476            &["**"],
1477        )
1478        .unwrap();
1479
1480        let config = ConfigBuilder::new()
1481            .unwrap()
1482            .user_tree(Some(temp.to_path_buf()))
1483            .build()
1484            .unwrap();
1485        let tree = config.user_tree(Lua51).unwrap();
1486        let lockfile = tree.lockfile().unwrap();
1487
1488        assert_json_snapshot!(lockfile, { ".**" => sorted_redaction() });
1489    }
1490
1491    #[test]
1492    fn add_rocks() {
1493        let temp = assert_fs::TempDir::new().unwrap();
1494        temp.copy_from(
1495            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-tree"),
1496            &["**"],
1497        )
1498        .unwrap();
1499
1500        let mock_hashes = LocalPackageHashes {
1501            rockspec: "sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="
1502                .parse()
1503                .unwrap(),
1504            source: "sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="
1505                .parse()
1506                .unwrap(),
1507        };
1508
1509        let config = ConfigBuilder::new()
1510            .unwrap()
1511            .user_tree(Some(temp.to_path_buf()))
1512            .build()
1513            .unwrap();
1514        let tree = config.user_tree(Lua51).unwrap();
1515        let mut lockfile = tree.lockfile().unwrap().write_guard();
1516
1517        let test_package = PackageSpec::parse("test1".to_string(), "0.1.0".to_string()).unwrap();
1518        let test_local_package = LocalPackage::from(
1519            &test_package,
1520            crate::lockfile::LockConstraint::Unconstrained,
1521            RockBinaries::default(),
1522            RemotePackageSource::Test,
1523            None,
1524            mock_hashes.clone(),
1525        );
1526        lockfile.add_entrypoint(&test_local_package);
1527
1528        let test_dep_package =
1529            PackageSpec::parse("test2".to_string(), "0.1.0".to_string()).unwrap();
1530        let mut test_local_dep_package = LocalPackage::from(
1531            &test_dep_package,
1532            crate::lockfile::LockConstraint::Constrained(">= 1.0.0".parse().unwrap()),
1533            RockBinaries::default(),
1534            RemotePackageSource::Test,
1535            None,
1536            mock_hashes.clone(),
1537        );
1538        test_local_dep_package.spec.pinned = PinnedState::Pinned;
1539        lockfile.add_dependency(&test_local_package, &test_local_dep_package);
1540
1541        assert_json_snapshot!(lockfile, { ".**" => sorted_redaction() });
1542    }
1543
1544    #[test]
1545    fn parse_nonexistent_lockfile() {
1546        let tree_path =
1547            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-tree");
1548
1549        let temp = assert_fs::TempDir::new().unwrap();
1550        temp.copy_from(&tree_path, &["**"]).unwrap();
1551
1552        remove_file(temp.join("5.1/lux.lock")).unwrap();
1553
1554        let config = ConfigBuilder::new()
1555            .unwrap()
1556            .user_tree(Some(temp.to_path_buf()))
1557            .build()
1558            .unwrap();
1559        let tree = config.user_tree(Lua51).unwrap();
1560
1561        let _ = tree.lockfile().unwrap().write_guard(); // Try to create the lockfile but don't actually do anything with it.
1562    }
1563
1564    fn get_test_lockfile() -> Lockfile<ReadOnly> {
1565        let sample_tree = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1566            .join("resources/test/sample-tree/5.1/lux.lock");
1567        Lockfile::new(sample_tree, RockLayoutConfig::default()).unwrap()
1568    }
1569
1570    #[test]
1571    fn test_sync_spec() {
1572        let lockfile = get_test_lockfile();
1573        let packages = vec![
1574            PackageReq::parse("neorg@8.8.1-1").unwrap().into(),
1575            PackageReq::parse("lua-cjson@2.1.0").unwrap().into(),
1576            PackageReq::parse("nonexistent").unwrap().into(),
1577        ];
1578
1579        let sync_spec = lockfile.lock.package_sync_spec(&packages);
1580
1581        assert_eq!(sync_spec.to_add.len(), 1);
1582
1583        // Should keep dependencies of neorg 8.8.1-1
1584        assert!(!sync_spec
1585            .to_remove
1586            .iter()
1587            .any(|pkg| pkg.name().to_string() == "nvim-nio"
1588                && pkg.constraint()
1589                    == LockConstraint::Constrained(">=1.7.0, <1.8.0".parse().unwrap())));
1590        assert!(!sync_spec
1591            .to_remove
1592            .iter()
1593            .any(|pkg| pkg.name().to_string() == "lua-utils.nvim"
1594                && pkg.constraint() == LockConstraint::Constrained("=1.0.2".parse().unwrap())));
1595        assert!(!sync_spec
1596            .to_remove
1597            .iter()
1598            .any(|pkg| pkg.name().to_string() == "plenary.nvim"
1599                && pkg.constraint() == LockConstraint::Constrained("=0.1.4".parse().unwrap())));
1600        assert!(!sync_spec
1601            .to_remove
1602            .iter()
1603            .any(|pkg| pkg.name().to_string() == "nui.nvim"
1604                && pkg.constraint() == LockConstraint::Constrained("=0.3.0".parse().unwrap())));
1605        assert!(!sync_spec
1606            .to_remove
1607            .iter()
1608            .any(|pkg| pkg.name().to_string() == "pathlib.nvim"
1609                && pkg.constraint()
1610                    == LockConstraint::Constrained(">=2.2.0, <2.3.0".parse().unwrap())));
1611    }
1612
1613    #[test]
1614    fn test_sync_spec_remove() {
1615        let lockfile = get_test_lockfile();
1616        let packages = vec![
1617            PackageReq::parse("lua-cjson@2.1.0").unwrap().into(),
1618            PackageReq::parse("nonexistent").unwrap().into(),
1619        ];
1620
1621        let sync_spec = lockfile.lock.package_sync_spec(&packages);
1622
1623        assert_eq!(sync_spec.to_add.len(), 1);
1624
1625        // Should remove:
1626        // - neorg
1627        // - dependencies unique to neorg
1628        assert!(sync_spec
1629            .to_remove
1630            .iter()
1631            .any(|pkg| pkg.name().to_string() == "neorg"
1632                && pkg.version() == &"8.8.1-1".parse().unwrap()));
1633        assert!(sync_spec
1634            .to_remove
1635            .iter()
1636            .any(|pkg| pkg.name().to_string() == "nvim-nio"
1637                && pkg.constraint()
1638                    == LockConstraint::Constrained(">=1.7.0, <1.8.0".parse().unwrap())));
1639        assert!(sync_spec
1640            .to_remove
1641            .iter()
1642            .any(|pkg| pkg.name().to_string() == "lua-utils.nvim"
1643                && pkg.constraint() == LockConstraint::Constrained("=1.0.2".parse().unwrap())));
1644        assert!(sync_spec
1645            .to_remove
1646            .iter()
1647            .any(|pkg| pkg.name().to_string() == "plenary.nvim"
1648                && pkg.constraint() == LockConstraint::Constrained("=0.1.4".parse().unwrap())));
1649        assert!(sync_spec
1650            .to_remove
1651            .iter()
1652            .any(|pkg| pkg.name().to_string() == "nui.nvim"
1653                && pkg.constraint() == LockConstraint::Constrained("=0.3.0".parse().unwrap())));
1654        assert!(sync_spec
1655            .to_remove
1656            .iter()
1657            .any(|pkg| pkg.name().to_string() == "pathlib.nvim"
1658                && pkg.constraint()
1659                    == LockConstraint::Constrained(">=2.2.0, <2.3.0".parse().unwrap())));
1660    }
1661
1662    #[test]
1663    fn test_sync_spec_empty() {
1664        let lockfile = get_test_lockfile();
1665        let packages = vec![];
1666        let sync_spec = lockfile.lock.package_sync_spec(&packages);
1667
1668        // Should remove all packages
1669        assert!(sync_spec.to_add.is_empty());
1670        assert_eq!(sync_spec.to_remove.len(), lockfile.rocks().len());
1671    }
1672
1673    #[test]
1674    fn test_sync_spec_different_constraints() {
1675        let lockfile = get_test_lockfile();
1676        let packages = vec![PackageReq::parse("nvim-nio>=2.0.0").unwrap().into()];
1677        let sync_spec = lockfile.lock.package_sync_spec(&packages);
1678
1679        let expected: PackageVersionReq = ">=2.0.0".parse().unwrap();
1680        assert!(sync_spec
1681            .to_add
1682            .iter()
1683            .any(|req| req.name().to_string() == "nvim-nio" && req.version_req() == &expected));
1684
1685        assert!(sync_spec
1686            .to_remove
1687            .iter()
1688            .any(|pkg| pkg.name().to_string() == "nvim-nio"));
1689    }
1690}