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