use std::fs;
use std::path::{Path, PathBuf};
use unity_assetdb::bake::{BakeOptions, bake};
use unity_assetdb::store;
fn bake_at(root: &Path) -> PathBuf {
let out_dir = out_dir_for(root);
let opts = BakeOptions {
project_root: root.to_path_buf(),
out_dir: out_dir.clone(),
name_sanitizer: None,
on_warn: None,
on_progress: None,
verbose_timing: false,
verbose_collisions: false,
};
bake(&opts).unwrap();
out_dir
}
fn out_dir_for(root: &Path) -> PathBuf {
root.join("Library").join("unity-assetdb")
}
fn db_file(root: &Path) -> PathBuf {
store::db_path(&out_dir_for(root))
}
fn cache_file(root: &Path) -> PathBuf {
store::cache_path(&out_dir_for(root))
}
fn write(path: &Path, body: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, body).unwrap();
}
fn make_fixture(root: &Path) {
fs::create_dir_all(root.join("ProjectSettings")).unwrap();
write(
&root.join("ProjectSettings/ProjectVersion.txt"),
"m_EditorVersion: 2022.3.0f1\n",
);
let prefab_dir = root.join("Assets/UI");
write(
&prefab_dir.join("Foo.prefab"),
"%YAML 1.1\n%TAG !u! tag:unity3d.com,2011:\n--- !u!1001 &100100000\nPrefabInstance:\n m_ObjectHideFlags: 0\n",
);
write(
&prefab_dir.join("Foo.prefab.meta"),
"fileFormatVersion: 2\nguid: aaaa1111aaaa1111aaaa1111aaaa1111\nPrefabImporter:\n externalObjects: {}\n",
);
write(
&root.join("Assets/SO/Bar.asset"),
"%YAML 1.1\n%TAG !u! tag:unity3d.com,2011:\n--- !u!114 &11400000\nMonoBehaviour:\n m_ObjectHideFlags: 0\n m_Script: {fileID: 11500000, guid: bbbb2222bbbb2222bbbb2222bbbb2222, type: 3}\n m_Name: Bar\n",
);
write(
&root.join("Assets/SO/Bar.asset.meta"),
"fileFormatVersion: 2\nguid: cccc3333cccc3333cccc3333cccc3333\nNativeFormatImporter: {}\n",
);
write(&root.join("Assets/Tex/Sheet.png"), "fake-png-bytes");
write(
&root.join("Assets/Tex/Sheet.png.meta"),
"fileFormatVersion: 2
guid: dddd4444dddd4444dddd4444dddd4444
TextureImporter:
spriteSheet:
sprites:
- serializedVersion: 2
name: spr_a
internalID: 11111
- serializedVersion: 2
name: spr_b
internalID: 22222
",
);
}
fn unique_tmp(label: &str) -> std::path::PathBuf {
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("unity-assetdb-bake-test-{label}-{pid}-{nanos}"))
}
#[test]
fn bake_then_load_roundtrip() {
let root = unique_tmp("roundtrip");
let _ = fs::remove_dir_all(&root);
make_fixture(&root);
let _out_dir = bake_at(&root);
let bin = db_file(&root);
assert!(
bin.exists(),
"asset-db.bin not created at {}",
bin.display()
);
let db = store::read(&bin).unwrap();
assert_eq!(
db.entries.len(),
3,
"expected 3 entries, got {:?}",
db.entries
);
let foo = db
.find_by_guid(0xaaaa1111aaaa1111aaaa1111aaaa1111_u128)
.expect("Foo.prefab missing");
assert_eq!(&*foo.name, "Foo");
match foo.asset_type {
store::AssetType::Native(n) => {
assert_eq!(n, unity_assetdb::class_id::ClassId::Prefab as u32);
}
store::AssetType::Script(_) => {
panic!("expected Native(Prefab) for Foo, got {:?}", foo.asset_type)
}
}
let bar = db
.find_by_guid(0xcccc3333cccc3333cccc3333cccc3333_u128)
.expect("Bar.asset missing");
assert_eq!(&*bar.name, "Bar");
match bar.asset_type {
store::AssetType::Script(idx) => {
assert_eq!(db.script_guid(idx), 0xbbbb2222bbbb2222bbbb2222bbbb2222_u128);
}
store::AssetType::Native(_) => {
panic!("expected Script for Bar, got {:?}", bar.asset_type)
}
}
let sheet = db
.find_by_guid(0xdddd4444dddd4444dddd4444dddd4444_u128)
.expect("Sheet.png missing");
assert_eq!(&*sheet.name, "Sheet");
match sheet.asset_type {
store::AssetType::Native(n) => {
assert_eq!(n, unity_assetdb::class_id::ClassId::Texture2D as u32);
}
store::AssetType::Script(_) => panic!("expected Native(Texture2D) for Sheet"),
}
assert_eq!(sheet.sub_assets.len(), 2);
assert_eq!(sheet.sub_assets[0].file_id, 11111);
assert_eq!(&*sheet.sub_assets[0].name, "spr_a");
assert_eq!(sheet.sub_assets[1].file_id, 22222);
assert_eq!(&*sheet.sub_assets[1].name, "spr_b");
let guids: Vec<u128> = db.entries.iter().map(|e| e.guid).collect();
let mut sorted = guids.clone();
sorted.sort_unstable();
assert_eq!(guids, sorted);
fs::remove_dir_all(&root).ok();
}
#[test]
fn cache_reuse_preserves_names() {
let root = unique_tmp("cache");
let _ = fs::remove_dir_all(&root);
make_fixture(&root);
let _out_dir = bake_at(&root);
let first = store::read(&db_file(&root)).unwrap();
let _out_dir = bake_at(&root);
let second = store::read(&db_file(&root)).unwrap();
assert_eq!(first.entries.len(), second.entries.len());
for (a, b) in first.entries.iter().zip(second.entries.iter()) {
assert_eq!(a.guid, b.guid);
assert_eq!(a.name, b.name);
assert_eq!(a.sub_assets.len(), b.sub_assets.len());
}
fs::remove_dir_all(&root).ok();
}
#[test]
fn duplicate_top_level_guid_hard_fails() {
let root = unique_tmp("dup-guid");
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("ProjectSettings")).unwrap();
write(
&root.join("ProjectSettings/ProjectVersion.txt"),
"m_EditorVersion: 2022.3.0f1\n",
);
write(
&root.join("Assets/A.prefab"),
"--- !u!1001 &100100000\nPrefabInstance: {}\n",
);
write(
&root.join("Assets/A.prefab.meta"),
"fileFormatVersion: 2\nguid: 1111111111111111111111111111aaaa\nPrefabImporter: {}\n",
);
write(
&root.join("Assets/B.prefab"),
"--- !u!1001 &100100000\nPrefabInstance: {}\n",
);
write(
&root.join("Assets/B.prefab.meta"),
"fileFormatVersion: 2\nguid: 1111111111111111111111111111aaaa\nPrefabImporter: {}\n",
);
let opts = BakeOptions {
project_root: root.to_path_buf(),
out_dir: out_dir_for(&root),
name_sanitizer: None,
on_warn: None,
on_progress: None,
verbose_timing: false,
verbose_collisions: false,
};
let err = bake(&opts).expect_err("expected hard-fail on duplicate GUID");
let msg = format!("{err}");
assert!(
msg.contains("duplicate top-level GUID"),
"unexpected error: {msg}"
);
fs::remove_dir_all(&root).ok();
}
#[test]
fn implicit_sprite_subasset_synthesis() {
use unity_assetdb::class_id::ClassId;
let root = unique_tmp("synth-single");
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("ProjectSettings")).unwrap();
write(
&root.join("ProjectSettings/ProjectVersion.txt"),
"m_EditorVersion: 2022.3.0f1\n",
);
write(&root.join("Assets/Tex/Icon.png"), "fake-png-bytes");
write(
&root.join("Assets/Tex/Icon.png.meta"),
"fileFormatVersion: 2
guid: eeee5555eeee5555eeee5555eeee5555
TextureImporter:
textureType: 8
spriteMode: 1
spriteSheet:
sprites: []
",
);
let _out_dir = bake_at(&root);
let db = store::read(&db_file(&root)).unwrap();
let entry = db
.find_by_guid(0xeeee5555eeee5555eeee5555eeee5555_u128)
.expect("Icon.png missing from db");
assert_eq!(&*entry.name, "Icon");
assert_eq!(
entry.sub_assets.len(),
1,
"Single-mode Sprite texture with empty sheet should bake to exactly one sub-asset"
);
assert_eq!(
entry.sub_assets[0].file_id,
ClassId::Sprite.canonical_subobject_fid()
);
assert_eq!(&*entry.sub_assets[0].name, "Icon");
fs::remove_dir_all(&root).ok();
}
#[test]
fn cache_hit_path_byte_identical_rebake() {
let root = unique_tmp("cache-hit-bytes");
let _ = fs::remove_dir_all(&root);
make_fixture(&root);
let _out_dir = bake_at(&root);
let first = fs::read(db_file(&root)).unwrap();
std::thread::sleep(std::time::Duration::from_millis(5));
let _out_dir = bake_at(&root);
let second = fs::read(db_file(&root)).unwrap();
assert_eq!(first, second, "second-bake bytes drifted from first");
fs::remove_dir_all(&root).ok();
}
#[test]
fn cache_does_not_detect_asset_only_touch() {
let root = unique_tmp("cache-asset-only-touch");
let _ = fs::remove_dir_all(&root);
make_fixture(&root);
let _out_dir = bake_at(&root);
let asset_path = root.join("Assets/UI/Foo.prefab");
let pre_meta_mtime = mtime_ns_of(&root.join("Assets/UI/Foo.prefab.meta"));
std::thread::sleep(std::time::Duration::from_millis(10));
let now = filetime::FileTime::now();
set_mtime(&asset_path, now);
let _out_dir = bake_at(&root);
let c = store::read_cache(&cache_file(&root)).unwrap();
let foo = c
.entries
.iter()
.find(|e| &*e.hint == "Assets/UI/Foo.prefab")
.unwrap();
assert_ne!(
foo.asset_mtime_ns,
mtime_ns_of(&asset_path),
"asset-only touch was unexpectedly detected (fast path may have changed)"
);
assert_eq!(
foo.meta_mtime_ns, pre_meta_mtime,
"meta mtime drifted between bakes — fixture leaked",
);
fs::remove_dir_all(&root).ok();
}
#[test]
fn cache_invalidates_on_meta_and_asset_touch() {
let root = unique_tmp("cache-both-touch");
let _ = fs::remove_dir_all(&root);
make_fixture(&root);
let _out_dir = bake_at(&root);
let meta_path = root.join("Assets/UI/Foo.prefab.meta");
let asset_path = root.join("Assets/UI/Foo.prefab");
std::thread::sleep(std::time::Duration::from_millis(10));
let now = filetime::FileTime::now();
set_mtime(&meta_path, now);
set_mtime(&asset_path, now);
let _out_dir = bake_at(&root);
let c = store::read_cache(&cache_file(&root)).unwrap();
let foo = c
.entries
.iter()
.find(|e| &*e.hint == "Assets/UI/Foo.prefab")
.unwrap();
assert_eq!(foo.meta_mtime_ns, mtime_ns_of(&meta_path));
assert_eq!(foo.asset_mtime_ns, mtime_ns_of(&asset_path));
fs::remove_dir_all(&root).ok();
}
#[test]
fn cache_invalidates_on_meta_mtime_drift() {
let root = unique_tmp("cache-meta-drift");
let _ = fs::remove_dir_all(&root);
make_fixture(&root);
let _out_dir = bake_at(&root);
std::thread::sleep(std::time::Duration::from_millis(10));
let meta_path = root.join("Assets/UI/Foo.prefab.meta");
let now = filetime::FileTime::now();
set_mtime(&meta_path, now);
let _out_dir = bake_at(&root);
let c = store::read_cache(&cache_file(&root)).unwrap();
let foo = c
.entries
.iter()
.find(|e| &*e.hint == "Assets/UI/Foo.prefab")
.unwrap();
let new_meta_mtime = mtime_ns_of(&meta_path);
assert_eq!(
foo.meta_mtime_ns, new_meta_mtime,
"cache meta_mtime not bumped after meta touch"
);
fs::remove_dir_all(&root).ok();
}
#[test]
fn cache_drops_entry_when_asset_deleted() {
let root = unique_tmp("cache-asset-deleted");
let _ = fs::remove_dir_all(&root);
make_fixture(&root);
let _out_dir = bake_at(&root);
let first = store::read(&db_file(&root)).unwrap();
assert!(
first
.find_by_guid(0xaaaa1111aaaa1111aaaa1111aaaa1111_u128)
.is_some(),
"Foo prefab should exist before deletion",
);
fs::remove_file(root.join("Assets/UI/Foo.prefab")).unwrap();
fs::remove_file(root.join("Assets/UI/Foo.prefab.meta")).unwrap();
let _out_dir = bake_at(&root);
let second = store::read(&db_file(&root)).unwrap();
assert!(
second
.find_by_guid(0xaaaa1111aaaa1111aaaa1111aaaa1111_u128)
.is_none(),
"deleted Foo prefab still in db",
);
fs::remove_dir_all(&root).ok();
}
fn set_mtime(path: &Path, t: filetime::FileTime) {
filetime::set_file_mtime(path, t).unwrap();
}
fn mtime_ns_of(path: &Path) -> u64 {
let md = fs::metadata(path).unwrap();
let st = md
.modified()
.unwrap()
.duration_since(std::time::UNIX_EPOCH)
.unwrap();
st.as_nanos() as u64
}
#[test]
fn bake_recovers_from_corrupt_cache() {
let root = unique_tmp("corrupt-cache");
let _ = fs::remove_dir_all(&root);
make_fixture(&root);
let _out_dir = bake_at(&root);
let cache_path = cache_file(&root);
assert!(cache_path.exists());
fs::write(&cache_path, b"this is not a valid cache file").unwrap();
let _out_dir = bake_at(&root);
let db = store::read(&db_file(&root)).unwrap();
assert_eq!(
db.entries.len(),
3,
"bake recovered, but entry count drifted: {}",
db.entries.len(),
);
fs::remove_dir_all(&root).ok();
}
#[test]
fn empty_project_bakes_cleanly() {
let root = unique_tmp("empty-project");
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("Assets")).unwrap();
fs::create_dir_all(root.join("ProjectSettings")).unwrap();
write(
&root.join("ProjectSettings/ProjectVersion.txt"),
"m_EditorVersion: 2022.3.0f1\n",
);
let _out_dir = bake_at(&root);
let db = store::read(&db_file(&root)).unwrap();
assert_eq!(db.entries.len(), 0);
assert_eq!(db.script_types.len(), 0);
fs::remove_dir_all(&root).ok();
}
#[test]
fn deeply_nested_assets_are_indexed() {
let root = unique_tmp("deep-nesting");
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("ProjectSettings")).unwrap();
write(
&root.join("ProjectSettings/ProjectVersion.txt"),
"m_EditorVersion: 2022.3.0f1\n",
);
let mut deep = root.join("Assets");
for i in 0..12 {
deep = deep.join(format!("L{i}"));
}
write(
&deep.join("Deep.prefab"),
"--- !u!1001 &100100000\nPrefabInstance: {}\n",
);
write(
&deep.join("Deep.prefab.meta"),
"fileFormatVersion: 2\nguid: deadbeefdeadbeefdeadbeefdeadbeef\nPrefabImporter: {}\n",
);
let _out_dir = bake_at(&root);
let db = store::read(&db_file(&root)).unwrap();
assert!(
db.find_by_guid(0xdeadbeefdeadbeefdeadbeefdeadbeef_u128)
.is_some(),
"deeply-nested prefab not indexed",
);
fs::remove_dir_all(&root).ok();
}
#[test]
fn walker_does_not_honor_gitignore_inside_assets() {
let root = unique_tmp("inside-gitignore");
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("ProjectSettings")).unwrap();
write(
&root.join("ProjectSettings/ProjectVersion.txt"),
"m_EditorVersion: 2022.3.0f1\n",
);
let prefab_body = "--- !u!1001 &100100000\nPrefabInstance: {}\n";
let make_meta =
|guid: &str| format!("fileFormatVersion: 2\nguid: {guid}\nPrefabImporter: {{}}\n");
write(&root.join("Assets/Foo/.gitignore"), "Ignored.prefab\n");
write(&root.join("Assets/Foo/Ignored.prefab"), prefab_body);
write(
&root.join("Assets/Foo/Ignored.prefab.meta"),
&make_meta("aaaa1111aaaa1111aaaa1111aaaa1111"),
);
write(&root.join("Assets/Foo/Kept.prefab"), prefab_body);
write(
&root.join("Assets/Foo/Kept.prefab.meta"),
&make_meta("bbbb2222bbbb2222bbbb2222bbbb2222"),
);
let _out_dir = bake_at(&root);
let db = store::read(&db_file(&root)).unwrap();
let hints: Vec<&str> = db.entries.iter().map(|e| &*e.hint).collect();
assert!(
hints.contains(&"Assets/Foo/Ignored.prefab"),
"inside-Assets gitignore was honored (should not be); hints: {hints:?}",
);
assert!(
hints.contains(&"Assets/Foo/Kept.prefab"),
"Kept.prefab missing: {hints:?}",
);
fs::remove_dir_all(&root).ok();
}
#[test]
fn walker_ignores_library_temp_and_unity_hidden() {
let root = unique_tmp("walker-ignores");
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("ProjectSettings")).unwrap();
write(
&root.join("ProjectSettings/ProjectVersion.txt"),
"m_EditorVersion: 2022.3.0f1\n",
);
write(&root.join(".gitignore"), "/Library/\n/Temp/\n");
let make_meta = |guid: &str| {
format!("fileFormatVersion: 2\nguid: {guid}\nPrefabImporter: {{}}\n")
};
let make_prefab = "--- !u!1001 &100100000\nPrefabInstance: {}\n";
write(&root.join("Assets/Visible/Bar.prefab"), make_prefab);
write(
&root.join("Assets/Visible/Bar.prefab.meta"),
&make_meta("aaaa1111aaaa1111aaaa1111aaaa1111"),
);
write(&root.join("Library/Scratch/InLib.prefab"), make_prefab);
write(
&root.join("Library/Scratch/InLib.prefab.meta"),
&make_meta("bbbb2222bbbb2222bbbb2222bbbb2222"),
);
write(&root.join("Temp/Scratch/InTemp.prefab"), make_prefab);
write(
&root.join("Temp/Scratch/InTemp.prefab.meta"),
&make_meta("cccc3333cccc3333cccc3333cccc3333"),
);
write(&root.join("Assets/.Hidden/InHidden.prefab"), make_prefab);
write(
&root.join("Assets/.Hidden/InHidden.prefab.meta"),
&make_meta("dddd4444dddd4444dddd4444dddd4444"),
);
write(&root.join("Assets/Foo~/InTilde.prefab"), make_prefab);
write(
&root.join("Assets/Foo~/InTilde.prefab.meta"),
&make_meta("eeee5555eeee5555eeee5555eeee5555"),
);
let _out_dir = bake_at(&root);
let db = store::read(&db_file(&root)).unwrap();
let hints: Vec<&str> = db.entries.iter().map(|e| &*e.hint).collect();
assert!(
hints.contains(&"Assets/Visible/Bar.prefab"),
"real asset missing: {hints:?}",
);
for ignored in [
"Library/Scratch/InLib.prefab",
"Temp/Scratch/InTemp.prefab",
"Assets/.Hidden/InHidden.prefab",
"Assets/Foo~/InTilde.prefab",
] {
assert!(
!hints.contains(&ignored),
"expected to be ignored, found: {ignored}",
);
}
assert_eq!(db.entries.len(), 1, "only the visible asset should bake");
fs::remove_dir_all(&root).ok();
}
#[test]
fn cache_file_lives_alongside_bin() {
let root = unique_tmp("cache-file");
let _ = fs::remove_dir_all(&root);
make_fixture(&root);
let _out_dir = bake_at(&root);
let cache = cache_file(&root);
assert!(
cache.exists(),
"cache file not created at {}",
cache.display()
);
let c = store::read_cache(&cache).unwrap();
assert_eq!(c.entries.len(), 3);
let foo = c
.entries
.iter()
.find(|e| &*e.hint == "Assets/UI/Foo.prefab")
.unwrap();
assert!(foo.meta_mtime_ns > 0);
assert!(foo.asset_mtime_ns > 0);
fs::remove_dir_all(&root).ok();
}