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}