From d6cb9947b28c305fd651cb474fd8703088880af5 Mon Sep 17 00:00:00 2001 From: Homer Date: Fri, 10 Jul 2026 20:29:08 +0200 Subject: [PATCH] Asset database file ops + script-file creation helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groundwork for the Stage-10 editor-UX batch (file explorer, New Script button), all pure logic proven by unit tests: - `AssetDatabase::move_asset` / `move_folder` / `delete_asset`: perform the disk operation and the uid-map update together, so renaming or moving an asset (or a whole folder) keeps its uid and every saved `AssetRef` resolving. Kinds re-classify from the new path; `..` and existing destinations are refused via the new `AssetDbError` enum; deleted uids are never reused. - Editor `assets.rs`: `script_template` (compiles under the sandboxed ScriptEngine — covered by a test), `sanitize_script_stem`, and `create_script_file` which writes a collision-free `assets/scripts/.rhai` and returns the relative path for database registration. - docs/assets.md: new "File operations" section + the missing Script row in the typed-folder table. Co-Authored-By: Claude Fable 5 --- docs/assets.md | 25 ++++ editor/src/assets.rs | 125 +++++++++++++++- engine/src/asset/database.rs | 280 +++++++++++++++++++++++++++++++++++ engine/src/asset/mod.rs | 3 +- 4 files changed, 430 insertions(+), 3 deletions(-) diff --git a/docs/assets.md b/docs/assets.md index b3ea5ce..c9eaee9 100644 --- a/docs/assets.md +++ b/docs/assets.md @@ -164,10 +164,34 @@ breaking references, since the uid travels with the file in the manifest. | `Model` | `models/` | `gltf`, `glb`, `obj` | | `Audio` | `audio/` | `wav`, `ogg`, `mp3`, `flac` | | `Ui` | `ui/` | (recognised by folder — shares `.ron` with scenes) | +| `Script` | `scripts/` | `rhai` | `AssetKind::classify(path)` infers the kind: the leading folder wins, with the file extension as a fallback for files dropped directly in `assets/`. +### File operations — rename, move, delete + +The database also *performs* file reorganisation, so the editor's file explorer +(and any tool) can rename or move assets **without breaking saved references** +— the disk operation and the uid map are updated together: + +```rust +# use oxide_engine::asset::{AssetDatabase, AssetUid}; +# fn demo(db: &mut AssetDatabase, uid: AssetUid) -> Result<(), oxide_engine::asset::AssetDbError> { +db.move_asset(uid, "textures/env/brick.png")?; // rename/move; uid unchanged +db.move_folder("textures/env", "textures/world")?; // every entry under it follows +db.delete_asset(uid)?; // file + entry; uid never reused +db.save()?; // persist the new paths +# Ok(()) +# } +``` + +All three refuse to touch anything outside `assets/` (`..` is rejected) and +refuse to overwrite an existing destination ([`AssetDbError`] has a variant per +failure). Kinds are re-classified from the new path, since a move can change +the typed folder. Deleting drops the entry but never reuses its uid — a +dangling reference stays dangling instead of silently pointing at a new file. + ### Asset-reference fields in the inspector A component points at an asset with an [`AssetRef`] field — **not** a live @@ -209,6 +233,7 @@ projects are seeded with a bundled default UI font (Inter, SIL OFL) under `fonts/`, referenced by its project-relative path like any other asset. [`AssetDatabase`]: ../engine/src/asset/database.rs +[`AssetDbError`]: ../engine/src/asset/database.rs [`AssetKind`]: ../engine/src/asset/database.rs [`AssetUid`]: ../engine/src/asset/database.rs [`AssetRef`]: ../engine/src/asset/database.rs diff --git a/editor/src/assets.rs b/editor/src/assets.rs index 5eb901f..cac2e78 100644 --- a/editor/src/assets.rs +++ b/editor/src/assets.rs @@ -1,5 +1,7 @@ -//! Bundled editor assets — locating the shared `assets/` tree and seeding a -//! new project's default content (currently the default UI font). +//! Bundled editor assets — locating the shared `assets/` tree, seeding a new +//! project's default content (currently the default UI font), and creating new +//! script files from the built-in template (the inspector's "New Script" +//! button). //! //! The editor ships a small set of shared assets (icons, the default UI font, …) //! installed by `install.sh` to `$PREFIX/share/oxide/assets`. At runtime we have @@ -91,6 +93,79 @@ pub fn seed_default_font(project_assets_dir: &Path) -> std::io::Result { Ok(true) } +/// The `.rhai` source the "New Script" button writes, personalised with the +/// script's file stem so the Console output identifies which script speaks. +/// +/// Kept to the two lifecycle hooks `docs/scripting.md` teaches first; the +/// `update` body ships commented out so a freshly created script visibly runs +/// (the `init` print) without moving anything until the author opts in. +pub fn script_template(stem: &str) -> String { + format!( + r#"// {stem}.rhai — attached via a Script component. +// +// Top-level statements run once when the script (re)starts. `init()` runs +// once after them; `update(dt)` runs every frame (dt = seconds). + +fn init() {{ + print("{stem}: init"); +}} + +fn update(dt) {{ + // e.g. rotate_y(dt * 1.5); +}} +"# + ) +} + +/// Reduces a typed script name to a safe file stem: keeps ASCII alphanumerics, +/// `-` and `_`, folds anything else (spaces, punctuation, Unicode) to `_`, +/// collapses runs, trims the ends, and drops a trailing `.rhai` the user may +/// have typed. An unusable input yields `"new_script"`. +pub fn sanitize_script_stem(name: &str) -> String { + let trimmed = name.trim(); + let trimmed = trimmed.strip_suffix(".rhai").unwrap_or(trimmed); + let mut stem = String::with_capacity(trimmed.len()); + for c in trimmed.chars() { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + stem.push(c); + } else if !stem.ends_with('_') { + stem.push('_'); + } + } + let stem = stem.trim_matches('_'); + if stem.is_empty() { + "new_script".to_owned() + } else { + stem.to_owned() + } +} + +/// Creates a new script file under `/scripts/` from the template, +/// returning its assets-relative path (e.g. `"scripts/my_script.rhai"`) for +/// registration in the [`AssetDatabase`](oxide_engine::asset::AssetDatabase). +/// +/// The desired name is [sanitized](sanitize_script_stem); a taken name gets a +/// numeric suffix (`stem_2`, `stem_3`, …) instead of failing or overwriting, +/// so the button always succeeds on a writable project. +pub fn create_script_file(assets_dir: &Path, desired_name: &str) -> std::io::Result { + use oxide_engine::asset::AssetKind; + + let stem = sanitize_script_stem(desired_name); + let dir = assets_dir.join(AssetKind::Script.folder()); + std::fs::create_dir_all(&dir)?; + let mut candidate = stem.clone(); + let mut n = 1; + while dir.join(format!("{candidate}.rhai")).exists() { + n += 1; + candidate = format!("{stem}_{n}"); + } + std::fs::write( + dir.join(format!("{candidate}.rhai")), + script_template(&candidate), + )?; + Ok(format!("{}/{candidate}.rhai", AssetKind::Script.folder())) +} + #[cfg(test)] mod tests { use super::*; @@ -124,4 +199,50 @@ mod tests { std::fs::remove_dir_all(tmp).ok(); } + + #[test] + fn sanitize_covers_typed_names() { + assert_eq!(sanitize_script_stem("spin"), "spin"); + assert_eq!(sanitize_script_stem(" My Cool Script! "), "My_Cool_Script"); + assert_eq!(sanitize_script_stem("door.rhai"), "door"); + assert_eq!(sanitize_script_stem("a//b\\c"), "a_b_c"); + assert_eq!(sanitize_script_stem("čárka"), "rka"); + // Unusable inputs fall back rather than producing "" or "_". + assert_eq!(sanitize_script_stem(""), "new_script"); + assert_eq!(sanitize_script_stem("!!!"), "new_script"); + assert_eq!(sanitize_script_stem(".rhai"), "new_script"); + } + + #[test] + fn create_script_writes_template_and_dodges_collisions() { + let mut tmp = std::env::temp_dir(); + tmp.push(format!("oxide_newscript_{}", std::process::id())); + let assets = tmp.join("assets"); + std::fs::create_dir_all(&assets).unwrap(); + + let rel = create_script_file(&assets, "door opener").unwrap(); + assert_eq!(rel, "scripts/door_opener.rhai"); + let text = std::fs::read_to_string(assets.join(&rel)).unwrap(); + assert!(text.contains("fn update(dt)")); + + // Same name again: suffixed, nothing overwritten. + let rel2 = create_script_file(&assets, "door opener").unwrap(); + assert_eq!(rel2, "scripts/door_opener_2.rhai"); + let rel3 = create_script_file(&assets, "door opener").unwrap(); + assert_eq!(rel3, "scripts/door_opener_3.rhai"); + + std::fs::remove_dir_all(tmp).ok(); + } + + #[test] + fn template_compiles_in_the_script_engine() { + // The template must never ship a syntax error: compile it exactly as + // the runtime would. + let asset = oxide_script::ScriptAsset::from_source( + "new_script.rhai", + script_template("new_script"), + ); + let engine = oxide_script::ScriptEngine::new(); + engine.compile(&asset).expect("template compiles"); + } } diff --git a/engine/src/asset/database.rs b/engine/src/asset/database.rs index 9087714..23609d4 100644 --- a/engine/src/asset/database.rs +++ b/engine/src/asset/database.rs @@ -178,6 +178,30 @@ pub fn asset_ref_target(type_name: &str) -> Option<&str> { Some(inner.rsplit("::").next().unwrap_or(inner).trim()) } +/// Errors from [`AssetDatabase`] file operations ([`move_asset`], [`delete_asset`], +/// [`move_folder`]) — the ops that touch both the on-disk file *and* the uid map. +/// +/// [`move_asset`]: AssetDatabase::move_asset +/// [`delete_asset`]: AssetDatabase::delete_asset +/// [`move_folder`]: AssetDatabase::move_folder +#[derive(Debug, thiserror::Error)] +pub enum AssetDbError { + /// The uid is not recorded in the database. + #[error("unknown asset uid {0:?}")] + UnknownUid(AssetUid), + /// The destination path is already taken, in the database or on disk. + /// Nothing was changed; pick another name. + #[error("destination already exists: {0}")] + DestinationExists(String), + /// The path is empty, escapes the assets directory (`..`), or would move a + /// folder into itself. + #[error("invalid assets-relative path: {0:?}")] + InvalidPath(String), + /// The underlying filesystem operation failed. + #[error(transparent)] + Io(#[from] std::io::Error), +} + /// A stable, per-project identifier for one asset. /// /// Unlike [`AssetId`](super::AssetId) — which is process-unique and changes @@ -507,6 +531,126 @@ impl AssetDatabase { Some(server.load::(path)) } + // --- file operations (disk + uid map together) -------------------------- + // + // These keep the uid attached to its asset across renames/moves — the whole + // point of the uid layer — so the editor's file explorer can reorganise a + // project without breaking any saved `AssetRef`. Deletion drops the entry + // but never reuses its uid (the allocator only counts up). + + /// Renames/moves the asset `uid` to `new_relative_path` (relative to + /// `assets/`), on disk and in the map, **keeping its uid** so saved + /// references still resolve. The kind is re-classified from the new path. + /// Parent directories are created as needed; moving onto an existing path + /// is refused. A no-op if the path is unchanged. + /// + /// Call [`save`](Self::save) afterwards to persist the new path. + pub fn move_asset( + &mut self, + uid: AssetUid, + new_relative_path: impl AsRef, + ) -> Result<(), AssetDbError> { + let entry = self.by_uid.get(&uid).ok_or(AssetDbError::UnknownUid(uid))?; + let new_path = checked_relative(new_relative_path.as_ref())?; + if new_path == entry.path { + return Ok(()); + } + if self.by_path.contains_key(&new_path) { + return Err(AssetDbError::DestinationExists(new_path)); + } + let assets_dir = self.assets_dir(); + let from_abs = assets_dir.join(entry.path.replace('/', std::path::MAIN_SEPARATOR_STR)); + let to_abs = assets_dir.join(new_path.replace('/', std::path::MAIN_SEPARATOR_STR)); + if to_abs.exists() { + return Err(AssetDbError::DestinationExists(new_path)); + } + if let Some(parent) = to_abs.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::rename(&from_abs, &to_abs)?; + + let old_path = entry.path.clone(); + self.by_path.remove(&old_path); + self.by_path.insert(new_path.clone(), uid); + let entry = self.by_uid.get_mut(&uid).expect("entry checked above"); + entry.kind = AssetKind::classify(&new_path); + entry.path = new_path; + Ok(()) + } + + /// Deletes the asset `uid`: removes its file from disk and drops its entry. + /// A file already missing from disk is fine (the entry is still dropped); + /// the uid is never reused. Call [`save`](Self::save) afterwards. + pub fn delete_asset(&mut self, uid: AssetUid) -> Result<(), AssetDbError> { + let entry = self.by_uid.get(&uid).ok_or(AssetDbError::UnknownUid(uid))?; + let abs = self + .assets_dir() + .join(entry.path.replace('/', std::path::MAIN_SEPARATOR_STR)); + match std::fs::remove_file(&abs) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e.into()), + } + let path = entry.path.clone(); + self.by_uid.remove(&uid); + self.by_path.remove(&path); + Ok(()) + } + + /// Renames/moves the folder `from` to `to` (both relative to `assets/`), + /// on disk and in the map: every entry under `from/` is rewritten to live + /// under `to/`, **keeping its uid** (kinds are re-classified — moving out + /// of a typed folder can change them). Refuses to overwrite an existing + /// destination or to move a folder into itself. Returns how many entries + /// moved. Call [`save`](Self::save) afterwards. + pub fn move_folder( + &mut self, + from: impl AsRef, + to: impl AsRef, + ) -> Result { + let from = checked_relative(from.as_ref())?; + let to = checked_relative(to.as_ref())?; + if from == to { + return Ok(0); + } + if to.starts_with(&format!("{from}/")) { + return Err(AssetDbError::InvalidPath(to)); + } + let assets_dir = self.assets_dir(); + let from_abs = assets_dir.join(from.replace('/', std::path::MAIN_SEPARATOR_STR)); + let to_abs = assets_dir.join(to.replace('/', std::path::MAIN_SEPARATOR_STR)); + if !from_abs.is_dir() { + return Err(AssetDbError::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("no such folder under assets/: {from}"), + ))); + } + if to_abs.exists() { + return Err(AssetDbError::DestinationExists(to)); + } + if let Some(parent) = to_abs.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::rename(&from_abs, &to_abs)?; + + let prefix = format!("{from}/"); + let moved: Vec = self + .by_uid + .values() + .filter(|e| e.path.starts_with(&prefix)) + .map(|e| e.uid) + .collect(); + for &uid in &moved { + let entry = self.by_uid.get_mut(&uid).expect("collected above"); + let new_path = format!("{to}/{}", &entry.path[prefix.len()..]); + let old_path = std::mem::replace(&mut entry.path, new_path.clone()); + entry.kind = AssetKind::classify(&new_path); + self.by_path.remove(&old_path); + self.by_path.insert(new_path, uid); + } + Ok(moved.len()) + } + // --- internals --------------------------------------------------------- fn highest_uid(&self) -> u64 { @@ -522,6 +666,20 @@ fn normalize_relative(path: &str) -> String { trimmed.trim_start_matches('/').to_string() } +/// Normalizes like [`normalize_relative`] and rejects paths that are empty or +/// escape the assets directory (`..` components) — file operations must never +/// touch anything outside `assets/`. +fn checked_relative(path: &str) -> Result { + let norm = normalize_relative(path); + let escapes = Path::new(&norm) + .components() + .any(|c| !matches!(c, std::path::Component::Normal(_))); + if norm.is_empty() || escapes { + return Err(AssetDbError::InvalidPath(path.to_string())); + } + Ok(norm) +} + /// 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) { @@ -738,6 +896,128 @@ mod tests { std::fs::remove_dir_all(root).ok(); } + #[test] + fn move_asset_keeps_uid_and_reclassifies() { + let root = temp_root("move_asset"); + touch_asset(&root, "textures/wall.png"); + let mut db = AssetDatabase::new(&root); + db.scan(); + let uid = db.uid_of("textures/wall.png").unwrap(); + + // Rename within the folder: same uid, file moved on disk. + db.move_asset(uid, "textures/brick.png").unwrap(); + assert_eq!(db.relative_path(uid), Some("textures/brick.png")); + assert!(db.uid_of("textures/wall.png").is_none()); + assert!(!root.join(ASSETS_DIR).join("textures/wall.png").exists()); + assert!(root.join(ASSETS_DIR).join("textures/brick.png").exists()); + + // Move into a subfolder that does not exist yet (created), then out of + // the typed folder entirely — kind follows the path. + db.move_asset(uid, "textures/env/brick.png").unwrap(); + assert!(root + .join(ASSETS_DIR) + .join("textures/env/brick.png") + .exists()); + assert_eq!(db.entry(uid).unwrap().kind, AssetKind::Texture); + db.move_asset(uid, "misc/brick.dat").unwrap(); + assert_eq!(db.entry(uid).unwrap().kind, AssetKind::Other); + + // A rescan does not disturb the moved entry's uid... (misc/ is not a + // typed folder, so the entry survives only because scan never saw it — + // move back first to prove the typed-folder case.) + db.move_asset(uid, "textures/brick.png").unwrap(); + db.scan(); + assert_eq!(db.uid_of("textures/brick.png"), Some(uid)); + + // No-op and error cases. + db.move_asset(uid, "textures/brick.png").unwrap(); // unchanged path: Ok + touch_asset(&root, "textures/taken.png"); + db.scan(); + assert!(matches!( + db.move_asset(uid, "textures/taken.png"), + Err(AssetDbError::DestinationExists(_)) + )); + assert!(matches!( + db.move_asset(uid, "../outside.png"), + Err(AssetDbError::InvalidPath(_)) + )); + assert!(matches!( + db.move_asset(AssetUid(999), "textures/x.png"), + Err(AssetDbError::UnknownUid(_)) + )); + + std::fs::remove_dir_all(root).ok(); + } + + #[test] + fn delete_asset_removes_file_and_entry_without_reusing_uid() { + let root = temp_root("delete_asset"); + touch_asset(&root, "audio/click.wav"); + let mut db = AssetDatabase::new(&root); + db.scan(); + let uid = db.uid_of("audio/click.wav").unwrap(); + + db.delete_asset(uid).unwrap(); + assert!(db.entry(uid).is_none()); + assert!(!root.join(ASSETS_DIR).join("audio/click.wav").exists()); + + // The freed uid is not handed to the next registration. + let next = db.register("audio/other.wav"); + assert_ne!(next, uid); + + // Deleting an entry whose file is already gone still drops the entry. + std::fs::remove_file(root.join(ASSETS_DIR).join("audio/other.wav")).ok(); + db.delete_asset(next).unwrap(); + assert!(db.entry(next).is_none()); + + assert!(matches!( + db.delete_asset(uid), + Err(AssetDbError::UnknownUid(_)) + )); + + std::fs::remove_dir_all(root).ok(); + } + + #[test] + fn move_folder_rewrites_entries_and_guards_self_moves() { + let root = temp_root("move_folder"); + touch_asset(&root, "textures/env/wall.png"); + touch_asset(&root, "textures/env/floor.png"); + touch_asset(&root, "textures/hero.png"); + let mut db = AssetDatabase::new(&root); + db.scan(); + let wall = db.uid_of("textures/env/wall.png").unwrap(); + let hero = db.uid_of("textures/hero.png").unwrap(); + + let moved = db.move_folder("textures/env", "textures/world").unwrap(); + assert_eq!(moved, 2); + assert_eq!(db.relative_path(wall), Some("textures/world/wall.png")); + assert!(db.uid_of("textures/world/floor.png").is_some()); + assert_eq!(db.relative_path(hero), Some("textures/hero.png")); + assert!(root + .join(ASSETS_DIR) + .join("textures/world/wall.png") + .exists()); + assert!(!root.join(ASSETS_DIR).join("textures/env").exists()); + + // Guards: into itself, onto an existing folder, missing source. + assert!(matches!( + db.move_folder("textures", "textures/world/deeper"), + Err(AssetDbError::InvalidPath(_)) + )); + std::fs::create_dir_all(root.join(ASSETS_DIR).join("models")).unwrap(); + assert!(matches!( + db.move_folder("textures/world", "models"), + Err(AssetDbError::DestinationExists(_)) + )); + assert!(matches!( + db.move_folder("textures/nope", "textures/x"), + Err(AssetDbError::Io(_)) + )); + + std::fs::remove_dir_all(root).ok(); + } + struct TxtLoader; impl crate::asset::AssetLoader for TxtLoader { type Asset = String; diff --git a/engine/src/asset/mod.rs b/engine/src/asset/mod.rs index 9244de1..debb796 100644 --- a/engine/src/asset/mod.rs +++ b/engine/src/asset/mod.rs @@ -18,7 +18,8 @@ mod handle; mod server; pub use database::{ - asset_ref_target, AssetDatabase, AssetEntry, AssetKind, AssetRef, AssetUid, ASSET_MANIFEST_FILE, + asset_ref_target, AssetDatabase, AssetDbError, AssetEntry, AssetKind, AssetRef, AssetUid, + ASSET_MANIFEST_FILE, }; pub use gltf::{load_gltf, load_gltf_slice, GltfError, GltfLoader, GltfMesh, GltfModel}; pub use handle::{AssetId, Handle, LoadState};