#![allow(
clippy::print_stdout,
reason = "example prints its report to the terminal, by design -- same as the handler this replaced"
)]
use anyhow::{bail, Context, Result};
use serde_json::{json, Value};
use std::collections::BTreeSet;
use std::fmt::Write as _;
use std::fs;
use std::path::{Path, PathBuf};
const DEFAULT_SCHEMA_EXPORT_DIR: &str = "schemas";
fn main() -> Result<()> {
let out_dir = parse_out_dir(std::env::args().skip(1).collect())?;
println!();
println!("📄 Schema Export");
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
println!();
let written = export_schemas(&out_dir)?;
println!("Wrote {} file(s) to {}:", written.len(), out_dir.display());
for path in &written {
println!(" - {}", path.display());
}
println!();
Ok(())
}
fn parse_out_dir(args: Vec<String>) -> Result<PathBuf> {
let mut iter = args.into_iter();
let mut out_dir = PathBuf::from(DEFAULT_SCHEMA_EXPORT_DIR);
while let Some(arg) = iter.next() {
match arg.as_str() {
"--out" => {
let value = iter.next().ok_or_else(|| anyhow::anyhow!("--out requires a value"))?;
out_dir = PathBuf::from(value);
}
other => bail!("unrecognized argument: {other}"),
}
}
Ok(out_dir)
}
struct ExportedSchema {
relative_json_path: PathBuf,
title: String,
schema: Value,
}
fn export_schemas(out_dir: &Path) -> Result<Vec<PathBuf>> {
let schemas = collect_schemas();
let mut written = Vec::new();
for exported in &schemas {
let path = out_dir.join(&exported.relative_json_path);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("Failed to create directory {}", parent.display()))?;
}
let pretty = serde_json::to_string_pretty(&exported.schema)
.with_context(|| format!("Failed to serialize schema for {}", exported.title))?;
fs::write(&path, format!("{pretty}\n")).with_context(|| format!("Failed to write {}", path.display()))?;
written.push(exported.relative_json_path.clone());
}
let readme_path = out_dir.join("README.md");
fs::create_dir_all(out_dir).with_context(|| format!("Failed to create directory {}", out_dir.display()))?;
fs::write(&readme_path, render_readme(&schemas)).context("Failed to write schemas/README.md")?;
written.push(PathBuf::from("README.md"));
Ok(written)
}
fn collect_schemas() -> Vec<ExportedSchema> {
let mut schemas = vec![
ExportedSchema {
relative_json_path: PathBuf::from("mecha10.schema.json"),
title: "Project config (mecha10.json)".to_string(),
schema: full_project_schema(),
},
ExportedSchema {
relative_json_path: PathBuf::from("base.schema.json"),
title: "Node config base (common/reserved fields)".to_string(),
schema: mecha10_schema_registry::schema_for::<mecha10_core::node_config_base::NodeConfigBase>(),
},
];
schemas.push(ExportedSchema {
relative_json_path: PathBuf::from("model-catalog.schema.json"),
title: "Model catalog (model_catalog.toml)".to_string(),
schema: mecha10_schema_registry::model_catalog_schema(),
});
schemas.push(ExportedSchema {
relative_json_path: PathBuf::from("environment.schema.json"),
title: "Simulation environment (environment.json)".to_string(),
schema: environment_config_schema(),
});
schemas.push(ExportedSchema {
relative_json_path: PathBuf::from("behavior.schema.json"),
title: "Behavior composition (behavior tree config.json)".to_string(),
schema: mecha10_behavior_runtime::BehaviorConfig::schema_value(),
});
schemas.push(ExportedSchema {
relative_json_path: PathBuf::from("rl-job.schema.json"),
title: "RL training job (configs/rl/jobs/*.yml)".to_string(),
schema: mecha10_schema_registry::schema_for::<mecha10_cli::services::train_jobs::JobDefinition>(),
});
schemas
}
fn environment_config_schema() -> Value {
mecha10_schema_registry::environment_config_schema()
}
fn full_project_schema() -> Value {
let mut schema = mecha10_cli_core::types::project::ProjectConfig::schema_value();
apply_legacy_project_schema_fragments(&mut schema);
inject_build_target_triple_enum(&mut schema);
schema
}
fn apply_legacy_project_schema_fragments(schema: &mut Value) {
let legacy_properties = json!({
"nodes": {
"type": "object",
"properties": {
"drivers": {
"type": "array",
"items": { "$ref": "#/definitions/node" },
"description": "Hardware driver nodes (legacy array form - see this property's own description)"
},
"standard": {
"type": "array",
"items": { "$ref": "#/definitions/standardNode" },
"description": "Standard/pre-built nodes from packages (legacy array form - see this property's own description)"
},
"custom": {
"type": "array",
"items": { "$ref": "#/definitions/node" },
"description": "Custom application nodes (legacy array form - see this property's own description)"
}
},
"description": "Node definitions (NodesConfig in mecha10-cli-core) - typically a flat identifier -> version map, though the legacy `drivers`/`standard`/`custom` arrays below also validate (no `additionalProperties` restriction). Keys: a framework node's full package id (\"@mecha10/speaker\"), a future registry node (\"@<org>/<name>\"), or a project node's own unprefixed name (\"my-custom\"); the legacy \"@local/*\" prefix is no longer supported.",
"examples": [
{ "@mecha10/speaker": "latest", "@mecha10/motor": "latest", "my-custom": "0.1.0" }
]
},
"run_targets": {
"type": "object",
"patternProperties": {
"^[a-z][a-z0-9_-]*$": {
"$ref": "#/definitions/runTarget"
}
},
"description": "Execution targets (robot, remote, dev, etc.)"
},
"scripts": {
"type": "object",
"patternProperties": {
"^[a-z][a-z0-9:_-]*$": {
"type": "string"
}
},
"description": "Script shortcuts (npm scripts-like)"
}
});
let legacy_definitions = json!({
"node": {
"type": "object",
"required": ["name", "path"],
"properties": {
"name": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$",
"description": "Node name (snake_case)"
},
"path": {
"type": "string",
"description": "Path to node source code"
},
"config": {
"type": "string",
"description": "Path to node configuration file"
},
"description": {
"type": "string",
"maxLength": 200,
"description": "Node description"
},
"run_target": {
"type": "string",
"description": "Where to run this node - a key of this project's `run_targets` map"
},
"enabled": {
"type": "boolean",
"default": true,
"description": "Whether node is enabled"
},
"auto_restart": {
"type": "boolean",
"default": true,
"description": "Auto-restart on crash"
},
"depends_on": {
"type": "array",
"items": { "type": "string" },
"description": "Node dependencies (other nodes that must start first)"
}
}
},
"standardNode": {
"type": "object",
"required": ["package", "binary"],
"properties": {
"package": {
"type": "string",
"pattern": "^@[a-z0-9-]+/[a-z0-9/-]+$",
"description": "Package identifier (e.g., @mecha10/speaker)",
"examples": ["@mecha10/speaker"]
},
"binary": {
"type": "string",
"description": "Binary name within package"
},
"config": {
"type": "string",
"description": "Path to configuration file"
}
}
},
"runTarget": {
"type": "object",
"properties": {
"description": {
"type": "string",
"maxLength": 200,
"description": "Target description"
},
"includes": {
"type": "array",
"items": { "type": "string" },
"description": "Node groups to include"
},
"resources": {
"type": "object",
"properties": {
"cpu_limit": {
"type": "string",
"pattern": "^\\d+(\\.\\d+)?$",
"description": "CPU limit (e.g., '2.0')"
},
"memory_limit": {
"type": "string",
"pattern": "^\\d+(K|M|G|T)?$",
"description": "Memory limit (e.g., '512M', '16G')"
},
"gpu": {
"oneOf": [
{ "type": "boolean" },
{
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
"vendor": {
"type": "string",
"enum": ["nvidia", "amd", "intel"]
},
"count": {
"type": "integer",
"minimum": 1
},
"optional": { "type": "boolean" }
}
}
],
"description": "GPU requirements"
},
"storage_type": {
"type": "string",
"enum": ["local", "ssd", "hdd", "any"],
"description": "Storage type requirement"
},
"storage_size_gb": {
"type": "integer",
"minimum": 1,
"description": "Storage size in GB"
},
"model_cache": {
"type": "string",
"description": "Path to model cache directory"
},
"devices": {
"type": "array",
"items": {
"type": "string",
"pattern": "^/dev/.+$"
},
"description": "Required device files (e.g., /dev/video0)"
}
},
"description": "Resource requirements"
}
}
}
});
if let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) {
if let Some(legacy_properties) = legacy_properties.as_object() {
for (name, value) in legacy_properties {
properties.insert(name.clone(), value.clone());
}
}
}
if let Some(root) = schema.as_object_mut() {
root.insert("definitions".to_string(), legacy_definitions);
}
}
fn inject_build_target_triple_enum(schema: &mut Value) {
let triples = json!([
"x86_64-apple-darwin",
"aarch64-apple-darwin",
"x86_64-unknown-linux-gnu",
"aarch64-unknown-linux-gnu"
]);
if let Some(items) = schema
.get_mut("$defs")
.and_then(|defs| defs.get_mut("TargetEntry"))
.and_then(|target_entry| target_entry.get_mut("properties"))
.and_then(|properties| properties.get_mut("platforms"))
.and_then(|platforms| platforms.get_mut("items"))
{
if let Some(items) = items.as_object_mut() {
items.insert("enum".to_string(), triples);
}
}
}
fn render_readme(schemas: &[ExportedSchema]) -> String {
let mut out = String::new();
out.push_str("# Schema reference\n\n");
out.push_str(
"Generated from this repo's JSON Schemas by `cargo run --example export_schemas` (LAB-1829) — \
do not hand-edit. Regenerate with:\n\n```\ncargo run -p mecha10-cli --example export_schemas\n```\n\n\
Descriptions come from the schemas themselves (Rust doc comments on node config \
structs, via `schemars`, or `get_project_schema()`'s own `description` fields for \
the project config). See [`docs/validate-configs.md`](../validate-configs.md) for how \
these schemas are used to validate configs, and the raw `.schema.json` files \
alongside this doc for the exact JSON Schema.\n\n\
This directory is also mirrored verbatim to the public \
[`mecha-industries/mecha10-schema`](https://github.com/mecha-industries/mecha10-schema) \
repo (LAB-4067), kept in sync by CI (`scripts/ci/schema-mirror-publish.sh`) — that's the \
fetchable location for consumers (e.g. the public dashboard docs page, LAB-4054) that \
can't read this private repo directly.\n\n",
);
out.push_str("## Contents\n\n");
for exported in schemas {
let anchor = slugify(&exported.title);
let _ = writeln!(out, "- [{}](#{anchor})", exported.title);
}
out.push('\n');
for exported in schemas {
let _ = writeln!(out, "## {}\n", exported.title);
if let Some(description) = exported.schema.get("description").and_then(Value::as_str) {
let _ = writeln!(out, "{description}\n");
}
let _ = writeln!(
out,
"Raw schema: [`{}`]({}).\n",
exported.relative_json_path.display(),
exported.relative_json_path.display()
);
render_schema_body(&exported.schema, &exported.schema, &mut out, &mut BTreeSet::new());
}
out
}
struct PendingSection {
title: String,
schema: Value,
}
fn render_schema_body(root: &Value, schema: &Value, out: &mut String, visited: &mut BTreeSet<String>) {
let mut pending: Vec<PendingSection> = Vec::new();
render_value_body(schema, out, &mut pending);
for container_key in ["$defs", "definitions"] {
if let Some(definitions) = root.get(container_key).and_then(Value::as_object) {
let mut names: Vec<&String> = definitions.keys().collect();
names.sort();
for name in names {
pending.push(PendingSection {
title: name.clone(),
schema: definitions[name].clone(),
});
}
}
}
let mut index = 0;
while index < pending.len() {
if visited.insert(pending[index].title.clone()) {
let _ = writeln!(out, "### {}\n", pending[index].title);
if let Some(description) = pending[index].schema.get("description").and_then(Value::as_str) {
let _ = writeln!(out, "{description}\n");
}
let schema_to_render = pending[index].schema.clone();
render_value_body(&schema_to_render, out, &mut pending);
}
index += 1;
}
}
fn render_value_body(schema: &Value, out: &mut String, pending: &mut Vec<PendingSection>) {
if let Some(properties) = schema.get("properties").and_then(Value::as_object) {
let required: BTreeSet<&str> = schema
.get("required")
.and_then(Value::as_array)
.map(|values| values.iter().filter_map(Value::as_str).collect())
.unwrap_or_default();
let mut names: Vec<&String> = properties.keys().collect();
names.sort();
out.push_str("| Field | Type | Required | Description |\n");
out.push_str("| --- | --- | --- | --- |\n");
for name in names {
let field_schema = &properties[name];
let type_label = type_label(field_schema);
let is_required = if required.contains(name.as_str()) { "yes" } else { "no" };
let description = field_description(field_schema).unwrap_or_default();
let _ = writeln!(out, "| `{name}` | {type_label} | {is_required} | {description} |");
if let Some(nested) = anonymous_nested_object(name, field_schema) {
pending.push(nested);
}
}
out.push('\n');
return;
}
if let Some(variants) = schema
.get("oneOf")
.or_else(|| schema.get("anyOf"))
.and_then(Value::as_array)
{
out.push_str("One of the following variants:\n\n");
for (index, variant) in variants.iter().enumerate() {
let label = variant_label(variant, index);
let _ = writeln!(out, "- **{label}**");
}
out.push('\n');
for (index, variant) in variants.iter().enumerate() {
let is_bare_reference_or_null = (variant.get("$ref").is_some()
|| variant.get("type").and_then(Value::as_str) == Some("null"))
&& variant.get("properties").is_none();
if is_bare_reference_or_null || (variant.get("enum").is_some() && variant.get("properties").is_none()) {
continue;
}
let label = variant_label(variant, index);
let _ = writeln!(out, "**{label}**\n");
render_value_body(variant, out, pending);
}
return;
}
if let Some(items) = schema.get("enum").and_then(Value::as_array) {
let values: Vec<String> = items.iter().map(display_scalar).collect();
let _ = writeln!(out, "Allowed values: {}\n", values.join(", "));
}
}
fn anonymous_nested_object(field_name: &str, field_schema: &Value) -> Option<PendingSection> {
let candidate = if field_schema.get("type").and_then(Value::as_str) == Some("array") {
field_schema.get("items")?
} else {
field_schema
};
if candidate.get("$ref").is_some() {
return None;
}
if let Some(all_of) = candidate.get("allOf").and_then(Value::as_array) {
if all_of.iter().any(|s| s.get("$ref").is_some()) {
return None;
}
}
if candidate.get("oneOf").is_some() || candidate.get("anyOf").is_some() {
return None;
}
let has_properties = candidate
.get("properties")
.and_then(Value::as_object)
.is_some_and(|props| !props.is_empty());
if !has_properties {
return None;
}
let title = candidate
.get("title")
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| field_name.to_string());
Some(PendingSection {
title,
schema: candidate.clone(),
})
}
fn variant_label(variant: &Value, index: usize) -> String {
if let Some(tag) = variant
.get("properties")
.and_then(|p| p.get("type"))
.and_then(|t| t.get("enum"))
.and_then(Value::as_array)
.and_then(|values| values.first())
.and_then(Value::as_str)
{
return format!("`{tag}`");
}
if let Some(tag) = variant
.get("enum")
.and_then(Value::as_array)
.and_then(|values| values.first())
.and_then(Value::as_str)
{
return format!("`{tag}`");
}
if let Some(title) = variant.get("title").and_then(Value::as_str) {
return title.to_string();
}
if variant.get("type").and_then(Value::as_str) == Some("null") {
return "`null`".to_string();
}
if let Some(reference) = variant.get("$ref").and_then(Value::as_str) {
return ref_type_label(reference);
}
format!("variant {index}")
}
fn field_description(schema: &Value) -> Option<String> {
schema
.get("description")
.and_then(Value::as_str)
.map(|s| s.replace('\n', " ").trim().to_string())
.filter(|s| !s.is_empty())
}
fn type_label(schema: &Value) -> String {
if let Some(reference) = schema.get("$ref").and_then(Value::as_str) {
return ref_type_label(reference);
}
if let Some(all_of) = schema.get("allOf").and_then(Value::as_array) {
if let Some(reference) = all_of.iter().find_map(|s| s.get("$ref")).and_then(Value::as_str) {
return ref_type_label(reference);
}
}
if let Some(one_of) = schema
.get("oneOf")
.or_else(|| schema.get("anyOf"))
.and_then(Value::as_array)
{
let labels: Vec<String> = one_of.iter().map(type_label).collect();
return format!("one of: {}", labels.join(", "));
}
if schema.get("type").and_then(Value::as_str) == Some("array") {
let item_label = schema.get("items").map_or_else(|| "any".to_string(), type_label);
return format!("array of {item_label}");
}
if schema.get("properties").is_some() {
return schema
.get("title")
.and_then(Value::as_str)
.map_or_else(|| "object".to_string(), |title| format!("`{title}`"));
}
match schema.get("type") {
Some(Value::String(t)) => t.clone(),
Some(Value::Array(types)) => types.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(" or "),
_ if schema.get("enum").is_some() => "enum".to_string(),
_ => "any".to_string(),
}
}
fn ref_type_label(reference: &str) -> String {
let name = reference.rsplit('/').next().unwrap_or(reference);
format!("`{name}`")
}
fn display_scalar(value: &Value) -> String {
match value {
Value::String(s) => format!("`{s}`"),
other => format!("`{other}`"),
}
}
fn slugify(title: &str) -> String {
title
.to_lowercase()
.chars()
.filter_map(|c| {
if c.is_alphanumeric() || c == ' ' || c == '-' {
Some(if c == ' ' { '-' } else { c })
} else {
None
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use tempfile::tempdir;
#[test]
fn parse_out_dir_defaults_when_no_args() {
assert_eq!(parse_out_dir(vec![]).unwrap(), PathBuf::from(DEFAULT_SCHEMA_EXPORT_DIR));
}
#[test]
fn parse_out_dir_reads_out_flag() {
let args = vec!["--out".to_string(), "custom/dir".to_string()];
assert_eq!(parse_out_dir(args).unwrap(), PathBuf::from("custom/dir"));
}
#[test]
fn parse_out_dir_rejects_unknown_arg() {
assert!(parse_out_dir(vec!["--bogus".to_string()]).is_err());
}
#[test]
fn parse_out_dir_rejects_missing_value() {
assert!(parse_out_dir(vec!["--out".to_string()]).is_err());
}
#[test]
fn type_label_resolves_plain_primitive() {
let schema = json!({"type": "string"});
assert_eq!(type_label(&schema), "string");
}
#[test]
fn type_label_resolves_direct_ref() {
let schema = json!({"$ref": "#/definitions/SourceConfig"});
assert_eq!(type_label(&schema), "`SourceConfig`");
}
#[test]
fn type_label_resolves_all_of_wrapped_ref() {
let schema = json!({
"description": "Frame source configuration",
"allOf": [{"$ref": "#/definitions/SourceConfig"}]
});
assert_eq!(type_label(&schema), "`SourceConfig`");
}
#[test]
fn type_label_resolves_array_of_ref() {
let schema = json!({"type": "array", "items": {"$ref": "#/definitions/ModelCatalogEntry"}});
assert_eq!(type_label(&schema), "array of `ModelCatalogEntry`");
}
#[test]
fn field_description_reads_plain_description() {
let schema = json!({"type": "string", "description": "Camera ID for identification"});
assert_eq!(
field_description(&schema).as_deref(),
Some("Camera ID for identification")
);
}
#[test]
fn field_description_reads_all_of_wrapped_description() {
let schema = json!({
"description": "Frame source configuration",
"allOf": [{"$ref": "#/definitions/SourceConfig"}]
});
assert_eq!(
field_description(&schema).as_deref(),
Some("Frame source configuration")
);
}
#[test]
fn render_value_body_produces_a_field_table_with_required_column() {
let root = json!({
"type": "object",
"required": ["name"],
"properties": {
"name": {"type": "string", "description": "The name"},
"note": {"type": "string", "description": "Optional note"}
}
});
let mut out = String::new();
render_value_body(&root, &mut out, &mut Vec::new());
assert!(out.contains("| `name` | string | yes | The name |"));
assert!(out.contains("| `note` | string | no | Optional note |"));
}
#[test]
fn render_value_body_queues_anonymous_nested_array_item_object() {
let root = json!({
"type": "object",
"properties": {
"models": {
"type": "array",
"items": {
"type": "object",
"properties": {"name": {"type": "string", "description": "Model name"}}
}
}
}
});
let mut out = String::new();
render_schema_body(&root, &root, &mut out, &mut BTreeSet::new());
assert!(out.contains("### models"));
assert!(out.contains("| `name` | string | no | Model name |"));
}
#[test]
fn render_schema_body_documents_defs_entries_the_same_as_definitions_entries() {
let root = json!({
"type": "object",
"properties": {
"source": {"anyOf": [{"$ref": "#/$defs/SourceConfig"}, {"type": "null"}]}
},
"$defs": {
"SourceConfig": {
"type": "object",
"properties": {"path": {"type": "string"}}
}
}
});
let mut out = String::new();
render_schema_body(&root, &root, &mut out, &mut BTreeSet::new());
assert!(out.contains("### SourceConfig"));
assert!(out.contains("| `path` | string | no |"));
}
#[test]
fn render_schema_body_documents_referenced_definitions_once() {
let root = json!({
"type": "object",
"properties": {
"source": {"allOf": [{"$ref": "#/definitions/SourceConfig"}]}
},
"definitions": {
"SourceConfig": {
"type": "object",
"properties": {"path": {"type": "string"}}
}
}
});
let mut out = String::new();
render_schema_body(&root, &root, &mut out, &mut BTreeSet::new());
assert_eq!(out.matches("### SourceConfig").count(), 1);
assert!(out.contains("| `path` | string | no |"));
}
#[test]
fn project_schema_behaviors_property_reflects_the_real_behaviors_config_struct() {
let schema = full_project_schema();
let behaviors_ref = schema["properties"]["behaviors"]["anyOf"][0]["$ref"]
.as_str()
.expect("behaviors should be an anyOf with a $ref variant");
let def_name = behaviors_ref
.rsplit('/')
.next()
.expect("$ref should have a final segment");
let behaviors = &schema["$defs"][def_name];
assert_eq!(def_name, "BehaviorsConfig");
assert!(
behaviors["properties"].get("active").is_some(),
"expected `behaviors.properties.active`, got: {behaviors}"
);
assert!(
behaviors["properties"].get("default").is_none(),
"`behaviors` schema still has a stale `default` field: {behaviors}"
);
let required: Vec<&str> = behaviors["required"]
.as_array()
.expect("BehaviorsConfig schema should have a `required` array")
.iter()
.filter_map(Value::as_str)
.collect();
assert_eq!(required, vec!["active"]);
}
#[test]
fn full_project_schema_still_requires_only_name_and_version() {
let schema = full_project_schema();
let required: Vec<&str> = schema["required"]
.as_array()
.expect("ProjectConfig schema should have a `required` array")
.iter()
.filter_map(Value::as_str)
.collect();
assert_eq!(required, vec!["name", "version"]);
}
#[test]
fn full_project_schema_still_rejects_an_unknown_build_target_triple_enum_value() {
let schema = full_project_schema();
let triples = schema["$defs"]["TargetEntry"]["properties"]["platforms"]["items"]["enum"]
.as_array()
.expect("platforms.items should carry the buildTargetTriple enum");
assert!(triples.iter().any(|t| t == "aarch64-unknown-linux-gnu"));
assert!(!triples.iter().any(|t| t == "not-a-real-triple"));
}
#[test]
fn full_project_schema_still_accepts_legacy_nodes_drivers_standard_custom_arrays() {
let schema = full_project_schema();
let nodes = &schema["properties"]["nodes"];
assert!(nodes["properties"].get("drivers").is_some());
assert!(nodes["properties"].get("standard").is_some());
assert!(nodes["properties"].get("custom").is_some());
assert!(schema["definitions"].get("node").is_some());
assert!(schema["definitions"].get("standardNode").is_some());
}
#[test]
fn full_project_schema_still_covers_run_targets_and_scripts() {
let schema = full_project_schema();
assert!(schema["properties"].get("run_targets").is_some());
assert!(schema["properties"].get("scripts").is_some());
assert!(schema["definitions"].get("runTarget").is_some());
}
#[test]
fn variant_label_prefers_serde_tag_discriminant() {
let variant = json!({
"type": "object",
"properties": {"type": {"enum": ["mock"]}}
});
assert_eq!(variant_label(&variant, 0), "`mock`");
}
#[test]
fn export_schemas_writes_json_and_readme() {
let dir = tempdir().unwrap();
let written = export_schemas(dir.path()).unwrap();
assert!(written.contains(&PathBuf::from("mecha10.schema.json")));
assert!(written.contains(&PathBuf::from("base.schema.json")));
assert!(written.contains(&PathBuf::from("model-catalog.schema.json")));
assert!(written.contains(&PathBuf::from("environment.schema.json")));
assert!(written.contains(&PathBuf::from("behavior.schema.json")));
assert!(written.contains(&PathBuf::from("rl-job.schema.json")));
assert!(written.contains(&PathBuf::from("README.md")));
assert!(!written.iter().any(|p| p.starts_with("nodes")));
assert!(dir.path().join("mecha10.schema.json").exists());
assert!(dir.path().join("base.schema.json").exists());
assert!(dir.path().join("model-catalog.schema.json").exists());
assert!(dir.path().join("environment.schema.json").exists());
assert!(dir.path().join("behavior.schema.json").exists());
assert!(dir.path().join("rl-job.schema.json").exists());
assert!(!dir.path().join("nodes").exists());
let readme = fs::read_to_string(dir.path().join("README.md")).unwrap();
assert!(readme.contains("# Schema reference"));
assert!(readme.contains("## Project config (mecha10.json)"));
assert!(readme.contains("## Node config base (common/reserved fields)"));
assert!(!readme.contains("## Node config: camera"));
assert!(readme.contains("## Model catalog (model_catalog.toml)"));
assert!(readme.contains("## Simulation environment (environment.json)"));
assert!(readme.contains("## Behavior composition (behavior tree config.json)"));
assert!(readme.contains("## RL training job (configs/rl/jobs/*.yml)"));
assert!(readme.contains("### ModelCatalogEntry"));
assert!(readme.contains("| `name` |"));
}
#[test]
fn export_schemas_environment_schema_matches_the_bundled_template_source() {
let dir = tempdir().unwrap();
export_schemas(dir.path()).unwrap();
let written: Value =
serde_json::from_str(&fs::read_to_string(dir.path().join("environment.schema.json")).unwrap()).unwrap();
let bundled: Value =
serde_json::from_str(mecha10_templates::templates::ENVIRONMENT_CONFIG_SCHEMA_JSON).unwrap();
assert_eq!(written, bundled);
}
#[test]
fn export_schemas_behavior_schema_matches_behavior_runtimes_own_schema() {
let dir = tempdir().unwrap();
export_schemas(dir.path()).unwrap();
let written: Value =
serde_json::from_str(&fs::read_to_string(dir.path().join("behavior.schema.json")).unwrap()).unwrap();
assert_eq!(written, mecha10_behavior_runtime::BehaviorConfig::schema_value());
}
#[test]
fn export_schemas_base_schema_matches_node_config_base_and_exposes_relay_topics() {
let dir = tempdir().unwrap();
export_schemas(dir.path()).unwrap();
let written: Value =
serde_json::from_str(&fs::read_to_string(dir.path().join("base.schema.json")).unwrap()).unwrap();
assert_eq!(
written,
mecha10_schema_registry::schema_for::<mecha10_core::node_config_base::NodeConfigBase>()
);
assert!(written["properties"].get("relay_topics").is_some());
}
#[test]
fn export_schemas_fills_in_previously_missing_examples_and_bounds() {
let dir = tempdir().unwrap();
export_schemas(dir.path()).unwrap();
let env: Value =
serde_json::from_str(&fs::read_to_string(dir.path().join("environment.schema.json")).unwrap()).unwrap();
assert_eq!(
env["properties"]["dimensions"]["properties"]["width"]["examples"],
serde_json::json!([20.0])
);
}
#[test]
fn export_schemas_output_is_deterministic() {
let dir_a = tempdir().unwrap();
let dir_b = tempdir().unwrap();
export_schemas(dir_a.path()).unwrap();
export_schemas(dir_b.path()).unwrap();
let readme_a = fs::read_to_string(dir_a.path().join("README.md")).unwrap();
let readme_b = fs::read_to_string(dir_b.path().join("README.md")).unwrap();
assert_eq!(readme_a, readme_b);
let schema_a = fs::read_to_string(dir_a.path().join("mecha10.schema.json")).unwrap();
let schema_b = fs::read_to_string(dir_b.path().join("mecha10.schema.json")).unwrap();
assert_eq!(schema_a, schema_b);
}
}