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    pub fn entrypoint_layout(&self) -> &RockLayoutConfig {
895        &self.entrypoint_layout
896    }
897
898    /// Unsafe because this assumes a prior check if the package is present
899    ///
900    /// # Safety
901    ///
902    /// Ensure that the package is present in the lockfile before calling this function.
903    pub unsafe fn get_unchecked(&self, id: &LocalPackageId) -> &LocalPackage {
904        self.lock.get(id).unwrap_unchecked()
905    }
906
907    pub(crate) fn list(&self) -> HashMap<PackageName, Vec<LocalPackage>> {
908        self.lock.list()
909    }
910
911    pub(crate) fn has_rock(
912        &self,
913        req: &PackageReq,
914        filter: Option<RemotePackageTypeFilterSpec>,
915    ) -> Option<LocalPackage> {
916        self.lock.has_rock(req, filter)
917    }
918
919    /// Find all rocks that match the requirement
920    pub(crate) fn find_rocks(&self, req: &PackageReq) -> Vec<LocalPackageId> {
921        match self.list().get(req.name()) {
922            Some(packages) => packages
923                .iter()
924                .rev()
925                .filter(|package| req.version_req().matches(package.version()))
926                .map(|package| package.id())
927                .collect_vec(),
928            None => Vec::default(),
929        }
930    }
931
932    /// Validate the integrity of an installed package with the entry in this lockfile.
933    pub(crate) fn validate_integrity(
934        &self,
935        expected_package: &LocalPackage,
936    ) -> Result<(), LockfileIntegrityError> {
937        // NOTE: We can't query by ID, because when installing from a lockfile (e.g. during sync),
938        // the constraint is always `==`.
939        match self.list().get(expected_package.name()) {
940            None => Err(integrity_err_not_found(expected_package)),
941            Some(rocks) => match rocks
942                .iter()
943                .find(|rock| rock.version() == expected_package.version())
944            {
945                None => Err(integrity_err_not_found(expected_package)),
946                Some(installed_package) => {
947                    if expected_package
948                        .hashes
949                        .rockspec
950                        .matches(&installed_package.hashes.rockspec)
951                        .is_none()
952                    {
953                        return Err(LockfileIntegrityError::RockspecIntegrityMismatch {
954                            src: expected_package.source.to_string(),
955                            expected: expected_package.hashes.rockspec.clone(),
956                            got: installed_package.hashes.rockspec.clone(),
957                        });
958                    }
959                    if expected_package
960                        .hashes
961                        .source
962                        .matches(&installed_package.hashes.source)
963                        .is_none()
964                    {
965                        return Err(LockfileIntegrityError::SourceIntegrityMismatch {
966                            src: expected_package
967                                .source_url
968                                .as_ref()
969                                .map(|url| url.to_string())
970                                .unwrap_or(expected_package.source.to_string()),
971                            expected: expected_package.hashes.source.clone(),
972                            got: installed_package.hashes.source.clone(),
973                        });
974                    }
975                    Ok(())
976                }
977            },
978        }
979    }
980
981    fn flush(&self) -> Result<(), FlushLockfileError> {
982        let content = serde_json::to_string_pretty(&self).map_err(|err| FlushLockfileError {
983            filepath: self.filepath.to_path_buf(),
984            source: io::Error::other(err),
985        })?;
986
987        fs::sync::write(&self.filepath, content).map_err(|err| FlushLockfileError {
988            filepath: self.filepath.to_path_buf(),
989            source: io::Error::other(err),
990        })
991    }
992}
993
994impl<P: LockfilePermissions> WorkspaceLockfile<P> {
995    pub(crate) fn rocks(
996        &self,
997        deps: &LocalPackageLockType,
998    ) -> &BTreeMap<LocalPackageId, LocalPackage> {
999        match deps {
1000            LocalPackageLockType::Regular => self.dependencies.rocks(),
1001            LocalPackageLockType::Test => self.test_dependencies.rocks(),
1002            LocalPackageLockType::Build => self.build_dependencies.rocks(),
1003        }
1004    }
1005
1006    pub(crate) fn get(
1007        &self,
1008        id: &LocalPackageId,
1009        deps: &LocalPackageLockType,
1010    ) -> Option<&LocalPackage> {
1011        match deps {
1012            LocalPackageLockType::Regular => self.dependencies.get(id),
1013            LocalPackageLockType::Test => self.test_dependencies.get(id),
1014            LocalPackageLockType::Build => self.build_dependencies.get(id),
1015        }
1016    }
1017
1018    pub(crate) fn is_entrypoint(
1019        &self,
1020        package: &LocalPackageId,
1021        deps: &LocalPackageLockType,
1022    ) -> bool {
1023        match deps {
1024            LocalPackageLockType::Regular => self.dependencies.is_entrypoint(package),
1025            LocalPackageLockType::Test => self.test_dependencies.is_entrypoint(package),
1026            LocalPackageLockType::Build => self.build_dependencies.is_entrypoint(package),
1027        }
1028    }
1029
1030    pub(crate) fn package_sync_spec(
1031        &self,
1032        packages: &[LuaDependencySpec],
1033        deps: &LocalPackageLockType,
1034        strategy: &SyncStrategy<'_>,
1035    ) -> PackageSyncSpec {
1036        match deps {
1037            LocalPackageLockType::Regular => {
1038                self.dependencies.package_sync_spec(packages, strategy)
1039            }
1040            LocalPackageLockType::Test => {
1041                self.test_dependencies.package_sync_spec(packages, strategy)
1042            }
1043            LocalPackageLockType::Build => self
1044                .build_dependencies
1045                .package_sync_spec(packages, strategy),
1046        }
1047    }
1048
1049    pub(crate) fn local_pkg_lock(&self, deps: &LocalPackageLockType) -> &LocalPackageLock {
1050        match deps {
1051            LocalPackageLockType::Regular => &self.dependencies,
1052            LocalPackageLockType::Test => &self.test_dependencies,
1053            LocalPackageLockType::Build => &self.build_dependencies,
1054        }
1055    }
1056
1057    fn flush(&self) -> io::Result<()> {
1058        let content = serde_json::to_string_pretty(&self)?;
1059
1060        fs::sync::write(&self.filepath, content).map_err(io::Error::other)?;
1061
1062        Ok(())
1063    }
1064}
1065
1066impl Lockfile<ReadOnly> {
1067    /// Create a new `Lockfile`, writing an empty file if none exists.
1068    #[tracing::instrument(level = "trace")]
1069    pub(crate) fn new(
1070        filepath: PathBuf,
1071        rock_layout: RockLayoutConfig,
1072    ) -> Result<Lockfile<ReadOnly>, LockfileError> {
1073        // Ensure that the lockfile exists
1074        match File::options().create_new(true).write(true).open(&filepath) {
1075            Ok(mut file) => {
1076                let empty_lockfile: Lockfile<ReadOnly> = Lockfile {
1077                    filepath: filepath.clone(),
1078                    _marker: PhantomData,
1079                    version: LOCKFILE_VERSION_STR.into(),
1080                    lock: LocalPackageLock::default(),
1081                    entrypoint_layout: rock_layout.clone(),
1082                };
1083                let json_str =
1084                    serde_json::to_string(&empty_lockfile).map_err(LockfileError::WriteJson)?;
1085                write!(file, "{json_str}").map_err(|source| fs::FsError::Write {
1086                    path: filepath.to_path_buf(),
1087                    source,
1088                })?;
1089            }
1090            Err(err) if err.kind() == ErrorKind::AlreadyExists => {}
1091            Err(source) => {
1092                return Err(LockfileError::Fs(fs::FsError::FileOpen {
1093                    path: filepath.to_path_buf(),
1094                    source,
1095                }))
1096            }
1097        }
1098
1099        Self::load(filepath, Some(&rock_layout))
1100    }
1101
1102    /// Load a `Lockfile`, failing if none exists.
1103    /// If `expected_rock_layout` is `Some`, this fails if the rock layouts don't match
1104    #[tracing::instrument(level = "trace")]
1105    pub fn load(
1106        filepath: PathBuf,
1107        expected_rock_layout: Option<&RockLayoutConfig>,
1108    ) -> Result<Lockfile<ReadOnly>, LockfileError> {
1109        let content = fs::sync::read_to_string(&filepath)?;
1110        let mut lockfile: Lockfile<ReadOnly> =
1111            serde_json::from_str(&content).map_err(LockfileError::ParseJson)?;
1112        lockfile.filepath = filepath;
1113        if let Some(expected_rock_layout) = expected_rock_layout {
1114            if &lockfile.entrypoint_layout != expected_rock_layout {
1115                return Err(LockfileError::MismatchedRockLayout);
1116            }
1117        }
1118        Ok(lockfile)
1119    }
1120
1121    /// Creates a temporary, writeable lockfile which can never flush.
1122    pub(crate) fn into_temporary(self) -> Lockfile<ReadWrite> {
1123        Lockfile::<ReadWrite> {
1124            _marker: PhantomData,
1125            filepath: self.filepath,
1126            version: self.version,
1127            lock: self.lock,
1128            entrypoint_layout: self.entrypoint_layout,
1129        }
1130    }
1131
1132    /// Creates a lockfile guard, flushing the lockfile automatically
1133    /// once the guard goes out of scope.
1134    pub fn write_guard(self) -> LockfileGuard {
1135        LockfileGuard(self.into_temporary())
1136    }
1137
1138    /// Converts the current lockfile into a writeable one, executes `cb` and flushes
1139    /// the lockfile.
1140    #[tracing::instrument(level = "trace", skip_all)]
1141    pub fn map_then_flush<T, F, E>(self, cb: F) -> Result<T, FlushLockfileError>
1142    where
1143        F: FnOnce(&mut Lockfile<ReadWrite>) -> Result<T, E>,
1144        E: Error,
1145        E: From<io::Error>,
1146        E: Into<Box<dyn Error + Send + Sync>>,
1147    {
1148        let mut writeable_lockfile = self.into_temporary();
1149
1150        let result = cb(&mut writeable_lockfile).map_err(|err| FlushLockfileError {
1151            filepath: writeable_lockfile.filepath.to_path_buf(),
1152            source: io::Error::other(err),
1153        })?;
1154
1155        writeable_lockfile.flush()?;
1156
1157        Ok(result)
1158    }
1159
1160    // TODO: Add this once async closures are stabilized
1161    // Converts the current lockfile into a writeable one, executes `cb` asynchronously and flushes
1162    // the lockfile.
1163    //pub async fn map_then_flush_async<T, F, E, Fut>(self, cb: F) -> Result<T, E>
1164    //where
1165    //    F: AsyncFnOnce(&mut Lockfile<ReadWrite>) -> Result<T, E>,
1166    //    E: Error,
1167    //    E: From<io::Error>,
1168    //{
1169    //    let mut writeable_lockfile = self.into_temporary();
1170    //
1171    //    let result = cb(&mut writeable_lockfile).await?;
1172    //
1173    //    writeable_lockfile.flush()?;
1174    //
1175    //    Ok(result)
1176    //}
1177}
1178
1179impl WorkspaceLockfile<ReadOnly> {
1180    /// Create a new `ProjectLockfile`, writing an empty file if none exists.
1181    #[tracing::instrument(level = "trace")]
1182    pub fn new(filepath: PathBuf) -> Result<WorkspaceLockfile<ReadOnly>, LockfileError> {
1183        // Ensure that the lockfile exists
1184        match File::options().create_new(true).write(true).open(&filepath) {
1185            Ok(mut file) => {
1186                let empty_lockfile: WorkspaceLockfile<ReadOnly> = WorkspaceLockfile {
1187                    filepath: filepath.clone(),
1188                    _marker: PhantomData,
1189                    version: LOCKFILE_VERSION_STR.into(),
1190                    dependencies: LocalPackageLock::default(),
1191                    test_dependencies: LocalPackageLock::default(),
1192                    build_dependencies: LocalPackageLock::default(),
1193                };
1194                let json_str =
1195                    serde_json::to_string(&empty_lockfile).map_err(LockfileError::WriteJson)?;
1196                write!(file, "{json_str}").map_err(|source| fs::FsError::Write {
1197                    path: filepath.to_path_buf(),
1198                    source,
1199                })?;
1200            }
1201            Err(err) if err.kind() == ErrorKind::AlreadyExists => {}
1202            Err(source) => {
1203                return Err(LockfileError::Fs(fs::FsError::FileOpen {
1204                    path: filepath.to_path_buf(),
1205                    source,
1206                }))
1207            }
1208        }
1209
1210        Self::load(filepath)
1211    }
1212
1213    /// Load a `ProjectLockfile`, failing if none exists.
1214    #[tracing::instrument(level = "trace")]
1215    pub fn load(filepath: PathBuf) -> Result<WorkspaceLockfile<ReadOnly>, LockfileError> {
1216        let content = fs::sync::read_to_string(&filepath)?;
1217        let mut lockfile: WorkspaceLockfile<ReadOnly> =
1218            serde_json::from_str(&content).map_err(LockfileError::ParseJson)?;
1219
1220        lockfile.filepath = filepath;
1221
1222        Ok(lockfile)
1223    }
1224
1225    /// Creates a temporary, writeable project lockfile which can never flush.
1226    fn into_temporary(self) -> WorkspaceLockfile<ReadWrite> {
1227        WorkspaceLockfile::<ReadWrite> {
1228            _marker: PhantomData,
1229            filepath: self.filepath,
1230            version: self.version,
1231            dependencies: self.dependencies,
1232            test_dependencies: self.test_dependencies,
1233            build_dependencies: self.build_dependencies,
1234        }
1235    }
1236
1237    /// Creates a project lockfile guard, flushing the lockfile automatically
1238    /// once the guard goes out of scope.
1239    pub fn write_guard(self) -> ProjectLockfileGuard {
1240        ProjectLockfileGuard(self.into_temporary())
1241    }
1242}
1243
1244impl Lockfile<ReadWrite> {
1245    pub(crate) fn add_entrypoint(&mut self, rock: &LocalPackage) {
1246        self.add(rock);
1247        let id = rock.id().clone();
1248        if !self.lock.entrypoints.contains(&id) {
1249            self.lock.entrypoints.push(id)
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| {
1268                let id = dependency.id();
1269                if !rock.spec.dependencies.contains(&id) {
1270                    rock.spec.dependencies.push(id);
1271                }
1272            })
1273            .or_insert_with(|| {
1274                let id = dependency.id();
1275                let mut target = target.clone();
1276                if !target.spec.dependencies.contains(&id) {
1277                    target.spec.dependencies.push(id);
1278                }
1279                target
1280            });
1281        self.add(dependency);
1282    }
1283
1284    pub(crate) fn remove(&mut self, target: &LocalPackage) {
1285        self.lock.remove(target)
1286    }
1287
1288    pub(crate) fn remove_by_id(&mut self, target: &LocalPackageId) {
1289        self.lock.remove_by_id(target)
1290    }
1291
1292    pub(crate) fn sync(&mut self, lock: &LocalPackageLock) {
1293        self.lock = lock.clone();
1294    }
1295
1296    // TODO: `fn entrypoints() -> Vec<LockedRock>`
1297}
1298
1299impl WorkspaceLockfile<ReadWrite> {
1300    pub(crate) fn remove(&mut self, target: &LocalPackage, deps: &LocalPackageLockType) {
1301        match deps {
1302            LocalPackageLockType::Regular => self.dependencies.remove(target),
1303            LocalPackageLockType::Test => self.test_dependencies.remove(target),
1304            LocalPackageLockType::Build => self.build_dependencies.remove(target),
1305        }
1306    }
1307
1308    pub(crate) fn sync(&mut self, lock: &LocalPackageLock, deps: &LocalPackageLockType) {
1309        match deps {
1310            LocalPackageLockType::Regular => {
1311                self.dependencies = lock.clone();
1312            }
1313            LocalPackageLockType::Test => {
1314                self.test_dependencies = lock.clone();
1315            }
1316            LocalPackageLockType::Build => {
1317                self.build_dependencies = lock.clone();
1318            }
1319        }
1320    }
1321}
1322
1323/// Flushes a lockfile automatically when it goes out of scope
1324pub struct LockfileGuard(Lockfile<ReadWrite>);
1325
1326pub struct ProjectLockfileGuard(WorkspaceLockfile<ReadWrite>);
1327
1328impl Serialize for LockfileGuard {
1329    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1330    where
1331        S: serde::Serializer,
1332    {
1333        self.0.serialize(serializer)
1334    }
1335}
1336
1337impl Serialize for ProjectLockfileGuard {
1338    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1339    where
1340        S: serde::Serializer,
1341    {
1342        self.0.serialize(serializer)
1343    }
1344}
1345
1346impl<'de> Deserialize<'de> for LockfileGuard {
1347    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1348    where
1349        D: serde::Deserializer<'de>,
1350    {
1351        Ok(LockfileGuard(Lockfile::<ReadWrite>::deserialize(
1352            deserializer,
1353        )?))
1354    }
1355}
1356
1357impl<'de> Deserialize<'de> for ProjectLockfileGuard {
1358    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1359    where
1360        D: serde::Deserializer<'de>,
1361    {
1362        Ok(ProjectLockfileGuard(
1363            WorkspaceLockfile::<ReadWrite>::deserialize(deserializer)?,
1364        ))
1365    }
1366}
1367
1368impl Deref for LockfileGuard {
1369    type Target = Lockfile<ReadWrite>;
1370
1371    fn deref(&self) -> &Self::Target {
1372        &self.0
1373    }
1374}
1375
1376impl Deref for ProjectLockfileGuard {
1377    type Target = WorkspaceLockfile<ReadWrite>;
1378
1379    fn deref(&self) -> &Self::Target {
1380        &self.0
1381    }
1382}
1383
1384impl DerefMut for LockfileGuard {
1385    fn deref_mut(&mut self) -> &mut Self::Target {
1386        &mut self.0
1387    }
1388}
1389
1390impl DerefMut for ProjectLockfileGuard {
1391    fn deref_mut(&mut self) -> &mut Self::Target {
1392        &mut self.0
1393    }
1394}
1395
1396impl Drop for LockfileGuard {
1397    fn drop(&mut self) {
1398        let _ = self.flush();
1399    }
1400}
1401
1402impl Drop for ProjectLockfileGuard {
1403    fn drop(&mut self) {
1404        let _ = self.flush();
1405    }
1406}
1407
1408fn serialize_sorted_package_ids<S>(
1409    package_ids: &[LocalPackageId],
1410    serializer: S,
1411) -> Result<S::Ok, S::Error>
1412where
1413    S: Serializer,
1414{
1415    package_ids
1416        .iter()
1417        .sorted()
1418        .collect_vec()
1419        .serialize(serializer)
1420}
1421
1422fn integrity_err_not_found(package: &LocalPackage) -> LockfileIntegrityError {
1423    LockfileIntegrityError::PackageNotFound(
1424        package.name().clone(),
1425        package.version().clone(),
1426        package.spec.pinned,
1427        package
1428            .spec
1429            .constraint
1430            .clone()
1431            .unwrap_or("UNCONSTRAINED".into()),
1432    )
1433}
1434
1435fn deserialize_url<'de, D>(deserializer: D) -> Result<Url, D::Error>
1436where
1437    D: serde::Deserializer<'de>,
1438{
1439    let s = String::deserialize(deserializer)?;
1440    Url::parse(&s).map_err(serde::de::Error::custom)
1441}
1442
1443fn serialize_url<S>(url: &Url, serializer: S) -> Result<S::Ok, S::Error>
1444where
1445    S: Serializer,
1446{
1447    url.as_str().serialize(serializer)
1448}
1449
1450#[cfg(test)]
1451mod tests {
1452    use super::*;
1453    use std::{fs::remove_file, path::PathBuf};
1454
1455    use assert_fs::fixture::PathCopy;
1456    use insta::{assert_json_snapshot, sorted_redaction};
1457
1458    use crate::{config::ConfigBuilder, lua_version::LuaVersion, package::PackageSpec};
1459
1460    #[test]
1461    fn parse_lockfile() {
1462        let temp = assert_fs::TempDir::new().unwrap();
1463        temp.copy_from(
1464            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-tree"),
1465            &["**"],
1466        )
1467        .unwrap();
1468
1469        let config = ConfigBuilder::new()
1470            .unwrap()
1471            .user_tree(Some(temp.to_path_buf()))
1472            .build()
1473            .unwrap();
1474        let tree = config.user_tree(LuaVersion::Lua51).unwrap();
1475        let lockfile = tree.lockfile().unwrap();
1476
1477        assert_json_snapshot!(lockfile, { ".**" => sorted_redaction() });
1478    }
1479
1480    #[test]
1481    fn add_rocks() {
1482        let temp = assert_fs::TempDir::new().unwrap();
1483        temp.copy_from(
1484            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-tree"),
1485            &["**"],
1486        )
1487        .unwrap();
1488
1489        let mock_hashes = LocalPackageHashes {
1490            rockspec: "sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="
1491                .parse()
1492                .unwrap(),
1493            source: "sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="
1494                .parse()
1495                .unwrap(),
1496        };
1497
1498        let config = ConfigBuilder::new()
1499            .unwrap()
1500            .user_tree(Some(temp.to_path_buf()))
1501            .build()
1502            .unwrap();
1503        let tree = config.user_tree(LuaVersion::Lua51).unwrap();
1504        let mut lockfile = tree.lockfile().unwrap().write_guard();
1505
1506        let test_package = PackageSpec::parse("test1".to_string(), "0.1.0".to_string()).unwrap();
1507        let test_local_package = LocalPackage::from(
1508            &test_package,
1509            crate::lockfile::LockConstraint::Unconstrained,
1510            RockBinaries::default(),
1511            RemotePackageSource::Test,
1512            None,
1513            mock_hashes.clone(),
1514        );
1515        lockfile.add_entrypoint(&test_local_package);
1516
1517        let test_dep_package =
1518            PackageSpec::parse("test2".to_string(), "0.1.0".to_string()).unwrap();
1519        let mut test_local_dep_package = LocalPackage::from(
1520            &test_dep_package,
1521            crate::lockfile::LockConstraint::Constrained(">= 1.0.0".parse().unwrap()),
1522            RockBinaries::default(),
1523            RemotePackageSource::Test,
1524            None,
1525            mock_hashes.clone(),
1526        );
1527        test_local_dep_package.spec.pinned = PinnedState::Pinned;
1528        lockfile.add_dependency(&test_local_package, &test_local_dep_package);
1529
1530        assert_json_snapshot!(lockfile, { ".**" => sorted_redaction() });
1531    }
1532
1533    #[test]
1534    fn parse_nonexistent_lockfile() {
1535        let tree_path =
1536            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-tree");
1537
1538        let temp = assert_fs::TempDir::new().unwrap();
1539        temp.copy_from(&tree_path, &["**"]).unwrap();
1540
1541        remove_file(temp.join("5.1/lux.lock")).unwrap();
1542
1543        let config = ConfigBuilder::new()
1544            .unwrap()
1545            .user_tree(Some(temp.to_path_buf()))
1546            .build()
1547            .unwrap();
1548        let tree = config.user_tree(LuaVersion::Lua51).unwrap();
1549
1550        let _ = tree.lockfile().unwrap().write_guard(); // Try to create the lockfile but don't actually do anything with it.
1551    }
1552
1553    fn get_test_lockfile() -> Lockfile<ReadOnly> {
1554        let sample_tree = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1555            .join("resources/test/sample-tree/5.1/lux.lock");
1556        Lockfile::new(sample_tree, RockLayoutConfig::default()).unwrap()
1557    }
1558
1559    #[test]
1560    fn test_sync_spec() {
1561        let lockfile = get_test_lockfile();
1562        let packages = vec![
1563            PackageReq::parse("neorg@8.8.1-1").unwrap().into(),
1564            PackageReq::parse("lua-cjson@2.1.0").unwrap().into(),
1565            PackageReq::parse("nonexistent").unwrap().into(),
1566        ];
1567
1568        let sync_spec = lockfile
1569            .lock
1570            .package_sync_spec(&packages, &SyncStrategy::LockfileOnly);
1571
1572        assert_eq!(sync_spec.to_add.len(), 1);
1573
1574        // Should keep dependencies of neorg 8.8.1-1
1575        assert!(!sync_spec
1576            .to_remove
1577            .iter()
1578            .any(|pkg| pkg.name().to_string() == "nvim-nio"
1579                && pkg.constraint()
1580                    == LockConstraint::Constrained(">=1.7.0, <1.8.0".parse().unwrap())));
1581        assert!(!sync_spec
1582            .to_remove
1583            .iter()
1584            .any(|pkg| pkg.name().to_string() == "lua-utils.nvim"
1585                && pkg.constraint() == LockConstraint::Constrained("=1.0.2".parse().unwrap())));
1586        assert!(!sync_spec
1587            .to_remove
1588            .iter()
1589            .any(|pkg| pkg.name().to_string() == "plenary.nvim"
1590                && pkg.constraint() == LockConstraint::Constrained("=0.1.4".parse().unwrap())));
1591        assert!(!sync_spec
1592            .to_remove
1593            .iter()
1594            .any(|pkg| pkg.name().to_string() == "nui.nvim"
1595                && pkg.constraint() == LockConstraint::Constrained("=0.3.0".parse().unwrap())));
1596        assert!(!sync_spec
1597            .to_remove
1598            .iter()
1599            .any(|pkg| pkg.name().to_string() == "pathlib.nvim"
1600                && pkg.constraint()
1601                    == LockConstraint::Constrained(">=2.2.0, <2.3.0".parse().unwrap())));
1602    }
1603
1604    #[test]
1605    fn test_sync_spec_remove() {
1606        let lockfile = get_test_lockfile();
1607        let packages = vec![
1608            PackageReq::parse("lua-cjson@2.1.0").unwrap().into(),
1609            PackageReq::parse("nonexistent").unwrap().into(),
1610        ];
1611
1612        let sync_spec = lockfile
1613            .lock
1614            .package_sync_spec(&packages, &SyncStrategy::LockfileOnly);
1615
1616        assert_eq!(sync_spec.to_add.len(), 1);
1617
1618        // Should remove:
1619        // - neorg
1620        // - dependencies unique to neorg
1621        assert!(sync_spec
1622            .to_remove
1623            .iter()
1624            .any(|pkg| pkg.name().to_string() == "neorg"
1625                && pkg.version() == &"8.8.1-1".parse().unwrap()));
1626        assert!(sync_spec
1627            .to_remove
1628            .iter()
1629            .any(|pkg| pkg.name().to_string() == "nvim-nio"
1630                && pkg.constraint()
1631                    == LockConstraint::Constrained(">=1.7.0, <1.8.0".parse().unwrap())));
1632        assert!(sync_spec
1633            .to_remove
1634            .iter()
1635            .any(|pkg| pkg.name().to_string() == "lua-utils.nvim"
1636                && pkg.constraint() == LockConstraint::Constrained("=1.0.2".parse().unwrap())));
1637        assert!(sync_spec
1638            .to_remove
1639            .iter()
1640            .any(|pkg| pkg.name().to_string() == "plenary.nvim"
1641                && pkg.constraint() == LockConstraint::Constrained("=0.1.4".parse().unwrap())));
1642        assert!(sync_spec
1643            .to_remove
1644            .iter()
1645            .any(|pkg| pkg.name().to_string() == "nui.nvim"
1646                && pkg.constraint() == LockConstraint::Constrained("=0.3.0".parse().unwrap())));
1647        assert!(sync_spec
1648            .to_remove
1649            .iter()
1650            .any(|pkg| pkg.name().to_string() == "pathlib.nvim"
1651                && pkg.constraint()
1652                    == LockConstraint::Constrained(">=2.2.0, <2.3.0".parse().unwrap())));
1653    }
1654
1655    #[test]
1656    fn test_sync_spec_empty() {
1657        let lockfile = get_test_lockfile();
1658        let packages = vec![];
1659        let sync_spec = lockfile
1660            .lock
1661            .package_sync_spec(&packages, &SyncStrategy::LockfileOnly);
1662
1663        // Should remove all packages
1664        assert!(sync_spec.to_add.is_empty());
1665        assert_eq!(sync_spec.to_remove.len(), lockfile.rocks().len());
1666    }
1667
1668    #[test]
1669    fn test_sync_spec_different_constraints() {
1670        let lockfile = get_test_lockfile();
1671        let packages = vec![PackageReq::parse("nvim-nio>=2.0.0").unwrap().into()];
1672        let sync_spec = lockfile
1673            .lock
1674            .package_sync_spec(&packages, &SyncStrategy::LockfileOnly);
1675
1676        let expected: PackageVersionReq = ">=2.0.0".parse().unwrap();
1677        assert!(sync_spec
1678            .to_add
1679            .iter()
1680            .any(|req| req.name().to_string() == "nvim-nio" && req.version_req() == &expected));
1681
1682        assert!(sync_spec
1683            .to_remove
1684            .iter()
1685            .any(|pkg| pkg.name().to_string() == "nvim-nio"));
1686    }
1687
1688    #[test]
1689    fn test_sync_spec_ensure_installed() {
1690        let temp = assert_fs::TempDir::new().unwrap();
1691        temp.copy_from(
1692            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-tree"),
1693            &["**"],
1694        )
1695        .unwrap();
1696
1697        let config = ConfigBuilder::new()
1698            .unwrap()
1699            .user_tree(Some(temp.to_path_buf()))
1700            .build()
1701            .unwrap();
1702        let tree = config.user_tree(LuaVersion::Lua51).unwrap();
1703        let lockfile = tree.lockfile().unwrap();
1704
1705        let packages: Vec<LuaDependencySpec> = vec![
1706            PackageReq::parse("neorg@8.8.1-1").unwrap().into(),
1707            // This package isn't installed in the tree
1708            PackageReq::parse("lua-cjson@2.1.0").unwrap().into(),
1709            // And neither is this
1710            PackageReq::parse("nonexistent").unwrap().into(),
1711        ];
1712
1713        // Since lua-cjson is not present in the tree, it should get put into `to_add`
1714        let sync_spec = lockfile
1715            .lock
1716            .package_sync_spec(&packages, &SyncStrategy::EnsureInstalled(&tree));
1717
1718        assert!(!sync_spec
1719            .to_add
1720            .iter()
1721            .any(|req| req.name().to_string() == "neorg"));
1722
1723        assert!(sync_spec
1724            .to_add
1725            .iter()
1726            .any(|req| req.name().to_string() == "lua-cjson"));
1727
1728        assert!(sync_spec
1729            .to_add
1730            .iter()
1731            .any(|req| req.name().to_string() == "nonexistent"));
1732    }
1733}