mod find;
mod io;
pub use find::{WORLD_JSONL, find_world_jsonl};
pub use io::{
WorldJsonlAsset, known_names, parse_world_jsonl, patch_world_jsonl, patch_world_jsonl_to,
write_world_jsonl,
};
use crate::ecs::AssetOrigin;
use crate::registry::RegisteredType;
pub fn asset_name_from_path(path: &str) -> String {
std::path::Path::new(path)
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.replace('.', "_"))
.unwrap_or_else(|| path.to_string())
}
pub fn resolve_includes(assets: Vec<serde_json::Value>) -> std::io::Result<Vec<serde_json::Value>> {
let mut out = Vec::with_capacity(assets.len());
for entry in assets {
if let Some(path_val) = entry.get("$include") {
let path = path_val.as_str().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
"$include value must be a string path",
)
})?;
let content = std::fs::read_to_string(path).map_err(|e| {
std::io::Error::new(e.kind(), format!("$include '{}': {}", path, e))
})?;
let parsed: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("$include '{}': {}", path, e),
)
})?;
match parsed {
serde_json::Value::Array(items) => out.extend(items),
obj @ serde_json::Value::Object(_) => out.push(obj),
other => {
let kind = match &other {
serde_json::Value::Null => "null",
serde_json::Value::Bool(_) => "bool",
serde_json::Value::Number(_) => "number",
serde_json::Value::String(_) => "string",
_ => "unknown",
};
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"$include '{}': expected object or array, got {}",
path, kind
),
));
}
}
} else {
out.push(entry);
}
}
Ok(out)
}
pub fn load_world(content: &str) -> Result<Vec<serde_json::Value>, Vec<String>> {
let parsed = parse_world_jsonl(content).map_err(|e| vec![format!("syntax error: {e}")])?;
let raw = resolve_includes(parsed).map_err(|e| vec![e.to_string()])?;
let mut errors: Vec<String> = Vec::new();
let mut seen_names: std::collections::HashMap<&str, usize> = Default::default();
for (i, value) in raw.iter().enumerate() {
let name = value.get("name").and_then(|v| v.as_str());
let type_str = value.get("type").and_then(|v| v.as_str());
let label = name
.map(|n| format!("'{}'", n))
.unwrap_or_else(|| format!("asset[{}]", i));
if name.is_none() {
errors.push(format!("{}: missing `name` field", label));
}
let Some(type_str) = type_str else {
errors.push(format!("{}: missing `type` field", label));
continue;
};
let origin = if let Some(ct) = RegisteredType::parse(type_str) {
Some(ct.registration().origin)
} else {
errors.push(format!("{}: unknown type '{}'", label, type_str));
None
};
if matches!(origin, Some(AssetOrigin::RuntimeOnly)) {
errors.push(format!(
"{}: '{}' is RuntimeOnly: it is pushed by a system at runtime \
and cannot be declared in {}",
label, type_str, WORLD_JSONL
));
}
if let Some(n) = name {
let count = seen_names.entry(n).or_insert(0);
*count += 1;
if *count == 2 {
errors.push(format!(
"duplicate name '{}': asset names must be unique",
n
));
}
}
}
if errors.is_empty() {
Ok(raw)
} else {
Err(errors)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn load_world_accepts_valid_world() {
let content = r#"{"name":"a","type":"Window"}
{"name":"b","type":"Window"}
"#;
let raw = load_world(content).unwrap();
assert_eq!(raw.len(), 2);
}
#[test]
fn load_world_collects_all_errors() {
let content = r#"{"name":"a"}
{"type":"Window"}
"#;
let errs = load_world(content).unwrap_err();
assert!(errs.iter().any(|e| e.contains("missing `type`")));
assert!(errs.iter().any(|e| e.contains("missing `name`")));
}
#[test]
fn load_world_rejects_duplicate_names() {
let content = r#"{"name":"a","type":"Window"}
{"name":"a","type":"Window"}
"#;
let errs = load_world(content).unwrap_err();
assert!(errs.iter().any(|e| e.contains("duplicate name")));
}
#[test]
fn load_world_rejects_runtime_only_type() {
let content = r#"{"name":"t","type":"Transform"}"#;
let errs = load_world(content).unwrap_err();
assert!(errs.iter().any(|e| e.contains("RuntimeOnly")));
}
#[test]
fn load_world_rejects_unknown_type() {
let content = r#"{"name":"x","type":"NotARealType"}"#;
let errs = load_world(content).unwrap_err();
assert!(errs.iter().any(|e| e.contains("unknown type")));
}
#[test]
fn asset_name_from_path_replaces_dots_in_stem() {
assert_eq!(asset_name_from_path("/a/b/hero.model.glb"), "hero_model");
assert_eq!(asset_name_from_path("hero.png"), "hero");
}
#[test]
fn asset_name_from_path_falls_back_to_the_raw_path() {
assert_eq!(asset_name_from_path(".."), "..");
}
fn write_temp(dir: &tempfile::TempDir, name: &str, contents: &str) -> String {
let path = dir.path().join(name);
std::fs::write(&path, contents).unwrap();
path.to_str().unwrap().to_string()
}
#[test]
fn resolve_includes_passes_through_non_include_entries() {
let entries = vec![serde_json::json!({"name": "a", "type": "Window"})];
let out = resolve_includes(entries).unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0]["name"], "a");
}
#[test]
fn resolve_includes_inlines_an_array_file() {
let dir = tempfile::tempdir().unwrap();
let path = write_temp(
&dir,
"chunk.json",
r#"[{"name":"a","type":"Window"},{"name":"b","type":"Window"}]"#,
);
let entries = vec![
serde_json::json!({"$include": path}),
serde_json::json!({"name": "c", "type": "Window"}),
];
let out = resolve_includes(entries).unwrap();
let names: Vec<&str> = out.iter().filter_map(|v| v["name"].as_str()).collect();
assert_eq!(names, ["a", "b", "c"]);
}
#[test]
fn resolve_includes_inlines_a_single_object_file() {
let dir = tempfile::tempdir().unwrap();
let path = write_temp(&dir, "one.json", r#"{"name":"solo","type":"Window"}"#);
let out = resolve_includes(vec![serde_json::json!({"$include": path})]).unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0]["name"], "solo");
}
#[test]
fn resolve_includes_rejects_non_string_path() {
let err =
resolve_includes(vec![serde_json::json!({"$include": 42})]).expect_err("non-string");
assert!(err.to_string().contains("must be a string path"));
}
#[test]
fn resolve_includes_reports_a_read_error_with_the_path() {
let err = resolve_includes(vec![
serde_json::json!({"$include": "/no/such/include.json"}),
])
.expect_err("missing file");
assert!(err.to_string().contains("/no/such/include.json"));
}
#[test]
fn resolve_includes_reports_bad_json() {
let dir = tempfile::tempdir().unwrap();
let path = write_temp(&dir, "broken.json", "{ not valid json");
let err =
resolve_includes(vec![serde_json::json!({"$include": path})]).expect_err("bad json");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
}
#[test]
fn resolve_includes_rejects_a_non_object_non_array_file() {
let dir = tempfile::tempdir().unwrap();
let path = write_temp(&dir, "scalar.json", "42");
let err = resolve_includes(vec![serde_json::json!({"$include": path})])
.expect_err("kind mismatch");
assert!(err.to_string().contains("expected object or array"));
assert!(err.to_string().contains("number"));
}
}