Skip to main content

concinnity_cook/pipeline/
validate.rs

1//! The compile-free half of the pipeline: resolve every asset's type and args
2//! and run the structural checks, without producing a payload.
3
4use 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
13/// Validate a single asset's type and generator without running the full build
14/// pipeline. Called by the server on each world_add so the LLM gets per-asset
15/// feedback without waiting for a WebSocket round-trip.
16///
17/// Checks:
18///
19/// - asset type is registered (via `asset_api::create_asset_def`)
20/// - per-type structural checks via `crate::check`
21///
22/// Shader assets are not compiled here; use the validate_shader tool for that.
23///
24/// `platform` is the shader platform the world is cooked for; the shader-backed
25/// types are checked against it.
26pub fn validate_asset(
27    asset_type: &str,
28    name: &str,
29    args: &serde_json::Value,
30    platform: Platform,
31) -> Result<(), String> {
32    // Single-asset validation has no surrounding world to intern against; the
33    // resulting ids are throwaway. Reset so calls do not accumulate entries.
34    // Clear the resource handle map too: with no world there are no handles, so
35    // a resource reference falls back to the interner (parses without resolving
36    // to a real slot, which single-asset validation never needs).
37    asset_id::reset_interner();
38    crate::resource_handles::reset_resource_handles();
39    let type_norm = asset_type.to_lowercase().replace('_', "");
40
41    // Build-time types are valid in world.jsonl; they are consumed by expansion
42    // functions before the runtime asset registry sees them.
43    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    // A resource asset never builds a component def; validate it as a known type
58    // with a structural check instead of routing through `create_asset_def`.
59    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
75/// Validate world JSONL without running compilation. Runs the full front half
76/// of the pipeline (load, expand, semantic checks) plus a per-asset type/args
77/// resolution, but stops short of compiling payloads: intended for fast
78/// server-side pre-deploy checks where shader compilation is not needed.
79/// `assets_dir` is the asset search root the expansion passes resolve their
80/// sources and presets against. Every problem found is reported in a single
81/// newline-joined error.
82pub 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        // A resource asset does not build a component def, so skip the component
93        // resolution for it.
94        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    // The visual_novel demo world (in concinnity-infra/worlds) exercises
120    // Sprite + Screen + KeyBinding together. Validating it here catches asset
121    // registration / pipeline regressions before we ship the world.
122    #[test]
123    fn visual_novel_world_validates() {
124        // Inline a representative subset of the world so the test stays
125        // hermetic (no infra path lookup needed). Covers: an initial Screen,
126        // a Sprite under that screen's prefix, a TextLabel under it, a
127        // HitRegion firing screen:show on another Screen, and a KeyBinding to
128        // toggle a third (modal) Screen.
129        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        // Build-time types are expanded before the runtime registry sees
149        // them, so they validate structurally regardless of args.
150        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    // A resource-only type never builds a component def, so it is validated
164    // through the structural check alone rather than `create_asset_def`.
165    #[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    // A type that resolves through `create_asset_def` still has to satisfy its
185    // structural check, and a clean asset returns Ok.
186    #[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        // A Prop resolves as a type but has no mesh source to render.
191        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        // `generator` must be a string; a number fails args deserialization.
211        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    // The per-asset resolution pass reports every asset that fails, not just
222    // the first, and a clean world returns Ok.
223    #[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        // Args of the wrong shape survive the structural world checks and are
234        // rejected when the def is built.
235        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}