Skip to main content

loadsmith_registry/registries/
local.rs

1use std::{fs::File, io::BufReader, path::Path, pin::Pin};
2
3use camino::Utf8PathBuf;
4use loadsmith_core::{Dependency, PackageId, PackageRef, Version, VersionReq};
5use serde::Deserialize;
6use thunderstore::VersionIdent;
7use tracing::debug;
8
9use crate::{Error, Registry, ResolvedVersion, Result, VersionInfo};
10
11/// A registry that reads package data from the local filesystem.
12///
13/// `LocalRegistry` expects metadata containing a `"path"` field pointing to a
14/// local file or directory. Supported sources are:
15///
16/// * `.zip` archives containing a Thunderstore-style `manifest.json`
17/// * Unpacked directories with a Thunderstore `manifest.json`
18/// * `.dll` files (version extracted from metadata or filename)
19/// * Other files (version extracted from metadata or filename)
20///
21/// # Examples
22///
23/// ```rust
24/// use loadsmith_registry::LocalRegistry;
25///
26/// let registry = LocalRegistry::new();
27/// assert!(format!("{registry:?}").contains("LocalRegistry"));
28/// ```
29#[derive(Debug)]
30pub struct LocalRegistry;
31
32impl Default for LocalRegistry {
33    fn default() -> Self {
34        Self::new()
35    }
36}
37
38impl LocalRegistry {
39    /// Create a new `LocalRegistry`.
40    pub fn new() -> Self {
41        Self
42    }
43
44    pub fn read(&self, metadata: Metadata) -> Result<(Option<PackageId>, VersionInfo)> {
45        let source = Source::read(metadata)?;
46
47        Ok((
48            source.package_id(),
49            VersionInfo {
50                version: source.version().clone(),
51            },
52        ))
53    }
54}
55
56impl Registry for LocalRegistry {
57    fn version_info<'a>(
58        &'a self,
59        id: &'a PackageId,
60        metadata: Option<&'a serde_json::Value>,
61    ) -> Pin<Box<dyn Future<Output = Result<Vec<VersionInfo>>> + 'a>> {
62        Box::pin(async move {
63            let metadata = crate::read_metadata(metadata)?;
64            let source = Source::read(metadata)?;
65
66            source.check_package_id(id)?;
67
68            Ok(vec![VersionInfo {
69                version: source.version().clone(),
70            }])
71        })
72    }
73
74    fn resolve<'a>(
75        &'a self,
76        ref_: &'a PackageRef,
77        metadata: Option<&'a serde_json::Value>,
78    ) -> Pin<Box<dyn Future<Output = Result<ResolvedVersion>> + 'a>> {
79        Box::pin(async move {
80            let metadata = crate::read_metadata(metadata)?;
81            let source = Source::read(metadata)?;
82            source.check_package_ref(ref_)?;
83
84            let full_path = source.path.canonicalize_utf8()?;
85
86            Ok(ResolvedVersion {
87                url: full_path.into(),
88                size: source.size()?,
89                deps: source.dependencies()?,
90                checksum: source.checksum().map(Some)?,
91            })
92        })
93    }
94
95    fn revalidate_checksum<'a>(
96        &'a self,
97        ref_: &'a PackageRef,
98        metadata: Option<&'a serde_json::Value>,
99    ) -> Result<Option<loadsmith_core::Checksum>> {
100        let metadata = crate::read_metadata(metadata)?;
101        let source = Source::read(metadata)?;
102        source.check_package_ref(ref_)?;
103
104        let checksum = source.checksum()?;
105
106        Ok(Some(checksum))
107    }
108}
109
110#[derive(Debug, Deserialize)]
111pub struct Metadata {
112    path: Utf8PathBuf,
113    #[serde(
114        default,
115        skip_serializing_if = "Option::is_none",
116        rename = "deps_source"
117    )]
118    dependency_registry: Option<String>,
119    #[serde(
120        default,
121        skip_serializing_if = "Option::is_none",
122        rename = "source_version"
123    )]
124    source_version: Option<Version>,
125}
126
127struct Source {
128    path: Utf8PathBuf,
129    kind: SourceKind,
130    dependency_registry: String,
131}
132
133enum SourceKind {
134    Zip(ThunderstoreManifest),
135    Directory(ThunderstoreManifest),
136    Dll(Version),
137    Other(Version),
138}
139
140impl Metadata {
141    pub fn new(path: impl Into<Utf8PathBuf>) -> Self {
142        Self {
143            path: path.into(),
144            dependency_registry: None,
145            source_version: None,
146        }
147    }
148
149    pub fn with_dependency_registry(mut self, registry: impl Into<String>) -> Self {
150        self.dependency_registry = Some(registry.into());
151        self
152    }
153
154    pub fn with_source_version(mut self, version: impl Into<Version>) -> Self {
155        self.source_version = Some(version.into());
156        self
157    }
158}
159
160impl Source {
161    const DEFAULT_DEPENDENCY_REGISTRY: &'static str = "thunderstore";
162
163    fn read(metadata: Metadata) -> Result<Self> {
164        if !metadata.path.exists() {
165            return Err(Error::FileNotFound(metadata.path));
166        }
167
168        let kind = match (metadata.path.is_file(), metadata.path.extension()) {
169            (true, Some("zip")) => {
170                let manifest = read_zip_manifest(&metadata.path)?;
171
172                Self::warn_if_source_version_set(&manifest.version_number, &metadata);
173
174                SourceKind::Zip(manifest)
175            }
176            (true, Some("dll")) => {
177                SourceKind::Dll(metadata.source_version.ok_or(Error::LocalVersionMissing)?)
178            }
179            (false, _) => {
180                let manifest_path = metadata.path.join("manifest.json");
181                if !manifest_path.exists() {
182                    return Err(Error::LocalManifestMissing);
183                }
184
185                let manifest_str = std::fs::read_to_string(&manifest_path)?;
186                let manifest: ThunderstoreManifest = serde_json::from_str(&manifest_str)?;
187
188                Self::warn_if_source_version_set(&manifest.version_number, &metadata);
189
190                SourceKind::Directory(manifest)
191            }
192            _ => {
193                debug!(path = %metadata.path, "could not determine source kind");
194
195                SourceKind::Other(metadata.source_version.ok_or(Error::LocalVersionMissing)?)
196            }
197        };
198
199        let dependency_registry = metadata
200            .dependency_registry
201            .unwrap_or_else(|| Self::DEFAULT_DEPENDENCY_REGISTRY.to_string());
202
203        Ok(Source {
204            kind,
205            path: metadata.path,
206            dependency_registry,
207        })
208    }
209
210    fn warn_if_source_version_set(package_version: &Version, metadata: &Metadata) {
211        if let Some(source_version) = &metadata.source_version {
212            debug!(
213                path = %metadata.path,
214                %source_version,
215                %package_version,
216                "source_version is set, but is overriden by the local package's version"
217            );
218        }
219    }
220
221    fn check_package_id(&self, id: &PackageId) -> Result<()> {
222        match self.package_id() {
223            Some(package_id) if package_id == *id => Ok(()),
224            Some(_) => Err(Error::PackageNotFound),
225            None => Ok(()),
226        }
227    }
228
229    fn package_id(&self) -> Option<PackageId> {
230        match &self.kind {
231            SourceKind::Zip(manifest) | SourceKind::Directory(manifest) => {
232                let package_id = if let Some(namespace) = &manifest.namespace {
233                    format!("{}-{}", namespace, manifest.name)
234                } else {
235                    manifest.name.clone()
236                };
237
238                Some(PackageId::new(package_id))
239            }
240            SourceKind::Dll(_) | SourceKind::Other(_) => None,
241        }
242    }
243
244    fn check_package_ref(&self, ref_: &PackageRef) -> Result<()> {
245        self.check_package_id(ref_.id())?;
246
247        if self.version() != ref_.version() {
248            return Err(Error::VersionNotFound);
249        }
250
251        Ok(())
252    }
253
254    fn version(&self) -> &Version {
255        match &self.kind {
256            SourceKind::Zip(manifest) | SourceKind::Directory(manifest) => &manifest.version_number,
257            SourceKind::Dll(version) | SourceKind::Other(version) => version,
258        }
259    }
260
261    fn size(&self) -> Result<Option<u64>> {
262        match &self.kind {
263            SourceKind::Zip(_) | SourceKind::Dll(_) | SourceKind::Other(_) => {
264                let size = std::fs::metadata(&self.path)?.len();
265                Ok(Some(size))
266            }
267            SourceKind::Directory(_) => Ok(None),
268        }
269    }
270
271    fn dependencies(&self) -> Result<Vec<Dependency>> {
272        match &self.kind {
273            SourceKind::Zip(manifest) | SourceKind::Directory(manifest) => {
274                let deps = manifest
275                    .dependencies
276                    .iter()
277                    .map(|ident| {
278                        let package_id = PackageId::new(ident.package_id().into_string());
279
280                        Dependency::new(
281                            package_id,
282                            VersionReq::STAR,
283                            self.dependency_registry.clone(),
284                        )
285                    })
286                    .collect();
287
288                Ok(deps)
289            }
290            SourceKind::Dll(_) | SourceKind::Other(_) => Ok(Vec::new()),
291        }
292    }
293
294    fn checksum(&self) -> Result<loadsmith_core::Checksum> {
295        loadsmith_core::Checksum::compute_from_path(
296            &self.path,
297            loadsmith_core::ChecksumAlgorithm::Blake3,
298        )
299        .map_err(Into::into)
300    }
301}
302
303#[derive(Debug, Deserialize)]
304struct ThunderstoreManifest {
305    #[serde(default)]
306    namespace: Option<String>,
307    name: String,
308    #[allow(unused)]
309    description: String,
310    version_number: Version,
311    dependencies: Vec<VersionIdent>,
312    #[allow(unused)]
313    website_url: Option<String>,
314}
315
316fn read_zip_manifest(path: impl AsRef<Path>) -> Result<ThunderstoreManifest> {
317    let file = File::open(path.as_ref()).map(BufReader::new)?;
318    let mut archive = zip::ZipArchive::new(file)?;
319
320    let mut manifest_file = archive.by_name("manifest.json").map_err(|err| match err {
321        zip::result::ZipError::FileNotFound => Error::LocalManifestMissing,
322        other => Error::Zip(other),
323    })?;
324    let manifest: ThunderstoreManifest = serde_json::from_reader(&mut manifest_file)?;
325
326    Ok(manifest)
327}