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 b13e40770e
commit 1f3a034215
3 changed files with 405 additions and 3 deletions
+123 -2
View File
@@ -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<bool> {
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 `<assets_dir>/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<String> {
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");
}
}