buffrs/
lock.rs

1// Copyright 2023 Helsing GmbH
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::{collections::BTreeMap, path::Path};
16
17use miette::{Context, IntoDiagnostic, ensure};
18use semver::Version;
19use serde::{Deserialize, Serialize};
20use thiserror::Error;
21use tokio::fs;
22use url::Url;
23
24use crate::{
25    ManagedFile,
26    errors::{DeserializationError, FileExistsError, FileNotFound, SerializationError, WriteError},
27    package::{Package, PackageName},
28    registry::RegistryUri,
29};
30
31mod digest;
32pub use digest::{Digest, DigestAlgorithm};
33
34/// File name of the lockfile
35pub const LOCKFILE: &str = "Proto.lock";
36
37/// A locked dependency with exact name and version
38///
39/// Serializes as "name version" string (Cargo format)
40#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
41pub struct LockedDependency {
42    /// The name of the dependency package
43    pub name: PackageName,
44    /// The exact version of the dependency package
45    pub version: Version,
46}
47
48impl LockedDependency {
49    /// Creates a new LockedDependency
50    pub fn new(name: PackageName, version: Version) -> Self {
51        Self { name, version }
52    }
53}
54
55// Custom serialization to match Cargo's "name version" format
56impl Serialize for LockedDependency {
57    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
58    where
59        S: serde::Serializer,
60    {
61        serializer.serialize_str(&format!("{} {}", self.name, self.version))
62    }
63}
64
65// Custom deserialization from "name version" format
66impl<'de> Deserialize<'de> for LockedDependency {
67    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
68    where
69        D: serde::Deserializer<'de>,
70    {
71        let s = String::deserialize(deserializer)?;
72        let parts: Vec<&str> = s.split_whitespace().collect();
73
74        if parts.len() != 2 {
75            return Err(serde::de::Error::custom(format!(
76                "invalid locked dependency format: expected 'name version', got '{}'",
77                s
78            )));
79        }
80
81        let name = PackageName::new(parts[0])
82            .map_err(|e| serde::de::Error::custom(format!("invalid package name: {}", e)))?;
83        let version = Version::parse(parts[1])
84            .map_err(|e| serde::de::Error::custom(format!("invalid version: {}", e)))?;
85
86        Ok(LockedDependency { name, version })
87    }
88}
89
90/// Captures immutable metadata about a given package
91///
92/// It is used to ensure that future installations will use the exact same dependencies.
93#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, PartialOrd, Ord)]
94pub struct LockedPackage {
95    /// The name of the package
96    pub name: PackageName,
97    /// The cryptographic digest of the package contents
98    pub digest: Digest,
99    /// The URI of the registry that contains the package
100    pub registry: RegistryUri,
101    /// The identifier of the repository where the package was published
102    pub repository: String,
103    /// The exact version of the package
104    pub version: Version,
105    /// Names of dependency packages
106    pub dependencies: Vec<PackageName>,
107    /// Count of dependant packages in the current graph
108    ///
109    /// This is used to detect when an entry can be safely removed from the lockfile.
110    pub dependants: usize,
111}
112
113impl LockedPackage {
114    /// Captures the source, version and checksum of a Package for use in reproducible installs
115    pub fn lock(
116        package: &Package,
117        registry: RegistryUri,
118        repository: String,
119        dependants: usize,
120    ) -> Self {
121        Self {
122            name: package.name().to_owned(),
123            registry,
124            repository,
125            digest: package.digest(DigestAlgorithm::SHA256).to_owned(),
126            version: package.version().to_owned(),
127            dependencies: package
128                .manifest
129                .dependencies
130                .iter()
131                .flatten()
132                .map(|d| d.package.clone())
133                .collect(),
134            dependants,
135        }
136    }
137
138    /// Validates if another LockedPackage matches this one
139    pub fn validate(&self, package: &Package) -> miette::Result<()> {
140        let digest: Digest = DigestAlgorithm::SHA256.digest(&package.tgz);
141
142        #[derive(Error, Debug)]
143        #[error("{property} mismatch - expected {expected}, actual {actual}")]
144        struct ValidationError {
145            property: &'static str,
146            expected: String,
147            actual: String,
148        }
149
150        ensure!(
151            &self.name == package.name(),
152            ValidationError {
153                property: "name",
154                expected: self.name.to_string(),
155                actual: package.name().to_string(),
156            }
157        );
158
159        ensure!(
160            &self.version == package.version(),
161            ValidationError {
162                property: "version",
163                expected: self.version.to_string(),
164                actual: package.version().to_string(),
165            }
166        );
167
168        ensure!(
169            self.digest == digest,
170            ValidationError {
171                property: "digest",
172                expected: self.digest.to_string(),
173                actual: digest.to_string(),
174            }
175        );
176
177        Ok(())
178    }
179}
180
181impl From<&WorkspaceLockedPackage> for LockedPackage {
182    fn from(ws_locked: &WorkspaceLockedPackage) -> Self {
183        Self {
184            name: ws_locked.name.clone(),
185            version: ws_locked.version.clone(),
186            digest: ws_locked.digest.clone(),
187            registry: ws_locked.registry.clone(),
188            repository: ws_locked.repository.clone(),
189            dependencies: ws_locked
190                .dependencies
191                .iter()
192                .map(|d| d.name.clone())
193                .collect(),
194            dependants: ws_locked.dependants,
195        }
196    }
197}
198
199#[derive(Serialize, Deserialize)]
200struct RawPackageLockfile {
201    version: u16,
202    packages: Vec<LockedPackage>,
203}
204
205impl RawPackageLockfile {
206    pub fn v1(packages: Vec<LockedPackage>) -> Self {
207        Self {
208            version: 1,
209            packages,
210        }
211    }
212}
213
214/// Captures metadata about currently installed Packages
215///
216/// Used to ensure future installations will deterministically select the exact same packages.
217#[derive(Default, Debug, PartialEq, Clone)]
218pub struct PackageLockfile {
219    packages: BTreeMap<PackageName, LockedPackage>,
220}
221
222impl PackageLockfile {
223    /// Checks if the Lockfile currently exists in the filesystem
224    pub async fn exists() -> miette::Result<bool> {
225        Self::exists_at(LOCKFILE).await
226    }
227
228    /// Checks if the Lockfile currently exists in the filesystem at a given path
229    pub async fn exists_at(path: impl AsRef<Path>) -> miette::Result<bool> {
230        fs::try_exists(path)
231            .await
232            .into_diagnostic()
233            .wrap_err(FileExistsError(LOCKFILE))
234    }
235
236    /// Loads the Lockfile from the current directory
237    pub async fn read() -> miette::Result<Self> {
238        Self::read_from(LOCKFILE).await
239    }
240
241    /// Loads the Lockfile from a specific path.
242    pub async fn read_from(path: impl AsRef<Path>) -> miette::Result<Self> {
243        match fs::read_to_string(path).await {
244            Ok(contents) => {
245                let raw: RawPackageLockfile = toml::from_str(&contents)
246                    .into_diagnostic()
247                    .wrap_err(DeserializationError(ManagedFile::Lock))?;
248                Ok(Self::from_iter(raw.packages.into_iter()))
249            }
250            Err(err) if matches!(err.kind(), std::io::ErrorKind::NotFound) => {
251                Err(FileNotFound(LOCKFILE.into()).into())
252            }
253            Err(err) => Err(err).into_diagnostic(),
254        }
255    }
256
257    /// Loads the Lockfile from the current directory, if it exists, otherwise returns an empty one. Fails, if the exists() check fails
258    pub async fn read_or_default() -> miette::Result<Self> {
259        if PackageLockfile::exists().await? {
260            PackageLockfile::read().await
261        } else {
262            Ok(PackageLockfile::default())
263        }
264    }
265
266    /// Loads the Lockfile from a specific path, if it exists, otherwise returns an empty one. Fails, if the exists() check fails
267    pub async fn read_from_or_default(path: impl AsRef<Path>) -> miette::Result<Self> {
268        if PackageLockfile::exists_at(&path).await? {
269            PackageLockfile::read_from(path).await
270        } else {
271            Ok(PackageLockfile::default())
272        }
273    }
274
275    /// Persists a Lockfile to the filesystem
276    pub async fn write(&self, path: impl AsRef<Path>) -> miette::Result<()> {
277        let mut packages: Vec<_> = self
278            .packages
279            .values()
280            .map(|pkg| {
281                let mut locked = pkg.clone();
282                locked.dependencies.sort();
283                locked
284            })
285            .collect();
286
287        packages.sort();
288
289        let raw = RawPackageLockfile::v1(packages);
290        let lockfile_path = path.as_ref().join(LOCKFILE);
291
292        fs::write(
293            lockfile_path,
294            toml::to_string(&raw)
295                .into_diagnostic()
296                .wrap_err(SerializationError(ManagedFile::Lock))?
297                .into_bytes(),
298        )
299        .await
300        .into_diagnostic()
301        .wrap_err(WriteError(LOCKFILE))
302    }
303
304    /// Locates a given package in the Lockfile
305    pub fn get(&self, name: &PackageName) -> Option<&LockedPackage> {
306        self.packages.get(name)
307    }
308}
309
310impl TryFrom<Vec<WorkspaceLockedPackage>> for PackageLockfile {
311    type Error = miette::Error;
312
313    fn try_from(locked: Vec<WorkspaceLockedPackage>) -> Result<Self, Self::Error> {
314        let package_locked: Vec<LockedPackage> = locked.iter().map(LockedPackage::from).collect();
315
316        Ok(PackageLockfile::from_iter(package_locked))
317    }
318}
319
320impl FromIterator<LockedPackage> for PackageLockfile {
321    fn from_iter<I: IntoIterator<Item = LockedPackage>>(iter: I) -> Self {
322        Self {
323            packages: iter
324                .into_iter()
325                .map(|locked| (locked.name.clone(), locked))
326                .collect(),
327        }
328    }
329}
330
331/// Captures immutable metadata about a package in a workspace lockfile
332///
333/// Similar to LockedPackage, but includes versioned dependencies to support
334/// multiple versions of the same package in a workspace.
335#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, PartialOrd, Ord)]
336pub struct WorkspaceLockedPackage {
337    /// The name of the package
338    pub name: PackageName,
339    /// The exact version of the package
340    pub version: Version,
341    /// The cryptographic digest of the package contents
342    pub digest: Digest,
343    /// The URI of the registry that contains the package
344    pub registry: RegistryUri,
345    /// The identifier of the repository where the package was published
346    pub repository: String,
347    /// Locked dependencies with exact versions
348    #[serde(default)]
349    pub dependencies: Vec<LockedDependency>,
350    /// Count of dependant packages in the workspace
351    pub dependants: usize,
352}
353
354impl WorkspaceLockedPackage {
355    /// Creates a WorkspaceLockedPackage from a LockedPackage
356    pub fn from_locked_package(locked: LockedPackage, dependencies: Vec<LockedDependency>) -> Self {
357        Self {
358            name: locked.name,
359            version: locked.version,
360            digest: locked.digest,
361            registry: locked.registry,
362            repository: locked.repository,
363            dependencies,
364            dependants: locked.dependants,
365        }
366    }
367
368    /// Validates if another WorkspaceLockedPackage matches this one
369    pub fn validate(&self, package: &Package) -> miette::Result<()> {
370        let digest: Digest = DigestAlgorithm::SHA256.digest(&package.tgz);
371
372        #[derive(Error, Debug)]
373        #[error("{property} mismatch - expected {expected}, actual {actual}")]
374        struct ValidationError {
375            property: &'static str,
376            expected: String,
377            actual: String,
378        }
379
380        ensure!(
381            &self.name == package.name(),
382            ValidationError {
383                property: "name",
384                expected: self.name.to_string(),
385                actual: package.name().to_string(),
386            }
387        );
388
389        ensure!(
390            &self.version == package.version(),
391            ValidationError {
392                property: "version",
393                expected: self.version.to_string(),
394                actual: package.version().to_string(),
395            }
396        );
397
398        ensure!(
399            self.digest == digest,
400            ValidationError {
401                property: "digest",
402                expected: self.digest.to_string(),
403                actual: digest.to_string(),
404            }
405        );
406
407        Ok(())
408    }
409}
410
411#[derive(Serialize, Deserialize)]
412struct RawWorkspaceLockfile {
413    version: u16,
414    packages: Vec<WorkspaceLockedPackage>,
415}
416
417impl RawWorkspaceLockfile {
418    pub fn v1(packages: Vec<WorkspaceLockedPackage>) -> Self {
419        Self {
420            version: 1,
421            packages,
422        }
423    }
424}
425
426/// Captures metadata about packages installed in a workspace
427///
428/// Unlike package lockfiles which can only store one version per package,
429/// workspace lockfiles use (name, version) as the key to support multiple
430/// versions of the same package.
431#[derive(Debug, PartialEq, Clone)]
432pub struct WorkspaceLockfile {
433    packages: BTreeMap<(PackageName, Version), WorkspaceLockedPackage>,
434}
435
436impl WorkspaceLockfile {
437    /// Checks if the workspace lockfile exists at the given path
438    pub async fn exists_at(path: impl AsRef<Path>) -> miette::Result<bool> {
439        fs::try_exists(path)
440            .await
441            .into_diagnostic()
442            .wrap_err(FileExistsError(LOCKFILE))
443    }
444
445    /// Loads the workspace lockfile from a specific path
446    pub async fn read_from(path: impl AsRef<Path>) -> miette::Result<Self> {
447        match fs::read_to_string(path).await {
448            Ok(contents) => {
449                let raw: RawWorkspaceLockfile = toml::from_str(&contents)
450                    .into_diagnostic()
451                    .wrap_err(DeserializationError(ManagedFile::Lock))?;
452                Ok(Self::from_iter(raw.packages.into_iter()))
453            }
454            Err(err) if matches!(err.kind(), std::io::ErrorKind::NotFound) => {
455                Err(FileNotFound(LOCKFILE.into()).into())
456            }
457            Err(err) => Err(err).into_diagnostic(),
458        }
459    }
460
461    /// Persists the workspace lockfile to the filesystem
462    pub async fn write(&self, path: impl AsRef<Path>) -> miette::Result<()> {
463        let mut packages: Vec<_> = self
464            .packages
465            .values()
466            .map(|pkg| {
467                let mut locked = pkg.clone();
468                locked.dependencies.sort();
469                locked
470            })
471            .collect();
472
473        packages.sort();
474
475        let raw = RawWorkspaceLockfile::v1(packages);
476        let lockfile_path = path.as_ref().join(LOCKFILE);
477
478        fs::write(
479            lockfile_path,
480            toml::to_string(&raw)
481                .into_diagnostic()
482                .wrap_err(SerializationError(ManagedFile::Lock))?
483                .into_bytes(),
484        )
485        .await
486        .into_diagnostic()
487        .wrap_err(WriteError(LOCKFILE))
488    }
489
490    /// Locates a package by name and version
491    pub fn get(&self, name: &PackageName, version: &Version) -> Option<&WorkspaceLockedPackage> {
492        self.packages.get(&(name.clone(), version.clone()))
493    }
494
495    /// Returns all packages in the lockfile
496    pub fn packages(&self) -> impl Iterator<Item = &WorkspaceLockedPackage> {
497        self.packages.values()
498    }
499}
500
501/// A unified view over either a package or workspace lockfile
502#[derive(Debug, Clone)]
503pub enum Lockfile {
504    /// A single-package lockfile
505    Package(PackageLockfile),
506    /// A workspace-level lockfile
507    Workspace(WorkspaceLockfile),
508}
509
510impl Lockfile {
511    /// Locates a package by name and version
512    pub fn get(&self, name: &PackageName, version: &Version) -> Option<FileRequirement> {
513        match self {
514            Self::Package(lock) => lock
515                .get(name)
516                .filter(|p| p.version == *version)
517                .map(FileRequirement::from),
518            Self::Workspace(lock) => lock.get(name, version).map(FileRequirement::from),
519        }
520    }
521}
522
523/// This converts the results of package install to a workspace lockfile
524impl FromIterator<WorkspaceLockedPackage> for WorkspaceLockfile {
525    fn from_iter<I: IntoIterator<Item = WorkspaceLockedPackage>>(iter: I) -> Self {
526        Self {
527            packages: iter
528                .into_iter()
529                .map(|locked| ((locked.name.clone(), locked.version.clone()), locked))
530                .collect(),
531        }
532    }
533}
534
535/// Aggregates locked packages from multiple workspace members into a workspace lockfile
536///
537/// Merges packages by (name, version), deduplicating and summing dependants counts.
538impl TryFrom<Vec<WorkspaceLockedPackage>> for WorkspaceLockfile {
539    type Error = miette::Report;
540
541    fn try_from(locked_packages: Vec<WorkspaceLockedPackage>) -> Result<Self, Self::Error> {
542        use std::collections::BTreeMap;
543
544        let mut workspace_packages: BTreeMap<
545            (PackageName, semver::Version),
546            WorkspaceLockedPackage,
547        > = BTreeMap::new();
548
549        for locked in locked_packages {
550            let key = (locked.name.clone(), locked.version.clone());
551
552            workspace_packages
553                .entry(key)
554                .and_modify(|existing| {
555                    // Same package (name, version) - sum dependants
556                    existing.dependants += locked.dependants;
557
558                    // Verify consistency of other fields
559                    if existing.registry != locked.registry {
560                        tracing::warn!(
561                            "registry mismatch for {}@{}: {} vs {}. Using first seen.",
562                            locked.name,
563                            locked.version,
564                            existing.registry,
565                            locked.registry
566                        );
567                    }
568                    if existing.digest != locked.digest {
569                        tracing::warn!(
570                            "digest mismatch for {}@{}: {} vs {}. Using first seen.",
571                            locked.name,
572                            locked.version,
573                            existing.digest,
574                            locked.digest
575                        );
576                    }
577                    // Dependencies should be identical for same (name, version)
578                    if existing.dependencies != locked.dependencies {
579                        tracing::warn!(
580                            "dependencies mismatch for {}@{}: {:?} vs {:?}. Using first seen.",
581                            locked.name,
582                            locked.version,
583                            existing.dependencies,
584                            locked.dependencies
585                        );
586                    }
587                })
588                .or_insert(locked);
589        }
590
591        Ok(Self::from_iter(workspace_packages.into_values()))
592    }
593}
594
595impl From<PackageLockfile> for Vec<FileRequirement> {
596    /// Converts lockfile into list of required files
597    ///
598    /// Must return files with a stable order to ensure identical lockfiles lead to identical
599    /// buffrs-cache nix derivations
600    fn from(lock: PackageLockfile) -> Self {
601        lock.packages.values().map(FileRequirement::from).collect()
602    }
603}
604
605/// A requirement from a lockfile on a specific file being available in order to build the
606/// overall graph. It's expected that when a file is downloaded, it's made available to buffrs
607/// by setting the filename to the digest in whatever download directory.
608#[derive(Serialize, Clone, PartialEq, Eq)]
609pub struct FileRequirement {
610    pub(crate) package: PackageName,
611    pub(crate) url: Url,
612    pub(crate) digest: Digest,
613}
614
615impl FileRequirement {
616    /// URL where the file can be located.
617    pub fn url(&self) -> &Url {
618        &self.url
619    }
620
621    /// Construct new file requirement.
622    pub fn new(
623        url: &RegistryUri,
624        repository: &String,
625        name: &PackageName,
626        version: &Version,
627        digest: &Digest,
628    ) -> Self {
629        let mut url = url.clone();
630        let new_path = format!(
631            "{}/{}/{}/{}-{}.tgz",
632            url.path(),
633            repository,
634            name,
635            name,
636            version
637        );
638
639        url.set_path(&new_path);
640
641        Self {
642            package: name.to_owned(),
643            url: url.into(),
644            digest: digest.clone(),
645        }
646    }
647}
648
649impl From<LockedPackage> for FileRequirement {
650    fn from(package: LockedPackage) -> Self {
651        Self::new(
652            &package.registry,
653            &package.repository,
654            &package.name,
655            &package.version,
656            &package.digest,
657        )
658    }
659}
660
661impl From<&LockedPackage> for FileRequirement {
662    fn from(package: &LockedPackage) -> Self {
663        Self::new(
664            &package.registry,
665            &package.repository,
666            &package.name,
667            &package.version,
668            &package.digest,
669        )
670    }
671}
672
673impl From<WorkspaceLockedPackage> for FileRequirement {
674    fn from(package: WorkspaceLockedPackage) -> Self {
675        Self::new(
676            &package.registry,
677            &package.repository,
678            &package.name,
679            &package.version,
680            &package.digest,
681        )
682    }
683}
684
685impl From<&WorkspaceLockedPackage> for FileRequirement {
686    fn from(package: &WorkspaceLockedPackage) -> Self {
687        Self::new(
688            &package.registry,
689            &package.repository,
690            &package.name,
691            &package.version,
692            &package.digest,
693        )
694    }
695}
696
697#[cfg(test)]
698mod tests {
699    use std::{collections::BTreeMap, str::FromStr};
700
701    use semver::Version;
702
703    use crate::{package::PackageName, registry::RegistryUri};
704
705    use super::{
706        Digest, DigestAlgorithm, FileRequirement, LockedDependency, LockedPackage, PackageLockfile,
707        WorkspaceLockedPackage, WorkspaceLockfile,
708    };
709
710    fn simple_lockfile() -> PackageLockfile {
711        PackageLockfile {
712            packages: BTreeMap::from([
713                (
714                    PackageName::new("package1").unwrap(),
715                    LockedPackage {
716                        name: PackageName::new("package1").unwrap(),
717                        digest: Digest::from_parts(
718                            DigestAlgorithm::SHA256,
719                            "c109c6b120c525e6ea7b2db98335d39a3272f572ac86ba7b2d65c765c353c122",
720                        )
721                        .unwrap(),
722                        registry: RegistryUri::from_str("http://my-registry.com").unwrap(),
723                        repository: "my-repo".to_owned(),
724                        version: Version::new(0, 1, 0),
725                        dependencies: Default::default(),
726                        dependants: 1,
727                    },
728                ),
729                (
730                    PackageName::new("package2").unwrap(),
731                    LockedPackage {
732                        name: PackageName::new("package2").unwrap(),
733                        digest: Digest::from_parts(
734                            DigestAlgorithm::SHA256,
735                            "c109c6b120c525e6ea7b2db98335d39a3272f572ac86ba7b2d65c765c353bce3",
736                        )
737                        .unwrap(),
738                        registry: RegistryUri::from_str("http://my-registry.com").unwrap(),
739                        repository: "my-other-repo".to_owned(),
740                        version: Version::new(0, 2, 0),
741                        dependencies: Default::default(),
742                        dependants: 1,
743                    },
744                ),
745                (
746                    PackageName::new("package3").unwrap(),
747                    LockedPackage {
748                        name: PackageName::new("package3").unwrap(),
749                        digest: Digest::from_parts(
750                            DigestAlgorithm::SHA256,
751                            "c109c6b120c525e6ea7b2db98335d39a3272f572ac86ba7b2d65c765c353bce3",
752                        )
753                        .unwrap(),
754                        registry: RegistryUri::from_str("http://your-registry.com").unwrap(),
755                        repository: "your-repo".to_owned(),
756                        version: Version::new(0, 2, 0),
757                        dependencies: Default::default(),
758                        dependants: 1,
759                    },
760                ),
761                (
762                    PackageName::new("package4").unwrap(),
763                    LockedPackage {
764                        name: PackageName::new("package4").unwrap(),
765                        digest: Digest::from_parts(
766                            DigestAlgorithm::SHA256,
767                            "c109c6b120c525e6ea7b2db98335d39a3272f572ac86ba7b2d65c765c353bce3",
768                        )
769                        .unwrap(),
770                        registry: RegistryUri::from_str("http://your-registry.com").unwrap(),
771                        repository: "your-other-repo".to_owned(),
772                        version: Version::new(0, 2, 0),
773                        dependencies: Default::default(),
774                        dependants: 1,
775                    },
776                ),
777            ]),
778        }
779    }
780
781    #[test]
782    fn stable_file_requirement_order() {
783        let lock = simple_lockfile();
784        let files: Vec<FileRequirement> = lock.into();
785        for _ in 0..30 {
786            let other_files: Vec<FileRequirement> = simple_lockfile().into();
787            assert!(other_files == files)
788        }
789    }
790
791    #[tokio::test]
792    async fn test_exists_at_returns_false_for_nonexistent_file() {
793        use tempfile::TempDir;
794
795        let temp_dir = TempDir::new().unwrap();
796        let lockfile_path = temp_dir.path().join("Proto.lock");
797
798        let exists = PackageLockfile::exists_at(&lockfile_path).await.unwrap();
799        assert!(!exists);
800    }
801
802    #[tokio::test]
803    async fn test_exists_at_returns_true_for_existing_file() {
804        use tempfile::TempDir;
805        use tokio::fs;
806
807        let temp_dir = TempDir::new().unwrap();
808        let lockfile_path = temp_dir.path().join("Proto.lock");
809
810        // Create an empty lockfile
811        fs::write(&lockfile_path, "").await.unwrap();
812
813        let exists = PackageLockfile::exists_at(&lockfile_path).await.unwrap();
814        assert!(exists);
815    }
816
817    #[tokio::test]
818    async fn test_exists_at_accepts_reference_and_owned() {
819        use std::path::PathBuf;
820        use tempfile::TempDir;
821
822        let temp_dir = TempDir::new().unwrap();
823        let lockfile_path = temp_dir.path().join("Proto.lock");
824
825        // Test with reference
826        let exists_ref = PackageLockfile::exists_at(&lockfile_path).await.unwrap();
827        assert!(!exists_ref);
828
829        // Test with owned PathBuf
830        let lockfile_path_owned = PathBuf::from(&lockfile_path);
831        let exists_owned = PackageLockfile::exists_at(lockfile_path_owned)
832            .await
833            .unwrap();
834        assert!(!exists_owned);
835
836        // Test with &str
837        let path_str = lockfile_path.to_str().unwrap();
838        let exists_str = PackageLockfile::exists_at(path_str).await.unwrap();
839        assert!(!exists_str);
840    }
841
842    #[tokio::test]
843    async fn test_read_from_or_default_returns_default_when_file_missing() {
844        use tempfile::TempDir;
845
846        let temp_dir = TempDir::new().unwrap();
847        let lockfile_path = temp_dir.path().join("Proto.lock");
848
849        let lockfile = PackageLockfile::read_from_or_default(&lockfile_path)
850            .await
851            .unwrap();
852
853        assert_eq!(lockfile.packages.len(), 0);
854        assert_eq!(lockfile, PackageLockfile::default());
855    }
856
857    #[tokio::test]
858    async fn test_read_from_or_default_reads_existing_file() {
859        use tempfile::TempDir;
860
861        let temp_dir = TempDir::new().unwrap();
862        let lockfile_path = temp_dir.path().join("Proto.lock");
863
864        // Create and write a lockfile (write expects directory path)
865        let original_lockfile = simple_lockfile();
866        original_lockfile.write(temp_dir.path()).await.unwrap();
867
868        // Read it back using read_from_or_default
869        let loaded_lockfile = PackageLockfile::read_from_or_default(&lockfile_path)
870            .await
871            .unwrap();
872
873        assert_eq!(loaded_lockfile.packages.len(), 4);
874        assert!(
875            loaded_lockfile
876                .packages
877                .contains_key(&PackageName::new("package1").unwrap())
878        );
879        assert!(
880            loaded_lockfile
881                .packages
882                .contains_key(&PackageName::new("package2").unwrap())
883        );
884        assert!(
885            loaded_lockfile
886                .packages
887                .contains_key(&PackageName::new("package3").unwrap())
888        );
889        assert!(
890            loaded_lockfile
891                .packages
892                .contains_key(&PackageName::new("package4").unwrap())
893        );
894    }
895
896    #[test]
897    fn test_locked_dependency_serialization() {
898        // Test serialization within a vector (how it's actually used)
899        let deps = vec![
900            LockedDependency::new(
901                PackageName::unchecked("remote-lib-a"),
902                Version::new(1, 5, 0),
903            ),
904            LockedDependency::new(
905                PackageName::unchecked("remote-lib-b"),
906                Version::new(2, 0, 1),
907            ),
908        ];
909
910        #[derive(serde::Serialize, serde::Deserialize)]
911        struct TestWrapper {
912            dependencies: Vec<LockedDependency>,
913        }
914
915        let wrapper = TestWrapper { dependencies: deps };
916        let serialized = toml::to_string(&wrapper).unwrap();
917
918        // Verify Cargo-style "name version" format
919        assert!(serialized.contains("dependencies = ["));
920        assert!(serialized.contains("\"remote-lib-a 1.5.0\""));
921        assert!(serialized.contains("\"remote-lib-b 2.0.1\""));
922
923        // Verify round-trip
924        let deserialized: TestWrapper = toml::from_str(&serialized).unwrap();
925        assert_eq!(deserialized.dependencies.len(), 2);
926        assert_eq!(
927            deserialized.dependencies[0].name,
928            PackageName::unchecked("remote-lib-a")
929        );
930        assert_eq!(deserialized.dependencies[0].version, Version::new(1, 5, 0));
931        assert_eq!(
932            deserialized.dependencies[1].name,
933            PackageName::unchecked("remote-lib-b")
934        );
935        assert_eq!(deserialized.dependencies[1].version, Version::new(2, 0, 1));
936    }
937
938    #[test]
939    fn test_workspace_lockfile_serialization() {
940        // Create a workspace lockfile with two packages, one with dependencies
941        let pkg1 = WorkspaceLockedPackage {
942            name: PackageName::unchecked("remote-lib-a"),
943            version: Version::new(1, 0, 0),
944            registry: RegistryUri::from_str("https://my-registry.com").unwrap(),
945            repository: "test-repo".to_string(),
946            digest: Digest::from_parts(
947                DigestAlgorithm::SHA256,
948                "c109c6b120c525e6ea7b2db98335d39a3272f572ac86ba7b2d65c765c353c122",
949            )
950            .unwrap(),
951            dependencies: vec![LockedDependency::new(
952                PackageName::unchecked("remote-lib-b"),
953                Version::new(1, 5, 0),
954            )],
955            dependants: 2,
956        };
957
958        let pkg2 = WorkspaceLockedPackage {
959            name: PackageName::unchecked("remote-lib-b"),
960            version: Version::new(1, 5, 0),
961            registry: RegistryUri::from_str("https://my-registry.com").unwrap(),
962            repository: "test-repo".to_string(),
963            digest: Digest::from_parts(
964                DigestAlgorithm::SHA256,
965                "c109c6b120c525e6ea7b2db98335d39a3272f572ac86ba7b2d65c765c353bce3",
966            )
967            .unwrap(),
968            dependencies: vec![], // Leaf package
969            dependants: 1,
970        };
971
972        let lockfile = WorkspaceLockfile::from_iter(vec![pkg1, pkg2]);
973
974        // Serialize to TOML
975        let serialized = toml::to_string(&super::RawWorkspaceLockfile {
976            version: 1,
977            packages: lockfile.packages.values().cloned().collect(),
978        })
979        .unwrap();
980
981        // Verify format matches expected structure
982        assert!(serialized.contains("version = 1"));
983        assert!(serialized.contains("[[packages]]"));
984        assert!(serialized.contains("name = \"remote-lib-a\""));
985        assert!(serialized.contains("version = \"1.0.0\""));
986        assert!(serialized.contains("dependencies = [\"remote-lib-b 1.5.0\"]"));
987        assert!(serialized.contains("dependants = 2"));
988        assert!(serialized.contains("name = \"remote-lib-b\""));
989        assert!(serialized.contains("version = \"1.5.0\""));
990        assert!(serialized.contains("dependencies = []"));
991        assert!(serialized.contains("dependants = 1"));
992
993        // Verify round-trip deserialization
994        let raw: super::RawWorkspaceLockfile = toml::from_str(&serialized).unwrap();
995        assert_eq!(raw.version, 1);
996        assert_eq!(raw.packages.len(), 2);
997
998        let restored = WorkspaceLockfile::from_iter(raw.packages);
999        assert_eq!(restored.packages.len(), 2);
1000
1001        // Verify we can look up by (name, version)
1002        let found = restored.get(
1003            &PackageName::unchecked("remote-lib-a"),
1004            &Version::new(1, 0, 0),
1005        );
1006        assert!(found.is_some());
1007        assert_eq!(found.unwrap().dependencies.len(), 1);
1008    }
1009
1010    #[test]
1011    fn test_workspace_lockfile_supports_multiple_versions() {
1012        // Create two versions of the same package
1013        let pkg_v1 = WorkspaceLockedPackage {
1014            name: PackageName::unchecked("remote-lib"),
1015            version: Version::new(1, 0, 0),
1016            registry: RegistryUri::from_str("https://my-registry.com").unwrap(),
1017            repository: "test-repo".to_string(),
1018            digest: Digest::from_parts(
1019                DigestAlgorithm::SHA256,
1020                "c109c6b120c525e6ea7b2db98335d39a3272f572ac86ba7b2d65c765c353c122",
1021            )
1022            .unwrap(),
1023            dependencies: vec![],
1024            dependants: 1,
1025        };
1026
1027        let pkg_v2 = WorkspaceLockedPackage {
1028            name: PackageName::unchecked("remote-lib"),
1029            version: Version::new(2, 0, 0),
1030            registry: RegistryUri::from_str("https://my-registry.com").unwrap(),
1031            repository: "test-repo".to_string(),
1032            digest: Digest::from_parts(
1033                DigestAlgorithm::SHA256,
1034                "c109c6b120c525e6ea7b2db98335d39a3272f572ac86ba7b2d65c765c353bce3",
1035            )
1036            .unwrap(),
1037            dependencies: vec![],
1038            dependants: 1,
1039        };
1040
1041        let lockfile = WorkspaceLockfile::from_iter(vec![pkg_v1, pkg_v2]);
1042
1043        // Both versions should be stored
1044        assert_eq!(lockfile.packages.len(), 2);
1045
1046        // Should be able to look up each version independently
1047        let v1 = lockfile.get(
1048            &PackageName::unchecked("remote-lib"),
1049            &Version::new(1, 0, 0),
1050        );
1051        assert!(v1.is_some());
1052        assert_eq!(v1.unwrap().version, Version::new(1, 0, 0));
1053
1054        let v2 = lockfile.get(
1055            &PackageName::unchecked("remote-lib"),
1056            &Version::new(2, 0, 0),
1057        );
1058        assert!(v2.is_some());
1059        assert_eq!(v2.unwrap().version, Version::new(2, 0, 0));
1060    }
1061
1062    #[test]
1063    fn test_lockfile_package_returns_file_requirement() {
1064        let lockfile = simple_lockfile();
1065        let resolved = super::Lockfile::Package(lockfile);
1066
1067        // Should find package1 at version 0.1.0
1068        let result = resolved.get(
1069            &PackageName::new("package1").unwrap(),
1070            &Version::new(0, 1, 0),
1071        );
1072        assert!(result.is_some());
1073        let file_req = result.unwrap();
1074        assert!(file_req.url().as_str().contains("package1"));
1075
1076        // Should return None for wrong version
1077        let result = resolved.get(
1078            &PackageName::new("package1").unwrap(),
1079            &Version::new(9, 9, 9),
1080        );
1081        assert!(result.is_none());
1082
1083        // Should return None for unknown package
1084        let result = resolved.get(
1085            &PackageName::new("unknown").unwrap(),
1086            &Version::new(0, 1, 0),
1087        );
1088        assert!(result.is_none());
1089    }
1090
1091    #[test]
1092    fn test_lockfile_workspace_returns_file_requirement() {
1093        let pkg = WorkspaceLockedPackage {
1094            name: PackageName::unchecked("ws-pkg"),
1095            version: Version::new(1, 0, 0),
1096            registry: RegistryUri::from_str("https://registry.example.com").unwrap(),
1097            repository: "repo".to_string(),
1098            digest: Digest::from_parts(
1099                DigestAlgorithm::SHA256,
1100                "c109c6b120c525e6ea7b2db98335d39a3272f572ac86ba7b2d65c765c353c122",
1101            )
1102            .unwrap(),
1103            dependencies: vec![],
1104            dependants: 1,
1105        };
1106        let lockfile = WorkspaceLockfile::from_iter(vec![pkg]);
1107        let resolved = super::Lockfile::Workspace(lockfile);
1108
1109        // Should find ws-pkg at version 1.0.0
1110        let result = resolved.get(&PackageName::unchecked("ws-pkg"), &Version::new(1, 0, 0));
1111        assert!(result.is_some());
1112
1113        // Should return None for wrong version
1114        let result = resolved.get(&PackageName::unchecked("ws-pkg"), &Version::new(2, 0, 0));
1115        assert!(result.is_none());
1116    }
1117}