//! [`AssetDatabase`]: stable, project-relative asset references. //! //! The [`AssetServer`](super::AssetServer) loads assets by *path*, but a scene //! or UI document must not bake **absolute** system paths into its saved data — //! that would break the moment the project is moved to another machine or //! directory, and it is the chief obstacle to a clean game export (Stage 16). //! //! The asset database is the bridge. It assigns every imported asset a stable //! [`AssetUid`] and records, per project, the mapping //! **`AssetUid` ↔ assets-relative path** (e.g. `"fonts/Inter-Regular.ttf"`). //! Saved documents reference assets by `AssetUid`; resolving a uid yields the //! relative path, which combined with the (possibly new) project root gives an //! absolute path the [`AssetServer`](super::AssetServer) loads and deduplicates. //! Because the stored mapping is purely relative, a reference resolves to the //! same [`Handle`] across save/load **and** after the whole project directory //! moves. //! //! The uid layer (rather than referencing by relative path directly) means an //! asset can later be *renamed or moved within* the project without breaking //! references — the uid travels with the file in the manifest. //! //! Assets live under typed subfolders of the project's `assets/` directory //! ([`AssetKind`] → folder), so the database (and the editor's browser) can //! present and filter them by type without inspecting file contents. use std::collections::HashMap; use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use super::{AssetServer, Handle}; use crate::project::ASSETS_DIR; /// The manifest file (RON) at the project root recording the uid ↔ path map. /// /// It sits at the root rather than inside `assets/` so a scan of the typed /// asset folders never treats the manifest itself as an asset. pub const ASSET_MANIFEST_FILE: &str = "assets.manifest"; /// The typed category of a project asset. /// /// A kind fixes the asset's subfolder under `assets/` and the file extensions /// that belong to it, letting the database classify files by where they live /// (with extension as a fallback for files dropped directly in `assets/`). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum AssetKind { /// Text rendering fonts (`fonts/`): `.ttf`, `.otf`. Font, /// Images and textures (`textures/`): `.png`, `.jpg`, … Texture, /// 3D models (`models/`): `.gltf`, `.glb`, `.obj`. Model, /// Sound and music (`audio/`): `.wav`, `.ogg`, … Audio, /// Serialized UI documents (`ui/`). Ui, /// Game-logic scripts (`scripts/`): `.rhai` (Stage 10). Script, /// Anything that does not fall into a known typed folder or extension. Other, } impl AssetKind { /// The typed kinds in their canonical order (excludes [`Other`](Self::Other), /// which has no folder of its own). pub const TYPED: [AssetKind; 6] = [ AssetKind::Font, AssetKind::Texture, AssetKind::Model, AssetKind::Audio, AssetKind::Ui, AssetKind::Script, ]; /// The subfolder name under `assets/` for this kind (empty for /// [`Other`](Self::Other), which has no dedicated folder). pub fn folder(self) -> &'static str { match self { AssetKind::Font => "fonts", AssetKind::Texture => "textures", AssetKind::Model => "models", AssetKind::Audio => "audio", AssetKind::Ui => "ui", AssetKind::Script => "scripts", AssetKind::Other => "", } } /// The lower-case file extensions (without the dot) that belong to this /// kind. [`Other`](Self::Other) claims none. pub fn extensions(self) -> &'static [&'static str] { match self { AssetKind::Font => &["ttf", "otf"], AssetKind::Texture => &["png", "jpg", "jpeg", "tga", "bmp", "dds", "ktx2"], AssetKind::Model => &["gltf", "glb", "obj"], AssetKind::Audio => &["wav", "ogg", "mp3", "flac"], // UI documents share the `.ron` extension with scenes, so a UI asset // is recognised by its `ui/` folder rather than its extension. AssetKind::Ui => &[], AssetKind::Script => &["rhai"], AssetKind::Other => &[], } } /// The kind owning the typed `folder` name, if any. pub fn from_folder(folder: &str) -> Option { AssetKind::TYPED.into_iter().find(|k| k.folder() == folder) } /// The kind that claims `extension` (without the dot, any case), if any. pub fn from_extension(extension: &str) -> Option { let ext = extension.to_lowercase(); AssetKind::TYPED .into_iter() .find(|k| k.extensions().contains(&ext.as_str())) } /// Classifies an assets-relative path. The leading folder wins (so a file in /// `ui/` is [`Ui`](Self::Ui) regardless of extension); files outside a typed /// folder fall back to their extension, else [`Other`](Self::Other). pub fn classify(relative_path: &str) -> AssetKind { if let Some((head, _)) = relative_path.split_once('/') { if let Some(kind) = AssetKind::from_folder(head) { return kind; } } Path::new(relative_path) .extension() .and_then(|e| e.to_str()) .and_then(AssetKind::from_extension) .unwrap_or(AssetKind::Other) } /// The kind an asset reference of target type `target` refers to, used to /// filter an asset picker. `target` is the inner type of an `AssetRef` /// (or `Handle`) field (see [`asset_ref_target`]); unknown targets yield /// `None` so the picker can offer every kind. pub fn for_handle_target(target: &str) -> Option { match target { "Font" | "UiFont" => Some(AssetKind::Font), "GltfModel" | "Model" | "Mesh" => Some(AssetKind::Model), "Texture" | "Image" => Some(AssetKind::Texture), "AudioClip" | "Sound" | "Audio" => Some(AssetKind::Audio), "UiPanel" | "UiDocument" => Some(AssetKind::Ui), "ScriptAsset" | "Script" => Some(AssetKind::Script), _ => None, } } } /// If `type_name` is an asset-reference field spelling — [`AssetRef`] (the /// serializable reference components store) or a bare [`Handle`] — returns /// the inner target type's short name; otherwise `None`. /// /// Reflection records a field's *syntactic* type name (see /// [`FieldInfo::type_name`](crate::reflect::FieldInfo::type_name)), which for an /// asset-reference field is something like `"AssetRef < Font >"` or /// `"Handle"`. This normalizes whitespace, unwraps the single /// generic argument, and strips any module path, yielding e.g. `"Font"`. The /// editor uses it to recognise such fields and pick the right asset filter via /// [`AssetKind::for_handle_target`]. pub fn asset_ref_target(type_name: &str) -> Option<&str> { // Peel the wrapper structurally, trimming whitespace at each step, so both // `"AssetRef < Font >"` and `"AssetRef"` parse and we return a borrow // of the original string. let t = type_name.trim(); let inner = t .strip_prefix("AssetRef") .or_else(|| t.strip_prefix("Handle"))? .trim_start(); let inner = inner.strip_prefix('<')?.trim(); let inner = inner.strip_suffix('>')?.trim(); // Reject nested generics / multiple args we don't understand. if inner.contains('<') || inner.contains(',') { return None; } // Strip any module path (`crate::ui::Font` -> `Font`). Some(inner.rsplit("::").next().unwrap_or(inner).trim()) } /// A stable, per-project identifier for one asset. /// /// Unlike [`AssetId`](super::AssetId) — which is process-unique and changes /// every run — an `AssetUid` is persisted in the project's manifest and stays /// attached to its asset across sessions, so saved references keep resolving. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] pub struct AssetUid(pub u64); impl AssetUid { /// The raw numeric value. pub fn value(self) -> u64 { self.0 } } /// A typed, serializable reference to a project asset. /// /// This is what a **component** stores when it points at an asset (a UI label's /// font, a renderer's mesh, …). A live [`Handle`] is not serializable and is /// tied to one process run, so persisting it would be wrong; an `AssetRef` /// instead holds the stable [`AssetUid`] and resolves to a handle on demand via /// [`resolve`](Self::resolve) (database → relative path → server → handle). /// /// Being a thin wrapper over `Option`, it serializes compactly and /// round-trips through reflection's RON path, so an asset-reference field is /// editable in the inspector with no per-type code. The phantom `T` records the /// target asset type, which the editor reads from the field's spelling /// (`"AssetRef < Font >"`) via [`asset_ref_target`] to filter the picker. pub struct AssetRef { uid: Option, _marker: std::marker::PhantomData T>, } impl AssetRef { /// An empty reference, pointing at no asset. pub const fn none() -> Self { Self { uid: None, _marker: std::marker::PhantomData, } } /// A reference to the asset with stable id `uid`. pub const fn new(uid: AssetUid) -> Self { Self { uid: Some(uid), _marker: std::marker::PhantomData, } } /// The referenced asset's stable id, or `None` if empty. pub fn uid(self) -> Option { self.uid } /// Whether this reference points at an asset. pub fn is_some(self) -> bool { self.uid.is_some() } /// Points the reference at `uid` (or clears it with `None`). pub fn set(&mut self, uid: Option) { self.uid = uid; } /// Resolves to a loaded [`Handle`] via `db` + `server`, or `None` if the /// reference is empty or its uid is unknown to the database. pub fn resolve(self, db: &AssetDatabase, server: &AssetServer) -> Option> where T: Send + Sync + 'static, { db.load::(server, self.uid?) } } // Hand-written trait impls: deriving would wrongly require `T: Clone`/`Default` // etc., but an `AssetRef` carries no `T` value — only a uid + phantom marker. impl Clone for AssetRef { fn clone(&self) -> Self { *self } } impl Copy for AssetRef {} impl Default for AssetRef { fn default() -> Self { Self::none() } } impl PartialEq for AssetRef { fn eq(&self, other: &Self) -> bool { self.uid == other.uid } } impl Eq for AssetRef {} impl std::fmt::Debug for AssetRef { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_tuple("AssetRef").field(&self.uid).finish() } } // Serialize transparently as the inner `Option` so saved data is just // the uid (or unit `None`) and stays independent of `T`. impl Serialize for AssetRef { fn serialize(&self, serializer: S) -> Result { self.uid.serialize(serializer) } } impl<'de, T> Deserialize<'de> for AssetRef { fn deserialize>(deserializer: D) -> Result { Ok(Self { uid: Option::::deserialize(deserializer)?, _marker: std::marker::PhantomData, }) } } /// One asset's record in the database: its stable id, kind, and the path it /// lives at *relative to the project's `assets/` directory* (forward slashes). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AssetEntry { /// The stable identifier saved references use. pub uid: AssetUid, /// The asset's typed category. pub kind: AssetKind, /// Path relative to `assets/`, e.g. `"fonts/Inter-Regular.ttf"`. pub path: String, } /// The on-disk manifest: the uid allocator plus every known entry. #[derive(Debug, Clone, Default, Serialize, Deserialize)] struct Manifest { /// The next uid to hand out; persisted so a deleted asset's uid is never /// reused by a freshly imported one. next_uid: u64, /// Every recorded asset (sorted by uid when written, for stable diffs). entries: Vec, } /// Maps stable asset ids to project-relative paths and back, and resolves them /// to [`Handle`]s through an [`AssetServer`](super::AssetServer). /// /// Construct it for a project root with [`new`](Self::new) (empty) or /// [`open`](Self::open) (reading any existing manifest), then [`scan`](Self::scan) /// the asset folders or [`register`](Self::register) individual imports. The /// root may be changed with [`set_root`](Self::set_root) — e.g. after opening /// the same project from a new location — without disturbing the uid mapping. #[derive(Debug, Clone)] pub struct AssetDatabase { root: PathBuf, by_uid: HashMap, by_path: HashMap, next_uid: u64, } impl AssetDatabase { /// An empty database for the project rooted at `root`. pub fn new(root: impl AsRef) -> Self { Self { root: root.as_ref().to_path_buf(), by_uid: HashMap::new(), by_path: HashMap::new(), next_uid: 1, } } /// Opens the database for the project at `root`, reading its manifest if /// present. A missing or unreadable manifest yields an empty database (a /// later [`scan`](Self::scan) repopulates it from disk). pub fn open(root: impl AsRef) -> Self { let root = root.as_ref().to_path_buf(); let mut db = Self::new(&root); let manifest_path = root.join(ASSET_MANIFEST_FILE); if let Ok(text) = std::fs::read_to_string(&manifest_path) { if let Ok(manifest) = ron::from_str::(&text) { for entry in manifest.entries { db.by_path.insert(entry.path.clone(), entry.uid); db.by_uid.insert(entry.uid, entry); } db.next_uid = manifest.next_uid.max(db.highest_uid() + 1); } } db } /// Writes the manifest to `/assets.manifest`. pub fn save(&self) -> std::io::Result<()> { let mut entries: Vec = self.by_uid.values().cloned().collect(); entries.sort_by_key(|e| e.uid); let manifest = Manifest { next_uid: self.next_uid, entries, }; let text = ron::ser::to_string_pretty(&manifest, ron::ser::PrettyConfig::default()) .map_err(|e| std::io::Error::other(e.to_string()))?; std::fs::write(self.manifest_path(), text) } /// The project root the database resolves paths against. pub fn root(&self) -> &Path { &self.root } /// Points the database at a new project root (e.g. after the project /// directory moved). The uid ↔ relative-path mapping is unaffected, so all /// existing references keep resolving — now against the new location. pub fn set_root(&mut self, root: impl AsRef) { self.root = root.as_ref().to_path_buf(); } /// The `assets/` directory under the project root. pub fn assets_dir(&self) -> PathBuf { self.root.join(ASSETS_DIR) } /// The manifest file path. pub fn manifest_path(&self) -> PathBuf { self.root.join(ASSET_MANIFEST_FILE) } /// Records the asset at `relative_path` (relative to `assets/`), returning /// its uid — the existing one if already known, else a freshly allocated /// one. The kind is inferred from the path. Idempotent for a given path. pub fn register(&mut self, relative_path: impl AsRef) -> AssetUid { let path = normalize_relative(relative_path.as_ref()); if let Some(&uid) = self.by_path.get(&path) { return uid; } let uid = AssetUid(self.next_uid); self.next_uid += 1; let entry = AssetEntry { uid, kind: AssetKind::classify(&path), path: path.clone(), }; self.by_path.insert(path, uid); self.by_uid.insert(uid, entry); uid } /// Scans the typed asset folders under `assets/` and reconciles the /// database with what is on disk: existing files keep their uid, new files /// are [registered](Self::register), and entries whose files no longer exist /// are dropped. Returns the number of newly registered assets. /// /// Call [`save`](Self::save) afterwards to persist any new uids. pub fn scan(&mut self) -> usize { let assets_dir = self.assets_dir(); let mut found: Vec = Vec::new(); for kind in AssetKind::TYPED { collect_files(&assets_dir.join(kind.folder()), &assets_dir, &mut found); } // Drop entries whose backing file disappeared. let present: std::collections::HashSet<&String> = found.iter().collect(); let removed: Vec<(AssetUid, String)> = self .by_uid .values() .filter(|e| !present.contains(&e.path)) .map(|e| (e.uid, e.path.clone())) .collect(); for (uid, path) in removed { self.by_uid.remove(&uid); self.by_path.remove(&path); } // Register anything new. let before = self.by_uid.len(); for path in found { self.register(path); } self.by_uid.len().saturating_sub(before) } /// The entry for `uid`, if known. pub fn entry(&self, uid: AssetUid) -> Option<&AssetEntry> { self.by_uid.get(&uid) } /// The uid recorded for an assets-relative path, if any. pub fn uid_of(&self, relative_path: impl AsRef) -> Option { self.by_path .get(&normalize_relative(relative_path.as_ref())) .copied() } /// The assets-relative path for `uid`, if known. pub fn relative_path(&self, uid: AssetUid) -> Option<&str> { self.by_uid.get(&uid).map(|e| e.path.as_str()) } /// The absolute filesystem path for `uid` under the current root, if known. pub fn absolute_path(&self, uid: AssetUid) -> Option { self.by_uid.get(&uid).map(|e| { self.assets_dir() .join(e.path.replace('/', std::path::MAIN_SEPARATOR_STR)) }) } /// Every entry, in unspecified order. pub fn entries(&self) -> impl Iterator { self.by_uid.values() } /// Entries of a given kind, in unspecified order. pub fn entries_of_kind(&self, kind: AssetKind) -> impl Iterator { self.by_uid.values().filter(move |e| e.kind == kind) } /// The number of recorded assets. pub fn len(&self) -> usize { self.by_uid.len() } /// Whether the database has no entries. pub fn is_empty(&self) -> bool { self.by_uid.is_empty() } /// Resolves `uid` to a loaded [`Handle`] via `server`, or `None` if the /// uid is unknown. The handle is deduplicated by the server, so resolving /// the same uid (even after the project moved) yields the same asset. pub fn load( &self, server: &AssetServer, uid: AssetUid, ) -> Option> { let path = self.absolute_path(uid)?; Some(server.load::(path)) } // --- internals --------------------------------------------------------- fn highest_uid(&self) -> u64 { self.by_uid.keys().map(|u| u.0).max().unwrap_or(0) } } /// Normalizes a path to the database's canonical relative form: forward slashes, /// no leading `./` or separator. fn normalize_relative(path: &str) -> String { let trimmed = path.replace('\\', "/"); let trimmed = trimmed.strip_prefix("./").unwrap_or(&trimmed); trimmed.trim_start_matches('/').to_string() } /// Recursively collects files under `dir`, pushing each one's path relative to /// `base` (forward slashes) into `out`. A missing `dir` is silently skipped. fn collect_files(dir: &Path, base: &Path, out: &mut Vec) { let Ok(read) = std::fs::read_dir(dir) else { return; }; for entry in read.flatten() { let path = entry.path(); if path.is_dir() { collect_files(&path, base, out); } else if let Ok(rel) = path.strip_prefix(base) { out.push(rel.to_string_lossy().replace('\\', "/")); } } } #[cfg(test)] mod tests { use super::*; use std::sync::atomic::{AtomicU32, Ordering}; static COUNTER: AtomicU32 = AtomicU32::new(0); fn temp_root(tag: &str) -> PathBuf { let mut path = std::env::temp_dir(); path.push(format!( "oxide_assetdb_test_{}_{}_{tag}", std::process::id(), COUNTER.fetch_add(1, Ordering::SeqCst), )); path } /// Creates `assets/` under `root` with placeholder contents. fn touch_asset(root: &Path, rel: &str) { let full = root.join(ASSETS_DIR).join(rel); std::fs::create_dir_all(full.parent().unwrap()).unwrap(); std::fs::write(full, b"x").unwrap(); } #[test] fn classify_by_folder_then_extension() { assert_eq!(AssetKind::classify("fonts/Inter.ttf"), AssetKind::Font); assert_eq!(AssetKind::classify("ui/menu.ron"), AssetKind::Ui); assert_eq!(AssetKind::classify("textures/wall.png"), AssetKind::Texture); // No typed folder → fall back to extension. assert_eq!(AssetKind::classify("loose.glb"), AssetKind::Model); assert_eq!(AssetKind::classify("notes.md"), AssetKind::Other); } #[test] fn asset_ref_target_parses_and_maps_to_kind() { // The derive's spelling (spaces around the generic args), for both the // serializable `AssetRef` and a bare `Handle`. assert_eq!(asset_ref_target("AssetRef < Font >"), Some("Font")); assert_eq!(asset_ref_target("Handle < Font >"), Some("Font")); // Compact and module-qualified spellings. assert_eq!(asset_ref_target("AssetRef"), Some("GltfModel")); assert_eq!(asset_ref_target("Handle"), Some("Font")); // Non-reference and unsupported (nested / multi-arg) fields. assert_eq!(asset_ref_target("f32"), None); assert_eq!(asset_ref_target("Vec>"), None); assert_eq!(asset_ref_target("HashMap"), None); // Target type -> picker filter kind. assert_eq!(AssetKind::for_handle_target("Font"), Some(AssetKind::Font)); assert_eq!( AssetKind::for_handle_target("GltfModel"), Some(AssetKind::Model) ); assert_eq!(AssetKind::for_handle_target("Whatever"), None); } #[test] fn asset_ref_serializes_as_uid_and_resolves() { // Empty and populated references round-trip through RON as just the uid. let empty = AssetRef::::none(); assert!(!empty.is_some()); let ron_empty = ron::to_string(&empty).unwrap(); assert_eq!( ron::from_str::>(&ron_empty).unwrap(), empty ); let r = AssetRef::::new(AssetUid(7)); let round: AssetRef = ron::from_str(&ron::to_string(&r).unwrap()).unwrap(); assert_eq!(round.uid(), Some(AssetUid(7))); // resolve() goes ref -> db -> server -> handle. let root = temp_root("assetref"); let full = root.join(ASSETS_DIR).join("textures"); std::fs::create_dir_all(&full).unwrap(); std::fs::write(full.join("a.txt"), "hi").unwrap(); let mut db = AssetDatabase::new(&root); let uid = db.register("textures/a.txt"); let server = AssetServer::empty(); server.register_loader(TxtLoader); let reference = AssetRef::::new(uid); let handle = reference.resolve(&db, &server).unwrap(); assert_eq!(handle.get().unwrap().as_str(), "hi"); // An empty ref resolves to nothing. assert!(AssetRef::::none().resolve(&db, &server).is_none()); std::fs::remove_dir_all(root).ok(); } #[test] fn register_is_idempotent_and_infers_kind() { let mut db = AssetDatabase::new(temp_root("register")); let a = db.register("fonts/Inter-Regular.ttf"); let b = db.register("fonts/Inter-Regular.ttf"); assert_eq!(a, b, "same path returns same uid"); assert_eq!(db.len(), 1); assert_eq!(db.entry(a).unwrap().kind, AssetKind::Font); // Path normalization: a `./`-prefixed, back-slashed spelling collapses. assert_eq!(db.uid_of(".\\fonts\\Inter-Regular.ttf"), Some(a)); } #[test] fn scan_picks_up_typed_folders_and_prunes_missing() { let root = temp_root("scan"); touch_asset(&root, "fonts/Inter.ttf"); touch_asset(&root, "textures/wall.png"); touch_asset(&root, "models/cube.glb"); let mut db = AssetDatabase::new(&root); assert_eq!(db.scan(), 3); assert_eq!(db.len(), 3); assert_eq!(db.entries_of_kind(AssetKind::Font).count(), 1); // Remove one file and rescan: it is pruned, the rest keep their uids. let font_uid = db.uid_of("fonts/Inter.ttf").unwrap(); let wall_uid = db.uid_of("textures/wall.png").unwrap(); std::fs::remove_file(root.join(ASSETS_DIR).join("fonts/Inter.ttf")).unwrap(); assert_eq!(db.scan(), 0); assert_eq!(db.len(), 2); assert!(db.entry(font_uid).is_none()); assert_eq!(db.uid_of("textures/wall.png"), Some(wall_uid)); std::fs::remove_dir_all(root).ok(); } #[test] fn manifest_round_trips_uids() { let root = temp_root("manifest"); std::fs::create_dir_all(&root).unwrap(); touch_asset(&root, "fonts/Inter.ttf"); touch_asset(&root, "audio/click.wav"); let mut db = AssetDatabase::new(&root); db.scan(); let font_uid = db.uid_of("fonts/Inter.ttf").unwrap(); let click_uid = db.uid_of("audio/click.wav").unwrap(); let next = db.next_uid; db.save().unwrap(); // Reload from the manifest: every uid is preserved, and the allocator // does not reuse a freed id. let reloaded = AssetDatabase::open(&root); assert_eq!(reloaded.uid_of("fonts/Inter.ttf"), Some(font_uid)); assert_eq!(reloaded.uid_of("audio/click.wav"), Some(click_uid)); assert_eq!(reloaded.next_uid, next); std::fs::remove_dir_all(root).ok(); } #[test] fn reference_survives_save_load_and_moved_project() { // A reference (uid) saved with the project must resolve to the same // handle after reload AND after the whole project directory moves. let root = temp_root("move_src"); touch_asset(&root, "fonts/Inter.ttf"); let mut db = AssetDatabase::open(&root); db.scan(); db.save().unwrap(); let uid = db.uid_of("fonts/Inter.ttf").unwrap(); // Simulate moving the project to a new directory on disk. let moved = temp_root("move_dst"); std::fs::create_dir_all(&moved).unwrap(); copy_dir(&root, &moved); // Open the database from the new location: same uid, new absolute path. let moved_db = AssetDatabase::open(&moved); assert_eq!(moved_db.uid_of("fonts/Inter.ttf"), Some(uid)); let abs = moved_db.absolute_path(uid).unwrap(); assert!(abs.starts_with(&moved)); assert!(abs.exists()); std::fs::remove_dir_all(root).ok(); std::fs::remove_dir_all(moved).ok(); } #[test] fn load_dedups_through_the_server() { // Use a tiny custom loader so we don't need a real asset format. let root = temp_root("load"); let full = root.join(ASSETS_DIR).join("textures"); std::fs::create_dir_all(&full).unwrap(); std::fs::write(full.join("a.txt"), "hi").unwrap(); let mut db = AssetDatabase::new(&root); let uid = db.register("textures/a.txt"); let server = AssetServer::empty(); server.register_loader(TxtLoader); let h1 = db.load::(&server, uid).unwrap(); let h2 = db.load::(&server, uid).unwrap(); assert_eq!(h1.id(), h2.id(), "same uid resolves to one shared asset"); assert_eq!(h1.get().unwrap().as_str(), "hi"); assert!(db.load::(&server, AssetUid(999)).is_none()); std::fs::remove_dir_all(root).ok(); } struct TxtLoader; impl crate::asset::AssetLoader for TxtLoader { type Asset = String; fn extensions(&self) -> &'static [&'static str] { &["txt"] } fn load(&self, path: &Path) -> Result { std::fs::read_to_string(path).map_err(|e| crate::asset::AssetError::Load { path: path.to_path_buf(), message: e.to_string(), }) } } fn copy_dir(from: &Path, to: &Path) { for entry in std::fs::read_dir(from).unwrap().flatten() { let dst = to.join(entry.file_name()); if entry.path().is_dir() { std::fs::create_dir_all(&dst).unwrap(); copy_dir(&entry.path(), &dst); } else { std::fs::copy(entry.path(), dst).unwrap(); } } } }