Skip to main content

loadsmith_registry/registries/
offline.rs

1use std::{collections::HashMap, pin::Pin};
2
3use loadsmith_core::{Checksum, Dependency, FileUrl, PackageId, PackageRef, Version};
4use serde::{Deserialize, Serialize};
5
6use crate::{Error, Registry, ResolvedVersion, Result, VersionInfo};
7
8/// A package definition used with [`OfflineRegistry`].
9///
10/// Holds a package identifier and a list of available versions.
11///
12/// # Examples
13///
14/// ```rust
15/// use loadsmith_registry::Package;
16/// use loadsmith_core::PackageId;
17///
18/// let pkg = Package::new(PackageId::new("author-name"), Vec::new());
19/// assert_eq!(pkg.id.as_str(), "author-name");
20/// assert!(pkg.versions.is_empty());
21/// ```
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct Package {
24    pub id: PackageId,
25    pub versions: Vec<PackageVersion>,
26}
27
28impl Package {
29    /// Create a new package with the given identifier and version list.
30    pub fn new(id: impl Into<PackageId>, versions: Vec<PackageVersion>) -> Self {
31        Self {
32            id: id.into(),
33            versions,
34        }
35    }
36
37    fn version_by_version<'a>(&'a self, version: &Version) -> Result<&'a PackageVersion> {
38        self.versions
39            .iter()
40            .find(|v| v.version == *version)
41            .ok_or(Error::VersionNotFound)
42    }
43}
44
45/// A single version entry inside an [`OfflineRegistry`] package.
46///
47/// Includes the download URL, optional size and checksum, and the dependency
48/// list. Builder methods ([`with_size`](PackageVersion::with_size),
49/// [`with_checksum`](PackageVersion::with_checksum),
50/// [`with_deps`](PackageVersion::with_deps)) are provided for ergonomic
51/// construction.
52///
53/// # Examples
54///
55/// ```rust
56/// use loadsmith_registry::PackageVersion;
57/// use loadsmith_core::{Version, FileUrl, Dependency, VersionReq};
58///
59/// let dep = Dependency::new("other-mod", VersionReq::STAR, "thunderstore");
60/// let pv = PackageVersion::new(
61///     Version::new(1, 0, 0),
62///     FileUrl::try_from_url("https://example.com/pkg.zip").unwrap(),
63/// )
64/// .with_size(4096)
65/// .with_deps(vec![dep]);
66/// assert_eq!(pv.version.to_string(), "1.0.0");
67/// assert_eq!(pv.size, Some(4096));
68/// assert_eq!(pv.deps.len(), 1);
69/// ```
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct PackageVersion {
72    pub version: Version,
73    pub url: FileUrl,
74    #[serde(default)]
75    pub size: Option<u64>,
76    #[serde(default)]
77    pub checksum: Option<Checksum>,
78    pub deps: Vec<Dependency>,
79}
80
81impl PackageVersion {
82    /// Create a new version entry with a version number and download URL.
83    ///
84    /// Optional fields (`size`, `checksum`, `deps`) are initialised to `None`
85    /// or an empty vector. Use the builder methods to populate them.
86    pub fn new(version: impl Into<Version>, download_url: impl Into<FileUrl>) -> Self {
87        Self {
88            version: version.into(),
89            url: download_url.into(),
90            size: None,
91            checksum: None,
92            deps: Vec::new(),
93        }
94    }
95
96    /// Set the download size in bytes.
97    pub fn with_size(mut self, size: u64) -> Self {
98        self.size = Some(size);
99        self
100    }
101
102    /// Set the cryptographic checksum.
103    pub fn with_checksum(mut self, checksum: impl Into<Checksum>) -> Self {
104        self.checksum = Some(checksum.into());
105        self
106    }
107
108    /// Replace the dependency list.
109    pub fn with_deps(mut self, deps: Vec<Dependency>) -> Self {
110        self.deps = deps;
111        self
112    }
113}
114
115/// An in-memory registry pre-populated with static package data.
116///
117/// Useful for testing, offline scenarios, or mirroring the public Thunderstore
118/// index. Every package and its versions are loaded up-front and stored in a
119/// [`HashMap`] keyed by [`PackageId`].
120///
121/// # Examples
122///
123/// ```rust
124/// use std::collections::HashMap;
125/// use loadsmith_registry::{OfflineRegistry, Package, PackageVersion, Registry};
126/// use loadsmith_core::{PackageId, Version, FileUrl, PackageRef};
127///
128/// # #[tokio::main]
129/// # async fn main() {
130/// let pkg = Package::new(
131///     PackageId::new("author-name"),
132///     vec![PackageVersion::new(
133///         Version::new(1, 0, 0),
134///         FileUrl::try_from_url("https://example.com/pkg.zip").unwrap(),
135///     )],
136/// );
137/// let mut packages = HashMap::new();
138/// packages.insert(PackageId::new("author-name"), pkg);
139/// let registry = OfflineRegistry::new(packages);
140///
141/// // List versions.
142/// let versions = registry
143///     .version_info(&PackageId::new("author-name"), None)
144///     .await
145///     .unwrap();
146/// assert_eq!(versions.len(), 1);
147///
148/// // Resolve a specific version.
149/// let ref_ = PackageRef::new("author-name", Version::new(1, 0, 0));
150/// let resolved = registry.resolve(&ref_, None).await.unwrap();
151/// assert!(resolved.url.to_string().contains("example.com"));
152/// # }
153/// ```
154#[derive(Debug, Clone, Serialize, Deserialize, Default)]
155pub struct OfflineRegistry {
156    packages: HashMap<PackageId, Package>,
157}
158
159impl OfflineRegistry {
160    /// Create a new offline registry from a map of packages.
161    ///
162    /// The map is keyed by [`PackageId`] — each entry must match the `id`
163    /// stored inside its [`Package`] value.
164    pub fn new(packages: HashMap<PackageId, Package>) -> Self {
165        Self { packages }
166    }
167
168    fn package_by_id<'a>(&'a self, id: &PackageId) -> Result<&'a Package> {
169        self.packages.get(id).ok_or(Error::PackageNotFound)
170    }
171}
172
173impl Registry for OfflineRegistry {
174    fn version_info<'a>(
175        &'a self,
176        id: &'a PackageId,
177        _metadata: Option<&'a serde_json::Value>,
178    ) -> Pin<Box<dyn Future<Output = Result<Vec<VersionInfo>>> + 'a>> {
179        Box::pin(async move {
180            let package = self.package_by_id(id)?;
181
182            let versions = package
183                .versions
184                .iter()
185                .map(|v| VersionInfo {
186                    version: v.version.clone(),
187                })
188                .collect();
189
190            Ok(versions)
191        })
192    }
193
194    fn resolve<'a>(
195        &'a self,
196        ref_: &'a PackageRef,
197        _metadata: Option<&'a serde_json::Value>,
198    ) -> Pin<Box<dyn Future<Output = Result<ResolvedVersion>> + 'a>> {
199        Box::pin(async move {
200            let version = self
201                .package_by_id(ref_.id())?
202                .version_by_version(ref_.version())?;
203
204            Ok(ResolvedVersion {
205                url: version.url.clone(),
206                size: version.size,
207                checksum: version.checksum.clone(),
208                deps: version.deps.clone(),
209            })
210        })
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    use std::assert_matches;
219
220    #[tokio::test]
221    async fn it_works() {
222        let registry = OfflineRegistry::new(HashMap::from_iter([(
223            PackageId::new("author-name"),
224            Package::new(
225                PackageId::new("author-name"),
226                vec![
227                    PackageVersion::new(
228                        Version::new(1, 0, 0),
229                        FileUrl::try_from_url("https://example.com/package-1.0.0.zip").unwrap(),
230                    ),
231                    PackageVersion::new(
232                        Version::new(1, 1, 0),
233                        FileUrl::try_from_url("https://example.com/package-1.1.0.zip").unwrap(),
234                    ),
235                ],
236            ),
237        )]));
238
239        let versions = registry
240            .version_info(&PackageId::new("author-name"), None)
241            .await
242            .unwrap();
243
244        assert_eq!(versions.len(), 2);
245
246        let non_existent_package = registry
247            .version_info(&PackageId::new("non-existent"), None)
248            .await;
249
250        assert_matches!(non_existent_package, Err(Error::PackageNotFound));
251    }
252}