scena 1.7.2

A Rust-native scene-graph renderer with typed scene state, glTF assets, and explicit prepare/render lifecycles.
Documentation
use serde_json::Value;

mod authoring;
mod expectations;
mod imports;
mod overlays;
mod setup;
mod suggestions;

use super::RecipeBuildPolicy;
use super::types::{
    SCENE_RECIPE_SCHEMA_V1, SCENE_RECIPE_VALIDATION_SCHEMA_V1, SceneRecipeDiagnosticV1,
    SceneRecipeV1, SceneRecipeValidationReportV1,
};
use authoring::{has_authored_renderable_nodes, validate_authoring_sections};
use overlays::{
    validate_callouts, validate_exploded_view, validate_measurements, validate_section_box,
};
use suggestions::{
    CAPTURE_FIELDS, ROOT_FIELDS, UNSUPPORTED_SECTION_FIELDS, UNSUPPORTED_WORKFLOW_FIELDS,
    nearest_capture_field, nearest_root_field,
};

pub fn validate_scene_recipe_json(text: &str) -> SceneRecipeValidationReportV1 {
    validate_scene_recipe_json_with_policy(text, &RecipeBuildPolicy::default())
}

pub fn validate_scene_recipe_json_with_policy(
    text: &str,
    policy: &RecipeBuildPolicy,
) -> SceneRecipeValidationReportV1 {
    if text.len() > policy.max_recipe_bytes() {
        return recipe_too_large_report(text.len(), policy.max_recipe_bytes());
    }
    match serde_json::from_str::<Value>(text) {
        Ok(value) => validate_scene_recipe_value(value),
        Err(error) => validation_report(vec![diagnostic(
            "invalid_json",
            "error",
            "$",
            format!("recipe is not valid JSON: {error}"),
            "emit a JSON object with schema scena.scene_recipe.v1",
            None,
            false,
        )]),
    }
}

pub fn validate_scene_recipe_value(value: Value) -> SceneRecipeValidationReportV1 {
    validate_scene_recipe_value_with_policy(value, &RecipeBuildPolicy::default())
}

pub fn validate_scene_recipe_value_with_policy(
    value: Value,
    policy: &RecipeBuildPolicy,
) -> SceneRecipeValidationReportV1 {
    let mut diagnostics = Vec::new();
    validate_scene_recipe_value_inner(&value, policy, &mut diagnostics);
    validation_report(diagnostics)
}

pub fn parse_valid_scene_recipe_json(
    text: &str,
) -> Result<SceneRecipeV1, SceneRecipeValidationReportV1> {
    parse_valid_scene_recipe_json_with_policy(text, &RecipeBuildPolicy::default())
}

pub fn parse_valid_scene_recipe_json_with_policy(
    text: &str,
    policy: &RecipeBuildPolicy,
) -> Result<SceneRecipeV1, SceneRecipeValidationReportV1> {
    if text.len() > policy.max_recipe_bytes() {
        return Err(recipe_too_large_report(
            text.len(),
            policy.max_recipe_bytes(),
        ));
    }
    let value = match serde_json::from_str::<Value>(text) {
        Ok(value) => value,
        Err(_) => return Err(validate_scene_recipe_json_with_policy(text, policy)),
    };
    let report = validate_scene_recipe_value_with_policy(value.clone(), policy);
    if !report.ok {
        return Err(report);
    }
    serde_json::from_value::<SceneRecipeV1>(value).map_err(|error| {
        validation_report(vec![diagnostic(
            "invalid_shape",
            "error",
            "$",
            format!("recipe shape did not match scena.scene_recipe.v1: {error}"),
            "use `scena schema get scena.scene_recipe.v1` for the accepted shape",
            None,
            false,
        )])
    })
}

fn validate_scene_recipe_value_inner(
    value: &Value,
    policy: &RecipeBuildPolicy,
    diagnostics: &mut Vec<SceneRecipeDiagnosticV1>,
) {
    let Some(object) = value.as_object() else {
        diagnostics.push(diagnostic(
            "invalid_shape",
            "error",
            "$",
            "recipe must be a JSON object",
            "emit a JSON object with schema, imports, and optional capture fields",
            None,
            false,
        ));
        return;
    };

    validate_root_fields(object.keys().map(String::as_str), diagnostics);
    validate_schema(object.get("schema"), diagnostics);
    let allow_empty_imports = has_authored_renderable_nodes(object);
    imports::validate_imports(object.get("imports"), allow_empty_imports, diagnostics);
    validate_authoring_sections(object, policy, diagnostics);
    let import_ids = imports::import_ids(object.get("imports"));
    validate_section_box(object.get("section_box"), &import_ids, diagnostics);
    validate_measurements(object.get("measurements"), diagnostics);
    validate_callouts(object.get("callouts"), &import_ids, diagnostics);
    validate_exploded_view(object.get("exploded_view"), &import_ids, diagnostics);
    setup::validate_scene_setup(object.get("scene"), diagnostics);
    setup::validate_render_setup(object.get("render"), diagnostics);
    expectations::validate_expectations(object.get("expect"), diagnostics);
    validate_capture(object.get("capture"), diagnostics);
    validate_metadata(object.get("metadata"), diagnostics);
}

pub fn recipe_too_large_report(
    byte_len: usize,
    max_recipe_bytes: usize,
) -> SceneRecipeValidationReportV1 {
    validation_report(vec![diagnostic(
        "policy_violation",
        "error",
        "$",
        format!(
            "recipe document is {byte_len} bytes, exceeding RecipeBuildPolicy max_recipe_bytes {max_recipe_bytes}"
        ),
        "use a smaller recipe document or raise the operator-owned max_recipe_bytes policy",
        None,
        false,
    )])
}

fn validate_root_fields<'a>(
    keys: impl Iterator<Item = &'a str>,
    diagnostics: &mut Vec<SceneRecipeDiagnosticV1>,
) {
    for key in keys {
        if ROOT_FIELDS.contains(&key) {
            continue;
        }
        if UNSUPPORTED_WORKFLOW_FIELDS.contains(&key) {
            diagnostics.push(diagnostic(
                "unsupported_workflow",
                "error",
                format!("$.{key}"),
                format!("recipe field '{key}' would make the recipe a workflow script"),
                "keep recipes as declarative snapshots; the host owns sequencing, loops, and time",
                None,
                false,
            ));
        } else if UNSUPPORTED_SECTION_FIELDS.contains(&key) {
            diagnostics.push(diagnostic(
                "unsupported_feature",
                "error",
                format!("$.{key}"),
                format!("recipe section '{key}' is not implemented in this scena build"),
                "remove the section or wait for the feature slice that owns it",
                None,
                false,
            ));
        } else {
            diagnostics.push(diagnostic(
                "unknown_field",
                "error",
                format!("$.{key}"),
                format!("recipe field '{key}' is not part of scena.scene_recipe.v1"),
                "remove the field or use metadata for caller-owned opaque data",
                nearest_root_field(key).map(str::to_owned),
                false,
            ));
        }
    }
}

fn validate_schema(schema: Option<&Value>, diagnostics: &mut Vec<SceneRecipeDiagnosticV1>) {
    match schema.and_then(Value::as_str) {
        Some(SCENE_RECIPE_SCHEMA_V1) => {}
        Some(found) => diagnostics.push(diagnostic(
            "schema_mismatch",
            "error",
            "$.schema",
            format!("expected schema '{SCENE_RECIPE_SCHEMA_V1}', got '{found}'"),
            "set schema to scena.scene_recipe.v1",
            None,
            false,
        )),
        None => diagnostics.push(diagnostic(
            "missing_schema",
            "error",
            "$.schema",
            "recipe must declare schema scena.scene_recipe.v1",
            "add `\"schema\": \"scena.scene_recipe.v1\"`",
            None,
            true,
        )),
    }
}

fn validate_capture(capture: Option<&Value>, diagnostics: &mut Vec<SceneRecipeDiagnosticV1>) {
    let Some(capture) = capture else {
        return;
    };
    let Some(object) = capture.as_object() else {
        diagnostics.push(diagnostic(
            "invalid_capture",
            "error",
            "$.capture",
            "capture must be an object",
            "emit capture:{width,height}",
            None,
            false,
        ));
        return;
    };
    for key in object.keys() {
        if !CAPTURE_FIELDS.contains(&key.as_str()) {
            diagnostics.push(diagnostic(
                "unknown_field",
                "error",
                format!("$.capture.{key}"),
                format!("capture field '{key}' is not part of scena.scene_recipe.v1"),
                "remove the field; CLI --out owns artifact paths",
                nearest_capture_field(key).map(str::to_owned),
                false,
            ));
        }
    }
    for field in ["width", "height"] {
        match object.get(field).and_then(Value::as_u64) {
            Some(value) if value > 0 && value <= u64::from(u32::MAX) => {}
            _ => diagnostics.push(diagnostic(
                "invalid_capture",
                "error",
                format!("$.capture.{field}"),
                format!("capture {field} must be a positive integer"),
                "use a positive pixel dimension",
                None,
                false,
            )),
        }
    }
}

fn validate_metadata(metadata: Option<&Value>, diagnostics: &mut Vec<SceneRecipeDiagnosticV1>) {
    let Some(metadata) = metadata else {
        return;
    };
    if !metadata.is_object() {
        diagnostics.push(diagnostic(
            "invalid_metadata",
            "error",
            "$.metadata",
            "metadata must be an object when present",
            "put caller-owned opaque values under metadata object keys",
            None,
            false,
        ));
    }
}

fn validation_report(diagnostics: Vec<SceneRecipeDiagnosticV1>) -> SceneRecipeValidationReportV1 {
    SceneRecipeValidationReportV1 {
        schema: SCENE_RECIPE_VALIDATION_SCHEMA_V1.to_owned(),
        ok: !diagnostics
            .iter()
            .any(|diagnostic| diagnostic.severity == "error"),
        diagnostics,
    }
}

pub(super) fn diagnostic(
    code: impl Into<String>,
    severity: impl Into<String>,
    path: impl Into<String>,
    message: impl Into<String>,
    help: impl Into<String>,
    suggestion: Option<String>,
    auto_fixable: bool,
) -> SceneRecipeDiagnosticV1 {
    SceneRecipeDiagnosticV1 {
        code: code.into(),
        severity: severity.into(),
        path: path.into(),
        message: message.into(),
        help: help.into(),
        suggestion,
        auto_fixable,
    }
}