use anyhow::{Context, Result};
use serde_json::Value;
use std::collections::BTreeSet;
use std::fmt::Write as _;
use std::fs;
use std::path::{Path, PathBuf};
struct ExportedSchema {
relative_json_path: PathBuf,
title: String,
schema: Value,
}
pub struct SchemaExportService;
impl SchemaExportService {
pub fn export(out_dir: &Path) -> Result<Vec<PathBuf>> {
export_schemas(out_dir)
}
}
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 docs/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: project_schema_with_introspected_behaviors(),
}];
for node_type in mecha10_schema_registry::BUILTIN_NODE_TYPES.iter() {
let schema = mecha10_schema_registry::node_type_schema(node_type).unwrap_or_else(|| {
panic!("mecha10-schema-registry declares '{node_type}' as built-in but has no schema for it")
});
schemas.push(ExportedSchema {
relative_json_path: PathBuf::from("nodes").join(format!("{node_type}.schema.json")),
title: format!("Node config: {node_type}"),
schema,
});
}
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
}
fn project_schema_with_introspected_behaviors() -> Value {
let mut schema = mecha10_core::schema_validation::get_project_schema();
let behaviors_schema = schema_for::<mecha10_cli_core::types::project::BehaviorsConfig>();
if let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) {
properties.insert("behaviors".to_string(), behaviors_schema);
}
schema
}
fn schema_for<T: schemars::JsonSchema>() -> Value {
let root_schema = schemars::SchemaGenerator::default().into_root_schema_for::<T>();
serde_json::to_value(root_schema).expect("schemars Schema always serializes to JSON")
}
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 `mecha10 schema export` (LAB-1829) — \
do not hand-edit. Regenerate with:\n\n```\ncargo run -p mecha10-cli --bin mecha10 -- schema export\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",
);
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);
if let Some(definitions) = root.get("definitions").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 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_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 = project_schema_with_introspected_behaviors();
let behaviors = &schema["properties"]["behaviors"];
assert_eq!(behaviors["title"], "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 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("model-catalog.schema.json")));
assert!(written.contains(&PathBuf::from("nodes/camera.schema.json")));
assert!(written.contains(&PathBuf::from("README.md")));
assert!(dir.path().join("mecha10.schema.json").exists());
assert!(dir.path().join("nodes/camera.schema.json").exists());
assert!(dir.path().join("model-catalog.schema.json").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: camera"));
assert!(readme.contains("## Model catalog (model_catalog.toml)"));
assert!(readme.contains("### ModelCatalogEntry"));
assert!(readme.contains("| `name` |"));
}
#[test]
fn export_schemas_covers_every_builtin_node_type() {
let dir = tempdir().unwrap();
export_schemas(dir.path()).unwrap();
for node_type in mecha10_schema_registry::BUILTIN_NODE_TYPES.iter() {
let path = dir.path().join("nodes").join(format!("{node_type}.schema.json"));
assert!(
path.exists(),
"expected schema file for '{node_type}' at {}",
path.display()
);
}
}
#[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);
}
}