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