1use crate::asset::dependencies::{extract_dependencies, type_has_dependency_extractor};
42use crate::asset::id_registry::AssetIdRegistry;
43use anyhow::{anyhow, Context, Result};
44use khora_core::asset::{AssetMetadata, AssetSource, AssetUUID};
45use std::{
46 collections::HashMap,
47 path::{Path, PathBuf},
48};
49
50pub fn asset_type_for_extension(ext: &str) -> Option<String> {
67 let lower = ext.to_ascii_lowercase();
68 let canonical: Option<&'static str> = match lower.as_str() {
69 "gltf" | "glb" | "obj" | "fbx" => Some("mesh"),
71 "png" | "jpg" | "jpeg" | "tga" | "bmp" | "hdr" => Some("texture"),
73 "wav" | "ogg" | "mp3" | "flac" => Some("audio"),
75 "wgsl" | "hlsl" | "glsl" => Some("shader"),
77 "ttf" | "otf" => Some("font"),
79 "kscene" | "scene" => Some("scene"),
81 "kmat" | "mat" => Some("material"),
83 "kscript" => Some("script"),
85 "kprefab" => Some("prefab"),
87 _ => None,
88 };
89 Some(canonical.map(|s| s.to_string()).unwrap_or(lower))
90}
91
92pub const EXTENSIONLESS_ASSET_TYPE: &str = "blob";
97
98const SCAN_IGNORE_PREFIXES: &[&str] = &[".", "~"];
101
102const SCAN_IGNORE_SUFFIXES: &[&str] = &[".tmp", ".swp", ".bak", "~"];
105
106pub fn should_skip_file(name: &str) -> bool {
111 if name.is_empty() {
112 return true;
113 }
114 if SCAN_IGNORE_PREFIXES
115 .iter()
116 .any(|prefix| name.starts_with(prefix))
117 {
118 return true;
119 }
120 if SCAN_IGNORE_SUFFIXES
121 .iter()
122 .any(|suffix| name.ends_with(suffix))
123 {
124 return true;
125 }
126 false
127}
128
129pub struct IndexBuilder<'a> {
134 assets_root: &'a Path,
135 registry: Option<&'a AssetIdRegistry>,
136}
137
138impl<'a> IndexBuilder<'a> {
139 pub fn new(assets_root: &'a Path) -> Self {
148 Self {
149 assets_root,
150 registry: None,
151 }
152 }
153
154 pub fn with_registry(mut self, registry: &'a AssetIdRegistry) -> Self {
159 self.registry = Some(registry);
160 self
161 }
162
163 pub fn build_metadata(&self) -> Result<Vec<AssetMetadata>> {
170 if !self.assets_root.exists() {
171 return Ok(Vec::new());
172 }
173
174 let mut entries: Vec<(String, PathBuf, PathBuf, String)> = Vec::new();
178
179 for entry in walkdir::WalkDir::new(self.assets_root)
180 .follow_links(false)
181 .into_iter()
182 .filter_map(|e| e.ok())
183 {
184 if !entry.file_type().is_file() {
185 continue;
186 }
187 let abs = entry.path();
188 let rel = match abs.strip_prefix(self.assets_root) {
189 Ok(r) => r.to_path_buf(),
190 Err(_) => continue,
191 };
192 let file_name = abs.file_name().and_then(|n| n.to_str()).unwrap_or("");
193 if should_skip_file(file_name) {
194 continue;
195 }
196 let type_name = match rel.extension().and_then(|e| e.to_str()) {
197 Some(ext) => asset_type_for_extension(ext)
198 .unwrap_or_else(|| EXTENSIONLESS_ASSET_TYPE.to_string()),
199 None => EXTENSIONLESS_ASSET_TYPE.to_string(),
200 };
201 let rel_fwd = rel_to_forward_slash(&rel);
202 entries.push((rel_fwd, rel, abs.to_path_buf(), type_name));
203 }
204
205 entries.sort_by(|a, b| a.0.cmp(&b.0));
209
210 let mut metadata = Vec::with_capacity(entries.len());
211 for (rel_fwd, rel_path, abs_path, type_name) in entries {
212 let uuid = match self.registry {
214 Some(reg) => reg.resolve(&rel_fwd),
215 None => AssetUUID::new_v5(&rel_fwd),
216 };
217 let dependencies = if type_has_dependency_extractor(&type_name) {
221 match std::fs::read(&abs_path) {
222 Ok(bytes) => extract_dependencies(&type_name, &bytes),
223 Err(e) => {
224 log::warn!(
225 "asset index: failed to read '{}' for dependency extraction: {e}",
226 rel_fwd
227 );
228 Vec::new()
229 }
230 }
231 } else {
232 Vec::new()
233 };
234 let mut variants = HashMap::with_capacity(1);
235 variants.insert("default".to_string(), AssetSource::Path(rel_path.clone()));
236 metadata.push(AssetMetadata {
237 uuid,
238 source_path: rel_path,
239 asset_type_name: type_name,
240 dependencies,
241 variants,
242 tags: Vec::new(),
243 });
244 }
245 Ok(metadata)
246 }
247
248 pub fn build_index_bytes(&self) -> Result<Vec<u8>> {
251 let metadata = self.build_metadata()?;
252 let cfg = bincode::config::standard();
253 bincode::serde::encode_to_vec(&metadata, cfg)
254 .map_err(|e| anyhow!("Failed to encode asset index: {}", e))
255 .context("IndexBuilder::build_index_bytes")
256 }
257}
258
259fn rel_to_forward_slash(rel: &Path) -> String {
263 rel.components()
264 .map(|c| c.as_os_str().to_string_lossy().into_owned())
265 .collect::<Vec<_>>()
266 .join("/")
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272 use crate::vfs::VirtualFileSystem;
273 use std::fs;
274 use tempfile::tempdir;
275
276 #[test]
277 fn ext_canonical_mapping() {
278 assert_eq!(asset_type_for_extension("png").as_deref(), Some("texture"));
279 assert_eq!(asset_type_for_extension("PNG").as_deref(), Some("texture"));
280 assert_eq!(asset_type_for_extension("gltf").as_deref(), Some("mesh"));
281 assert_eq!(asset_type_for_extension("kscene").as_deref(), Some("scene"));
282 assert_eq!(
283 asset_type_for_extension("kscript").as_deref(),
284 Some("script")
285 );
286 assert_eq!(asset_type_for_extension("xyz").as_deref(), Some("xyz"));
289 assert_eq!(asset_type_for_extension("MD").as_deref(), Some("md"));
290 }
291
292 #[test]
293 fn index_builder_honours_frozen_registry_uuid() {
294 let dir = tempdir().unwrap();
295 let root = dir.path();
296 fs::create_dir_all(root.join("meshes")).unwrap();
297 fs::write(root.join("meshes").join("hero.gltf"), b"GLTF").unwrap();
298
299 let default_uuid = AssetUUID::new_v5("meshes/hero.gltf");
301 let md = IndexBuilder::new(root).build_metadata().unwrap();
302 assert_eq!(md[0].uuid, default_uuid);
303
304 let mut reg = AssetIdRegistry::load(root);
307 let frozen = AssetUUID::new_v5("meshes/protagonist.gltf");
308 reg.freeze("meshes/hero.gltf", frozen);
309
310 let md2 = IndexBuilder::new(root)
311 .with_registry(®)
312 .build_metadata()
313 .unwrap();
314 assert_eq!(
315 md2[0].uuid, frozen,
316 "IndexBuilder must resolve the frozen registry UUID, not new_v5(path)"
317 );
318 assert_ne!(md2[0].uuid, default_uuid);
319 }
320
321 #[test]
322 fn build_metadata_tracks_every_file_under_assets() {
323 let dir = tempdir().unwrap();
324 let root = dir.path();
325 fs::create_dir_all(root.join("textures")).unwrap();
326 fs::create_dir_all(root.join("scenes")).unwrap();
327 fs::create_dir_all(root.join("docs")).unwrap();
328 fs::write(root.join("textures").join("foo.png"), b"PNG").unwrap();
329 fs::write(root.join("scenes").join("default.kscene"), b"SCN").unwrap();
330 fs::write(root.join("docs").join("README.md"), b"hello").unwrap();
331 fs::write(root.join("notes"), b"raw").unwrap();
332 fs::write(root.join(".DS_Store"), b"junk").unwrap();
334 fs::write(root.join("textures").join("foo.png.tmp"), b"junk").unwrap();
335 fs::write(root.join("textures").join("foo.png~"), b"junk").unwrap();
336
337 let metadata = IndexBuilder::new(root).build_metadata().unwrap();
338 let by_path: std::collections::HashMap<String, String> = metadata
339 .iter()
340 .map(|m| {
341 (
342 rel_to_forward_slash(&m.source_path),
343 m.asset_type_name.clone(),
344 )
345 })
346 .collect();
347
348 assert_eq!(
349 by_path.get("textures/foo.png").map(String::as_str),
350 Some("texture")
351 );
352 assert_eq!(
353 by_path.get("scenes/default.kscene").map(String::as_str),
354 Some("scene")
355 );
356 assert_eq!(
358 by_path.get("docs/README.md").map(String::as_str),
359 Some("md")
360 );
361 assert_eq!(by_path.get("notes").map(String::as_str), Some("blob"));
363 assert!(!by_path.contains_key(".DS_Store"));
365 assert!(!by_path.contains_key("textures/foo.png.tmp"));
366 assert!(!by_path.contains_key("textures/foo.png~"));
367 assert_eq!(metadata.len(), 4);
368 }
369
370 #[test]
371 fn build_metadata_is_deterministic() {
372 let dir = tempdir().unwrap();
373 let root = dir.path();
374 fs::create_dir_all(root.join("textures")).unwrap();
375 fs::create_dir_all(root.join("audio")).unwrap();
376 fs::write(root.join("textures").join("a.png"), b"a").unwrap();
377 fs::write(root.join("textures").join("b.png"), b"b").unwrap();
378 fs::write(root.join("audio").join("c.wav"), b"c").unwrap();
379
380 let bytes_a = IndexBuilder::new(root).build_index_bytes().unwrap();
381 let bytes_b = IndexBuilder::new(root).build_index_bytes().unwrap();
382 assert_eq!(
383 bytes_a, bytes_b,
384 "two consecutive builds must be byte-equal"
385 );
386 }
387
388 #[test]
389 fn build_index_bytes_round_trip_through_vfs() {
390 let dir = tempdir().unwrap();
391 let root = dir.path();
392 fs::create_dir_all(root.join("textures")).unwrap();
393 fs::write(root.join("textures").join("foo.png"), b"PNG").unwrap();
394
395 let bytes = IndexBuilder::new(root).build_index_bytes().unwrap();
396 let vfs = VirtualFileSystem::new(&bytes).expect("VFS must decode our index");
397 assert_eq!(vfs.asset_count(), 1);
398 let uuid = AssetUUID::new_v5("textures/foo.png");
399 let meta = vfs.get_metadata(&uuid).expect("VFS must surface the asset");
400 assert_eq!(meta.asset_type_name, "texture");
401 }
402
403 fn write_kmat(path: &Path, material: &khora_core::asset::StandardMaterial) {
406 let json =
407 khora_data::ecs::material_to_json(material).expect("material serializes to JSON");
408 let ron = ron::ser::to_string(&json).expect("material JSON encodes to RON");
409 fs::write(path, ron).unwrap();
410 }
411
412 #[test]
413 fn material_metadata_lists_its_textures_sorted_and_deduped() {
414 let dir = tempdir().unwrap();
415 let root = dir.path();
416 fs::create_dir_all(root.join("textures")).unwrap();
417 fs::create_dir_all(root.join("materials")).unwrap();
418 fs::write(root.join("textures").join("base.png"), b"PNG").unwrap();
419 fs::write(root.join("textures").join("normal.png"), b"PNG").unwrap();
420
421 let base_uuid = AssetUUID::new_v5("textures/base.png");
425 let normal_uuid = AssetUUID::new_v5("textures/normal.png");
426 let material = khora_core::asset::StandardMaterial {
427 base_color_texture: Some(base_uuid),
428 metallic_roughness_texture: Some(base_uuid),
430 normal_map: Some(normal_uuid),
431 ..Default::default()
432 };
433 write_kmat(&root.join("materials").join("wood.kmat"), &material);
434
435 let metadata = IndexBuilder::new(root).build_metadata().unwrap();
436 let mat_meta = metadata
437 .iter()
438 .find(|m| m.asset_type_name == "material")
439 .expect("the material must be indexed");
440
441 let mut expected = vec![base_uuid, normal_uuid];
442 expected.sort();
443 assert_eq!(
444 mat_meta.dependencies, expected,
445 "material deps must be both textures, sorted and deduped"
446 );
447
448 for tex in metadata.iter().filter(|m| m.asset_type_name == "texture") {
451 assert!(
452 tex.dependencies.is_empty(),
453 "texture '{}' must have no dependencies",
454 rel_to_forward_slash(&tex.source_path)
455 );
456 }
457 }
458
459 #[test]
460 fn build_index_bytes_is_byte_equal_with_material_and_textures() {
461 let dir = tempdir().unwrap();
462 let root = dir.path();
463 fs::create_dir_all(root.join("textures")).unwrap();
464 fs::create_dir_all(root.join("materials")).unwrap();
465 fs::write(root.join("textures").join("a.png"), b"A").unwrap();
466 fs::write(root.join("textures").join("b.png"), b"B").unwrap();
467
468 let material = khora_core::asset::StandardMaterial {
469 base_color_texture: Some(AssetUUID::new_v5("textures/a.png")),
470 normal_map: Some(AssetUUID::new_v5("textures/b.png")),
471 ..Default::default()
472 };
473 write_kmat(&root.join("materials").join("m.kmat"), &material);
474
475 let bytes_a = IndexBuilder::new(root).build_index_bytes().unwrap();
476 let bytes_b = IndexBuilder::new(root).build_index_bytes().unwrap();
477 assert_eq!(
478 bytes_a, bytes_b,
479 "an index over a material + textures must be byte-deterministic"
480 );
481 }
482
483 #[test]
484 fn corrupt_material_still_builds_with_empty_deps() {
485 let dir = tempdir().unwrap();
486 let root = dir.path();
487 fs::create_dir_all(root.join("materials")).unwrap();
488 fs::write(
489 root.join("materials").join("broken.kmat"),
490 b"this is not valid kmat RON",
491 )
492 .unwrap();
493
494 let metadata = IndexBuilder::new(root)
495 .build_metadata()
496 .expect("a corrupt material must not abort the index build");
497 let mat_meta = metadata
498 .iter()
499 .find(|m| m.asset_type_name == "material")
500 .expect("the corrupt material is still indexed");
501 assert!(
502 mat_meta.dependencies.is_empty(),
503 "a corrupt material yields empty deps, not a panic"
504 );
505 }
506
507 #[test]
508 fn build_metadata_empty_root_returns_empty_vec() {
509 let dir = tempdir().unwrap();
510 let metadata = IndexBuilder::new(dir.path()).build_metadata().unwrap();
511 assert!(metadata.is_empty());
512 }
513
514 #[test]
515 fn build_metadata_missing_root_returns_empty_vec() {
516 let metadata = IndexBuilder::new(Path::new("/__nonexistent__"))
517 .build_metadata()
518 .unwrap();
519 assert!(metadata.is_empty());
520 }
521}