Skip to main content

lux_lib/lockfile/
mod.rs

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