Skip to main content

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}