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