Skip to main content

lux_lib/lockfile/
mod.rs

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