loadsmith_thunderstore/index/in_memory.rs
1use std::{
2 collections::HashMap,
3 io::{BufReader, BufWriter},
4 path::Path,
5 sync::Arc,
6};
7
8use futures::{TryStreamExt, pin_mut};
9use loadsmith_core::{PackageId, PackageRef, Version};
10use loadsmith_registry::ResolvedVersion;
11use parking_lot::{Mutex, RawMutex, lock_api::MutexGuard};
12use serde::{Deserialize, Serialize};
13use tracing::debug;
14
15use crate::{Error, PackageIdExt, Result};
16
17/// An in-memory package index backed by a thunderstore client.
18///
19/// Stores package data in a [`HashMap`] and supports serialisation to/from
20/// disk for caching.
21///
22/// # Examples
23///
24/// ```
25/// use loadsmith_thunderstore::in_memory::InMemoryIndex;
26/// use thunderstore::Client;
27///
28/// let client = Client::new();
29/// let index = InMemoryIndex::new(client);
30/// ```
31#[derive(Debug, Clone)]
32pub struct InMemoryIndex {
33 client: thunderstore::Client,
34 state: Arc<Mutex<State>>,
35}
36
37/// The serialisable state of an in-memory index.
38///
39/// Maps community names to their indexed packages.
40///
41/// # Examples
42///
43/// ```
44/// use loadsmith_thunderstore::in_memory::State;
45///
46/// let state = State::default();
47/// ```
48#[derive(Debug, Default, Serialize, Deserialize)]
49pub struct State(HashMap<String, Community>);
50
51/// A community package collection within an in-memory index.
52///
53/// Tracks whether the index is complete and stores packages by their
54/// [`PackageId`].
55///
56/// # Examples
57///
58/// ```
59/// use loadsmith_thunderstore::in_memory::Community;
60///
61/// let community = Community::default();
62/// ```
63#[derive(Debug, Default, Serialize, Deserialize)]
64pub struct Community {
65 complete: bool,
66 packages: HashMap<PackageId, thunderstore::models::PackageV1>,
67}
68
69impl InMemoryIndex {
70 /// Creates a new empty in-memory index.
71 ///
72 /// # Examples
73 ///
74 /// ```
75 /// use loadsmith_thunderstore::in_memory::InMemoryIndex;
76 /// use thunderstore::Client;
77 ///
78 /// let index = InMemoryIndex::new(Client::new());
79 /// ```
80 pub fn new(client: thunderstore::Client) -> Self {
81 Self::new_with_state(client, State::default())
82 }
83
84 /// Loads a previously saved index from disk, or creates a new one if the
85 /// file does not exist.
86 ///
87 /// # Examples
88 ///
89 /// ```no_run
90 /// use loadsmith_thunderstore::in_memory::InMemoryIndex;
91 /// use thunderstore::Client;
92 ///
93 /// let index = InMemoryIndex::load(Client::new(), "./index.yaml").unwrap();
94 /// ```
95 pub fn load(client: thunderstore::Client, path: impl AsRef<Path>) -> Result<Self> {
96 let path = path.as_ref();
97 let state = if path.exists() {
98 State::load(path)?
99 } else {
100 debug!(path = %path.display(), "index file does not exist, creating new");
101 State::default()
102 };
103 Ok(Self::new_with_state(client, state))
104 }
105
106 /// Serialises the index state to a JSON file on disk.
107 ///
108 /// The `filter` closure controls which packages are included.
109 ///
110 /// # Examples
111 ///
112 /// ```no_run
113 /// use loadsmith_thunderstore::in_memory::InMemoryIndex;
114 /// use thunderstore::Client;
115 ///
116 /// let index = InMemoryIndex::new(Client::new());
117 /// index.save("./index.json", |_| true).unwrap();
118 /// ```
119 pub fn save<F>(&self, path: impl AsRef<Path>, filter: F) -> Result<()>
120 where
121 F: FnMut(&PackageId) -> bool,
122 {
123 self.lock().save(path.as_ref(), filter)
124 }
125
126 fn new_with_state(client: thunderstore::Client, state: State) -> Self {
127 Self {
128 client,
129 state: Arc::new(Mutex::new(state)),
130 }
131 }
132
133 /// Acquires the inner lock on the index state.
134 ///
135 /// # Examples
136 ///
137 /// ```
138 /// use loadsmith_thunderstore::in_memory::InMemoryIndex;
139 /// use thunderstore::Client;
140 ///
141 /// let index = InMemoryIndex::new(Client::new());
142 /// let _state = index.lock();
143 /// ```
144 pub fn lock(&self) -> MutexGuard<'_, RawMutex, State> {
145 self.state.lock()
146 }
147
148 /// Fetches and stores the package index for a thunderstore community.
149 ///
150 /// # Examples
151 ///
152 /// ```no_run
153 /// use loadsmith_thunderstore::in_memory::InMemoryIndex;
154 /// use thunderstore::Client;
155 ///
156 /// let index = InMemoryIndex::new(Client::new());
157 /// let rt = tokio::runtime::Runtime::new().unwrap();
158 /// rt.block_on(async {
159 /// index.update("lethal-company").await.unwrap();
160 /// });
161 /// ```
162 pub async fn update(&self, community: impl Into<String>) -> Result<()> {
163 let community = community.into();
164
165 debug!(community, "updating index");
166
167 let stream = self.client.stream_package_index_v1(&community).await?;
168 pin_mut!(stream);
169
170 while let Some(batch) = stream.try_next().await? {
171 self.lock().community_mut(&community).extend(batch);
172 }
173
174 self.lock().community_mut(community).complete = true;
175
176 Ok(())
177 }
178
179 /// Returns version information for a package, if found.
180 ///
181 /// Returns `None` when the index is complete and the package is not
182 /// present. Returns an `IndexNotComplete` error when the index has not
183 /// been fully populated.
184 ///
185 /// # Examples
186 ///
187 /// ```no_run
188 /// use loadsmith_core::PackageId;
189 /// use loadsmith_thunderstore::in_memory::InMemoryIndex;
190 /// use thunderstore::Client;
191 ///
192 /// let index = InMemoryIndex::new(Client::new());
193 /// let rt = tokio::runtime::Runtime::new().unwrap();
194 /// rt.block_on(async {
195 /// let versions = index.version_info(&PackageId::new("Author-Pkg")).await.unwrap();
196 /// });
197 /// ```
198 pub async fn version_info(
199 &self,
200 id: &PackageId,
201 ) -> Result<Option<Vec<loadsmith_registry::VersionInfo>>> {
202 let lock = self.lock();
203
204 match lock.get(id) {
205 Ok(Some(package)) => {
206 let versions = package
207 .versions
208 .iter()
209 .map(|version| {
210 let version = version.number.clone().into();
211
212 loadsmith_registry::VersionInfo { version }
213 })
214 .collect::<Vec<_>>();
215
216 Ok(Some(versions))
217 }
218 Ok(None) => Ok(None),
219 Err(e) => Err(e),
220 }
221 }
222
223 /// Resolves a package reference to a specific version's download URL,
224 /// size, and dependencies.
225 ///
226 /// # Examples
227 ///
228 /// ```no_run
229 /// use loadsmith_core::{PackageId, PackageRef, Version};
230 /// use loadsmith_thunderstore::in_memory::InMemoryIndex;
231 /// use thunderstore::Client;
232 ///
233 /// let index = InMemoryIndex::new(Client::new());
234 /// let rt = tokio::runtime::Runtime::new().unwrap();
235 /// rt.block_on(async {
236 /// let resolved = index
237 /// .resolve(&PackageRef::new(
238 /// PackageId::new("Author-Pkg"),
239 /// Version::new(1, 0, 0),
240 /// ))
241 /// .await
242 /// .unwrap();
243 /// });
244 /// ```
245 pub async fn resolve(&self, ref_: &PackageRef) -> Result<Option<ResolvedVersion>> {
246 let lock = self.lock();
247
248 match lock.get(ref_.id()) {
249 Ok(Some(package)) => {
250 let Some(version) = package
251 .versions
252 .iter()
253 .find(|v| Version::from(v.number.clone()) == *ref_.version())
254 else {
255 return Ok(None);
256 };
257
258 let is_modpack = package.categories.contains(super::MODPACK_CATEGORY);
259 let deps = super::dependencies_from_idents(&version.dependencies, is_modpack);
260
261 Ok(Some(ResolvedVersion {
262 url: version.download_url.clone().into(),
263 size: Some(version.file_size),
264 checksum: None,
265 deps,
266 }))
267 }
268 Ok(None) => Ok(None),
269 Err(e) => Err(e),
270 }
271 }
272}
273
274impl State {
275 /// Loads state from a JSON file on disk.
276 fn load(path: &Path) -> Result<Self> {
277 debug!(path = %path.display(), "loading index from disk");
278
279 let file = std::fs::File::open(path).map(BufReader::new)?;
280 let state = serde_yaml_ng::from_reader(file)?;
281
282 Ok(state)
283 }
284
285 /// Serialises the state to a JSON file.
286 fn save<F>(&self, path: &Path, _filter: F) -> Result<()>
287 where
288 F: FnMut(&PackageId) -> bool,
289 {
290 debug!(path = %path.display(), "writing index to disk");
291
292 let file = std::fs::File::create(path).map(BufWriter::new)?;
293 serde_json::to_writer(file, self)?;
294
295 Ok(())
296 }
297
298 /// Looks up a package across all indexed communities.
299 ///
300 /// Returns `IndexNotComplete` if any community index has not finished
301 /// fetching.
302 ///
303 /// # Examples
304 ///
305 /// ```
306 /// use loadsmith_thunderstore::in_memory::State;
307 /// use loadsmith_core::PackageId;
308 ///
309 /// let state = State::default();
310 /// let result = state.get(&PackageId::new("Unknown-Pkg"));
311 /// assert!(result.is_err()); // index is not complete
312 /// ```
313 pub fn get<'a>(
314 &'a self,
315 id: &PackageId,
316 ) -> Result<Option<&'a thunderstore::models::PackageV1>> {
317 let complete = !self.0.is_empty() && self.0.values().all(|community| community.complete);
318
319 match self.0.values().find_map(|community| community.get(id)) {
320 Some(package) => Ok(Some(package)),
321 None if !complete => Err(Error::IndexNotComplete),
322 None => Ok(None),
323 }
324 }
325
326 /// Returns a mutable reference to the community entry, creating it if
327 /// it does not exist.
328 ///
329 /// # Examples
330 ///
331 /// ```
332 /// use loadsmith_thunderstore::in_memory::State;
333 ///
334 /// let mut state = State::default();
335 /// let community = state.community_mut("rounds");
336 /// ```
337 pub fn community_mut(&mut self, community: impl Into<String>) -> &mut Community {
338 self.0.entry(community.into()).or_default()
339 }
340}
341
342impl Community {
343 /// Inserts a batch of packages into this community's index.
344 ///
345 /// # Examples
346 ///
347 /// ```no_run
348 /// use loadsmith_thunderstore::in_memory::Community;
349 ///
350 /// let mut community = Community::default();
351 /// // PackageV1 batches are typically obtained from the thunderstore API
352 /// ```
353 pub fn extend<I>(&mut self, packages: I)
354 where
355 I: IntoIterator<Item = thunderstore::models::PackageV1>,
356 {
357 self.packages.extend(
358 packages
359 .into_iter()
360 .map(|package| (PackageId::from_ts_ident(package.ident.clone()), package)),
361 );
362 }
363
364 /// Looks up a package by its [`PackageId`].
365 ///
366 /// # Examples
367 ///
368 /// ```
369 /// use loadsmith_thunderstore::in_memory::Community;
370 /// use loadsmith_core::PackageId;
371 ///
372 /// let community = Community::default();
373 /// assert!(community.get(&PackageId::new("Unknown-Pkg")).is_none());
374 /// ```
375 pub fn get(&self, id: &PackageId) -> Option<&thunderstore::models::PackageV1> {
376 self.packages.get(id)
377 }
378}