Skip to main content

lux_lib/lockfile/
mod.rs

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