assets/
library.rs

1//! A library serves as a device-wide registry of asset packs.
2
3use crate::{AssetPack, AssetPackError};
4use bevy::prelude::{Resource, debug, info};
5use semver::Version;
6use serialization::{Deserialize, SerializationFormat, Serialize, deserialize, serialize_to};
7use std::collections::HashMap;
8use std::fs::{File, create_dir_all};
9use std::io::Read;
10use std::path::{Path, PathBuf};
11use thiserror::Error;
12use utils::{DirectoryError, cache_path, config_path};
13
14/// The name of the library configuration file.
15const LIBRARY_FILE_NAME: &str = "library.toml";
16
17/// An [`AssetLibrary`] is a device-wide registry of packs that save files can refer to.
18/// It handles as the bridge between relative paths within an asset pack and the actual paths on
19/// a user's device.
20#[derive(Resource, Debug, Serialize, Deserialize)]
21pub struct AssetLibrary {
22    /// The version of the software that last touched the library, used to help with future migrations.
23    version: Version,
24    /// A map of asset packs, keyed by their (public) identifiers.
25    registered_packs: HashMap<String, AssetLibraryEntry>,
26    /// A map of currently loaded asset packs, keyed by their (public) identifiers.
27    ///
28    /// Given that this map is only persisted for the runtime of the application,
29    /// it's possible that an [`AssetPack`] is 'known' but not loaded.
30    #[serde(skip)]
31    loaded_packs: HashMap<String, AssetPack>,
32}
33
34/// Represents an entry in the library, containing additional metadata about an asset pack.
35#[derive(Default, Debug, Serialize, Deserialize)]
36struct AssetLibraryEntry {
37    /// Location of the asset pack on disk.
38    ///
39    /// Currently only filesystem packs are supported (e.g., no network-backed protocols like HTTP, FTP, ...)
40    root: PathBuf,
41    /// The location of the asset pack's index, this allows storing the index in a different location than the pack itself.
42    index: PathBuf,
43}
44
45/// The errors that can occur when loading or saving the [`AssetLibrary`].
46#[derive(Error, Debug)]
47pub enum AssetLibraryError {
48    /// An error occurred while locating the library configuration folder.
49    #[error("failed to locate library configuration")]
50    LocateConfigFolder(#[from] DirectoryError),
51    /// An error occurred reading the configuration file itself.
52    #[error("failed to read library configuration")]
53    ReadFile(#[from] std::io::Error),
54    /// An error occurred while (de)serialising the library configuration.
55    #[error("failed to (de)serialize library configuration")]
56    Serialisation(#[from] serialization::SerializationError),
57    /// Wrapper for the [`AssetPackError`].
58    #[error(transparent)]
59    OpenAssetPack(#[from] AssetPackError),
60    /// The requested asset pack was not loaded or registered, depending on the operation.
61    #[error("Could not resolve AssetPack with ID '{0}'")]
62    NotFound(String),
63}
64
65impl Default for AssetLibrary {
66    fn default() -> Self {
67        Self {
68            version: utils::version().clone(),
69            registered_packs: HashMap::new(),
70            loaded_packs: HashMap::new(),
71        }
72    }
73}
74
75impl AssetLibrary {
76    /// Attempts to [`AssetLibrary::load`] the library from `path`, and returns the default value on [`IOError`].
77    ///
78    /// Any other error will be propagated by this method.
79    ///
80    /// # Errors
81    /// See [`AssetLibrary::load`].
82    pub fn load_or_default(path: Option<PathBuf>) -> Result<Self, AssetLibraryError> {
83        match Self::load(path) {
84            Err(AssetLibraryError::ReadFile(_)) => {
85                debug!("Failed to load library, using default");
86                Ok(Self::default())
87            }
88            result => result,
89        }
90    }
91
92    /// Attempts to load the asset library from `path`, where `path` is the configuration directory.
93    /// If `None` is passed, the [`utils::config_path`] method is used instead.
94    ///
95    /// # Errors
96    /// An error can be returned for the following situations:
97    /// - The configuration folder could not be retrieved: [`AssetLibraryError::LocateConfigFolder`]
98    /// - An error occurs while trying to read the config file (doesn't exist, permissions, ...):
99    ///   [`AssetLibraryError::LocateConfigFolder`]
100    /// - The file was found, could be read but failed to deserialize: [`AssetLibraryError::Serialization`].
101    pub fn load(path: Option<PathBuf>) -> Result<Self, AssetLibraryError> {
102        let path = Self::get_path(path)?.join(LIBRARY_FILE_NAME);
103
104        debug!("Attempting to load {}", path.display());
105        let mut file = File::open(path).map_err(AssetLibraryError::ReadFile)?;
106        let mut contents = String::new();
107        file.read_to_string(&mut contents)
108            .map_err(AssetLibraryError::ReadFile)?;
109
110        deserialize(contents.as_bytes(), &SerializationFormat::Toml)
111            .map_err(AssetLibraryError::Serialisation)
112    }
113
114    /// Recursively removes all cache and config of all packs and the library itself.
115    /// To clean up a specific [`AssetPack`], use [`AssetLibrary::delete_pack`].
116    ///
117    /// # Errors
118    /// See errors returned by [`AssetLibrary::delete_pack`].
119    pub fn delete(&mut self) -> Result<(), AssetLibraryError> {
120        info!("Running delete on asset library");
121        let ids = self.iter().map(|(id, _)| id.clone()).collect::<Vec<_>>();
122        for id in ids {
123            self.delete_pack(&id)?;
124        }
125
126        let path = Self::get_path(None)?.join(LIBRARY_FILE_NAME);
127        let _ = std::fs::remove_file(path);
128        Ok(())
129    }
130
131    /// Attempts to delete cache and config files of a given [`AssetPack`] and then unregisters it
132    /// from the library.
133    ///
134    /// # Errors
135    /// If any IO related errors occur while removing the pack, this method can return a
136    /// [`AssetLibraryError::OpenAssetPack`].
137    pub fn delete_pack(&mut self, id: &String) -> Result<(), AssetLibraryError> {
138        debug!("Deleting pack {}", id);
139        if !self.is_pack_loaded(id) {
140            self.load_pack(id)?;
141        }
142
143        if let Some(pack) = self.get_pack_mut(id) {
144            pack.delete()?;
145
146            self.loaded_packs.remove(id);
147            self.registered_packs.remove(id);
148        }
149
150        Ok(())
151    }
152
153    /// Saves the asset library.
154    ///
155    /// # Errors
156    /// An error can be returned for the following situations:
157    /// - The configuration folder could not be retrieved: [`AssetLibraryError::LocateConfigFolder`]
158    /// - An error occurs while trying to read the config file (doesn't exist, permissions, ...):
159    ///   [`AssetLibraryError::LocateConfigFolder`]
160    /// - The file was found, could be read but failed to deserialize: [`AssetLibraryError::Serialization`].
161    pub fn save(&self, path: Option<PathBuf>) -> Result<(), AssetLibraryError> {
162        let path = Self::get_path(path)?;
163
164        debug!("Saving library to {}", path.display());
165        create_dir_all(&path)?; // Ensure the directory exists.
166        let file =
167            File::create(path.join(LIBRARY_FILE_NAME)).map_err(AssetLibraryError::ReadFile)?;
168        serialize_to(self, &SerializationFormat::Toml, &file)?;
169        Ok(())
170    }
171
172    /// Registers a new [`AssetPack`] in the library and returns the [`AssetPack`]'s ID.
173    ///
174    /// Note that you should only use this to create a *new* pack, not to load an existing one.
175    ///
176    /// # Errors
177    /// - The configuration folder could not be retrieved: [`AssetLibraryError::LocateConfigFolder`]
178    /// - An error occurs while trying to read the config file (doesn't exist, permissions, ...):
179    ///   [`AssetLibraryError::LocateConfigFolder`]
180    /// - The file was found, could be read but failed to deserialize: [`AssetLibraryError::Serialization`].
181    pub fn add_pack(
182        &mut self,
183        root: &Path,
184        name: Option<String>,
185    ) -> Result<String, AssetLibraryError> {
186        let meta_dir = cache_path()?;
187        let pack = AssetPack::new(root, meta_dir.as_path(), name)?;
188        let pack_id = pack.id.clone();
189        let entry = AssetLibraryEntry {
190            root: root.to_path_buf(),
191            index: meta_dir.clone(),
192        };
193
194        self.registered_packs.insert(pack_id.clone(), entry);
195        self.loaded_packs.insert(pack_id.clone(), pack);
196
197        info!("Registered pack {}", pack_id);
198        Ok(pack_id)
199    }
200
201    /// Attempt to load a previously registered [`AssetPack`].
202    ///
203    /// # Errors
204    /// - If the asset pack isn't previously registered using [`AssetLibrary::add_pack`] this method
205    ///   returns [`AssetLibraryError::NotFound`].
206    /// - If an error occurs while loading the [`AssetPack`], it returns [`AssetLibraryError::OpenAssetPack`].
207    pub fn load_pack(&mut self, id: &String) -> Result<&mut AssetPack, AssetLibraryError> {
208        let Some(entry) = self.registered_packs.get(id) else {
209            return Err(AssetLibraryError::NotFound(id.clone()));
210        };
211
212        let pack = AssetPack::load_manifest(entry.root.as_path(), entry.index.as_path())?;
213        self.loaded_packs.insert(id.clone(), pack);
214
215        debug!("Loaded pack {}", id);
216        self.loaded_packs
217            .get_mut(id)
218            .ok_or(AssetLibraryError::NotFound(id.clone()))
219    }
220
221    /// Adds a method to check if a given [`AssetPack`] is already loaded in the current library.
222    #[inline]
223    #[must_use]
224    pub fn is_pack_loaded(&self, id: &String) -> bool {
225        self.loaded_packs.contains_key(id)
226    }
227
228    /// Checks whether an asset pack is "known" (registered) within the current asset library.
229    ///
230    /// You can register new asset packs using [`AssetLibrary::add_pack`].
231    #[inline]
232    #[must_use]
233    pub fn is_pack_registered(&self, id: &String) -> bool {
234        self.registered_packs.contains_key(id)
235    }
236
237    /// Attempts to retrieve an previously loaded [`AssetPack`] from the current library.
238    ///
239    /// If `None` is returned the pack either doesn't exist or hasn't been loaded yet (see [`AssetLibrary::load_pack`]).
240    #[inline]
241    #[must_use]
242    pub fn get_pack(&self, id: &String) -> Option<&AssetPack> {
243        self.loaded_packs.get(id)
244    }
245
246    /// Attempts to retrieve an previously loaded [`AssetPack`] from the current library.
247    ///
248    /// If `None` is returned the pack either doesn't exist or hasn't been loaded yet (see [`AssetLibrary::load_pack`]).
249    #[inline]
250    #[must_use]
251    pub fn get_pack_mut(&mut self, id: &String) -> Option<&mut AssetPack> {
252        self.loaded_packs.get_mut(id)
253    }
254
255    /// Iterator over all registered packs.
256    #[inline]
257    pub fn iter(&self) -> impl Iterator<Item = (&String, &PathBuf)> {
258        self.registered_packs
259            .iter()
260            .map(|(key, value)| (key, &value.root))
261    }
262
263    /// Either returns `path` or `config_path()` if `path` is `None`.
264    ///
265    /// # Errors
266    /// Returns an error if the configuration folder cannot be found.
267    fn get_path(path: Option<PathBuf>) -> Result<PathBuf, AssetLibraryError> {
268        let path = if let Some(path) = path {
269            path
270        } else {
271            config_path().map_err(AssetLibraryError::LocateConfigFolder)?
272        };
273
274        Ok(path)
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    #![allow(clippy::missing_panics_doc)]
281    #![allow(clippy::missing_errors_doc)]
282
283    use super::*;
284
285    #[test]
286    fn add_pack_creates_asset_pack() -> anyhow::Result<()> {
287        let tmp = tempfile::tempdir()?;
288        let mut library = AssetLibrary::default();
289        let pack_id = library.add_pack(tmp.path(), None)?;
290
291        assert_eq!(library.registered_packs.len(), 1);
292        assert!(library.registered_packs.contains_key(&pack_id));
293        Ok(())
294    }
295
296    #[test]
297    fn save_and_load_library() -> anyhow::Result<()> {
298        let tmp = tempfile::tempdir()?;
299        let mut library = AssetLibrary::default();
300        let pack_id = library.add_pack(tmp.path(), None)?;
301
302        library.save(Some(tmp.path().to_path_buf()))?;
303        let library = AssetLibrary::load_or_default(Some(tmp.path().to_path_buf()))?;
304
305        assert_eq!(library.registered_packs.len(), 1);
306        assert!(library.registered_packs.contains_key(&pack_id));
307        Ok(())
308    }
309
310    #[test]
311    fn load_asset_pack_requires_registration() -> anyhow::Result<()> {
312        let tmp = tempfile::tempdir()?;
313        let mut library = AssetLibrary::default();
314        let pack = AssetPack::new(tmp.path(), tmp.path(), None)?;
315
316        library
317            .load_pack(&pack.id)
318            .expect_err("Asset pack should not be registered");
319        assert!(library.loaded_packs.is_empty());
320        assert!(library.registered_packs.is_empty());
321        Ok(())
322    }
323
324    #[test]
325    fn iterate_registered_packs() -> anyhow::Result<()> {
326        let tmp = tempfile::tempdir()?;
327        let mut library = AssetLibrary::default();
328        let pack_id = library.add_pack(tmp.path(), None)?;
329        library.loaded_packs.clear();
330
331        for (id, entry) in library.iter() {
332            assert_eq!(id, &pack_id);
333            assert_eq!(entry, &tmp.path().to_path_buf());
334        }
335        Ok(())
336    }
337}