Files
Oxide/engine/src/asset/database.rs
T
Homer Simpson 9eead719b0 Import Oxide engine (Stages 0–10) under MIT license
Full project snapshot migrated to new Gitea remote without history:
engine, editor, physics, script, examples, tests, docs, and assets.
Relicensed from GPLv3 to MIT and updated repo URLs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 20:41:02 +02:00

767 lines
29 KiB
Rust

//! [`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> {
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<AssetKind> {
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<T>`
/// (or `Handle<T>`) field (see [`asset_ref_target`]); unknown targets yield
/// `None` so the picker can offer every kind.
pub fn for_handle_target(target: &str) -> Option<AssetKind> {
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<T>`] (the
/// serializable reference components store) or a bare [`Handle<T>`] — 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<crate::ui::Font>"`. 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<Font>"` 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<T>`] is not serializable and is
/// tied to one process run, so persisting it would be wrong; an `AssetRef<T>`
/// 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<AssetUid>`, 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<T> {
uid: Option<AssetUid>,
_marker: std::marker::PhantomData<fn() -> T>,
}
impl<T> AssetRef<T> {
/// 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<AssetUid> {
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<AssetUid>) {
self.uid = uid;
}
/// Resolves to a loaded [`Handle<T>`] 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<Handle<T>>
where
T: Send + Sync + 'static,
{
db.load::<T>(server, self.uid?)
}
}
// Hand-written trait impls: deriving would wrongly require `T: Clone`/`Default`
// etc., but an `AssetRef<T>` carries no `T` value — only a uid + phantom marker.
impl<T> Clone for AssetRef<T> {
fn clone(&self) -> Self {
*self
}
}
impl<T> Copy for AssetRef<T> {}
impl<T> Default for AssetRef<T> {
fn default() -> Self {
Self::none()
}
}
impl<T> PartialEq for AssetRef<T> {
fn eq(&self, other: &Self) -> bool {
self.uid == other.uid
}
}
impl<T> Eq for AssetRef<T> {}
impl<T> std::fmt::Debug for AssetRef<T> {
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<AssetUid>` so saved data is just
// the uid (or unit `None`) and stays independent of `T`.
impl<T> Serialize for AssetRef<T> {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.uid.serialize(serializer)
}
}
impl<'de, T> Deserialize<'de> for AssetRef<T> {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
Ok(Self {
uid: Option::<AssetUid>::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<AssetEntry>,
}
/// 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<AssetUid, AssetEntry>,
by_path: HashMap<String, AssetUid>,
next_uid: u64,
}
impl AssetDatabase {
/// An empty database for the project rooted at `root`.
pub fn new(root: impl AsRef<Path>) -> 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<Path>) -> 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::<Manifest>(&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 `<root>/assets.manifest`.
pub fn save(&self) -> std::io::Result<()> {
let mut entries: Vec<AssetEntry> = 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<Path>) {
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<str>) -> 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<String> = 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<str>) -> Option<AssetUid> {
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<PathBuf> {
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<Item = &AssetEntry> {
self.by_uid.values()
}
/// Entries of a given kind, in unspecified order.
pub fn entries_of_kind(&self, kind: AssetKind) -> impl Iterator<Item = &AssetEntry> {
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<T>`] 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<T: Send + Sync + 'static>(
&self,
server: &AssetServer,
uid: AssetUid,
) -> Option<Handle<T>> {
let path = self.absolute_path(uid)?;
Some(server.load::<T>(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<String>) {
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/<rel>` 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<T>` and a bare `Handle<T>`.
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<GltfModel>"), Some("GltfModel"));
assert_eq!(asset_ref_target("Handle<crate::ui::Font>"), Some("Font"));
// Non-reference and unsupported (nested / multi-arg) fields.
assert_eq!(asset_ref_target("f32"), None);
assert_eq!(asset_ref_target("Vec<AssetRef<Font>>"), None);
assert_eq!(asset_ref_target("HashMap<String, u32>"), 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::<String>::none();
assert!(!empty.is_some());
let ron_empty = ron::to_string(&empty).unwrap();
assert_eq!(
ron::from_str::<AssetRef<String>>(&ron_empty).unwrap(),
empty
);
let r = AssetRef::<String>::new(AssetUid(7));
let round: AssetRef<String> = 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::<String>::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::<String>::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::<String>(&server, uid).unwrap();
let h2 = db.load::<String>(&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::<String>(&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<String, crate::asset::AssetError> {
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();
}
}
}
}