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