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