Asset database file ops + script-file creation helpers

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/<stem>.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 <noreply@anthropic.com>
This commit is contained in:
Homer
2026-07-10 20:29:08 +02:00
parent e88a7e2bb1
commit d6cb9947b2
4 changed files with 430 additions and 3 deletions
+280
View File
@@ -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::<T>(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<str>,
) -> 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<str>,
to: impl AsRef<str>,
) -> Result<usize, AssetDbError> {
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<AssetUid> = 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<String, AssetDbError> {
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<String>) {
@@ -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;
+2 -1
View File
@@ -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};