Skip to main content

lux_lib/lockfile/
mod.rs

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