concinnity_cook/pipeline/
validate.rs1use std::path::Path;
5
6use concinnity_core::platform::Platform;
7
8use crate::asset_api::{self, AssetRequest};
9use crate::ecs::asset_id;
10
11use super::errors_to_io;
12
13pub fn validate_asset(
27 asset_type: &str,
28 name: &str,
29 args: &serde_json::Value,
30 platform: Platform,
31) -> Result<(), String> {
32 asset_id::reset_interner();
38 crate::resource_handles::reset_resource_handles();
39 let type_norm = asset_type.to_lowercase().replace('_', "");
40
41 if matches!(
44 type_norm.as_str(),
45 "environment"
46 | "lightrig"
47 | "materialpalette"
48 | "camerashot"
49 | "prefab"
50 | "sceneimport"
51 | "characterschema"
52 | "charactermodel"
53 ) {
54 return Ok(());
55 }
56
57 if crate::registry::RegisteredType::parse(asset_type).is_some_and(|t| t.is_resource()) {
60 crate::check::check_asset(&type_norm, name, args, platform)?;
61 return Ok(());
62 }
63
64 let req = AssetRequest {
65 asset_type: asset_type.to_string(),
66 args: Some(args.clone()),
67 };
68 asset_api::create_asset_def(&req, platform).map_err(|e| format!("Asset '{}': {}", name, e))?;
69
70 crate::check::check_asset(&type_norm, name, args, platform)?;
71
72 Ok(())
73}
74
75pub fn validate_world_jsonl(
83 content: &str,
84 assets_dir: Option<&Path>,
85 platform: Platform,
86) -> std::io::Result<()> {
87 let loaded =
88 crate::build_only::prepare_world(content, assets_dir, platform).map_err(errors_to_io)?;
89
90 let mut errors: Vec<String> = Vec::new();
91 for asset in &loaded.assets {
92 if crate::registry::RegisteredType::parse(&asset.asset_type)
95 .is_some_and(|t| t.is_resource())
96 {
97 continue;
98 }
99 let req = AssetRequest {
100 asset_type: asset.asset_type.clone(),
101 args: Some(asset.args.clone()),
102 };
103 if let Err(e) = asset_api::create_asset_def(&req, platform) {
104 errors.push(format!("Asset '{}': {}", asset.name, e));
105 }
106 }
107
108 if errors.is_empty() {
109 Ok(())
110 } else {
111 Err(errors_to_io(errors))
112 }
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118
119 #[test]
123 fn visual_novel_world_validates() {
124 let world = r#"{"name":"gfx","type":"GraphicsConfig","args":{}}
130{"name":"f","type":"Font","args":{"size_px":20}}
131{"name":"title_menu","type":"Screen","args":{"initial":true}}
132{"name":"title_menu_bg","type":"Sprite","args":{"x":0,"y":0,"width":640,"height":360,"tint":[0.1,0.1,0.1,1]}}
133{"name":"title_menu_lbl","type":"TextLabel","args":{"font":"f","content":"Start","x":260,"y":160}}
134{"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"}}
135{"name":"vn_page_1","type":"Screen","args":{}}
136{"name":"vn_page_1_text","type":"TextLabel","args":{"font":"f","content":"hello","x":40,"y":40}}
137{"name":"vn_page_1_next","type":"HitRegion","args":{"x":0,"y":0,"width":640,"height":360,"action":"screen:show:title_menu"}}
138{"name":"pause_menu","type":"Screen","args":{}}
139{"name":"pause_menu_dim","type":"Sprite","args":{"x":0,"y":0,"width":640,"height":360,"tint":[0,0,0,0.6]}}
140{"name":"esc","type":"KeyBinding","args":{"key":"Escape","action":"screen:toggle:pause_menu"}}
141"#;
142 validate_world_jsonl(world, None, Platform::Metal)
143 .expect("visual_novel-shaped world should validate");
144 }
145
146 #[test]
147 fn validate_asset_accepts_build_time_expansion_types() {
148 for ty in [
151 "SceneImport",
152 "Environment",
153 "LightRig",
154 "Prefab",
155 "CharacterSchema",
156 "CharacterModel",
157 ] {
158 validate_asset(ty, "x", &serde_json::json!({}), Platform::Metal)
159 .unwrap_or_else(|e| panic!("{ty} should validate: {e}"));
160 }
161 }
162
163 #[test]
166 fn validate_asset_routes_resource_only_types_past_the_component_registry() {
167 validate_asset(
168 "AudioClip",
169 "clip",
170 &serde_json::json!({"source": "a.wav"}),
171 Platform::Metal,
172 )
173 .expect("a source-backed AudioClip validates");
174 let err = validate_asset(
175 "Texture",
176 "tex",
177 &serde_json::json!({"generator": "nope"}),
178 Platform::Metal,
179 )
180 .expect_err("an unknown texture generator is rejected");
181 assert!(err.contains("nope"), "got: {err}");
182 }
183
184 #[test]
187 fn validate_asset_runs_the_structural_check_after_type_resolution() {
188 validate_asset("Scene", "day", &serde_json::json!({}), Platform::Metal)
189 .expect("a Scene validates");
190 let err = validate_asset(
192 "Prop",
193 "empty_prop",
194 &serde_json::json!({}),
195 Platform::Metal,
196 )
197 .expect_err("a source-less Prop is rejected");
198 assert!(err.contains("empty_prop"), "got: {err}");
199 }
200
201 #[test]
202 fn validate_asset_unknown_type_mentions_the_asset_name() {
203 let err = validate_asset("Bogus", "my_thing", &serde_json::json!({}), Platform::Metal)
204 .expect_err("unknown type");
205 assert!(err.contains("my_thing"), "got: {err}");
206 }
207
208 #[test]
209 fn validate_asset_bad_args_mention_the_asset_name() {
210 let err = validate_asset(
212 "ProceduralMesh",
213 "bad_mesh",
214 &serde_json::json!({"generator": 5}),
215 Platform::Metal,
216 )
217 .expect_err("bad args");
218 assert!(err.contains("bad_mesh"), "got: {err}");
219 }
220
221 #[test]
224 fn validate_world_jsonl_collects_every_resolution_failure() {
225 let world = concat!(
226 r#"{"name":"first","type":"ProceduralMesh","args":{"generator":"box"}}"#,
227 "\n",
228 r#"{"name":"clip","type":"AudioClip","args":{"source":"a.wav"}}"#,
229 "\n",
230 );
231 validate_world_jsonl(world, None, Platform::Metal).expect("a resolvable world validates");
232
233 let bad = concat!(
236 r#"{"name":"t1","type":"PointLight","args":{"intensity":"soon"}}"#,
237 "\n",
238 r#"{"name":"t2","type":"PointLight","args":{"intensity":"later"}}"#,
239 "\n",
240 );
241 let err = validate_world_jsonl(bad, None, Platform::Metal)
242 .expect_err("mistyped args do not resolve");
243 let msg = err.to_string();
244 assert!(msg.contains("Asset 't1'"), "got: {msg}");
245 assert!(msg.contains("Asset 't2'"), "got: {msg}");
246 }
247}