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 crate::asset_api::{self, AssetRequest};
7use crate::ecs::asset_id;
8
9use super::errors_to_io;
10
11/// Validate a single asset's type and generator without running the full build
12/// pipeline. Called by the server on each world_add so the LLM gets per-asset
13/// feedback without waiting for a WebSocket round-trip.
14///
15/// Checks:
16///
17/// - asset type is registered (via `asset_api::create_asset_def`)
18/// - per-type structural checks via `crate::check`
19///
20/// Shader assets are not compiled here; use the validate_shader tool for that.
21pub fn validate_asset(
22    asset_type: &str,
23    name: &str,
24    args: &serde_json::Value,
25) -> Result<(), String> {
26    // Single-asset validation has no surrounding world to intern against; the
27    // resulting ids are throwaway. Reset so calls do not accumulate entries.
28    // Clear the resource handle map too: with no world there are no handles, so
29    // a resource reference falls back to the interner (parses without resolving
30    // to a real slot, which single-asset validation never needs).
31    asset_id::reset_interner();
32    crate::resource_handles::reset_resource_handles();
33    let type_norm = asset_type.to_lowercase().replace('_', "");
34
35    // Build-time types are valid in world.jsonl; they are consumed by expansion
36    // functions before the runtime asset registry sees them.
37    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    // A resource asset never builds a component def; validate it as a known type
52    // with a structural check instead of routing through `create_asset_def`.
53    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
69/// Validate world JSONL without running compilation. Runs the full front half
70/// of the pipeline (load, expand, semantic checks) plus a per-asset type/args
71/// resolution, but stops short of compiling payloads: intended for fast
72/// server-side pre-deploy checks where shader compilation is not needed.
73/// `assets_dir` is the asset search root the expansion passes resolve their
74/// sources and presets against. Every problem found is reported in a single
75/// newline-joined error.
76pub 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        // A resource asset does not build a component def, so skip the component
82        // resolution for it.
83        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    // The visual_novel demo world (in concinnity-infra/worlds) exercises
109    // Sprite + Screen + KeyBinding together. Validating it here catches asset
110    // registration / pipeline regressions before we ship the world.
111    #[test]
112    fn visual_novel_world_validates() {
113        // Inline a representative subset of the world so the test stays
114        // hermetic (no infra path lookup needed). Covers: an initial Screen,
115        // a Sprite under that screen's prefix, a TextLabel under it, a
116        // HitRegion firing screen:show on another Screen, and a KeyBinding to
117        // toggle a third (modal) Screen.
118        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        // Build-time types are expanded before the runtime registry sees
137        // them, so they validate structurally regardless of args.
138        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    // A resource-only type never builds a component def, so it is validated
152    // through the structural check alone rather than `create_asset_def`.
153    #[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    // A type that resolves through `create_asset_def` still has to satisfy its
163    // structural check, and a clean asset returns Ok.
164    #[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        // A Prop resolves as a type but has no mesh source to render.
168        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        // `generator` must be a string; a number fails args deserialization.
183        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    // The per-asset resolution pass reports every asset that fails, not just
193    // the first, and a clean world returns Ok.
194    #[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        // Args of the wrong shape survive the structural world checks and are
205        // rejected when the def is built.
206        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}