use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use super::asset_guid::AssetGuid;
use super::manifest::AssetType;
pub const META_EXTENSION: &str = "meta";
#[derive(Debug, thiserror::Error)]
pub enum AssetDatabaseError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssetMeta {
pub guid: AssetGuid,
pub asset_type: AssetType,
#[serde(default)]
pub import_settings: serde_json::Value,
}
impl AssetMeta {
pub fn new(guid: AssetGuid, asset_type: AssetType) -> Self {
Self {
guid,
asset_type,
import_settings: serde_json::Value::Null,
}
}
pub fn load(sidecar: &Path) -> Result<Self, AssetDatabaseError> {
let bytes = std::fs::read(sidecar)?;
Ok(serde_json::from_slice(&bytes)?)
}
pub fn save(&self, sidecar: &Path) -> Result<(), AssetDatabaseError> {
let bytes = serde_json::to_vec_pretty(self)?;
std::fs::write(sidecar, bytes)?;
Ok(())
}
}
pub fn meta_path(asset_path: &Path) -> PathBuf {
let mut file_name = asset_path
.file_name()
.map(|name| name.to_os_string())
.unwrap_or_default();
file_name.push(".");
file_name.push(META_EXTENSION);
asset_path.with_file_name(file_name)
}
#[derive(Debug, Clone)]
pub struct AssetDatabaseEntry {
pub guid: AssetGuid,
pub path: PathBuf,
pub asset_type: AssetType,
}
#[derive(Debug, Clone, Default)]
pub struct AssetDatabase {
entries: HashMap<AssetGuid, AssetDatabaseEntry>,
guid_by_path: HashMap<PathBuf, AssetGuid>,
}
impl AssetDatabase {
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, entry: AssetDatabaseEntry) {
self.guid_by_path.insert(entry.path.clone(), entry.guid);
self.entries.insert(entry.guid, entry);
}
pub fn get(&self, guid: AssetGuid) -> Option<&AssetDatabaseEntry> {
self.entries.get(&guid)
}
pub fn resolve_path(&self, guid: AssetGuid) -> Option<&Path> {
self.entries.get(&guid).map(|entry| entry.path.as_path())
}
pub fn resolve_guid(&self, path: &Path) -> Option<AssetGuid> {
self.guid_by_path.get(path).copied()
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = &AssetDatabaseEntry> {
self.entries.values()
}
pub fn ensure_guid(
&mut self,
asset_path: &Path,
asset_type: AssetType,
) -> Result<AssetGuid, AssetDatabaseError> {
let sidecar = meta_path(asset_path);
let meta = if sidecar.exists() {
AssetMeta::load(&sidecar)?
} else {
let meta = AssetMeta::new(AssetGuid::random(), asset_type);
meta.save(&sidecar)?;
meta
};
self.insert(AssetDatabaseEntry {
guid: meta.guid,
path: asset_path.to_path_buf(),
asset_type: meta.asset_type,
});
Ok(meta.guid)
}
pub fn scan(
&mut self,
root: &Path,
classify: &dyn Fn(&Path) -> Option<AssetType>,
) -> Result<(), AssetDatabaseError> {
for entry in std::fs::read_dir(root)? {
let entry = entry?;
let path = entry.path();
if entry.file_type()?.is_dir() {
self.scan(&path, classify)?;
continue;
}
if path.extension().and_then(|extension| extension.to_str()) == Some(META_EXTENSION) {
continue;
}
if let Some(asset_type) = classify(&path) {
self.ensure_guid(&path, asset_type)?;
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn meta_path_appends_extension() {
assert_eq!(
meta_path(Path::new("assets/hero.glb")),
PathBuf::from("assets/hero.glb.meta")
);
}
#[test]
fn meta_round_trips() {
let meta = AssetMeta::new(AssetGuid::random(), AssetType::Texture);
let json = serde_json::to_string(&meta).expect("encode");
let decoded: AssetMeta = serde_json::from_str(&json).expect("decode");
assert_eq!(decoded.guid, meta.guid);
assert_eq!(decoded.asset_type, meta.asset_type);
}
#[test]
fn resolves_guid_and_path_both_ways() {
let mut database = AssetDatabase::new();
let guid = AssetGuid::random();
database.insert(AssetDatabaseEntry {
guid,
path: PathBuf::from("meshes/cube.glb"),
asset_type: AssetType::Mesh,
});
assert_eq!(
database.resolve_path(guid),
Some(Path::new("meshes/cube.glb"))
);
assert_eq!(
database.resolve_guid(Path::new("meshes/cube.glb")),
Some(guid)
);
}
#[test]
fn ensure_guid_mints_then_reuses_from_sidecar() {
let directory =
std::env::temp_dir().join(format!("nightshade_assetdb_{}", std::process::id()));
std::fs::create_dir_all(&directory).expect("create temp dir");
let asset = directory.join("hero.glb");
std::fs::write(&asset, b"placeholder").expect("write asset");
let mut database = AssetDatabase::new();
let first = database
.ensure_guid(&asset, AssetType::Mesh)
.expect("first ensure");
let second = database
.ensure_guid(&asset, AssetType::Mesh)
.expect("second ensure");
assert_eq!(first, second, "the guid must persist through the sidecar");
assert!(meta_path(&asset).exists(), "a sidecar must be written");
std::fs::remove_dir_all(&directory).ok();
}
#[test]
fn scan_indexes_recognized_assets() {
let directory =
std::env::temp_dir().join(format!("nightshade_assetscan_{}", std::process::id()));
let textures = directory.join("textures");
std::fs::create_dir_all(&textures).expect("create temp dirs");
std::fs::write(directory.join("hero.glb"), b"model").expect("write model");
std::fs::write(textures.join("wall.png"), b"image").expect("write texture");
std::fs::write(directory.join("notes.txt"), b"ignore").expect("write text");
let mut database = AssetDatabase::new();
database
.scan(&directory, &|path| match path
.extension()
.and_then(|extension| extension.to_str())
{
Some("glb") => Some(AssetType::Mesh),
Some("png") => Some(AssetType::Texture),
_ => None,
})
.expect("scan");
assert_eq!(database.len(), 2, "only recognized assets are indexed");
assert!(meta_path(&directory.join("hero.glb")).exists());
assert!(meta_path(&textures.join("wall.png")).exists());
std::fs::remove_dir_all(&directory).ok();
}
}