nightshade 0.48.0

A cross-platform data-oriented game engine.
Documentation
use std::collections::HashMap;
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

use super::asset_guid::AssetGuid;
use super::manifest::AssetType;

/// Extension appended to an asset path to locate its sidecar, so
/// `hero.glb` pairs with `hero.glb.meta`.
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),
}

/// Sidecar stored next to a source asset. Holds the asset's stable
/// identity plus opaque per-importer settings. The sidecar is the source
/// of truth for identity and is version-controlled alongside the asset.
#[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(())
    }
}

/// Returns the sidecar path for an asset, appending `.meta` to the full
/// file name so two assets that differ only by extension keep distinct
/// sidecars.
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,
}

/// Project-wide index from a stable [`AssetGuid`] to an asset path and
/// back, built from the `.meta` sidecars beside each asset. Resolves the
/// GUID references that source scenes and prefabs carry.
#[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()
    }

    /// Returns the GUID for an asset, reading its sidecar when present or
    /// minting a fresh GUID and writing a new sidecar when absent. The
    /// asset is indexed either way.
    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)
    }

    /// Walks `root` recursively and ensures a GUID for every file that
    /// `classify` recognizes as an asset, reading or minting its sidecar.
    /// `.meta` sidecars are skipped. The caller supplies `classify` so
    /// asset-extension conventions live with the project, not the engine.
    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();
    }
}