1use anyhow::{Context, Result};
18use serde_json::Value;
19use std::collections::BTreeSet;
20use std::fmt::Write as _;
21use std::fs;
22use std::path::{Path, PathBuf};
23
24struct ExportedSchema {
27 relative_json_path: PathBuf,
30 title: String,
32 schema: Value,
33}
34
35pub struct SchemaExportService;
40
41impl SchemaExportService {
42 pub fn export(out_dir: &Path) -> Result<Vec<PathBuf>> {
51 export_schemas(out_dir)
52 }
53}
54
55fn export_schemas(out_dir: &Path) -> Result<Vec<PathBuf>> {
64 let schemas = collect_schemas();
65 let mut written = Vec::new();
66
67 for exported in &schemas {
68 let path = out_dir.join(&exported.relative_json_path);
69 if let Some(parent) = path.parent() {
70 fs::create_dir_all(parent).with_context(|| format!("Failed to create directory {}", parent.display()))?;
71 }
72 let pretty = serde_json::to_string_pretty(&exported.schema)
73 .with_context(|| format!("Failed to serialize schema for {}", exported.title))?;
74 fs::write(&path, format!("{pretty}\n")).with_context(|| format!("Failed to write {}", path.display()))?;
75 written.push(exported.relative_json_path.clone());
76 }
77
78 let readme_path = out_dir.join("README.md");
79 fs::create_dir_all(out_dir).with_context(|| format!("Failed to create directory {}", out_dir.display()))?;
80 fs::write(&readme_path, render_readme(&schemas)).context("Failed to write docs/schemas/README.md")?;
81 written.push(PathBuf::from("README.md"));
82
83 Ok(written)
84}
85
86fn collect_schemas() -> Vec<ExportedSchema> {
89 let mut schemas = vec![ExportedSchema {
90 relative_json_path: PathBuf::from("mecha10.schema.json"),
91 title: "Project config (mecha10.json)".to_string(),
92 schema: project_schema_with_introspected_behaviors(),
93 }];
94
95 for node_type in mecha10_schema_registry::BUILTIN_NODE_TYPES.iter() {
96 let schema = mecha10_schema_registry::node_type_schema(node_type).unwrap_or_else(|| {
97 panic!("mecha10-schema-registry declares '{node_type}' as built-in but has no schema for it")
98 });
99 schemas.push(ExportedSchema {
100 relative_json_path: PathBuf::from("nodes").join(format!("{node_type}.schema.json")),
101 title: format!("Node config: {node_type}"),
102 schema,
103 });
104 }
105
106 schemas.push(ExportedSchema {
107 relative_json_path: PathBuf::from("model-catalog.schema.json"),
108 title: "Model catalog (model_catalog.toml)".to_string(),
109 schema: mecha10_schema_registry::model_catalog_schema(),
110 });
111
112 schemas
113}
114
115fn project_schema_with_introspected_behaviors() -> Value {
136 let mut schema = mecha10_core::schema_validation::get_project_schema();
137 let behaviors_schema = mecha10_schema_registry::schema_for::<mecha10_cli_core::types::project::BehaviorsConfig>();
138 if let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) {
139 properties.insert("behaviors".to_string(), behaviors_schema);
140 }
141 schema
142}
143
144fn render_readme(schemas: &[ExportedSchema]) -> String {
147 let mut out = String::new();
148 out.push_str("# Schema reference\n\n");
149 out.push_str(
150 "Generated from this repo's JSON Schemas by `mecha10 schema export` (LAB-1829) — \
151 do not hand-edit. Regenerate with:\n\n```\ncargo run -p mecha10-cli --bin mecha10 -- schema export\n```\n\n\
152 Descriptions come from the schemas themselves (Rust doc comments on node config \
153 structs, via `schemars`, or `get_project_schema()`'s own `description` fields for \
154 the project config). See [`docs/validate-configs.md`](../validate-configs.md) for how \
155 these schemas are used to validate configs, and the raw `.schema.json` files \
156 alongside this doc for the exact JSON Schema.\n\n",
157 );
158
159 out.push_str("## Contents\n\n");
160 for exported in schemas {
161 let anchor = slugify(&exported.title);
162 let _ = writeln!(out, "- [{}](#{anchor})", exported.title);
163 }
164 out.push('\n');
165
166 for exported in schemas {
167 let _ = writeln!(out, "## {}\n", exported.title);
168 if let Some(description) = exported.schema.get("description").and_then(Value::as_str) {
169 let _ = writeln!(out, "{description}\n");
170 }
171 let _ = writeln!(
172 out,
173 "Raw schema: [`{}`]({}).\n",
174 exported.relative_json_path.display(),
175 exported.relative_json_path.display()
176 );
177
178 render_schema_body(&exported.schema, &exported.schema, &mut out, &mut BTreeSet::new());
179 }
180
181 out
182}
183
184struct PendingSection {
191 title: String,
192 schema: Value,
193}
194
195fn render_schema_body(root: &Value, schema: &Value, out: &mut String, visited: &mut BTreeSet<String>) {
201 let mut pending: Vec<PendingSection> = Vec::new();
202 render_value_body(schema, out, &mut pending);
203
204 if let Some(definitions) = root.get("definitions").and_then(Value::as_object) {
205 let mut names: Vec<&String> = definitions.keys().collect();
206 names.sort();
207 for name in names {
208 pending.push(PendingSection {
209 title: name.clone(),
210 schema: definitions[name].clone(),
211 });
212 }
213 }
214
215 let mut index = 0;
216 while index < pending.len() {
217 if visited.insert(pending[index].title.clone()) {
218 let _ = writeln!(out, "### {}\n", pending[index].title);
219 if let Some(description) = pending[index].schema.get("description").and_then(Value::as_str) {
220 let _ = writeln!(out, "{description}\n");
221 }
222 let schema_to_render = pending[index].schema.clone();
223 render_value_body(&schema_to_render, out, &mut pending);
224 }
225 index += 1;
226 }
227}
228
229fn render_value_body(schema: &Value, out: &mut String, pending: &mut Vec<PendingSection>) {
235 if let Some(properties) = schema.get("properties").and_then(Value::as_object) {
236 let required: BTreeSet<&str> = schema
237 .get("required")
238 .and_then(Value::as_array)
239 .map(|values| values.iter().filter_map(Value::as_str).collect())
240 .unwrap_or_default();
241
242 let mut names: Vec<&String> = properties.keys().collect();
243 names.sort();
244
245 out.push_str("| Field | Type | Required | Description |\n");
246 out.push_str("| --- | --- | --- | --- |\n");
247 for name in names {
248 let field_schema = &properties[name];
249 let type_label = type_label(field_schema);
250 let is_required = if required.contains(name.as_str()) { "yes" } else { "no" };
251 let description = field_description(field_schema).unwrap_or_default();
252 let _ = writeln!(out, "| `{name}` | {type_label} | {is_required} | {description} |");
253 if let Some(nested) = anonymous_nested_object(name, field_schema) {
254 pending.push(nested);
255 }
256 }
257 out.push('\n');
258 return;
259 }
260
261 if let Some(variants) = schema
262 .get("oneOf")
263 .or_else(|| schema.get("anyOf"))
264 .and_then(Value::as_array)
265 {
266 out.push_str("One of the following variants:\n\n");
267 for (index, variant) in variants.iter().enumerate() {
268 let label = variant_label(variant, index);
269 let _ = writeln!(out, "- **{label}**");
270 }
271 out.push('\n');
272 for (index, variant) in variants.iter().enumerate() {
273 let is_bare_reference_or_null = (variant.get("$ref").is_some()
274 || variant.get("type").and_then(Value::as_str) == Some("null"))
275 && variant.get("properties").is_none();
276 if is_bare_reference_or_null || (variant.get("enum").is_some() && variant.get("properties").is_none()) {
277 continue;
278 }
279 let label = variant_label(variant, index);
280 let _ = writeln!(out, "**{label}**\n");
281 render_value_body(variant, out, pending);
282 }
283 return;
284 }
285
286 if let Some(items) = schema.get("enum").and_then(Value::as_array) {
287 let values: Vec<String> = items.iter().map(display_scalar).collect();
288 let _ = writeln!(out, "Allowed values: {}\n", values.join(", "));
289 }
290}
291
292fn anonymous_nested_object(field_name: &str, field_schema: &Value) -> Option<PendingSection> {
301 let candidate = if field_schema.get("type").and_then(Value::as_str) == Some("array") {
302 field_schema.get("items")?
303 } else {
304 field_schema
305 };
306
307 if candidate.get("$ref").is_some() {
308 return None;
309 }
310 if let Some(all_of) = candidate.get("allOf").and_then(Value::as_array) {
311 if all_of.iter().any(|s| s.get("$ref").is_some()) {
312 return None;
313 }
314 }
315 if candidate.get("oneOf").is_some() || candidate.get("anyOf").is_some() {
316 return None;
317 }
318
319 let has_properties = candidate
320 .get("properties")
321 .and_then(Value::as_object)
322 .is_some_and(|props| !props.is_empty());
323 if !has_properties {
324 return None;
325 }
326
327 let title = candidate
328 .get("title")
329 .and_then(Value::as_str)
330 .map(str::to_string)
331 .unwrap_or_else(|| field_name.to_string());
332 Some(PendingSection {
333 title,
334 schema: candidate.clone(),
335 })
336}
337
338fn variant_label(variant: &Value, index: usize) -> String {
343 if let Some(tag) = variant
344 .get("properties")
345 .and_then(|p| p.get("type"))
346 .and_then(|t| t.get("enum"))
347 .and_then(Value::as_array)
348 .and_then(|values| values.first())
349 .and_then(Value::as_str)
350 {
351 return format!("`{tag}`");
352 }
353 if let Some(tag) = variant
354 .get("enum")
355 .and_then(Value::as_array)
356 .and_then(|values| values.first())
357 .and_then(Value::as_str)
358 {
359 return format!("`{tag}`");
360 }
361 if let Some(title) = variant.get("title").and_then(Value::as_str) {
362 return title.to_string();
363 }
364 if variant.get("type").and_then(Value::as_str) == Some("null") {
365 return "`null`".to_string();
366 }
367 if let Some(reference) = variant.get("$ref").and_then(Value::as_str) {
368 return ref_type_label(reference);
369 }
370 format!("variant {index}")
371}
372
373fn field_description(schema: &Value) -> Option<String> {
378 schema
379 .get("description")
380 .and_then(Value::as_str)
381 .map(|s| s.replace('\n', " ").trim().to_string())
382 .filter(|s| !s.is_empty())
383}
384
385fn type_label(schema: &Value) -> String {
389 if let Some(reference) = schema.get("$ref").and_then(Value::as_str) {
390 return ref_type_label(reference);
391 }
392 if let Some(all_of) = schema.get("allOf").and_then(Value::as_array) {
393 if let Some(reference) = all_of.iter().find_map(|s| s.get("$ref")).and_then(Value::as_str) {
394 return ref_type_label(reference);
395 }
396 }
397 if let Some(one_of) = schema
398 .get("oneOf")
399 .or_else(|| schema.get("anyOf"))
400 .and_then(Value::as_array)
401 {
402 let labels: Vec<String> = one_of.iter().map(type_label).collect();
403 return format!("one of: {}", labels.join(", "));
404 }
405 if schema.get("type").and_then(Value::as_str) == Some("array") {
406 let item_label = schema.get("items").map_or_else(|| "any".to_string(), type_label);
407 return format!("array of {item_label}");
408 }
409 if schema.get("properties").is_some() {
410 return schema
411 .get("title")
412 .and_then(Value::as_str)
413 .map_or_else(|| "object".to_string(), |title| format!("`{title}`"));
414 }
415 match schema.get("type") {
416 Some(Value::String(t)) => t.clone(),
417 Some(Value::Array(types)) => types.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(" or "),
418 _ if schema.get("enum").is_some() => "enum".to_string(),
419 _ => "any".to_string(),
420 }
421}
422
423fn ref_type_label(reference: &str) -> String {
426 let name = reference.rsplit('/').next().unwrap_or(reference);
427 format!("`{name}`")
428}
429
430fn display_scalar(value: &Value) -> String {
431 match value {
432 Value::String(s) => format!("`{s}`"),
433 other => format!("`{other}`"),
434 }
435}
436
437fn slugify(title: &str) -> String {
440 title
441 .to_lowercase()
442 .chars()
443 .filter_map(|c| {
444 if c.is_alphanumeric() || c == ' ' || c == '-' {
445 Some(if c == ' ' { '-' } else { c })
446 } else {
447 None
448 }
449 })
450 .collect()
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456 use serde_json::json;
457 use tempfile::tempdir;
458
459 #[test]
460 fn type_label_resolves_plain_primitive() {
461 let schema = json!({"type": "string"});
462 assert_eq!(type_label(&schema), "string");
463 }
464
465 #[test]
466 fn type_label_resolves_direct_ref() {
467 let schema = json!({"$ref": "#/definitions/SourceConfig"});
468 assert_eq!(type_label(&schema), "`SourceConfig`");
469 }
470
471 #[test]
472 fn type_label_resolves_all_of_wrapped_ref() {
473 let schema = json!({
474 "description": "Frame source configuration",
475 "allOf": [{"$ref": "#/definitions/SourceConfig"}]
476 });
477 assert_eq!(type_label(&schema), "`SourceConfig`");
478 }
479
480 #[test]
481 fn type_label_resolves_array_of_ref() {
482 let schema = json!({"type": "array", "items": {"$ref": "#/definitions/ModelCatalogEntry"}});
483 assert_eq!(type_label(&schema), "array of `ModelCatalogEntry`");
484 }
485
486 #[test]
487 fn field_description_reads_plain_description() {
488 let schema = json!({"type": "string", "description": "Camera ID for identification"});
489 assert_eq!(
490 field_description(&schema).as_deref(),
491 Some("Camera ID for identification")
492 );
493 }
494
495 #[test]
496 fn field_description_reads_all_of_wrapped_description() {
497 let schema = json!({
498 "description": "Frame source configuration",
499 "allOf": [{"$ref": "#/definitions/SourceConfig"}]
500 });
501 assert_eq!(
502 field_description(&schema).as_deref(),
503 Some("Frame source configuration")
504 );
505 }
506
507 #[test]
508 fn render_value_body_produces_a_field_table_with_required_column() {
509 let root = json!({
510 "type": "object",
511 "required": ["name"],
512 "properties": {
513 "name": {"type": "string", "description": "The name"},
514 "note": {"type": "string", "description": "Optional note"}
515 }
516 });
517 let mut out = String::new();
518 render_value_body(&root, &mut out, &mut Vec::new());
519 assert!(out.contains("| `name` | string | yes | The name |"));
520 assert!(out.contains("| `note` | string | no | Optional note |"));
521 }
522
523 #[test]
524 fn render_value_body_queues_anonymous_nested_array_item_object() {
525 let root = json!({
526 "type": "object",
527 "properties": {
528 "models": {
529 "type": "array",
530 "items": {
531 "type": "object",
532 "properties": {"name": {"type": "string", "description": "Model name"}}
533 }
534 }
535 }
536 });
537 let mut out = String::new();
538 render_schema_body(&root, &root, &mut out, &mut BTreeSet::new());
539 assert!(out.contains("### models"));
540 assert!(out.contains("| `name` | string | no | Model name |"));
541 }
542
543 #[test]
544 fn render_schema_body_documents_referenced_definitions_once() {
545 let root = json!({
546 "type": "object",
547 "properties": {
548 "source": {"allOf": [{"$ref": "#/definitions/SourceConfig"}]}
549 },
550 "definitions": {
551 "SourceConfig": {
552 "type": "object",
553 "properties": {"path": {"type": "string"}}
554 }
555 }
556 });
557 let mut out = String::new();
558 render_schema_body(&root, &root, &mut out, &mut BTreeSet::new());
559 assert_eq!(out.matches("### SourceConfig").count(), 1);
560 assert!(out.contains("| `path` | string | no |"));
561 }
562
563 #[test]
564 fn project_schema_behaviors_property_reflects_the_real_behaviors_config_struct() {
565 let schema = project_schema_with_introspected_behaviors();
566 let behaviors = &schema["properties"]["behaviors"];
567
568 assert_eq!(behaviors["title"], "BehaviorsConfig");
569 assert!(
570 behaviors["properties"].get("active").is_some(),
571 "expected `behaviors.properties.active`, got: {behaviors}"
572 );
573 assert!(
574 behaviors["properties"].get("default").is_none(),
575 "`behaviors` schema still has a stale `default` field: {behaviors}"
576 );
577 let required: Vec<&str> = behaviors["required"]
578 .as_array()
579 .expect("BehaviorsConfig schema should have a `required` array")
580 .iter()
581 .filter_map(Value::as_str)
582 .collect();
583 assert_eq!(required, vec!["active"]);
584 }
585
586 #[test]
587 fn variant_label_prefers_serde_tag_discriminant() {
588 let variant = json!({
589 "type": "object",
590 "properties": {"type": {"enum": ["mock"]}}
591 });
592 assert_eq!(variant_label(&variant, 0), "`mock`");
593 }
594
595 #[test]
596 fn export_schemas_writes_json_and_readme() {
597 let dir = tempdir().unwrap();
598 let written = export_schemas(dir.path()).unwrap();
599
600 assert!(written.contains(&PathBuf::from("mecha10.schema.json")));
601 assert!(written.contains(&PathBuf::from("model-catalog.schema.json")));
602 assert!(written.contains(&PathBuf::from("nodes/camera.schema.json")));
603 assert!(written.contains(&PathBuf::from("README.md")));
604
605 assert!(dir.path().join("mecha10.schema.json").exists());
606 assert!(dir.path().join("nodes/camera.schema.json").exists());
607 assert!(dir.path().join("model-catalog.schema.json").exists());
608
609 let readme = fs::read_to_string(dir.path().join("README.md")).unwrap();
610 assert!(readme.contains("# Schema reference"));
611 assert!(readme.contains("## Project config (mecha10.json)"));
612 assert!(readme.contains("## Node config: camera"));
613 assert!(readme.contains("## Model catalog (model_catalog.toml)"));
614 assert!(readme.contains("### ModelCatalogEntry"));
615 assert!(readme.contains("| `name` |"));
616 }
617
618 #[test]
619 fn export_schemas_covers_every_builtin_node_type() {
620 let dir = tempdir().unwrap();
621 export_schemas(dir.path()).unwrap();
622 for node_type in mecha10_schema_registry::BUILTIN_NODE_TYPES.iter() {
623 let path = dir.path().join("nodes").join(format!("{node_type}.schema.json"));
624 assert!(
625 path.exists(),
626 "expected schema file for '{node_type}' at {}",
627 path.display()
628 );
629 }
630 }
631
632 #[test]
633 fn export_schemas_output_is_deterministic() {
634 let dir_a = tempdir().unwrap();
635 let dir_b = tempdir().unwrap();
636 export_schemas(dir_a.path()).unwrap();
637 export_schemas(dir_b.path()).unwrap();
638
639 let readme_a = fs::read_to_string(dir_a.path().join("README.md")).unwrap();
640 let readme_b = fs::read_to_string(dir_b.path().join("README.md")).unwrap();
641 assert_eq!(readme_a, readme_b);
642
643 let schema_a = fs::read_to_string(dir_a.path().join("mecha10.schema.json")).unwrap();
644 let schema_b = fs::read_to_string(dir_b.path().join("mecha10.schema.json")).unwrap();
645 assert_eq!(schema_a, schema_b);
646 }
647}