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