assets/
packs.rs

1//! An asset pack is a single root folder that contains asset and subfolders.
2
3use bevy::prelude::{Asset, AssetServer, Component, Handle, debug, info};
4use rhai::{Engine, OptimizationLevel, Scope};
5use serialization::{Deserialize, SerializationFormat, Serialize, deserialize, serialize_to};
6use std::collections::HashMap;
7use std::fs::File;
8use std::io::read_to_string;
9use std::path::Path;
10use std::path::PathBuf;
11use thiserror::Error;
12use utils::file_name;
13use walkdir::WalkDir;
14
15/// The filename of the asset pack manifests.
16const MANIFEST_FILE_NAME: &str = "asset_pack.toml";
17
18/// An [`AssetPack`] is a single root folder that contains assets and subfolders.
19///
20/// The asset pack handles the indexing, categorising and loading the assets.
21#[derive(Component, Debug)]
22pub struct AssetPack {
23    /// The state the pack is currently in.
24    ///
25    /// This is used to track whether a pack needs to perform operations to be usable, whether some
26    /// operations failed and so forth.
27    pub state: AssetPackState,
28    /// The identifier of this string, usually a hash or short ID defined by the creator of the asset
29    /// pack represented.
30    ///
31    /// This ID is used when referring to files under [`AssetPack::root`].
32    pub id: String,
33    /// The human-readable name of this [`AssetPack`].
34    ///
35    /// This is not guaranteed to be unique! If you need to identify this pack, please use [`AssetPack::id`].
36    pub name: String,
37    /// The "root" directory under which the assets live for this pack.
38    ///
39    /// This is used internally to generate relative paths (that are portable) from absolute paths
40    /// used in the asset loader.
41    pub root: PathBuf,
42    /// The directory in which metadata about the [`AssetPack`] is kept.
43    /// This ranges from index metadata, scripts to thumbnails, this directory is not guaranteed to
44    /// exist between runs and may be cleaned to recover disk space. The operations in this directory
45    /// should be ephemeral by design.
46    pub meta_dir: PathBuf,
47    /// Internal mapping table between asset identifiers and their physical paths.
48    ///
49    /// Each path value is relative to the [`AssetPack::root`].
50    index: HashMap<String, PathBuf>,
51    /// A [Rhai](https://rhai.rs/) script that is used during indexing operations to assist in categorising
52    /// the assets in the pack.
53    script: Option<String>,
54}
55
56/// Internal "copy" of the [`AssetPack`] struct intended for saving/loading to disk.
57#[derive(Serialize, Deserialize, Debug)]
58#[allow(
59    clippy::missing_docs_in_private_items,
60    reason = "Copied from the original struct"
61)]
62struct _AssetPack {
63    pub id: String,
64    pub name: String,
65    index: HashMap<String, PathBuf>,
66    script: Option<String>,
67}
68
69/// Describes the current state of an [`AssetPack`].
70#[derive(Default, Debug, Serialize, Deserialize)]
71pub enum AssetPackState {
72    /// The asset pack was just created, no validation or checks to its current state have been made.
73    /// Additional processing is required to validate the pack's state before it can be used.
74    #[default]
75    Created,
76    /// The asset pack is currently (re)indexing its contents.
77    Indexing,
78    /// Something went wrong during processing, leaving this pack in an invalid state.
79    Invalid(String),
80    /// The pack is ready to use.
81    Ready,
82}
83
84/// Describes the errors that can occur when working with [`AssetPack`]s
85#[derive(Error, Debug)]
86pub enum AssetPackError {
87    /// Thrown when creating/opening the asset pack manifest fails.
88    #[error("An IO error occurred while reading/writing the asset pack manifest")]
89    ManifestFile(#[from] std::io::Error),
90    /// Thrown when the serialisation of an asset pack manifest fails.
91    #[error("An error occurred while serialising the asset pack manifest")]
92    Serialisation(#[from] serialization::SerializationError),
93}
94
95impl AssetPack {
96    /// Generate a new [`AssetPack`] in the [`AssetPackState::Created`] state.
97    ///
98    /// # Errors
99    /// This method may return an error if it fails to [canonicalize](https://doc.rust-lang.org/std/fs/fn.canonicalize.html)
100    /// the root path.
101    pub fn new(root: &Path, meta_dir: &Path, name: Option<String>) -> Result<Self, AssetPackError> {
102        let root = root.canonicalize()?;
103        let id = blake3::hash(root.as_os_str().as_encoded_bytes()).to_string();
104        let name = name
105            .or_else(|| file_name(&root))
106            .unwrap_or_else(|| id.clone());
107
108        info!("Created new asset pack with ID: {}", id);
109        Ok(Self {
110            state: AssetPackState::Created,
111            id: id.clone(),
112            name,
113            root,
114            meta_dir: meta_dir.to_path_buf(),
115            index: HashMap::new(),
116            script: None,
117        })
118    }
119
120    /// Deletes all cache and config for this [`AssetPack`].
121    ///
122    /// # Errors
123    /// Can return [`AssetPackError::ManifestFile`] when it fails to clean up any files.
124    pub(crate) fn delete(&self) -> Result<(), AssetPackError> {
125        info!("Deleting asset pack: {}", self.id);
126        let config_file = self.root.join(MANIFEST_FILE_NAME);
127
128        std::fs::remove_file(config_file)?;
129        std::fs::remove_dir_all(self.meta_dir.clone())?;
130        Ok(())
131    }
132
133    /// Attempts to save the manifest for this [`AssetPack`] to disk.
134    /// The resulting file will be written under [`AssetPack::root`].
135    ///
136    /// # Errors
137    /// - [`AssetPackError::ManifestFile`] when the file/folder for the manifest couldn't be created.
138    /// - [`AssetPackError::Serialisation`] when serialising the manifest fails.
139    pub fn save_manifest(&self) -> Result<(), AssetPackError> {
140        debug!("Saving manifest for {}", self.id);
141        let config = _AssetPack::from(self);
142        let manifest = self.root.join(MANIFEST_FILE_NAME);
143        let manifest = File::create(manifest).map_err(AssetPackError::ManifestFile)?;
144
145        serialize_to(&config, &SerializationFormat::Toml, manifest)
146            .map_err(AssetPackError::Serialisation)
147    }
148
149    /// Attempts to load an [`AssetPack`] from its manifest in the `root` folder.
150    /// The resulting [`AssetPack`] will always be in [`AssetPackState::Crated`].
151    ///
152    /// # Errors
153    /// - [`AssetPackError::ManifestFile`] when the file/folder for the manifest couldn't be opened.
154    /// - [`AssetPackError::Serialisation`] when serialising the manifest fails.
155    pub fn load_manifest(root: &Path, meta_dir: &Path) -> Result<Self, AssetPackError> {
156        let manifest = root.join(MANIFEST_FILE_NAME);
157        debug!("Loading manifest for {}", manifest.display());
158        let manifest = File::open(manifest).map_err(AssetPackError::ManifestFile)?;
159        let manifest = read_to_string(manifest).map_err(AssetPackError::ManifestFile)?;
160
161        let manifest: _AssetPack = deserialize(manifest.as_bytes(), &SerializationFormat::Toml)?;
162        info!("Loaded manifest for {}", manifest.id);
163        Ok(Self {
164            state: AssetPackState::Created,
165            id: manifest.id,
166            name: manifest.name,
167            root: root.to_path_buf(),
168            meta_dir: meta_dir.to_path_buf(),
169            index: manifest.index,
170            script: manifest.script,
171        })
172    }
173
174    /// TODO: TEMPORARY IMPLEMENTATION
175    #[allow(clippy::missing_panics_doc, reason = "Temporary implementation")]
176    pub fn index(&mut self) {
177        let walker = WalkDir::new(&self.root);
178        let engine = Engine::new();
179        let mut scope = Scope::new();
180        let script = engine
181            .compile(include_str!("../scripts/filter.rhai"))
182            .unwrap();
183        let script = engine.optimize_ast(&scope, script, OptimizationLevel::Full);
184
185        {
186            #[cfg(feature = "dev")]
187            let _span = bevy::prelude::info_span!("Indexing", name = "indexing").entered();
188
189            let mut count = 0;
190            for entry in walker.into_iter().flatten() {
191                if !engine
192                    .call_fn::<bool>(&mut scope, &script, "filter", (String::new(),))
193                    .unwrap()
194                {
195                    continue;
196                }
197
198                let path = entry.path().to_path_buf();
199                let path = path.strip_prefix(&self.root).unwrap();
200                let key = blake3::hash(path.as_os_str().as_encoded_bytes()).to_string();
201
202                self.index.insert(key, path.to_path_buf());
203                count += 1;
204            }
205
206            info!("Finished indexing {count} assets");
207        }
208
209        self.save_manifest().unwrap();
210    }
211
212    /// Attempts to resolve the given identifier into a [`PathBuf`].
213    #[must_use]
214    pub fn resolve(&self, id: &String) -> Option<PathBuf> {
215        debug!("{} is resolving asset {}", self.id, id);
216        self.index.get(id).map(|path| self.root.join(path))
217    }
218
219    /// Attempts to load the asset associated with the given path.
220    #[must_use = "Unused asset handle would be dropped immediately"]
221    pub fn load<T>(&self, asset_server: &AssetServer, id: &String) -> Option<Handle<T>>
222    where
223        T: Asset,
224    {
225        if let Some(path) = self.resolve(id) {
226            return Some(asset_server.load::<T>(path));
227        }
228
229        None
230    }
231}
232
233impl From<&AssetPack> for _AssetPack {
234    fn from(pack: &AssetPack) -> Self {
235        Self {
236            id: pack.id.clone(),
237            name: pack.name.clone(),
238            index: pack.index.clone(),
239            script: pack.script.clone(),
240        }
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    #![allow(clippy::missing_panics_doc)]
247    #![allow(clippy::missing_errors_doc)]
248
249    use super::*;
250    use tempfile::tempdir;
251
252    #[test]
253    fn new_asset_pack_id_is_stable() {
254        let path = Path::new(".");
255        let pack = AssetPack::new(path, path, None).unwrap();
256        let pack2 = AssetPack::new(path, path, None).unwrap();
257
258        assert_eq!(pack.id, pack2.id);
259    }
260
261    #[test]
262    fn new_asset_pack_id_unique() -> anyhow::Result<()> {
263        let path1 = tempdir()?;
264        let path2 = tempdir()?;
265        let pack1 = AssetPack::new(path1.path(), path1.path(), None)?;
266        let pack2 = AssetPack::new(path2.path(), path2.path(), None)?;
267
268        assert_ne!(pack1.id, pack2.id);
269        Ok(())
270    }
271
272    #[test]
273    #[should_panic = "Should fail to create asset pack"]
274    fn new_asset_error_on_invalid_path() {
275        let path = Path::new("./does/not/exist");
276        AssetPack::new(path, path, None).expect("Should fail to create asset pack");
277    }
278}