concinnity_cook/pipeline/
validate.rs1use std::path::Path;
5
6use crate::asset_api::{self, AssetRequest};
7use crate::ecs::asset_id;
8
9use super::errors_to_io;
10
11pub fn validate_asset(
22 asset_type: &str,
23 name: &str,
24 args: &serde_json::Value,
25) -> Result<(), String> {
26 asset_id::reset_interner();
32 crate::resource_handles::reset_resource_handles();
33 let type_norm = asset_type.to_lowercase().replace('_', "");
34
35 if matches!(
38 type_norm.as_str(),
39 "environment"
40 | "lightrig"
41 | "materialpalette"
42 | "camerashot"
43 | "prefab"
44 | "sceneimport"
45 | "characterschema"
46 | "charactermodel"
47 ) {
48 return Ok(());
49 }
50
51 if crate::registry::RegisteredType::parse(asset_type).is_some_and(|t| t.is_resource()) {
54 crate::check::check_asset(&type_norm, name, args)?;
55 return Ok(());
56 }
57
58 let req = AssetRequest {
59 asset_type: asset_type.to_string(),
60 args: Some(args.clone()),
61 };
62 asset_api::create_asset_def(&req).map_err(|e| format!("Asset '{}': {}", name, e))?;
63
64 crate::check::check_asset(&type_norm, name, args)?;
65
66 Ok(())
67}
68
69pub fn validate_world_jsonl(content: &str, assets_dir: Option<&Path>) -> std::io::Result<()> {
77 let loaded = crate::build_only::prepare_world(content, assets_dir).map_err(errors_to_io)?;
78
79 let mut errors: Vec<String> = Vec::new();
80 for asset in &loaded.assets {
81 if crate::registry::RegisteredType::parse(&asset.asset_type)
84 .is_some_and(|t| t.is_resource())
85 {
86 continue;
87 }
88 let req = AssetRequest {
89 asset_type: asset.asset_type.clone(),
90 args: Some(asset.args.clone()),
91 };
92 if let Err(e) = asset_api::create_asset_def(&req) {
93 errors.push(format!("Asset '{}': {}", asset.name, e));
94 }
95 }
96
97 if errors.is_empty() {
98 Ok(())
99 } else {
100 Err(errors_to_io(errors))
101 }
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107
108 #[test]
112 fn visual_novel_world_validates() {
113 let world = r#"{"name":"gfx","type":"GraphicsConfig","args":{}}
119{"name":"f","type":"Font","args":{"size_px":20}}
120{"name":"title_menu","type":"Screen","args":{"initial":true}}
121{"name":"title_menu_bg","type":"Sprite","args":{"x":0,"y":0,"width":640,"height":360,"tint":[0.1,0.1,0.1,1]}}
122{"name":"title_menu_lbl","type":"TextLabel","args":{"font":"f","content":"Start","x":260,"y":160}}
123{"name":"title_menu_btn","type":"HitRegion","args":{"x":260,"y":156,"width":120,"height":40,"label":"title_menu_lbl","action":"screen:show:vn_page_1"}}
124{"name":"vn_page_1","type":"Screen","args":{}}
125{"name":"vn_page_1_text","type":"TextLabel","args":{"font":"f","content":"hello","x":40,"y":40}}
126{"name":"vn_page_1_next","type":"HitRegion","args":{"x":0,"y":0,"width":640,"height":360,"action":"screen:show:title_menu"}}
127{"name":"pause_menu","type":"Screen","args":{}}
128{"name":"pause_menu_dim","type":"Sprite","args":{"x":0,"y":0,"width":640,"height":360,"tint":[0,0,0,0.6]}}
129{"name":"esc","type":"KeyBinding","args":{"key":"Escape","action":"screen:toggle:pause_menu"}}
130"#;
131 validate_world_jsonl(world, None).expect("visual_novel-shaped world should validate");
132 }
133
134 #[test]
135 fn validate_asset_accepts_build_time_expansion_types() {
136 for ty in [
139 "SceneImport",
140 "Environment",
141 "LightRig",
142 "Prefab",
143 "CharacterSchema",
144 "CharacterModel",
145 ] {
146 validate_asset(ty, "x", &serde_json::json!({}))
147 .unwrap_or_else(|e| panic!("{ty} should validate: {e}"));
148 }
149 }
150
151 #[test]
154 fn validate_asset_routes_resource_only_types_past_the_component_registry() {
155 validate_asset("AudioClip", "clip", &serde_json::json!({"source": "a.wav"}))
156 .expect("a source-backed AudioClip validates");
157 let err = validate_asset("Texture", "tex", &serde_json::json!({"generator": "nope"}))
158 .expect_err("an unknown texture generator is rejected");
159 assert!(err.contains("nope"), "got: {err}");
160 }
161
162 #[test]
165 fn validate_asset_runs_the_structural_check_after_type_resolution() {
166 validate_asset("Scene", "day", &serde_json::json!({})).expect("a Scene validates");
167 let err = validate_asset("Prop", "empty_prop", &serde_json::json!({}))
169 .expect_err("a source-less Prop is rejected");
170 assert!(err.contains("empty_prop"), "got: {err}");
171 }
172
173 #[test]
174 fn validate_asset_unknown_type_mentions_the_asset_name() {
175 let err =
176 validate_asset("Bogus", "my_thing", &serde_json::json!({})).expect_err("unknown type");
177 assert!(err.contains("my_thing"), "got: {err}");
178 }
179
180 #[test]
181 fn validate_asset_bad_args_mention_the_asset_name() {
182 let err = validate_asset(
184 "ProceduralMesh",
185 "bad_mesh",
186 &serde_json::json!({"generator": 5}),
187 )
188 .expect_err("bad args");
189 assert!(err.contains("bad_mesh"), "got: {err}");
190 }
191
192 #[test]
195 fn validate_world_jsonl_collects_every_resolution_failure() {
196 let world = concat!(
197 r#"{"name":"first","type":"ProceduralMesh","args":{"generator":"box"}}"#,
198 "\n",
199 r#"{"name":"clip","type":"AudioClip","args":{"source":"a.wav"}}"#,
200 "\n",
201 );
202 validate_world_jsonl(world, None).expect("a resolvable world validates");
203
204 let bad = concat!(
207 r#"{"name":"t1","type":"PointLight","args":{"intensity":"soon"}}"#,
208 "\n",
209 r#"{"name":"t2","type":"PointLight","args":{"intensity":"later"}}"#,
210 "\n",
211 );
212 let err = validate_world_jsonl(bad, None).expect_err("mistyped args do not resolve");
213 let msg = err.to_string();
214 assert!(msg.contains("Asset 't1'"), "got: {msg}");
215 assert!(msg.contains("Asset 't2'"), "got: {msg}");
216 }
217}